原文: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.jsserver.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, reactreact-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, namecountry ,另外一个对象数组代表运动员获得的奖牌。

你可以在仓库中下载 所有的图片文件 ,复制到: src/static/img/

React 组件

我们将把应用的视图分成若干个组件:

  • 用于创建视图的一些小的 UI 组件: AthletePreview, Flag, MedalAthletesMenu
  • 一个 Layout 组件,作为主组件用来定义应用的通用样式(header, content 和 footer)
  • 代表主要部分的两个主组件: IndexPageAthletePage
  • 用作 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, codeshowName 。第一个是强制性的, 必须传递给组件以显示对应的国旗。 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 的 reactes2015 设置将所有引入的 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 页面!

构建通用的 React 和 Node 应用的更多相关文章

  1. 实践案例丨教你一键构建部署发布前端和Node.js服务

    如何使用华为云服务一键构建部署发布前端和Node.js服务 构建部署,一直是一个很繁琐的过程 作为开发,最害怕遇到版本发布,特别是前.后端一起上线发布,项目又特别多的时候. 例如你有10个项目,前后端 ...

  2. Java学习笔记之使用反射+泛型构建通用DAO

    PS:最近简单的学了学后台Servlet+JSP.也就只能学到这里了.没那么多精力去学SSH了,毕竟Android还有很多东西都没学完.. 学习内容: 1.如何使用反射+泛型构建通用DAO. 1.使用 ...

  3. 使用React、Node.js、MongoDB、Socket.IO开发一个角色投票应用的学习过程(三)

    这几篇都是我原来首发在 segmentfault 上的地址:https://segmentfault.com/a/1190000005040834 突然想起来我这个博客冷落了好多年了,也该更新一下,呵 ...

  4. Webpact打包React后端Node+Express

    Webpact打包React后端Node+Express 前言 React官方推荐用Browserify或者Webpack 来开发React组件. Webpack 是什么?是德国开发者 Tobias ...

  5. 手把手教你webpack、react和node.js环境配置(上篇)

    很多人刚学习react的时候,往往因为繁琐的配置而头疼,这里我将手把手教大家怎么用webpack配置react和redux的环境,这篇教程包括前端react和后台node整个网站的环境配置,对node ...

  6. 手把手教你webpack、react和node.js环境配置(下篇)

    上篇我介绍了前端下webpack和react.redux等环境的配置,这篇将继续重点介绍后台node.js的配置. 这里是上篇链接:手把手教你webpack.react和node.js环境配置(上篇) ...

  7. Spring Boot-------JPA——EntityManager构建通用DAO

    EntityManager EntityManager 是用来对实体Bean 进行操作的辅助类.他可以用来产生/删除持久化的实体Bean,通过主键查找实体bean,也可以通过EJB3 QL 语言查找满 ...

  8. 如何使用TDD和React Testing Library构建健壮的React应用程序

    如何使用TDD和React Testing Library构建健壮的React应用程序 当我开始学习React时,我努力的一件事就是以一种既有用又直观的方式来测试我的web应用程序. 每次我想测试它时 ...

  9. 关于在react和node中,经常出现的const

    const是定义一个常量,在ECM6当中,定义局部变量可以用let.定义全局变量用var......这是ECM6的新特性,好吧,包子在这里只是记录一下,希望大家在将来写react或者node的时候,不 ...

随机推荐

  1. ASP.NET Core 中间件之压缩、缓存

    前言 今天给大家介绍一下在 ASP.NET Core 日常开发中用的比较多的两个中间件,它们都是出自于微软的 ASP.NET 团队,他们分别是 Microsoft.AspNetCore.Respons ...

  2. .NET Core 首例 Office 开源跨平台组件(NPOI Core)

    前言 最近项目中,需要使用到 Excel 导出,找了一圈发现没有适用于 .NET Core的,不依赖Office和操作系统限制的 Office 组件,于是萌生了把 NPOI 适配并移植到 .NET C ...

  3. java中文乱码解决之道(一)-----认识字符集

    沉寂了许久(大概有三个多月了吧),LZ"按捺不住"开始写博了! java编码中的中文问题是一个老生常谈的问题了,每次遇到中文乱码LZ要么是按照以前的经验修改,要么则是baidu.c ...

  4. 拨开迷雾,找回自我:DDD 应对具体业务场景,Domain Model 到底如何设计?

    写在前面 除了博文内容之外,和 netfocus 兄的讨论,也可以让你学到很多(至少我是这样),不要错过哦. 阅读目录: 迷雾森林 找回自我 开源地址 后记 毫无疑问,领域驱动设计的核心是领域模型,领 ...

  5. 2017-1-5 天气雨 React 学习笔记

    官方example 中basic-click-counter <script type="text/babel"> var Counter = React.create ...

  6. 如何正确使用日志Log

    title: 如何正确使用日志Log date: 2015-01-08 12:54:46 categories: [Python] tags: [Python,log] --- 文章首发地址:http ...

  7. [C#][算法] 用菜鸟的思维学习算法 -- 马桶排序、冒泡排序和快速排序

    用菜鸟的思维学习算法 -- 马桶排序.冒泡排序和快速排序 [博主]反骨仔 [来源]http://www.cnblogs.com/liqingwen/p/4994261.html  目录 马桶排序(令人 ...

  8. 小兔JS教程(四)-- 彻底攻略JS数组

    在开始本章之前,先给出上一节的答案,参考答案地址: http://www.xiaotublog.com/demo.html?path=homework/03/index2 1.JS数组的三大特性 在J ...

  9. dagger2系列之依赖方式dependencies、包含方式(从属方式)SubComponent

    本篇是实战文章,从代码的角度分析这两种方式.本文参考自下列文章: http://www.jianshu.com/p/1d42d2e6f4a5 http://www.jianshu.com/p/94d4 ...

  10. ,net core mvc 文件上传

    工作用到文件上传的功能,在这个分享下 ~~ Controller: public class PictureController : Controller { private IHostingEnvi ...