原文:Build a universal React and Node App
演示:https://judo-heroes.herokuapp.com/
译者:nzbin
译者的话:这是一篇非常优秀的 React 教程,该文对 React 组件、React Router 以及 Node 做了很好的梳理。我是 9 月份读的该文章,当时跟着教程做了一遍,收获很大。但是由于时间原因,直到现在才与大家分享,幸好赶在年底之前完成了译文,否则一定会成为 2016 年的小遗憾。翻译仓促,其中还有个别不通顺的地方,望见谅。
关于通用的 JavaScript
将 Node.js 作为运行 web 程序的后端系统的一个优势就是我们只需使用 JavaScript 这一种语言。由于这个原因,前后端可以共享一些代码,这可以将浏览器及服务器中重复的代码减少到最小。创建 JavaScript 代码的艺术是 "环境未知的",如今被看做 "通用的 JavaScript",这条术语在经过 很 长时间 争论 之后,似乎取代了原始的名称 "同构的 JavaScript"。
我们在创建一个通用的 JavaScript 应用程序时,主要考虑的是:
- 模块共享: 如何将 Node.js 模块用在浏览器中。
- 通用渲染: 如何从服务端渲染应用的视图 (在应用初始化时) ,以及当用户浏览其它部分时,如何继续在浏览器中直接呈现其他视图(避免整页刷新)。
- 通用路由: 如何从服务器和浏览器中识别与当前路由相关的视图。
- 通用数据检索: 如何从服务器和浏览器访问数据(主要通过 API)。
通用的 JavaScript 仍然是一个非常新的领域,还没有框架或者方法可以成为解决所有这些问题的 "事实上" 的标准。尽管,已经有无数稳定的以及众所周知的库和工具可以成功地构建一个通用的 JavaScript 的 Web 应用程序。
在这篇文章中,我们将使用 React (包括 React Router 库) 和 Express 来构建一个展示通用渲染和路由的简单的应用程序。我们也将通过 Babel 来享受令人愉快的 EcmaScript 2015 语法以及使用 Webpack 构建浏览器端的代码。
我们将做什么?
我是一个 柔道迷 ,所以我们今天要创建的应用叫做 "柔道英雄"。 这个 web 应用展示了最有名的柔道运动员以及他们在奥运会及著名国际赛事中获得的奖牌情况。
这个 app 有两个主要的视图:
一个是首页,你可以选择运动员:
另一个是运动员页面,展示了他们的奖牌及其他信息:
为了更好的理解工作原理,你可以看看这个应用的 demo 并且浏览一下整个视图。
无论如何,你可能会问自己! 是的,它看起来像一个非常简单的应用,有一些数据及视图...
其实应用的幕后有一些普通用户不会注意的特殊的事情,但却使开发非常有趣: 这个应用使用了通用渲染及路由!
我们可以使用浏览器的开发者工具证明这一点。 当我们在浏览器中首次载入一个页面(任意页面, 不需要是首页, 试试 这一个) ,服务器提供了视图的所有 HTML 代码并且浏览器只需下载链接的资源(图像, 样式表及脚本):
然后当我们切换视图的时候,一切都在浏览器中发生:没有从服务器加载的 HTML 代码, 只有被浏览器加载的新资源 (如下示例中的 3 张新图片) :
我们可以在命令行使用 curl 命令做另一个快速测试 (如果你仍然不相信):
curl -sS "https://judo-heroes.herokuapp.com/athlete/teddy-riner"
你将看到整个从服务器端生成的 HTML 页面(包括被 React 渲染的代码):
我保证你现在已经信心满满地想要跃跃欲试,所以让我们开始编码吧!
文件结构
在教程的最后,我们的文件结构会像下面的文件树一样:
├── package.json ├── webpack.config.js ├── src │ ├── app-client.js │ ├── routes.js │ ├── server.js │ ├── components │ │ ├── AppRoutes.js │ │ ├── AthletePage.js │ │ ├── AthletePreview.js │ │ ├── AthletesMenu.js │ │ ├── Flag.js │ │ ├── IndexPage.js │ │ ├── Layout.js │ │ ├── Medal.js │ │ └── NotFoundPage.js │ ├── data │ │ └── athletes.js │ ├── static │ │ ├── index.html │ │ ├── css │ │ ├── favicon.ico │ │ ├── img │ │ └── js │ └── views ` └── index.ejs
主文件夹中有 package.json
(描述项目并且定义依赖) 和 webpack.config.js
(Webpack 配置文件)。
余下的代码都保存在 src
文件夹中, 其中包含路由 (routes.js
) 和渲染 (app-client.js
和 server.js
) 所需的主要文件。它包含四个子文件夹:
components
: 包含所有的 React 组件data
: 包含数据 "模块"static
: 包含应用所需的所有静态文件 (css, js, images, etc.) 和一个测试应用的index.html。
views
: 包含渲染服务器端的 HTML 内容的模板。
项目初始化
需要在你的电脑上安装 Node.js (最好是版本 6) 和 NPM。
在硬盘上的任意地方创建一个名为 judo-heroes
的文件夹并且在给目录下打开终端,然后输入:
npm init
这将会启动 Node.js 项目并允许我们添加所有需要的依赖。
我们需要安装 babel, ejs, express, react 和 react-router 。 你可以输入以下命令:
npm install --save babel-cli@6.11.x babel-core@6.13.x babel-preset-es2015@6.13.x babel-preset-react@6.11.x ejs@2.5.x express@4.14.x react@15.3.x react-dom@15.3.x react-router@2.6.x
我们也需要安装 Webpack (以及它的 Babel loader 扩展) 和 http-server 作为开发依赖:
npm install --save-dev webpack@1.13.x babel-loader@6.2.x http-server@0.9.x
HTML boilerplate
现在, 我建设你已经具备了 React 和 JSX 以及基于组件方法的基础知识。 如果没有,你可以读一下 excellent article on React components 或者 React related articles on Scotch.io。
首先我们只专注于创建一个实用的 "单页应用" (只有客户端渲染). 稍后我们将看到如何通过添加通用的渲染和路由来改进它。
因此我们需要一个 HTML 模板作为应用的主入口,将其保存在 src/static/index.html
:
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Judo Heroes - A Universal JavaScript demo application with React</title> <link rel="stylesheet" href="/css/style.css"> </head> <body> <div id="main"></div> <script src="/js/bundle.js"></script> </body> </html>
这里没有什么特别的。只需强调两件事:
- 需要一个简单的 "手写" 样式表,你可以直接 下载 ,把它保存在
src/static/css/
。 - 引用包含所有前端 JavaScript 代码的
/js/bundle.js
文件。 之后的文章会介绍如何使用 Webpack 和 Babel 生成该文件, 所以你现在不用担心。
数据模块
在一个真实的应用中,我们可能会使用 API 来获取应用所需的数据。
在这个案例中只有 5 个运动员及其相关信息的很少的数据, 所以可以简单点,把数据保存在 JavaScript 模块中。这种方法可以很简单的在组件或模块中同步导入数据, 避免增加复杂度以及在通用 JavaScript 项目中管理异步 API 的陷阱, 这也不是这篇文章的目的。
让我们看一下这模块:
// src/data/athletes.js const athletes = [ { 'id': 'driulis-gonzalez', 'name': 'Driulis González', 'country': 'cu', 'birth': '1973', 'image': 'driulis-gonzalez.jpg', 'cover': 'driulis-gonzalez-cover.jpg', 'link': 'https://en.wikipedia.org/wiki/Driulis_González', 'medals': [ { 'year': '1992', 'type': 'B', 'city': 'Barcelona', 'event': 'Olympic Games', 'category': '-57kg' }, { 'year': '1993', 'type': 'B', 'city': 'Hamilton', 'event': 'World Championships', 'category': '-57kg' }, { 'year': '1995', 'type': 'G', 'city': 'Chiba', 'event': 'World Championships', 'category': '-57kg' }, { 'year': '1995', 'type': 'G', 'city': 'Mar del Plata', 'event': 'Pan American Games', 'category': '-57kg' }, { 'year': '1996', 'type': 'G', 'city': 'Atlanta', 'event': 'Olympic Games', 'category': '-57kg' }, { 'year': '1997', 'type': 'S', 'city': 'Osaka', 'event': 'World Championships', 'category': '-57kg' }, { 'year': '1999', 'type': 'G', 'city': 'Birmingham', 'event': 'World Championships', 'category': '-57kg' }, { 'year': '2000', 'type': 'S', 'city': 'Sydney', 'event': 'Olympic Games', 'category': '-57kg' }, { 'year': '2003', 'type': 'G', 'city': 'S Domingo', 'event': 'Pan American Games', 'category': '-63kg' }, { 'year': '2003', 'type': 'S', 'city': 'Osaka', 'event': 'World Championships', 'category': '-63kg' }, { 'year': '2004', 'type': 'B', 'city': 'Athens', 'event': 'Olympic Games', 'category': '-63kg' }, { 'year': '2005', 'type': 'B', 'city': 'Cairo', 'event': 'World Championships', 'category': '-63kg' }, { 'year': '2006', 'type': 'G', 'city': 'Cartagena', 'event': 'Central American and Caribbean Games', 'category': '-63kg' }, { 'year': '2006', 'type': 'G', 'city': 'Cartagena', 'event': 'Central American and Caribbean Games', 'category': 'Tema' }, { 'year': '2007', 'type': 'G', 'city': 'Rio de Janeiro', 'event': 'Pan American Games', 'category': '-63kg' }, { 'year': '2007', 'type': 'G', 'city': 'Rio de Janeiro', 'event': 'World Championships', 'category': '-63kg' }, ], }, { // ... } ]; export default athletes;
为简洁起见这里的文件已被截断,我们只是展示一个运动员的数据。如果你想看全部的代码, 在官方仓库中查看。你可以把文件下载到 src/data/athletes.js
。
如你所见,这个文件包含了一个对象数组。数组中的每个对象代表一个运动员,包含一些通用的信息比如 id
, name
和 country
,另外一个对象数组代表运动员获得的奖牌。
你可以在仓库中下载 所有的图片文件 ,复制到: src/static/img/
。
React 组件
我们将把应用的视图分成若干个组件:
- 用于创建视图的一些小的 UI 组件:
AthletePreview
,Flag
,Medal
和AthletesMenu
- 一个
Layout
组件,作为主组件用来定义应用的通用样式(header, content 和 footer) - 代表主要部分的两个主组件:
IndexPage
和AthletePage
- 用作 404 页面的一个额外的 "页面" 组件:
NotFoundPage
- 使用 React Router 管理视图间路由的
AppRoutes
组件
Flag 组件
我们将要创建的第一个组件会展示一个漂亮的国旗以及它所代表的国家名:
// src/components/Flag.js import React from 'react'; const data = { 'cu': { 'name': 'Cuba', 'icon': 'flag-cu.png', }, 'fr': { 'name': 'France', 'icon': 'flag-fr.png', }, 'jp': { 'name': 'Japan', 'icon': 'flag-jp.png', }, 'nl': { 'name': 'Netherlands', 'icon': 'flag-nl.png', }, 'uz': { 'name': 'Uzbekistan', 'icon': 'flag-uz.png', } }; export default class Flag extends React.Component { render() { const name = data[this.props.code].name; const icon = data[this.props.code].icon; return ( <span className="flag"> <img className="icon" title={name} src={`/img/${icon}`}/> {this.props.showName && <span className="name"> {name}</span>} </span> ); } }
你可能注意到这个组件使用了一个国家的数组作为数据源。 这样做是有道理的,因为我们只需要很小的数据。由于是演示应用,所以数据不会变。在真实的拥有巨大以及复杂数据的应用中,你可能会使用 API 或者不同的机制将数据连接到组件。
在这个组件中同样需要注意的是我们使用了两个不同的 props, code
和 showName
。第一个是强制性的, 必须传递给组件以显示对应的国旗。 showName
props 是可选的,如果设置为 true ,组件将会在国旗的后面显示国家名。
如果你想在真实的 app 中创建可重用的组件,你需要添加 props 的验证及默认值, 但我们省略这一步,因为这不是我们要构建的应用程序的目标。
Medal 组件
Medal
组件与 Flag
组件类似。它接受一些 props,这些属性代表与奖牌相关的数据: type
(G
表示金牌, S
表示银牌以及 B
表示铜牌), year
(哪一年赢得), event
(赛事名称), city
(举办比赛的城市)以及 category
(运动员赢得比赛的级别)。
// src/components/Medal.js import React from 'react'; const typeMap = { 'G': 'Gold', 'S': 'Silver', 'B': 'Bronze' }; export default class Medal extends React.Component { render() { return ( <li className="medal"> <span className={`symbol symbol-${this.props.type}`} title={typeMap[this.props.type]}>{this.props.type}</span> <span className="year">{this.props.year}</span> <span className="city"> {this.props.city}</span> <span className="event"> ({this.props.event})</span> <span className="category"> {this.props.category}</span> </li> ); } }
作为前面的组件,我们也使用一个小对象将奖牌类型的代码映射成描述性名称。
Athletes Menu 组件
这一步我们将要创建在每个运动员页面的顶端显示的菜单,这样用户不需要返回首页就可以很方便的切换运动员:
// src/components/AthletesMenu.js import React from 'react'; import { Link } from 'react-router'; import athletes from '../data/athletes'; export default class AthletesMenu extends React.Component { render() { return ( <nav className="atheletes-menu"> {athletes.map(menuAthlete => { return <Link key={menuAthlete.id} to={`/athlete/${menuAthlete.id}`} activeClassName="active"> {menuAthlete.name} </Link>; })} </nav> ); } }
这个组件非常简单, 但是有几个需要注意的地方:
- 我们在组件中直接导入数据模块,这样可以在应用中访问运动员的列表。
- 我们使用
map
方法遍历所有的运动员,给每个人生成一个Link
。 Link
是 React Router 为了在视图间生成链接所提供的特殊组件。- 最后,我们使用
activeClassName
属性,当当前路由与链接路径匹配时会添加active
的类。
Athlete Preview 组件
AthletePreview
组件用在首页显示运动员的图片及名称。来看一下它的代码:
// src/components/AthletePreview.js import React from 'react'; import { Link } from 'react-router'; export default class AthletePreview extends React.Component { render() { return ( <Link to={`/athlete/${this.props.id}`}> <div className="athlete-preview"> <img src={`img/${this.props.image}`}/> <h2 className="name">{this.props.name}</h2> <span className="medals-count"><img src="/img/medal.png"/> {this.props.medals.length}</span> </div> </Link> ); } }
代码非常简单。我们打算接受许多 props 来描述运动员的特征,比如 id
, image
, name
以及 medals
。再次注意我们使用 Link
组件在运动员页面创建了一个链接。
Layout 组件
既然我们已经创建了所有的基本组件,现在我们开始创建那些给应用程序提供视觉结构的组件。 第一个是 Layout
组件, 它的唯一用途就是给整个应用提供展示模板,包括页头区、 主内容区以及页脚区:
// src/components/Layout.js import React from 'react'; import { Link } from 'react-router'; export default class Layout extends React.Component { render() { return ( <div className="app-container"> <header> <Link to="/"> <img className="logo" src="/img/logo-judo-heroes.png"/> </Link> </header> <div className="app-content">{this.props.children}</div> <footer> <p> This is a demo app to showcase universal rendering and routing with <strong>React</strong> and <strong>Express</strong>. </p> </footer> </div> ); } }
组件非常简单,只需看代码就能了解它是如何工作的。 我们在这里使用了一个有趣的 props, children
属性. 这是 React 提供给每个组件的特殊属性,允许在一个组件中嵌套组件。
我们将在路由的部分看到 React Router 如何在 Layout
组件中嵌套另一个组件。
Index Page 组件
这个组件构成了整个首页,它包含了之前定义的一些组件:
// src/components/IndexPage.js import React from 'react'; import AthletePreview from './AthletePreview'; import athletes from '../data/athletes'; export default class IndexPage extends React.Component { render() { return ( <div className="home"> <div className="athletes-selector"> {athletes.map(athleteData => <AthletePreview key={athleteData.id} {...athleteData} />)} </div> </div> ); } }
在这个组件中我们需要注意,我们使用了之前定义的 AthletePreview
组件。基本上我们在数据模块中遍历所有的运动员, 给每个人创建一个 AthletePreview
组件。因为 AthletePreview
组件的数据是未知的,所以我们需要使用 JSX 扩展操作符 ({...object}
) 来传递当前运动员的所有信息。
Athlete Page 组件
我们用同样的方式创建 AthletePage
组件:
// src/components/AthletePage.js import React from 'react'; import { Link } from 'react-router'; import NotFoundPage from './NotFoundPage'; import AthletesMenu from './AthletesMenu'; import Medal from './Medal'; import Flag from './Flag'; import athletes from '../data/athletes'; export default class AthletePage extends React.Component { render() { const id = this.props.params.id; const athlete = athletes.filter((athlete) => athlete.id === id)[0]; if (!athlete) { return <NotFoundPage/>; } const headerStyle = { backgroundImage: `url(/img/${athlete.cover})` }; return ( <div className="athlete-full"> <AthletesMenu/> <div className="athlete"> <header style={headerStyle}/> <div className="picture-container"> <img src={`/img/${athlete.image}`}/> <h2 className="name">{athlete.name}</h2> </div> <section className="description"> Olympic medalist from <strong><Flag code={athlete.country} showName="true"/></strong>, born in {athlete.birth} (Find out more on <a href={athlete.link} target="_blank">Wikipedia</a>). </section> <section className="medals"> <p>Winner of <strong>{athlete.medals.length}</strong> medals:</p> <ul>{ athlete.medals.map((medal, i) => <Medal key={i} {...medal}/>) }</ul> </section> </div> <div className="navigateBack"> <Link to="/">« Back to the index</Link> </div> </div> ); } }
现在, 你一定可以理解上面的大部分代码以及如何用其它的组件创建这个视图。需要强调的是这个页面组件只能从外部接受运动员的 id, 所以我们引入数据模块来检索运动员的相关信息。我们在 render
方法开始之前对数据采用了 filter
函数。我们也考虑了接受的 id 在数据模块中不存在的情况。这种情况下会渲染 NotFoundPage
组件,我们会在后面的部分创建这个组件。
最后一个重要的细节是我们通过 this.props.params.id
(而不是简单的 this.props.id
)来访问 id:当在 Route
中使用组件时, React Router 会创建一个特殊的对象 params
,并且它允许给组件传递路由参数。当我们知道如何设置应用的路由部分时,这个概念更容易理解。
Not Found Page 组件
现在让来看看 NotFoundPage
组件, 它是生成 404 页面代码的模板:
// src/components/NotFoundPage.js import React from 'react'; import { Link } from 'react-router'; export default class NotFoundPage extends React.Component { render() { return ( <div className="not-found"> <h1>404</h1> <h2>Page not found!</h2> <p> <Link to="/">Go back to the main page</Link> </p> </div> ); } }
App Routes 组件
我们创建的最后一个组件是 AppRoutes
组件,它是使用 React Router 渲染所有视图的主要组件。这个组件将使用 routes
模块,让我们先睹为快:
// src/routes.js import React from 'react' import { Route, IndexRoute } from 'react-router' import Layout from './components/Layout'; import IndexPage from './components/IndexPage'; import AthletePage from './components/AthletePage'; import NotFoundPage from './components/NotFoundPage'; const routes = ( <Route path="/" component={Layout}> <IndexRoute component={IndexPage}/> <Route path="athlete/:id" component={AthletePage}/> <Route path="*" component={NotFoundPage}/> </Route> ); export default routes;
在这个文件中我们使用 React Router 的 Route
组件将路由映射到之前定义的组件中。注意如何在一个主 Route
组件中嵌套路由。我解释一下它的原理:
- 跟路由会将
/
路径映射到Layout
组件。这允许我们在应用程序的每个部分使用自定义的 layout 。在嵌套路由中定义的组件将会代替this.props.children
属性在Layout
组件中被渲染,我们在之前已经讨论过。 - 第一个子路由是
IndexRoute
,这个特殊的路由所定义的组件会在我们浏览父路由(/)的索引页时被渲染。我们将IndexPage
组件作为索引路由。 - 路径
athlete/:id
被映射为AthletePage
。注意我们使用了命名参数:id
。所以这个路由会匹配所有前缀是/athlete/
的路径, 余下的部分将关联参数id
并对应组件中的this.props.params.id
。 - 最后匹配所有的路由
*
会将其它路径映射到NotFoundPage
组件。这个路由必须被定义为最后一条 。
现在看一下如何在 AppRoutes
组件中通过 React Router 使用路由:
// src/components/AppRoutes.js import React from 'react'; import { Router, browserHistory } from 'react-router'; import routes from '../routes'; export default class AppRoutes extends React.Component { render() { return ( <Router history={browserHistory} routes={routes} onUpdate={() => window.scrollTo(0, 0)}/> ); } }
基本上我们只需导入 Router
组件,然后把它添加到 render
函数中。router 组件会在 router
属性中接收路由的映射。我们也定义了 history
属性来指定要使用 HTML5 的浏览历史记录(as an alternative you could also use hashHistory).
最后我们也添加了 onUpdate
回调函数,它的作用是每当连接被点击后窗口都会滚动到顶部。
应用程序入口
完成我们的应用程序的首个版本的最后一部分代码就是编写在浏览器中启动 app 的 JavaScript 逻辑代码:
// src/app-client.js import React from 'react'; import ReactDOM from 'react-dom'; import AppRoutes from './components/AppRoutes'; window.onload = () => { ReactDOM.render(<AppRoutes/>, document.getElementById('main')); };
我们在这里唯一要做的就是导入 AppRoutes
组件,然后使用 ReactDOM.render
方法渲染。React app 将会在 #main
DOM 元素中生成。
设置 Webpack 和 Babel
在运行应用之前,我们需要使用 Webpack 生成包含所有 React 组件的 bundle.js
组件。这个文件将会被浏览器执行,因此 Webpack 要确保将所有模块转换成可以在大多数浏览器环境执行的代码。 Webpack 会把 ES2015 和 React JSX 语法转换成相等的 ES5 语法(使用 Babel), 这样就可以在每个浏览器中执行。此外, 我们可以使用 Webpack 来优化最终生成的代码,比如将所有的脚本压缩合并成一个文件。
来写一下 webpack 的配置文件:
// webpack.config.js const webpack = require('webpack'); const path = require('path'); module.exports = { entry: path.join(__dirname, 'src', 'app-client.js'), output: { path: path.join(__dirname, 'src', 'static', 'js'), filename: 'bundle.js' }, module: { loaders: [{ test: path.join(__dirname, 'src'), loader: ['babel-loader'], query: { cacheDirectory: 'babel_cache', presets: ['react', 'es2015'] } }] }, plugins: [ new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) }), new webpack.optimize.DedupePlugin(), new webpack.optimize.OccurenceOrderPlugin(), new webpack.optimize.UglifyJsPlugin({ compress: { warnings: false }, mangle: true, sourcemap: false, beautify: false, dead_code: true }) ] };
在配置文件的第一部分,我们定义了文件入口以及输出路径。 文件入口是启动应用的 JavaScript 文件。Webpack 会使用递归方法将打包进 bundle 文件的那些包含或导入的资源进行筛选。
module.loaders
部分会对特定文件进行转化。在这里我们想使用 Babel 的 react
和 es2015
设置将所有引入的 JavaScript 文件转化成 ES5 代码。
最后一部分我们使用 plugins
声明及配置我们想要使用的所有优化插件:
DefinePlugin
允许我们在打包的过程中将NODE_ENV
变量定义为全局变量,和在脚本中定义的一样。 有些模块 (比如 React) 会依赖于它启用或禁用当前环境(产品或开发)的特定功能。DedupePlugin
删除所有重复的文件 (模块导入多个模块).OccurenceOrderPlugin
可以减少打包后文件的体积。UglifyJsPlugin
使用 UglifyJs 压缩和混淆打包的文件。
现在我们已经准备好生成 bundle 文件,只需运行:
NODE_ENV=production node_modules/.bin/webpack -p
NODE_ENV
环境变量和 -p
选项用于在产品模式下生成 bundle 文件,这会应用一些额外的优化,比如在 React 库中删除所有的调试代码。
如果一切运行正常,你将会在 src/static/js/bundle.js
目录中看到 bundle 文件。
玩一玩单页应用
我们已经准备好玩一玩应用程序的第一个版本了!
我们还没有 Node.js 的 web 服务器,因此现在我们可以使用 http-server
模块(之前安装的开发依赖) 运行一个简单的静态文件服务器:
node_modules/.bin/http-server src/static
现在你的应用已经可以在 http://localhost:8080 上运行。
好了,现在花些时间玩一玩,点击所有的链接,浏览所有的部分。
一切似乎工作正常? 嗯,是的! 只是有一些错误警告... 如果你在首页之外的部分刷新页面, 服务器会返回 404 错误。
解决这个问题的方法有很多。我们会使用通用路由及渲染方案解决这个问题,所以让我们开始下一部分吧!
使用 Express 搭建服务端路由及渲染
我们现在准备将应用程序升级到下一个版本,并编写缺少的服务器端部分。
为了具有服务端路由及渲染, 稍后我们将使用 Express 编写一个相对较小的服务端脚本。
渲染部分将使用 ejs 模板替换 index.html
文件,并保存在 src/views/index.ejs
:
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Judo Heroes - A Universal JavaScript demo application with React</title> <link rel="stylesheet" href="/css/style.css"> </head> <body> <div id="main"><%- markup -%></div> <script src="/js/bundle.js"></script> </body> </html>
与原始 HTML 文件仅有的不同就是我们在 #main
div 元素中使用了模板变量 <%- markup -%>
,为了在服务端生成的 HTML 代码中包含 React markup 。
现在我们准备写服务端应用:
// src/server.js import path from 'path'; import { Server } from 'http'; import Express from 'express'; import React from 'react'; import { renderToString } from 'react-dom/server'; import { match, RouterContext } from 'react-router'; import routes from './routes'; import NotFoundPage from './components/NotFoundPage'; // initialize the server and configure support for ejs templates const app = new Express(); const server = new Server(app); app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views')); // define the folder that will be used for static assets app.use(Express.static(path.join(__dirname, 'static'))); // universal routing and rendering app.get('*', (req, res) => { match( { routes, location: req.url }, (err, redirectLocation, renderProps) => { // in case of error display the error message if (err) { return res.status(500).send(err.message); } // in case of redirect propagate the redirect to the browser if (redirectLocation) { return res.redirect(302, redirectLocation.pathname + redirectLocation.search); } // generate the React markup for the current route let markup; if (renderProps) { // if the current route matched we have renderProps markup = renderToString(<RouterContext {...renderProps}/>); } else { // otherwise we can render a 404 page markup = renderToString(<NotFoundPage/>); res.status(404); } // render the index template with the embedded React markup return res.render('index', { markup }); } ); }); // start the server const port = process.env.PORT || 3000; const env = process.env.NODE_ENV || 'production'; server.listen(port, err => { if (err) { return console.error(err); } console.info(`Server running on http://localhost:${port} [${env}]`); });
代码添加了注释, 所以不难理解其中原理。
其中重要的代码就是使用 app.get('*', (req, res) => {...})
定义的 Express 路由。 这是一个 Express catch-all 路由,它会在服务端将所有的 GET 请求编译成 URL 。 在这个路由中, 我们使用 React Router match
函数来授权路由逻辑。
ReactRouter.match
接收两个参数:第一个参数是配置对象,第二个是回调函数。配置对象需要有两个键值:
routes
: 用于传递 React Router 的路由配置。在这里,我们传递用于服务端渲染的相同配置。location
: 这是用来指定当前请求的 URL 。
回调函数在匹配结束时调用。它接收三个参数, error
, redirectLocation
以及 renderProps
, 我们可以通过这些参数确定匹配的结果。
我们可能有四种需要处理的情况:
- 第一种情况是路由解析中存在错误。为了处理这种情况, 我们只是简单的向浏览器返回一个 500 内部服务器错误。
- 第二种情况是我们匹配的路由是一个重定向路由。这种情况下,我们需要创建一个服务端重定向信息 (302 重定向) 使浏览器跳转到新的地址 (这种情况在我们的应用中并不会真的发生,因为我们并没有在 React Router 配置中使用重定向路由, 但是我们要对这一情况做好准备以防升级应用).
- 第三种情况是,当我们匹配一个路由必须渲染相关组件。这种情况下,
renderProps
对象参数包含了我们需要渲染组件的数据。我们需要渲染的组件是RouterContext
(包含在 React Router 模块中),这就是使用renderProps
中的值渲染整个组件树的原因。 - 最后一种情况是,当路由不匹配的时候,我们只是简单的向浏览器返回一个 404 未找到的错误。
这是服务器端路由机制的核心,我们使用 ReactDOM.renderToString
函数渲染与当前路由匹配的组件的 HTML 代码。
最后,我们将产生的 HTML 代码注入到我们之前编写的 index.ejs
模板中,这样就可以得到发送到浏览器的 HTML 页面。
现在我们准备好运行 server.js
脚本,但是因为它使用 JSX 语法,所以我们不能简单的使用 node
编译器运行。我们需要使用 babel-node
以及如下的完整的命令 (从项目的根文件夹) :
NODE_ENV=production node_modules/.bin/babel-node --presets 'react,es2015' src/server.js
启动已完成的应用
现在你的应用已经可以在 http://localhost:3000 上运行,因为是教程,项目到此就算完成了。
再次任意地检查应用,并尝试所有的部分和链接。你会注意到这一次我们可以刷新每一页并且服务器能够识别当前路由并呈现正确的页面。
小建议: 不要忘了输入一个随意的不存在的 URL 来检查 404 页面!