React + Koa 实现服务端渲染(SSR)

⚛️React是目前前端社区最流行的UI库之一,它的基于组件化的开发方式极大地提高了前端开发体验,React经过拆分一个大的应用至一个个小的组件,来使得咱们的代码更加的可被重用,以及得到更好的可维护性,等等还有其余不少的优势...javascript

Part II 版本 传送门css

经过React, 咱们一般会开发一个单页应用(SPA),单页应用在浏览器端会比传统的网页有更好的用户体验,浏览器通常会拿到一个body为空的html,而后加载script指定的js, 当全部js加载完毕后,开始执行js, 最后再渲染到dom中, 在这个过程当中,通常用户只能等待,什么都作不了,若是用户在一个高速的网络中,高配置的设备中,以上先要加载全部的js而后再执行的过程可能不是什么大问题,可是有不少状况是咱们的网速通常,设备也可能不是最好的,在这种状况下的单页应用可能对用户来讲是个不好的用户体验,用户可能还没体验到浏览器端SPA的好处时,就已经离开网站了,这样的话你的网站作的再好也不会有太多的浏览量。html

可是咱们总不能回到之前的一个页面一个页面的传统开发吧,现代化的UI库都提供了服务端渲染(SSR)的功能,使得咱们开发的SPA应用也能完美的运行在服务端,大大加快了首屏渲染的时间,这样的话用户既能更快的看到网页的内容,与此同时,浏览器同时加载须要的js,加载完后把全部的dom事件,及各类交互添加到页面中,最后仍是以一个SPA的形式运行,这样的话咱们既提高了首屏渲染的时间,又能得到SPA的客户端用户体验,对于SEO也是个必须的功能😀。前端

OK,咱们大体了解了SSR的必要性,下面咱们就能够在一个React App中来实现服务端渲染的功能,BTW, 既然咱们已经处在一个处处是async/await的环境中,这里的服务端咱们使用koa2来实现咱们的服务端渲染。java

初始化一个普通的单页应用SPA

首先咱们先无论服务端渲染的东西,咱们先建立一个基于React和React-Router的SPA,等咱们把一个完整的SPA建立好后,再加入SSR的功能来最大化提高app的性能。node

首先进入app入口 App.js:react

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 importreact-loadable,咱们能够很容易作到这一点。webpack

用React-Loadable来时间代码拆分

使用以前,先安装 react-loadable:git

npm install react-loadable
# or
yarn add react-loadable
复制代码

而后在你的 javascript中:github

//...
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} />
复制代码

很简单吧,咱们只须要import react-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,你也能够用一些服务端模板引擎(如: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, 如今在你的 AppSSR.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文章whats-new-with-server-side-rendering-in-react-16.

💖要查看完整的demo, 参考 koa-web-kit, koa-web-kit是一个现代化的基于React/Koa的全栈开发框架,包括React SSR支持,能够直接用来测试服务端渲染的功能😀

结论

好了,以上就是React-SSR + Koa的简单实践,经过SSR,咱们既提高了性能,又很好的知足了SEO的要求,Best of the Both Worlds🍺。

PPT in Browser

English Version

相关文章
相关标签/搜索