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

Hey Guys, 以前写过一篇React + Koa 服务端渲染SSR的文章,都是大半年前的事了🤣,最近回顾了一下,发现有些以前主流的懒加载组件的库已通过时了,而后关于SSR彷佛以前的文章没有涉及到React-v16的功能,特别是v16新加的stream API,只是在上一篇文章的末尾提了一下,因此在这篇Part 2的版本中会添加这些新功能🍺javascript

Why use [Part II]?: Go to play The Last of Us and wait for The Last of Us Part II🚸html

🎉主要内容:

  • ✂️替换react-loadable,使用loadable-components
  • 📉使用 loadable-components 来实现浏览器端和服务端的异步组件功能
  • 🚰使用 react stream API 实现服务端渲染
  • 💾为服务端渲染的内容(html)添加缓存机制, 适用于同步和stream API

✂️ 替换 react-loadable

react-loadable已经很久没维护了,并且跟最新的webpack4+,还有babel7+都不兼容,还会有Deprecation Warning,若是你使用koa-web-kitv2.8及以前的版本的话,webpack build的时候会出现warning,并且可能还有一些潜在未知的坑在里面,因此咱们第一件要作的事就是把它替换成别的库,并且要跟最新的React.lazy|React Suspense这类API完美兼容,loadable-components是个官方推荐的库, 若是咱们既想在客户端懒加载组件,又想实现SSR的话(React.lazy暂不支持SSR).java

首先咱们安装须要的库:node

# For `dependencies`:
npm i @loadable/component @loadable/server
# For `devDependencies`:
npm i -D @loadable/babel-plugin @loadable/webpack-plugin
复制代码

而后你能够在对应的webpack配置文件及babel配置文件里把react-loadable/webpackreact-loadable/babel移除掉,替换成@loadable/webpack-plugin@loadable/babel-plugin。 而后下一步咱们须要对咱们的懒加载的组件作一些修改。react

📉使用 loadable-components 来实现浏览器端和服务端的异步组件功能

在一个须要懒加载 React 组件的地方:webpack

// import Loadable from 'react-loadable';
import loadable from '@loadable/component';

const Loading = <h3>Loading...</h3>;
const HelloAsyncLoadable = loadable(
  () => import('components/Hello'),
  { fallback: Loading, }
);
//简单使用
export default MyComponent() {
  return (
    <div> <HelloAsyncLoadable /> </div>
  )
}
//配合 react-router 使用
export default MyComponent() {
  return (
    <Router>
      <Route path="/hello" render={props => <HelloAsyncLoadable {...props}/>}/>
    </Router> 
  )
}
复制代码

其实跟以前react-loadable的使用方式差很少,传一个callback进去,返回动态import,也能够选择性的传入loading时须要显示的组件。git

而后咱们须要在入口文件中hydrate服务端渲染出来的内容,在src/index.js:github

import React from 'react';
import ReactDOM from 'react-dom';
import { loadableReady } from '@loadable/component';
import App from './App';

loadableReady(() => {
  ReactDOM.hydrate(
    <App />, document.getElementById('app') ); }); 复制代码

OK, 上面这个基本就是客户端须要作的修改,下一步咱们须要对服务端的代码作修改,来使得loadable-components能完美的运行在SSR的环境中。web

在以前使用react-loadable的时候,咱们须要在服务端调用Loadable.preloadAll()来预先加载全部异步的组件,由于在服务端不必实时异步加载组件,初始化的时候就能够所有加载进来,可是在使用loadable-components的时候已经不须要了,因此直接删掉这个方法的调用。而后在咱们的服务端的webpack入口文件中:数据库

import path from 'path';
import { StaticRouter } from 'react-router-dom';
import ReactDOMServer from 'react-dom/server';
import { ChunkExtractor } from '@loadable/server';
import AppRoutes from 'src/AppRoutes';
//...可能还一下其余的库

function render(url, initialData = {}) {
  const extractor = new ChunkExtractor({ statsFile: path.resolve('../dist/loadable-stats.json') });
  const jsx = extractor.collectChunks(
    <StaticRouter location={url}>
      <AppRoutes initialData={data} />
    </StaticRouter>
  );
  const html = ReactDOMServer.renderToString(jsx);
  const renderedScriptTags = extractor.getScriptTags();
  const renderedLinkTags = extractor.getLinkTags();
  const renderedStyleTags = extractor.getStyleTags();
  return `
      <!DOCTYPE html>
        <html lang="en">
        <head>
          <meta charset="UTF-8">
          <title>React App</title>
          ${renderedLinkTags}
          ${renderedStyleTags}
        </head>
        <body>
          <div id="app">${html}</div>
          <script type="text/javascript">window.__INITIAL_DATA__ = ${JSON.stringify(
            initialData
          )}</script>
          ${renderedScriptTags}
        </body>
      </html>
    `;
}
复制代码

其实就是renderToString附近那块作一些修改,根据新的库换了一些写法,对于同步渲染基本上就OK了😀。

🚰 服务端渲染使用 React Stream API

React v16+中,React团队添加了一个Stream APIrenderToNodeStream来提高渲染大型React App的性能,因为JS的单线程特色,频繁同步的调用renderToString会柱塞event loop,使得其余的http请求/任务会等待很长时间,很影响性能,因此接下来咱们使用流API来提高渲染的性能。

以一个koa route做为例子:

