React服務端渲染實現(基於Dva)
- 基於 Dva 的 SSR 解決方案 (react-router-v4, redux, redux-saga)
- 支援 Dynamic Import (不再使用Dva自帶的 dva/dynamic載入元件)
- 支援 CSS Modules
SSR實現邏輯
概覽

.png)
上圖是SSR的執行時流程圖(暫時不考慮構建的問題)
圖中左側是瀏覽器端看到的頁面原始碼。其中紅色框標識的3個部分,是SSR需要關注的重點內容。
-
最簡單的是中間一個框,它是服務端渲染的App的內容部分。
-
第一個是分片(splitting)程式碼檔案。即SSR Server必須要知道,瀏覽器要正確展示這個頁面,需要包含哪些分片的js程式碼。 如果不計算並返回這個script標籤,那麼瀏覽器render這個list 組建時,會發現這個元件不存在,還需要非同步載入並re-render 頁面。
-
最後一個框,是服務端返回的 window._preloadedState 即 全域性狀態物件。瀏覽器端要使用這個物件對redux的store進行初始化。
收到客戶端的SSR請求後,SSR Server將依次執行如下五部操作:
- 對請求的路徑,進行路由匹配;並 "獲取/載入"(獲取對應同步元件,載入對應非同步元件) 所涉及的元件
// 初始化 const history = createMemoryHistory(); history.push(req.path); const initialState = {}; const app = dva({history, initialState}); app.router(router); const App = app.start(); let routes = getRoutes(app); // 匹配路由,獲取需要載入的Route元件(包含Loadable元件) const matchedComponents = matchRoutes(routes, req.path).map(({route}) => { if (!route.component.preload) { // 同步元件 return route.component; } else { // 非同步元件 return route.component.preload().then(res => res.default) } }); const loadedComponents = await Promise.all(matchedComponents); 複製程式碼
- 對1中元件進行初始化(如需),進行介面請求,並等待請求返回。
注: 需要進行資料初始化的元件,需要定義 static fetching 方法
const actionList = loadedComponents.map(component => { if (component.fetching) { return component.fetching({ ...app._store, ...component.props, path: req.path }); } else { return null; } }); await Promise.all(actionList); 複製程式碼
- 呼叫 ReactDOMServer.renderString 渲染資料
//Render Dva App。同時使用Loadable.Capture 捕捉本次渲染包含的Loadable元件集合Array<String>。 const modules = []; const markup = renderToString( <Loadable.Capture report={module => modules.push(module)}> <App location={req.path} context={{}}/> </Loadable.Capture> ); //構造需要render的 script標籤。其中利用了react-loadable的webpack外掛在構建過程中生成的module字典 let bundles = getBundles(moduleDict, modules); let scripts = bundles.filter(bundle => bundle.file.endsWith('.js')); let scriptMarkups = scripts.map(bundle => { return `<script src="/public/${bundle.file}"></script>` }).join('\n'); 複製程式碼
Loadable 的相關概念和用法,請參考 github: ofollow,noindex">react-loadable
Code Splitting
- 獲取preloadedState
const preloadedState = app._store.getState(); 複製程式碼
- 拼裝Html,並返回
res.send(` <!DOCTYPE html> <html> <head> <title>React Server Side Demo With Dva</title> <link href="/public/style.css" rel="stylesheet"> </head> <body> <div id="app">${markup}</div> <script>window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(/</g, '\\\u003c')}</script> <script src="/public/main.js"></script> ${scriptMarkups} </body> </html> `); 複製程式碼
如何支援Dva
本節分幾個部分:
- 如何既支援 dva/dynamic 又支援 SSR
- SSR Server 端如何支援 Dva
- SSR Client 端如何支援 Dva
如何既支援 dva/dynamic 又支援 SSR
之前使用dva的Code Splitting功能時,用的是 dva/dynamic。示例程式碼如下:
import dynamic from 'dva/dynamic'; const UserPageComponent = dynamic({ app, models: () => [ import('./models/users'), ], component: () => import('./routes/UserPage'), }); 複製程式碼
它的問題是不支援SSR。解決方法是使用 react-loadable 代替 dva/dynamic。為了不影響dva的功能, 我們需要了解 dva/dynamic 除了實現了載入元件之外還實現了哪些功能。
通過查閱dva原始碼,發現 dva/dynamic 額外實現的功能比較純粹,就是 register model
// packages/dva/src/dynamic.js const cached = {}; function registerModel(app, model) { model = model.default || model; if (!cached[model.namespace]) { app.model(model); cached[model.namespace] = 1; } } // ..... 省略部分程式碼 export default function dynamic(config) { const { app, models: resolveModels, component: resolveComponent } = config; return asyncComponent({ resolve: config.resolve || function () { const models = typeof resolveModels === 'function' ? resolveModels() : []; const component = resolveComponent(); return new Promise((resolve) => { Promise.all([...models, component]).then((ret) => { if (!models || !models.length) { return resolve(ret[0]); } else { const len = models.length; ret.slice(0, len).forEach((m) => { m = m.default || m; if (!Array.isArray(m)) { m = [m]; } // 註冊 model m.map(_ => registerModel(app, _)); }); resolve(ret[len]); } }); }); }, ...config, }); } 複製程式碼
因此,我們需要在 react-loadable 的基礎上,增加 registerModel 功能,且需要自己維護 cached model 這個物件。
為什麼選擇 react-loadable ?
通過翻閱若干個支援SSR Code Splitting的Repo,只有 react-loadable 比較好的支援 "多個檔案載入"。
下面是react-loadable 的基本用法:
Loadable({ loader: () => import('./components/Bar'), loading: Loading, timeout: 10000, // 10 seconds }); 複製程式碼
不難發現, 這是不能夠完全匹配 dva/dynamic 的能力的。因為在Dva裡,有model這個概念。 我們不僅需要載入UI元件本身,還需要載入它所依賴的model檔案。而react-loadable 可以很好的支援這個特性。
下面是 react-loadable 的 Loadable.Map 用法
Loadable.Map({ loader: { Bar: () => import('./Bar'), i18n: () => fetch('./i18n/bar.json').then(res => res.json()), }, render(loaded, props) { let Bar = loaded.Bar.default; let i18n = loaded.i18n; return <Bar {...props} i18n={i18n}/>; }, }); 複製程式碼
經過修改,我們可以得到相容dva的dynamic方案。 例如,有一個頁面叫做 Grid。它依賴2個model,分別是 grid 和 user。
Loadable.Map({ loader: { Grid: () => import('./routes/Grid.js'), grid: () => import('./models/grid.js'), user: () => import('./models/user.js'), }, delay: 200, timeout: 1000, loading: Loading, render(loaded, props) { let Grid = loaded["Grid"].default; let grid = loaded["grid"].default; let user = loaded["grid"].default; registerModel(app, grid); registerModel(app, user); return <Grid {...props} />; }, }); 複製程式碼
對於複雜的專案,可能有很多route配置,寫上面這個配置項程式碼較多。我們可以考慮對其進行封裝。 基於此,我們可以考慮實現 dynamicLoader 方法。
const dynamicLoader = (app, modelNameList, componentName) => { let loader = {}; let models = []; let fn = (path, prefix) => { return () => import(`./${prefix}/${path}`); }; if (modelNameList && modelNameList.length > 0) { for (let i in modelNameList) { if (modelNameList.hasOwnProperty(i)) { let model = modelNameList[i]; if (loader[model] === undefined) { loader[model] = fn(model, 'models'); models.push(model); } } } } loader[componentName] = fn(componentName, 'routes'); return Loadable.Map({ loader: loader, loading: Loading, render(loaded, props) { let C = loaded[componentName].default; for (let i in models) { if (models.hasOwnProperty(i)) { let model = models[i]; if (loaded[model] && getApp()) { registerModel(app, loaded[model]); } } } return <C {...props}/>; }, }); }; // 使用 const routes = [{ path: '/popular/:id', component: dynamicLoader(app, ['grid'], 'Grid') }]; 複製程式碼
但是,上述程式碼在 SSR Server端是無法工作的。
首先,react-loadable 需要在webpack打包過程中生成Loadable元件的資料字典。 SSR Server 需要利用這個字典的資訊生成 分片js程式碼的 script 標籤。
字典檔案示例:
// react-loadable.json { "./routes/Grid.js": [ { "id": 141, "name": "./src/routes/Grid.js", "file": "0.js", "publicPath": "/public/0.js" } ], "lodash/isArray": [ { "id": 296, "name": "./node_modules/lodash/isArray.js", "file": "0.js", "publicPath": "/public/0.js" }, { "id": 296, "name": "./node_modules/lodash/isArray.js", "file": "1.js", "publicPath": "/public/1.js" } ] //... 以下省略 } 複製程式碼
實際使用發現,上述程式碼 dynamicLoader 無法生成正確的字典。
後經過Debug發現,問題根源是程式碼中使用了帶引數的 import。即 import( ./${prefix}/${path}
) , 而webpack 在構建過程中無法靜態獲取Loadable元件的路徑。因此,不能使用帶引數的 import。
最終的方案是,定義路由配置檔案 routes.json。然後編寫一個路由生成器,生成需要的路由檔案。
示例的routes.json 檔案如下:
[ { "path": "/", "exact": true, "dva_route": "./routes/Home.js", "dva_models": [] }, { "path": "/popular/:id", "dva_route": "./routes/Grid.js", "dva_models": [ "./models/grid.js" ] }, { "path": "/topic", "dva_route": "./routes/Topic.js", "dva_models": [] } ] 複製程式碼
到此,我們就完成了對於dva/dynamic 和 SSR 的支援。
SSR Server 端如何支援 Dva
- app.start
預設情況下:
app.start('#root'); 複製程式碼
server 端應該不加引數
// 官方示例 import { IntlProvider } from 'react-intl'; ... const App = app.start(); ReactDOM.render(<IntlProvider><App /></IntlProvider>, htmlElement); // 本實現的示例 const App = app.start(); const markup = renderToString( <Loadable.Capture report={module => modules.push(module)}> <App location={req.path} context={{}}/> </Loadable.Capture> ); 複製程式碼
- model register
const matchedComponents = matchRoutes(routes, req.path).map(({route}) => { if (!route.component.preload) { return route.component; } else { // 載入Loadable元件 return route.component.preload().then(res => { if (res.default) { // Loadable 元件 return res.default; } else { // Loadable.Map 元件 let result; for (let i in res) { if (res.hasOwnProperty(i)) { if (res[i].default.hasOwnProperty('namespace')) { // model 元件 registerModel(app, res[i]); } else { // route 元件 result = res[i].default; } } } return result; } }) } }); 複製程式碼
- 呼叫元件初始化方法fetching時,需要傳入 dispatch。而全域性的dispatch物件在 app._store 裡
const actionsList = loadedComponents.map(component => { if (component.fetching) { return component.fetching({ ...app._store, ...component.props, path: req.path }); } else { return null; } }); // 示例 fetching 方法 static fetching({dispatch, path}) { let language = path.substr("/popular/".length); return [ dispatch({type: 'grid/init', payload: {language}}), ]; } 複製程式碼
客戶端如何支援 Dva
- render
Loadable.preloadReady().then(() => { const App = app.start(); hydrate( <App/>, document.getElementById('app') ); }); 複製程式碼
- 元件的初始化資料方法 fetching
由於一個route 可能需要依賴多個model作為資料來源。故返回一個dispatch 的陣列。這樣server就可以通過多個介面拿資料。
static fetching({dispatch, path, params}) { let language = path.substr("/popular/".length); return [ dispatch({type: 'grid/init', payload: {language}}), dispatch({type: 'user/fetch', payload: {userId: params.userId}}) ]; } 複製程式碼
使用
- npm install
- npm run dev
- view localhost:3000