back back-top comments magnifier menu mobile right smile views

React-Router 中文简明教程(下)

Jean
Jean

react-router-part3

更新于 2017-1-17

这篇为 React-Router 教程的最后一章,前两章为:
React-Router 中文简明教程(上)
React-Router 中文简明教程(中)

文章大纲

本篇示例源码:
react-router-demo-part3
( 打开源码边看教程 可以帮助你更好的理解 )

十. 使用 browserHistory 属性让 URL 更简洁

前面章节中,我们将Router组件的history属性设为hashHistoryhistory属性用于监听切换 URL,URL 地址 默认 被解析成一个hash值(即#后面的部分,比如http://localhost:8080/#/about?_k=hvsqla),所以你也可以省略不写history属性。

现代浏览器可以直接使用 JavaScript 操作 URL 而不发起 HTTP 请求,所以就不再需要依赖 hash 来实现路由,将history设为browserHistory可以直接显示路径(比如http://localhost:8080/about)。

修改index.js,导入browserHistory替代hashHistory

// index.js
// ...
// 导入 browserHistory 替代 hashHistory
import { Router, Route, browserHistory, IndexRoute } from 'react-router'

render((
    <Router history={browserHistory}>
    {/* ... */}
    </Router>
), document.getElementById('app'))

npm start启动服务器,打开http://localhost:8080点击导航链接 About 一切正常~ 浏览器 URL 地址变简洁了 显示为http://localhost:8080/about,但刷新浏览器后 你会看到页面显示“Cannot GET /about”这是个404错误,表示找不到网页!

出现这个问题的原因在于:无论你传递了什么 URL,服务器都需要传递给你的 app,因为你的应用直在操纵浏览器中的 URL,但是当前的服务器却不知道如何处理这些 URL。

如何解决这个问题?可以在 webpack-dev-server 中使用–history-api-fallback选项,打开package.json,在“start”字段后添加–history-api-fallback参数:

"start": "webpack-dev-server --inline --content-base . --history-api-fallback"

接着,将index.html中所有的相对路径改为绝对路径,比如:

<!-- index.html -->
<!-- index.css -> /index.css -->
<link rel="stylesheet" href="/index.css">

<!-- bundle.js -> /bundle.js -->
<script src="/bundle.js"></script>

重启服务器,npm start,打开http://localhost:8080/about,再次刷新也一切正常~

十一. 搭建生产环境的 server

之前我们使用的 webpack-dev-server 并不是用于真正生产环境的 server,本节我们将体验下如何搭建一个生产环境的 server,首先需要安装三个模块:express(基于 Node.js 的 web 应用开发框架)if-env(用于切换开发和生产环境运行 npm start)compression(服务端 gzip 压缩)

npm install express if-env compression --save

修改package.json,使用if-env“start”中进行判断,这样很方便,当我们运行npm start命令,如果检测到环境变量NODE_ENV值为production就执行npm run start:prod(生产环境),否则执行npm run start:dev(开发环境),具体如下:

"scripts": {
    "start": "if-env NODE_ENV=production && npm run start:prod || npm run start:dev",
    "start:dev": "webpack-dev-server --inline --content-base . --history-api-fallback",
    "start:prod": "webpack && node server.js"
},

打开 webpack.config.js,修改output选项:

// webpack.config.js
output: {
    path: 'public',
    filename: 'bundle.js',
    publicPath: '/'
},

现在我们需要用 Express 创建一个生产环境的 server,在根目录下 创建server.js

// server.js
var express = require('express')
var path = require('path')

var app = express()

// 通过 Express 托管静态资源,比如 index.css
// 访问静态资源文件时,express.static 中间件会根据目录查找所需的文件
app.use(express.static(__dirname))

// 设置路由规则,将所有的路由请求发送至 index.html
app.get('*', function (req, res) {
    res.sendFile(path.join(__dirname, 'index.html'))
})

// 启动服务器
var PORT = process.env.PORT || 8080
app.listen(PORT, function() {
    console.log('Production Express server running at localhost:' + PORT)
})

现在运行:

NODE_ENV=production npm start

恭喜!现在我们已经成功搭建了一个生产环境的 server,可以随意点击链接进行测试。
尝试打开http://localhost:8080/package.json,哎哟!页面显示了package.json的源码,这样的文件我们当然不希望被访问到,所以还需要配置下哪些目录能被访问:

1. 在根目录下创建public文件夹
2. 将index.htmlindex.css放进public

修改 server.js,将静态文件指向正确的目录:

// server.js
// ...
// 添加 path.join
app.use(express.static(path.join(__dirname, 'public')))

// ...
app.get('*', function (req, res) {
    // 在中间添加 'public' 路径
    res.sendFile(path.join(__dirname, 'public', 'index.html'))
})

还需要在webpack.config.js中修改输出选项的path‘public’

// webpack.config.js
// ...
output: {
    path: 'public',
    // ...
}

最后,在启动文件中添加–content-base参数:

"start:dev": "webpack-dev-server --inline --content-base public --history-api-fallback",

Okay,现在我们就不会再从根目录启动公共文件了,我们在webpack.config.js中添加一些用于压缩优化的代码:

// webpack.config.js

// 首先导入 webpack 模块
var webpack = require('webpack')

module.exports = {
    // ...

    // 判断如果环境变量值为生产环境 就使用以下插件:
    // `DedupePlugin` —— 打包的时候删除重复或者相似的文件
    // `OccurrenceOrderPlugin` —— 根据模块调用次数,给模块分配合适的ids,减少文件大小
    // `UglifyJsPlugin` —— 用于压缩js
    plugins: process.env.NODE_ENV === 'production' ? [
        new webpack.optimize.DedupePlugin(),
        new webpack.optimize.OccurrenceOrderPlugin(),
        new webpack.optimize.UglifyJsPlugin()
    ] : [],

    // ...
}

在 express 中开启 gzip 压缩,修改server.js

// server.js
// ...
var compression = require('compression')

var app = express()
// 必须写在最前面(放在 var app = express() 语句后面就行)
app.use(compression())

重启服务器,运行:

NODE_ENV=production npm start

现在你会发现 命令行中打印出 UglifyJS 日志,bundle.js也被压缩了。

十二. 表单处理

大多数导航使用Link组件用于用户点击跳转,但对于表单提交、点击按钮响应等情况,如何和 React-Router 结合呢?

我们在modules/Repos.js中构建一个简单的表单:

// modules/Repos.js
import React from 'react';
import NavLink from './NavLink';
import { browserHistory } from 'react-router';

export default class Repos extends React.Component {
    constructor(props) {
        super(props);
        this.handleSubmit = this.handleSubmit.bind(this);
    }

    handleSubmit(event) {
        event.preventDefault();
        const userName = event.target.elements[0].value;
        const repo = event.target.elements[1].value;
        const path = `/repos/${userName}/${repo}`;
        browserHistory.push(path);
    }

    render() {
        return (
            <div>
                <h2>Repos</h2>
                <ul>
                    <li><NavLink to="/repos/reactjs/react-router">React Router</NavLink></li>
                    <li><NavLink to="/repos/facebook/react">React</NavLink></li>

                    {/* 表单 */}
                    <li>
                        <form onSubmit={this.handleSubmit}>
                            <input type="text" placeholder="userName"/> / {' '}
                            <input type="text" placeholder="repo"/>{' '}
                            <button type="submit">Go</button>
                        </form>
                    </li>
                </ul>
                { this.props.children }
            </div>
        );
    }
}

这里有两种解决方法,第一种比第二种更简洁。

第一种方法 是使用browserHistory.push

// modules/Repos.js
// ...
import { browserHistory } from 'react-router';

export default class Repos extends React.Component {
    // ...
    handleSubmit(event) {
        // ...
        const path = `/repos/${userName}/${repo}`;
        browserHistory.push(path);
    }
    // ...
}

第二种方法 可以使用context对象:

// modules/Repos.js
// ...

export default class Repos extends React.Component {
    // ...
    handleSubmit(event) {
        // ...
        const path = `/repos/${userName}/${repo}`;
        this.context.router.push(path);
    }
    // ...
}

Repos.contextTypes = {
    router: React.PropTypes.object
};

打开http://localhost:8080/repos/,在表单中输入字段后 点击按钮 “Go” 进行测试,两种方法的结果是一样的:

react-router-part3-result1

本篇示例源码:
react-router-demo-part3

十三. 服务端渲染

好吧,首先你要明白服务器端渲染的核心 在 React 中是个比较容易理解的概念,就是利用renderToString返回组件渲染结果的 HTML 字符串,然后再将这个 HTML 字符串拼接到页面中 并在浏览器显示。

render(<App/>, domNode)
// 在服务端渲染
const markup = renderToString(<App/>)

这不是火箭科学,也并非微不足道。你要知道,当一个 React 项目变得复杂时,代码也随之膨胀,这会导致页面加载的速度变慢,尤其表现在流量珍贵的移动端。我们如何在享受 React 组件式开发便利的同时 提高页面加载性能呢?答案就是想方设法在服务端渲染 React 组件。

在你还没明白前,我会先抛出一堆 webpack 的”诡计”,然后我们再来聊 Router。

众所周知 node 是无法理解和直接运行 JSX 的,我们需要先编译它。像babel/register这样的编译器显然不适合直接用在服务端生产环境,那么就可以使用 webpack 在服务器端对 JSX 进行编译打包,就像在客户端所做的一样。

创建 新文件webpack.server.config.js,将下面的东西放进去:

var fs = require('fs');
var path = require('path');

module.exports = {

    entry: path.resolve(__dirname, 'server.js'),

    output: {
        filename: 'server.bundle.js'
    },

    target: 'node',

    // keep node_module paths out of the bundle
    externals: fs.readdirSync(path.resolve(__dirname, 'node_modules')).concat([
        'react-dom/server', 'react/addons',
    ]).reduce(function (ext, mod) {
        ext[mod] = 'commonjs ' + mod;
        return ext;
    }, {}),

    node: {
        __filename: true,
        __dirname: true
    },

    module: {
        loaders: [
            {
                test: /\.js$/, exclude: /node_modules/,
                loader: 'babel-loader?presets[]=es2015&presets[]=react'
            }
        ]
    }

}

这里不会细说上面这些代码具体做了什么,但你肯定能看出我们将通过 webpack 来运行server.js。在跑应用之前,我们需要在package.json“scripts”字段中添加一些内容来构建服务端打包命令:

"scripts": {
    "start": "if-env NODE_ENV=production && npm run start:prod || npm run start:dev",
    "start:dev": "webpack-dev-server --inline --content-base public/ --history-api-fallback",
    "start:prod": "npm run build && node server.bundle.js",
    "build:client": "webpack",
    "build:server": "webpack --config webpack.server.config.js",
    "build": "npm run build:client && npm run build:server"
},

现在,当我们运行NODE_ENV=production npm start,客户端和服务端会同时使用 webpack 进行打包。

Ok,下面可以来说说有关 Router 的内容了,我们需要将路由的内容单独提取为一个模块,这样方便客户端和服务端都能导入它。创建 新文件./modules/routes.js,将你的路由和组件内容都移进去:

// modules/routes.js
import React from 'react';
import { Route, IndexRoute } from 'react-router';
import App from './App';
import About from './About';
import Repos from './Repos';
import Repo from './Repo';
import Home from './Home';

module.exports = (
    <Route path="/" component={App}>
        <IndexRoute component={Home}/>
        <Route path="/repos" component={Repos}>
        <Route path="/repos/:userName/:repoName" component={Repo}/>
        </Route>
        <Route path="/about" component={About}/>
    </Route>
);

这样就可以直接在index.js中导入routes模块:

// index.js
import React from 'react';
import { render } from 'react-dom';
import { Router, browserHistory } from 'react-router';
// 导入 routes 模块,并放入 Router 组件中
import routes from './modules/routes';

render(
    <Router routes={routes} history={browserHistory}/>,
    document.getElementById('app')
);

打开server.js,从 react-router 中导入Router, browserHistory这两个模块帮助我们在服务端渲染。

如果我们试图在服务端像客户端一样render一个<Router/>,我们将会得到一个空白页,原因在于服务端渲染是同步的 路由匹配却是异步的,而客户端由于没等到异步操作完成就渲染了一次,服务端返回的数据就被丢弃了。

此外,大多数应用希望使用路由帮助加载数据,所以无关异步路由,你要想知道在实际渲染之前页面将渲染什么,你得在渲染前先加载路由异步操作完成后所返回的数据。

首先我们从 react-router 中导入matchRouterContext,然后匹配路由到 URL 并最终渲染。macth方法可以确保在路由异步操作完成后执行回调函数。
修改server.js

// ...
import React from 'react';
// 使用 `renderToString` 将组件渲染的结果转为 HTML 字符串
import { renderToString } from 'react-dom/server';
// `match` 可以确保在路由异步操作完成后执行回调函数
import { match, RouterContext } from 'react-router';
import routes from './modules/routes';

// ...

// 将所有请求发送给 index.html,这样 `browserHistory` 可以工作

app.get('*', (req, res) => {
    // 匹配路由到 URL
    match({ routes: routes, location: req.url }, (err, redirect, props) => {
        // `RouterContext` 为 `Router` 所 render 的内容,
        // 当 `Router` 监听 `browserHistory` 的变化时,将它的 `props` 保存在 state(状态)中
        // 但 app 在服务器端是无状态的,所以需要使用 `match` 在渲染前得到这些 `props`
        const appHtml = renderToString(<RouterContext {...props}/>);

        // 虽然还有其他方式能将 HTML 存储在模版里,但还没一种能和 React-Router 完美协作
        // 所以这里只使用了一个叫 `renderPage` 的函数
        res.send(renderPage(appHtml));
    })
})

function renderPage(appHtml) {
    // 将 HTML 放到 es6 模版字符串``中,${appHtml} 占位符将 `appHtml`的值插进来
    return `
        <!doctype html public="storage">
        <html>
        <meta charset="utf-8"/>
        <title>My First React Router App</title>
        <link rel="stylesheet" href="/index.css">
        <div id="app">${appHtml}</div>
        <script src="/bundle.js"></script>
        `
}

var PORT = process.env.PORT || 8080;
app.listen(PORT, function() {
    console.log('Production Express server running at localhost:' + PORT);
})

现在你可以运行NODE_ENV=production npm start并在浏览器访问应用,你可以看到页面内容并且服务器也将我们的应用发送到浏览器中,但当你点击界面上链接时,你会注意到客户端会响应但却并没向服务端请求用户界面,很酷是吧?!

原因很简单,之前match回调函数中的代码过于简单了 并没考虑到生产环境下的各种情况,应该像下面这样在代码中加一些判断:

app.get('*', (req, res) => {
    match({ routes: routes, location: req.url }, (err, redirect, props) => {
        if (err) {
            // 路由匹配过程中发生错误时,发送错误信息
            res.status(500).send(err.message)
        } else if (redirect) {
            // 我们还没说到路由钩子 `onEnter`,但在用户进入路由前可以进行跳转操作
            // 这里我们跳转到服务器进行处理
            res.redirect(redirect.pathname + redirect.search)
        } else if (props) {
            // 如果我们获取到 props 然后匹配到一条路由,说明可以进行 render 了
            const appHtml = renderToString(<RouterContext {...props}/>)
            res.send(renderPage(appHtml))
        } else {
            // 没有错误,也没有跳转,什么都匹配不到的情况下
            res.status(404).send('Not Found')
        }
    });
});

React 服务器端渲染目前还是比较新的技术,还没有最佳的实践,尤其在数据加载方面。本教程到此也结束了,希望这对你来说是个崭新的开始。

本篇源码:react-router-demo-part3

本文由 前端先生 原创,欢迎转载分享,但请注明出处。

0条评论

发表评论

电子邮件地址不会被公开。 必填项已用*标注

扫描二维码分享到微信