React + Koa 實現服務端渲染(SSR) Part II

jasonboy7發表於2019-02-25

Hey Guys, 之前寫過一篇React + Koa 服務端渲染SSR的文章,都是大半年前的事了?,最近回顧了一下,發現有些之前主流的懶載入元件的庫已經過時了,然後關於SSR似乎之前的文章沒有涉及到React-v16的功能,特別是v16新加的stream API,只是在上一篇文章的末尾提了一下,所以在這篇Part 2的版本中會新增這些新功能?

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

?主要內容:

  • ✂️替換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).

首先我們安裝需要的庫:

# 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。 然後下一步我們需要對我們的懶載入的元件做一些修改。

?使用 loadable-components 來實現瀏覽器端和服務端的非同步元件功能

在一個需要懶載入 React 元件的地方:

// 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時需要顯示的元件。

然後我們需要在入口檔案中hydrate服務端渲染出來的內容,在src/index.js:

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的環境中。

在之前使用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

相關文章