router.get('/index', async ctx => {
  //防止koa自动处理response, 咱们要直接把react stream pipe到ctx.res
  ctx.respond = false;
  //见下面render方法
  const {htmlStream, extractor} = render(ctx.url);
  const before = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> ${extractor.getStyleTags()} </head> <body><div id="app">`;
  //先往res里html 头部信息,包括div容器的一半 
  ctx.res.write(before);
  //把react放回的stream pipe进res, 而且传入`end:false`关闭流的自动关闭,由于咱们还有下面一半的html没有写进去
  htmlStream.pipe(
    ctx.res,
    { end: false }
  );
  //监听react stream的结束,而后把后面剩下的html写进html document
  htmlStream.on('end', () => {
    const after = `</div> <script type="text/javascript">window.__INITIAL_DATA__ = ${JSON.stringify( extra.initialData || {} )}</script> ${extractor.getScriptTags()} </body> </html>`;
    ctx.res.write(after);
    //所有写完后,结束掉http response
    ctx.res.end();
  });
});
function render(url){
  //...
  //替换renderToString 为 renderToNodeStream,返回一个ReadableStream,其余都差很少
  const htmlStream = ReactDOMServer.renderToNodeStream(jsx);
  return {
    htmlStream,
    extractor,
  }
  //...
}
复制代码

上面的代码加了注释说明每一行的功能,主要分为3个部分,咱们先向response写入head相关的html, 而后把react返回的readableStream pipe到response, 监听react stream的结束,而后写入剩下通常的html, 而后手动调用res.end()结束repsonse stream,由于咱们上面关闭了response stream 的自动关闭,因此这里要手动end掉,否则浏览器会一直处于pending状态。

使用Stream API OK后,咱们还有一个在生产环境中常见的问题:对于每个进来的请求,特别是一些静态页面,咱们其实不必都从新渲染一次App, 这样的话对于同步渲染和stream渲染都会或多或少产生影响,特别是当App很大的时候,因此为了解决这样的问题,咱们须要在这中间加一层缓存,咱们能够存到内存,文件,或者数据库,取决于你项目的实际状况。

💾为服务端渲染添加缓存机制, 适用于同步和stream API

若是咱们使用renderToString的话其实很简单,只须要拿到html后根据key(url或者其余的)存到某个地方就好了,可是对于Stream 渲染的话可能会有些tricky。由于咱们把react的stream直接pipe到response了,这里咱们使用了2种stream类型,ReadableStream(ReactDom.renderToNodeStream)和WritableStream(ctx.res),但其实node里还有其余的stream类型,其中的TransformStream类型就能够帮咱们解决上面stream的问题,咱们能够在把react的readableStream pipe到TransformStream,而后这个TransformStream再pipe到res, 在transform的过程当中(其实这里咱们没有修改任何数据,只是为了拿到全部的html),咱们就能够拿到全部react渲染出来的内容了,而后在transform结束时把全部拿到的chunk组合起来就是完整的html, 再像同步渲染的方式同样缓存起来就搞定了🦄

OK,不扯淡了, 直接上代码:

const { Transform } = require('stream');
//这里简单用Map做为缓存的地方
const cache = new Map();
//临时的数组用来把react stream每次拿到的数据块存起来
const bufferedChunks = [];
//建立一个transform Stream来获取全部的chunk
const cacheStream = new Transform({
  //每次从react stream拿到数据后,会调用此方法,存到bufferedChunks里面,而后原封不动的扔给res
  transform(data, enc, cb) {
    bufferedChunks.push(data);
    cb(null, data);
  },

  //等所有结束后会调用flush
  flush(cb) {
    //把bufferedChunks组合起来,转成html字符串,set到cache中
    cache.set(key, Buffer.concat(bufferedChunks).toString() );
    cb();
  },
});
复制代码

能够把上面的代码封装成一个方法,以便每次请求进来方便调用,而后咱们在使用的时候:

//假设上面的代码已经封装到createCacheStream方法里了,key能够为当前的url,或者其余的
const cacheStream = createCacheStream(key);
//cacheStream如今会pipe到res
cacheStream.pipe(
  res,
  { end: false }
);
//这里只显示部分html
const before = ` <!DOCTYPE html> <html lang="en"> <head>...`;
//如今是往cacheStream里直接写html
cacheStream.write(before);
// res.write(before);
//react stream pipe到cacheStream
htmlStream.pipe(
  cacheStream,
  { end: false }
);
//同上监听react渲染结束
htmlStream.on('end', () => {
  const after = `</div> <script type="text/javascript">window.__INITIAL_DATA__ = ${JSON.stringify( {} )}</script> ${extractor.getScriptTags()} </body> </html>`;
  cacheStream.write(after);
  console.log('streaming rest html content done!');
  //结束http response
  res.end();
  //结束cacheStream
  cacheStream.end();
});
复制代码

上面咱们把htmlStream 经过管道扔给cacheStream,来让cacheStream能够获取react渲染出来的html,而且缓存起来,而后下次同一个url请求过来时,咱们能够经过key检查一下(如: cache.has(key))当前url是否已经有渲染过的html了,有的话直接扔给浏览器而不须要再从新渲染一遍。

好了,上面就是此次SSR更新的主要内容了。

💖想尝试完整demo的话能够关顾一下 koa-web-kit, 而后体验SSR给你带来的效果吧😀

结论

Part II的主要内容就是上面这些,咱们主要替换了再也不维护的react-loadable,而后使用stream API来提高大型React App的渲染性能,再经过加上cache层进一步提高响应速度🎉。上面可能有些stream相关的API须要不熟悉的同窗先去了解一下node stream的相关内容,想要查看一下SSR的基础配置的话也能够回顾第一部分的内容。

🙌Stay tuned for Part III🙌

English Version: React Server Side Rendering with Koa Part II

相关文章
相关标签/搜索