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: 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