react 服务端渲染教程(基于DVA)

react 服务端渲染教程(基于DVA)

Github Repo 地址

dva-ssr

使用

  • npm install
  • npm run buildClient
  • npm run buildServer
  • npm run ssr
  • view localhost:3000

功能

  • 基于 Dva 的 SSR 解决方案
  • 支持 Code Splitting (不再使用Dva自带的 dva/dynamic加载组件)
  • 支持 CSS Modules

SSR实现逻辑

概览

在这里插入图片描述

上图是SSR的运行时流程图(暂时不考虑构建的问题)

图中左侧是浏览器端看到的页面源码。其中红色框标识的3个部分,是SSR需要关注的重点内容。

  • 最简单的是中间一个框,它是服务端渲染的App的内容部分。

  • 第一个是分片(splitting)代码文件。即SSR Server必须要知道,浏览器要正确展示这个页面,需要包含哪些分片的js代码。
    如果不计算并返回这个script标签,那么浏览器render这个list 组建时,会发现这个组件不存在,还需要异步加载并re-render 页面。

  • 最后一个框,是服务端返回的 window._preloadedState 即 全局状态对象。浏览器端要使用这个对象对redux的store进行初始化。

收到客户端的SSR请求后,SSR Server将依次执行如下五部操作:

  1. 对请求的路径,进行路由匹配;并 “获取/加载”(获取对应同步组件,加载对应异步组件) 所涉及的组件
 // 初始化 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); // 初始化 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. 对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); 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);
  1. 调用 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'); // 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

  1. 获取preloadedState
const preloadedState = app._store.getState(); const preloadedState = app._store.getState();
  1. 拼装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> `); 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

本节分几个部分:

  1. 如何既支持 dva/dynamic 又支持 SSR
  2. SSR Server 端如何支持 Dva
  3. 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'), }); 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, }); } // 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 }); 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}/>; }, }); 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} />; }, }); 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') }]; 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" } ] //... 以下省略 } // 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": [] } ] [ { "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
  1. app.start

默认情况下:

app.start('#root'); 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> ); // 官方示例 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> );
  1. 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; } }) } }); 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; } }) } });
  1. 调用组件初始化方法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}}), ]; } 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
  1. render
 Loadable.preloadReady().then(() => { const App = app.start(); hydrate( <App/>, document.getElementById('app') ); }); Loadable.preloadReady().then(() => { const App = app.start(); hydrate( <App/>, document.getElementById('app') ); });
  1. 组件的初始化数据方法 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}}) ]; } 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}}) ]; }

源码Github Repo 地址

dva-ssr