React 是目前前端社区最流行的 UI 库之一, 它的基于组件化的开发方式极大地提升了前端开发体验, React 通过拆分一个大的应用至一个个小的组件, 来使得我们的代码更加的可被重用, 以及获得更好的可维护性, 等等还有其他很多的优点...
通过 React, 我们通常会开发一个单页应用(SPA), 单页应用在浏览器端会比传统的网页有更好的用户体验, 浏览器一般会拿到一个 body 为空的 html, 然后加载 script 指定的 js, 当所有 js 加载完毕后, 开始执行 js, 最后再渲染到 dom 中, 在这个过程中, 一般用户只能等待, 什么都做不了, 如果用户在一个高速的网络中, 高配置的设备中, 以上先要加载所有的 js 然后再执行的过程可能不是什么大问题, 但是有很多情况是我们的网速一般, 设备也可能不是最好的, 在这种情况下的单页应用可能对用户来说是个很差的用户体验, 用户可能还没体验到浏览器端 SPA 的好处时, 就已经离开网站了, 这样的话你的网站做的再好也不会有太多的浏览量.
但是我们总不能回到以前的一个页面一个页面的传统开发吧, 现代化的 UI 库都提供了服务端渲染 (SSR) 的功能, 使得我们开发的 SPA 应用也能完美的运行在服务端, 大大加快了首屏渲染的时间, 这样的话用户既能更快的看到网页的内容, 与此同时, 浏览器同时加载需要的 js, 加载完后把所有的 dom 事件, 及各种交互添加到页面中, 最后还是以一个 SPA 的形式运行, 这样的话我们既提升了首屏渲染的时间, 又能获得 SPA 的客户端用户体验, 对于 SEO 也是个必须的功能.
OK, 我们大致了解了 SSR 的必要性, 下面我们就可以在一个 React App 中来实现服务端渲染的功能, BTW, 既然我们已经处在一个到处是 async/await 的环境中, 这里的服务端我们使用 koa2 https://koajs.com/ 来实现我们的服务端渲染.
初始化一个普通的单页应用 SPA
首先我们先不管服务端渲染的东西, 我们先创建一个基于 React 和 React-Router 的 SPA, 等我们把一个完整的 SPA 创建好后, 再加入 SSR 的功能来最大化提升 app 的性能.
首先进入 app 入口 App.js:
- import ReactDOM from 'react-dom';
- import { BrowserRouter as Router, Route } from 'react-router-dom';
- const Home = () => <div>Home</div>;
- const Hello = () => <div>Hello</div>;
- const App = () => {
- return (
- <Router>
- <Route exact path="/" component={Home} />
- <Route exact path="/hello" component={Hello} />
- </Router>
- )
- }
- ReactDOM.render(<App/>, document.getElementById('app'))
上面我们为路由 / 和 /hello 创建了 2 个只是渲染一些文字到页面的组件. 但当我们的项目变得越来越大, 组件越来越多, 最终我们打包出来的 js 可能会变得很大, 甚至变得不可控, 所以呢我们第一步需要优化的是代码拆分(code-splitting), 幸运的是通过 webpack dynamic import https://webpack.js.org/guides/code-splitting/#dynamic-imports 和 https://github.com/jamiebuilds/react-loadable , 我们可以很容易做到这一点.
用 React-Loadable 来时间代码拆分
使用之前, 先安装 react-loadable:
- npm install react-loadable
- # or
- yarn add react-loadable
然后在你的 javascript 中:
- //...
- import Loadable from 'react-loadable';
- //...
- const AsyncHello = Loadable({
- loading: <div>loading...</div>,
- // 把你的 Hello 组件写到单独的文件中
- // 然后使用 webpack 的 dynamic import
- loader: () => import('./Hello'),
- })
- // 然后在你的路由中使用 loadable 包装过的组件:
- <Route exact path="/hello" component={AsyncHello} />
很简单吧, 我们只需要 importreact-loadable, 然后传一些 option 进去就行了, 其中的 loading 选项是当动态加载 Hello 组件所需的 js 时, 渲染 loading 组件, 给用户一种加载中的感觉, 体验也会比什么都不加好.
好了, 现在如果我们访问首页的话, 只有 Home 组件依赖的 js 才会被加载, 然后点击某个链接进入 hello 页面的话, 会先渲染 loading 组件, 并同时异步加载 hello 组件依赖的 js, 加载完后, 替换掉 loading 来渲染 hello 组件. 通过基于路由拆分代码到不同的代码块, 我们的 SPA 已经有了很大的优化, cheers. 更叼的是 react-loadable 同样支持 SSR, 所以你可以在任意地方使用 react-loadable, 不管是运行在前端还是服务端, 要让 react-loadable 在服务端正常运行的话我们需要做一些额外的配置, 本文后面会讲到, 先不急.
到这里我们已经创建好一个基本的 React SPA, 加上代码拆分, 我们的 app 已经有了不错的性能, 但是我们还可以更加极致的优化 app 的性能, 下面我们通过增加 SSR 的功能来进一步提升加载速度, 顺便解决一下 SPA 中的 SEO 问题.
加入服务端渲染 (SSR) 功能
首先我们先搭建一个最简单的 koa web 服务器:
npm install koa koa-router
然后在 koa 的入口文件 app.js 中:
- const Koa = require('koa');
- const Router = require('koa-router');
- const app = new Koa();
- const router = new Router();
- router.get('*', async (ctx) => {
- ctx.body = `
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <title>React SSR</title>
- </head>
- <body>
- <div id="app"></div>
- <script type="text/javascript" src="/bundle.js"></script>
- </body>
- </html>
- `;
- });
- app.use(router.routes());
- app.listen(3000, '0.0.0.0');
上面 * 路由代表任意的 url 进来我们都默认渲染这个 html, 包括 html 中打包出来的 js, 你也可以用一些服务端模板引擎 (如: https://github.com/mozilla/nunjucks ) 来直接渲染 html 文件, 在 webpack 打包时通过 html-webpack-plugin 来自动插入打包出来的 js/CSS 资源路径.
OK, 我们的简易 koa server 好了, 接下来我们开始编写 React SSR 的入口文件 AppSSR.js, 这里我们需要使用 StaticRouter 来代替之前的 BrowserRouter, 因为在服务端, 路由是静态的, 用 BrowserRouter 的话是不起作用的, 后面还会做一些配置来使得 react-loadable 运行在服务端.
提示: 你可以把整个 node 端的代码用 ES6/JSX 风格编写, 而不是部分 commonjs, 部分 JSX, 但这样的话你需要用 webpack 把整个服务端的代码编译成 commonjs 风格, 才能使得它运行在 node 环境中, 这里的话我们把 React SSR 的代码单独抽出去, 然后在普通的 node 代码里去 require 它. 因为可能在一个现有的项目中, 之前都是 commonjs 的风格, 把以前的 node 代码一次性转成 ES6 的话成本有点大, 但是可以后期一步步的再迁移过去
OK, 现在在你的 AppSRR.js 中:
- import React from 'react';
- // 使用静态 static router
- import { StaticRouter } from 'react-router-dom';
- import ReactDOMServer from 'react-dom/server';
- import Loadable from 'react-loadable';
- // 下面这个是需要让 react-loadable 在服务端可运行需要的, 下面会讲到
- import { getBundles } from 'react-loadable/webpack';
- import stats from '../build/react-loadable.json';
- // 这里吧 react-router 的路由设置抽出去, 使得在浏览器跟服务端可以共用
- // 下面也会讲到...
- import AppRoutes from 'src/AppRoutes';
- // 这里我们创建一个简单的 class, 暴露一些方法出去, 然后在 koa 路由里去调用来实现服务端渲染
- class SSR {
- //koa 路由里会调用这个方法
- render(url, data) {
- let modules = [];
- const context = {};
- const html = ReactDOMServer.renderToString(
- <Loadable.Capture report={moduleName => modules.push(moduleName)}>
- <StaticRouter location={url} context={context}>
- <AppRoutes initialData={data} />
- </StaticRouter>
- </Loadable.Capture>
- );
- // 获取服务端已经渲染好的组件数组
- let bundles = getBundles(stats, modules);
- return {
- html,
- scripts: this.generateBundleScripts(bundles),
- };
- }
- // 把 SSR 过的组件都转成 script 标签扔到 html 里
- generateBundleScripts(bundles) {
- return bundles.filter(bundle => bundle.file.endsWith('.js')).map(bundle => {
- return `<script type="text/javascript" src="${bundle.file}"></script>\n`;
- });
- }
- static preloadAll() {
- return Loadable.preloadAll();
- }
- }
- export default SSR;
当编译这个文件的时候, 在 webpack 配置里使用 target: "node" 和 externals, 并且在你的打包前端 app 的 webpack 配置中, 需要加入 react-loadable 的插件, app 的打包需要在 ssr 打包之前运行, 不然拿不到 react-loadable 需要的各组件信息, 先来看 app 的打包:
- //webpack.config.dev.js, app bundle
- const ReactLoadablePlugin = require('react-loadable/webpack')
- .ReactLoadablePlugin;
- module.exports = {
- //...
- plugins: [
- //...
- new ReactLoadablePlugin({ filename: './build/react-loadable.json', }),
- ]
- }
在. babelrc 中加入 loadable plugin:
- {
- "plugins": [
- "syntax-dynamic-import",
- "react-loadable/babel",
- ["import-inspector", {
- "serverSideRequirePath": true
- }]
- ]
- }
上面的配置会让 react-loadable 知道哪些组件最终在服务端被渲染了, 然后直接插入到 html script 标签中, 并在前端初始化时把 SSR 过的组件考虑在内, 避免重复加载, 下面是 SSR 的打包:
- //webpack.ssr.js
- const nodeExternals = require('webpack-node-externals');
- module.exports = {
- //...
- target: 'node',
- output: {
- path: 'build/node',
- filename: 'ssr.js',
- libraryExport: 'default',
- libraryTarget: 'commonjs2',
- },
- // 避免把 node_modules 里的库都打包进去, 此 ssr js 会直接运行在 node 端,
- // 所以不需要打包进最终的文件中, 运行时会自动从 node_modules 里加载
- externals: [nodeExternals()],
- //...
- }
然后在 koa app.js, require 它, 并且调用 SSR 的方法:
- //...koa app.js
- //build 出来的 ssr.js
- const SSR = require('./build/node/ssr');
- //preload all components on server side, 服务端没有动态加载各个组件, 提前先加载好
- SSR.preloadAll();
- // 实例化一个 SSR 对象
- const s = new SSR();
- router.get('*', async (ctx) => {
- // 根据路由, 渲染不同的页面组件
- const rendered = s.render(ctx.url);
- const html = `
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- </head>
- <body>
- <div id="app">${rendered.html}</div>
- <script type="text/javascript" src="/runtime.js"></script>
- ${rendered.scripts.join()}
- <script type="text/javascript" src="/app.js"></script>
- </body>
- </html>
- `;
- ctx.body = html;
- });
- //...
以上是个简单的实现 React SSR 到 koa web server, 为了使 react-loadable 知道哪些组件在服务端渲染了, rendered 里面的 scripts 数组里面包含了 SSR 过的组件组成的各个 script 标签, 里面调用了
SSR#generateBundleScripts()
方法, 在插入时需要确保这些 script 标签在 runtime.js 之后((通过 CommonsChunkPlugin 来抽出来)), 并且在 app bundle 之前(也就是初始化的时候应该已经知道之前的哪些组件已经渲染过了). 更多 react-loadable 服务端支持, 参考这里.
上面我们还把 react-router 的路由都单独抽出去了, 使得它可以运行在浏览器跟服务端, 以下是 AppRoutes 组件:
- //AppRoutes.js
- import Loadable from 'react-loadable';
- //...
- const AsyncHello = Loadable({
- loading: <div>loading...</div>,
- loader: () => import('./Hello'),
- })
- function AppRoutes(props) {
- <Switch>
- <Route exact path="/hello" component={AsyncHello} />
- <Route path="/" component={Home} />
- </Switch>
- }
- export default AppRoutes
- // 然后在 App.js 入口中
- import AppRoutes from './AppRoutes';
- // ...
- export default () => {
- return (
- <Router>
- <AppRoutes/>
- </Router>
- )
- }
服务端渲染的初始状态
目前为止, 我们已经创建了一个 React SPA, 并且能在浏览器端跟服务端共同运行, 社区称之为 universal app 或者 isomophic app. 但是我们现在的 app 还有一个遗留问题, 一般来说我们 app 的数据或者状态都需要通过远端的 api 来异步获取, 拿到数据后我们才能开始渲染组件, 服务端 SSR 也是一样, 我们要动态的获取初始数据, 然后才能扔给 React 去做 SSR, 然后在浏览器端我们还要初始化就能同步获取这些 SSR 时的初始化数据, 避免浏览器端初始化时又重新获取了一遍.
下面我们简单从 github 获取一些项目的信息作为页面初始化的数据, 在 koa 的 app.js 中:
- //...
- const fetch = require('isomorphic-fetch');
- router.get('*', async (ctx) => {
- //fetch branch info from github
- const api = 'https://api.github.com/repos/jasonboy/wechat-jssdk/branches';
- const data = await fetch(api).then(res => res.json());
- // 传入初始化数据
- const rendered = s.render(ctx.url, data);
- const html = `
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- </head>
- <body>
- <div id="app">${rendered.html}</div>
- <script type="text/javascript">window.__INITIAL_DATA__ = ${JSON.stringify(data)}</script>
- <script type="text/javascript" src="/runtime.js"></script>
- ${rendered.scripts.join()}
- <script type="text/javascript" src="/app.js"></script>
- </body>
- </html>
- `;
- ctx.body = html;
- });
然后在你的 Hello 组件中, 你需要 checkwindow 里面 (或者在 App 入口中统一判断, 然后通过 props 传到子组件中) 是否存在
window.__INITIAL_DATA__,
有的话直接用来当做初始数据, 没有的话我们在 componentDidMount 生命周期函数中再去来数据:
- export default class Hello extends React.Component {
- constructor(props) {
- super(props);
- this.state = {
- // 这里直接判断 window, 如果是父组件传入的话, 通过 props 判断
- github: window.__INITIAL_DATA__ || [],
- };
- }
- componentDidMount() {
- // 判断没有数据的话, 再去请求数据
- // 请求数据的方法也可以抽出去, 以让浏览器及服务端能统一调用, 避免重复写
- if (this.state.github.length <= 0) {
- fetch('https://api.github.com/repos/jasonboy/wechat-jssdk/branches')
- .then(res => res.json())
- .then(data => {
- this.setState({ github: data });
- });
- }
- }
- render() {
- return (
- <div>
- <ul>
- {this.state.github.map(b => {
- return <li key={b.name}>{b.name}</li>;
- })}
- </ul>
- </div>
- );
- }
- }
好了, 现在如果页面被服务端渲染过的话, 浏览器会拿到所有渲染过的 html, 包括初始化数据, 然后通过这些 SSR 的内容配合加载的 js, 再组成一个完整的 SPA, 就像一个普通的 SPA 一样, 但是我们得到了更好的性能, 更好的 SEO.
React-v16 更新
在 React 的最新版 v16 中, SSR 的 API 做了很多的优化, 并且提供了新的基于流的 API 来更好的提升性能, 通过 streaming api, 服务端可以边渲染边把前面渲染好的 html 发到浏览器, 浏览器端也可以提前开始渲染页面而不是等服务端所有组件都渲染完成后才能开始浏览器端的初始化, 提升了性能也降低了服务端资源的消耗. 还有一个在浏览器端需要注意的是需要使用 ReactDOM.hydrate()来代替之前的
ReactDOM.render(),
更多的更新参考 medium 文章 https://medium.com/m/global-identity?redirectUrl=https://hackernoon.com/whats-new-with-server-side-rendering-in-react-16-9b0d78585d67
要查看完整的 demo, 参考 https://github.com/JasonBoy/koa-web-kit , koa-web-kit 是一个现代化的基于 React/Koa 的全栈开发框架, 包括 React SSR 支持, 可以直接用来测试服务端渲染的功能
结论
好了, 以上就是 React-SSR + Koa 的简单实践, 通过 SSR, 我们既提升了性能, 又很好的满足了 SEO 的要求, Best of the Both Worlds.
English Version https://blog.lovemily.me/react-server-side-rendering-with-koa/
来源: http://www.jb51.net/article/140666.htm