上图是SSR的运行时流程图(暂时不考虑构建的问题)javascript
图中左侧是浏览器端看到的页面源码。其中红色框标识的3个部分,是SSR须要关注的重点内容。css
最简单的是中间一个框,它是服务端渲染的App的内容部分。html
第一个是分片(splitting)代码文件。即SSR Server必需要知道,浏览器要正确展现这个页面,须要包含哪些分片的js代码。 若是不计算并返回这个script标签,那么浏览器render这个list 组建时,会发现这个组件不存在,还须要异步加载并re-render 页面。java
最后一个框,是服务端返回的 window._preloadedState 即 全局状态对象。浏览器端要使用这个对象对redux的store进行初始化。node
收到客户端的SSR请求后,SSR Server将依次执行以下五部操做:react
// 初始化
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);
复制代码
注: 须要进行数据初始化的组件,须要定义 static fetching 方法webpack
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);
复制代码
// 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-loadablegit
const preloadedState = app._store.getState();
复制代码
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> `);
复制代码
本节分几个部分:github
以前使用dva的Code Splitting功能时,用的是 dva/dynamic。示例代码以下:web
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 的支持。
默认状况下:
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>
);
复制代码
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;
}
})
}
});
复制代码
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}}),
];
}
复制代码
Loadable.preloadReady().then(() => {
const App = app.start();
hydrate(
<App/>,
document.getElementById('app')
);
});
复制代码
因为一个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}})
];
}
复制代码