本文作者 Bermudarat
头图来自 Level up your React architecture with MVVM, 作者 Danijel Vincijanovic
在开始正文前,先介绍几个概念(已经了解的朋友可以跳过):
Server Side Rendering(SSR):服务端渲染,简而言之就是后台语言通过模版引擎生成 HTML 。实现方式依赖于后台语言,例如 Python Flask 的 Jinja、Django 框架、Java 的 VM、Node.js 的 Jade 等。
Client Side Rendering(CSR):客户端渲染,服务器只提供接口,路由以及渲染都丢给前端。
同构:前后端共用一套代码逻辑,所有渲染功能均由前端实现。在服务端输出含最基本的 HTML 文件;在客户端进一步渲染时,判断已有的 DOM 节点和即将渲染出的节点是否相同。如不同,重新渲染 DOM 节点,如相同,则只需绑定事件即可(这个过程,在 React 中称之为 注水)。同构是实现 SSR 的一种方式,侧重点在于代码复用。
静态路由:静态路由需要在页面渲染前声明好 URL 到页面的映射关系。如 Angular、Ember 中的路由,React Router v4 之前版本也采用此种路由。
动态路由:动态路由抛开了静态路由在渲染前定义映射关系的做法,在渲染过程中动态生成映射。React Router v4 版本提供了对动态路由的支持。
Code Splitting:也就是代码分割,是由诸如 Webpack,Rollup 和 Browserify(factor-bundle)这类打包器支持的一项技术,能够在打包文件时创建多个包并在运行时动态加载。
Next.js、 Nuxt.js 是目前成熟的同构框架,前者基于 React,后者基于 Vue。有了这些框架,开发者可以方便地搭建一个同构应用:只对首屏同构直出,满足 SEO 需求,减少白屏时间;使用前端路由进行页面跳转,实现局部渲染。这些同构框架,已经在工程中得到了广泛应用。然而知其然也要知其所以然,对于一个功能完善的同构应用,需要解决以下几个方面的问题:
上述问题的解决过程中,有很多坑会踩,本文主要讨论第一点。此外,提出一种解决方案,在服务端不使用中心化的路由配置,结合 Code Splitting ,通过一次预渲染,获取当前 URL 对应的模块名和数据获取方法。
React 提供了 四个方法 用来在服务端渲染 React 组件。其中,renderToStaticMarkup
、renderToStaticNodeStream
不会在 React 内部创建额外的 DOM 属性,通常用于生成静态页面。同构中常用的是 renderToString
、 renderToNodeStream
这两个方法:前者将应用渲染成字符串;后者将应用渲染为 Stream 流,可以显著降低首字节响应时间(TTFB)。
实现一个同构的 React 应用,需要以下几个步骤(下文均以字符串渲染为例):
renderToString
方法,将应用渲染成字符串;这是实现同构的通用思路,Next.js 框架也是这种思路。 以上步骤的第一步,是获取匹配当前 URL 的路由。不同的路由对应不同的数据获取方法,这是后续步骤的前提。
React Router v4 提供了 React Router Config 实现中心化的静态路由配置,用于获取 React 应用的路由信息,方便在服务端渲染时获取数据:
With the introduction of React Router v4, there is no longer a centralized route configuration. There are some use-cases where it is valuable to know about all the app's potential routes such as:
- Loading data on the server or in the lifecycle before rendering the next screen
- Linking to routes by name
- Static analysis
React Router Config 提供了 matchRoutes
方法实现路由匹配。如何使用,在 文档 中有详细的说明:
// routes 为中心化的路由配置文件 const routes = [ { path: "/", component: Root, loadData: () => getSomeData() } ]; const loadBranchData = location => { const branch = matchRoutes(routes, location.pathname); // 调用 route 上定义的数据获取方法 const promises = branch.map(({route, match}) => { return route.loadData ? route.loadData(match): Promise.resolve(null); }); return Promise.all(promises); }; // 预获取数据,并在 HTML 文件中写入数据 loadBranchData(req.URL).then(data => { putTheDataSomewhereTheClientCanFindIt(data); }); 复制代码
loadData
方法除了作为路由的属性外,也可以在 Root
的静态方法中定义。
// Root 组件 const Root = () => { ... }; Root.loadData = () => getSomeData(); // 路由配置 const routes = [ { path: "/", component: Root } ]; // 页面匹配 const loadBranchData = location => { // routes 为中心化的路由配置文件 const branch = matchRoutes(routes, location.pathname); // 调用 component 上的静态数据获取方法 const promises = branch.map(({route, match}) => { return route.component.loadData ? route.component.loadData(match): Promise.resolve(null); }); return Promise.all(promises); }; 复制代码
接下就可以使用预获取的数据进行渲染。
HTML 字符串中需要包含客户端渲染所需的 JS/CSS 标签。对于没有 Code Splitting 的应用,很容易定位这些资源文件。然而对于一个复杂的单页应用,不进行 Code Splitting 会导致 JS 文件体积过大,增加了传输时间和浏览器解析时间,从而导致页面性能下降。在 SSR 时,如何筛选出当前 URL 对应的 JS/CSS 文件,是接下来要解决的问题。
Webpack 根据 ECMAScript 提案实现了用于动态加载模块的 import
方法。React v16.6 版本提供了 React.lazy
和 Suspend
,用于动态加载组件。然而 React.lazy
和 Suspend
并不适用于 SSR,我们仍需要引入第三方的动态加载库:
React.lazy and Suspense are not yet available for server-side rendering. If you want to do code-splitting in a server rendered app, we recommend Loadable Components. It has a nice guide for bundle splitting with server-side rendering.
目前已有很多成熟的第三方的动态加载库: 早期的 React 官方文档中推荐的 react-loadable,最新推荐的 @loadable/component,以及 react-universal-component 等等,他们提出这样一种解决方案:
webpack --profile --json > compilation-stats.json
。除了命令行的方式,配置文件也可以通过 webpack-stats-plugin 插件生成。此外,一些第三方动态加载库也提供了插件生成这些配置(例如 react-loadable 提供的 ReactLoadablePlugin
);chunkNames
;chunkNames
对应的分块代码信息,并组装成 JS/CSS 标签。以 react-universal-component 为例,代码实现如下:
import {ReportChunks} from 'react-universal-component' import flushChunks from 'webpack-flush-chunks' import ReactDOM from 'react-dom/server' // webpackStats 中包含了应用中所有模块的数据信息,可以通过 webpack 打包获得 import webpackStats from './dist/webpackstats.json'; function renderToHtml () => { // 保存匹配当前 URL 的组件 chunk let chunkNames = []; const appHtml = ReactDOM.renderToString( // ReportChunks 通过 React Context 将 report 方法传递至每个动态加载组件上。组件在加载时,执行 report 方法,从而将组件的模块名传递至外部。 <ReportChunks report={chunkName => chunkNames.push(chunkName)}> <App /> </ReportChunks> ); // 提取 webpacStats 中 chunkNames 的信息,并组装为标签; const {scripts} = flushChunks(webpackStats, { chunkNames, }); // 后续省略 } 复制代码
综上,使用 React Router 进行服务端渲染,需要执行以下步骤:
上述过程,流程如下:
上述讨论中,在进行 URL 匹配时,我们使用了中心化的静态路由配置。React Router v4 版本的最大改进,就是提出了动态路由。Route
作为一种真正的 React 组件,与 UI 展示紧密结合,而不是之前版本中的伪组件。有了动态路由组件,我们不再需要中心化的路由配置。
与静态路由相比,动态路由在设计上有很多 改进之处。此外,动态路由在深层路由的书写上,也比中心化的静态路由要方便。 使用 React Router Config 进行中心化的静态路由配置需要提供如下的路由配置文件:
const routes = [ { component: Root, routes: [ { path: "/", exact: true, component: Home }, { path: "/child/:id", component: Child, routes: [ { path: "/child/:id/grand-child", component: GrandChild } ] } ] } ]; 复制代码
采用动态路由,则完全不需要上述配置文件。 以 Child
组件为例, 可以在组件中配置子路由。
function Child() { // 使用 match.path,可以避免前置路径的重复书写 let match = useRouteMatch(); return ( <div> <h>Child</h> <Route path={`${match.path}/grand-child`} /> </div> ) } 复制代码
但是如果使用动态路由的话,该如何与当前 URL 匹配呢?
前面介绍了,react-universal-component 等动态加载组件, 可以通过一次渲染,获取对应当前 URL 的模块名。
let chunkNames = []; const appHtml = ReactDOM.renderToString( <ReportChunks report={chunkName => chunkNames.push(chunkName)}> <App /> </ReportChunks> ); 复制代码
我们是否可以使用类似的方式,通过一次渲染,将定义在组件上的数据获取方法传递至外部呢?比如下面的书写方式:
let chunkNames = []; let loadDataMethods = []; const appHtml = ReactDOM.renderToString( <ReportChunks report={(chunkName, loadData) => { chunkNames.push(chunkName); loadDataMethods.push(loadData); }}> <App /> </ReportChunks> ); 复制代码
react-universal-component 中, ReportChunks
组件使用 React Context 将 report
方法传递至每个动态加载组件上。组件在加载时,执行 report
方法,将组件的模块名传递至外部。
因此,我们只需要修改动态加载方法,使其在执行 report
方法时,同时将模块名 chunkName
和组件上的静态方法返回即可:
// AsyncComponent 提供在服务端同步加载组件的功能 class AsyncComponent extends Component { constructor(props) { super(props); const {report} = props; // syncModule 为内置函数,不对用户暴露,主要功能是使用 webpack 提供的 require.resolveWeak 方法实现模块的同步加载; const comp = syncModule(resolveWeak, load); if (report && comp) { const exportStatic = {}; // 将 comp 的静态方法复制至 exportStatic hoistNonReactStatics(exportStatic, comp); exportStatic.chunkName = chunkName; // 将 chunkName 和静态方法传递给外部 report(exportStatic); } } // ... } 复制代码
完整的实现可以参考 react-asyncmodule。react-asyncmodule 提供了 AsyncChunk
组件,与 react-universal-component 提供的 ReportChunks
组件相似,作用是将 report
方法传递至每个动态加载组件上。使用方法如下:
let modules = []; const saveModule = (m) => { // m 中包含 chunkName 和静态数据获取方法; const { chunkName } = m; // 过滤重复的 chunkName if (modules.filter(e => e.chunkName === chunkName).length) return; modules.push(m); }; const appHtml = ReactDOM.renderToString( <AsyncChunk report={saveModule}> <App /> </AsyncChunk> ); 复制代码
完整流程如下:
通过一次预渲染,获取对应当前 URL 的模块名和数据获取方法,适用于大部分动态路由的场景。但是如果动态加载组件本身是否渲染依赖于数据,那么在预渲染时,这个组件的模块名和静态方法不能正常获取。如下:
const PageA = AsyncComponent(import('./PageA')); const BasicExample = (props) => { const {canRender} = props; return ( <Router> <Route exact path="/"> { canRender ? <PageA /> : <div>Render Nothing!</div> } </Route> </Router> ); }; BasicExample.getInitialProps = () => { // 此处获取 canRender,用于确定 PageA 组件是否渲染 }; 复制代码
预渲染时 canRender
为 undefined
, 不会渲染 PageA
,所以也不能获取到 PageA
对应的模块名和静态方法。正式渲染时,服务端渲染出的页面中会缺少 PageA
中的数据信息。为了解决这个问题,业务代码需要在 PageA
的 componentDidMount
生命周期中,进行数据的获取,以正确展示页面。
此外,预渲染可以使用 renderToStaticMarkup
方法,相比 renderToString
,renderToStaticMarkup
不会生成额外的 React 属性,因此减少了 HTML 字符串的大小。但是预渲染本身增加了服务端的计算压力,所以可以考虑缓存预渲染结果,实现思路如下:
moduleCache
;matchPath
方法,在 moduleCache
中查找是否有此 path string 模式(例如 /user/:name
)的缓存,如果有,则使用缓存的方法进行数据获取;使用这种方法,对于不同的 path string 模式,只需在第一次请求时进行一次预渲染。之后再次请求,使用缓存数据即可。
均以外链形式列出
本文发布自 网易云音乐前端团队,文章未经授权禁止任何形式的转载。我们一直在招人,如果你恰好准备换工作,又恰好喜欢云音乐,那就 加入我们!