React + Koa 實現服務端渲染(SSR) Part II
Hey Guys, 之前寫過一篇React + Koa 服務端渲染SSR 的文章,都是大半年前的事了,最近回顧了一下,發現有些之前主流的懶載入元件的庫已經過時了,然後關於SSR似乎之前的文章沒有涉及到React-v16的功能,特別是v16新加的stream API,只是在上一篇文章的末尾提了一下,所以在這篇Part 2的版本中會新增這些新功能:beer:
Why use [Part II]?: Go to playThe Last of Us
and wait forThe Last of Us Part II
:children_crossing:
:tada:主要內容:
-
:scissors:替換
react-loadable
,使用loadable-components -
:chart_with_downwards_trend:使用
loadable-components
來實現瀏覽器端和服務端的非同步元件功能 - :potable_water:使用 react stream API 實現服務端渲染
- :floppy_disk:為服務端渲染的內容(html)新增快取機制, 適用於同步和stream API
:scissors: 替換 react-loadable
react-loadable已經好久沒維護了,而且跟最新的webpack4+,還有babel7+都不相容,還會有Deprecation Warning,如果你使用koa-web-kit
v2.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/webpack
和react-loadable/babel
移除掉,替換成@loadable/webpack-plugin
和@loadable/babel-plugin
。
然後下一步我們需要對我們的懶載入的元件做一些修改。
:chart_with_downwards_trend:使用 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了:grinning:。
:potable_water: 服務端渲染使用 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很大的時候,所以為了解決這樣的問題,我們需要在這中間加一層快取,我們可以存到記憶體,檔案,或者資料庫,取決於你專案的實際情況。
:floppy_disk:為服務端渲染新增快取機制, 適用於同步和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更新的主要內容了。
:sparkling_heart: 想嘗試完整demo的話可以關顧一下koa-web-kit , 然後體驗SSR給你帶來的效果吧 :grinning: