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 forThe 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/webpack
和 react-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