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

相關文章
相關標籤/搜索