本文做者 Bermudarat头图来自 Level up your React architecture with MVVM, 做者 Danijel Vincijanovichtml
在开始正文前,先介绍几个概念(已经了解的朋友能够跳过):前端
Server Side Rendering(SSR):服务端渲染,简而言之就是后台语言经过模版引擎生成 HTML 。实现方式依赖于后台语言,例如 Python Flask 的 Jinja、Django 框架、Java 的 VM、Node.js 的 Jade 等。react
Client Side Rendering(CSR):客户端渲染,服务器只提供接口,路由以及渲染都丢给前端。webpack
同构:先后端共用一套代码逻辑,全部渲染功能均由前端实现。在服务端输出含最基本的 HTML 文件;在客户端进一步渲染时,判断已有的 DOM 节点和即将渲染出的节点是否相同。如不一样,从新渲染 DOM 节点,如相同,则只需绑定事件便可(这个过程,在 React 中称之为 注水)。同构是实现 SSR 的一种方式,侧重点在于代码复用。git
静态路由:静态路由须要在页面渲染前声明好 URL 到页面的映射关系。如 Angular、Ember 中的路由,React Router v4 以前版本也采用此种路由。github
动态路由:动态路由抛开了静态路由在渲染前定义映射关系的作法,在渲染过程当中动态生成映射。React Router v4 版本提供了对动态路由的支持。web
Code Splitting:也就是代码分割,是由诸如 Webpack,Rollup 和 Browserify(factor-bundle)这类打包器支持的一项技术,可以在打包文件时建立多个包并在运行时动态加载。json
Next.js、 Nuxt.js 是目前成熟的同构框架,前者基于 React,后者基于 Vue。有了这些框架,开发者能够方便地搭建一个同构应用:只对首屏同构直出,知足 SEO 需求,减小白屏时间;使用前端路由进行页面跳转,实现局部渲染。这些同构框架,已经在工程中获得了普遍应用。然而知其然也要知其因此然,对于一个功能完善的同构应用,须要解决如下几个方面的问题:后端
上述问题的解决过程当中,有不少坑会踩,本文主要讨论第一点。此外,提出一种解决方案,在服务端不使用中心化的路由配置,结合 Code Splitting ,经过一次预渲染,获取当前 URL 对应的模块名和数据获取方法。api
React 提供了 四个方法 用来在服务端渲染 React 组件。其中,renderToStaticMarkup
、renderToStaticNodeStream
不会在 React 内部建立额外的 DOM 属性,一般用于生成静态页面。同构中经常使用的是 renderToString
、 renderToNodeStream
这两个方法:前者将应用渲染成字符串;后者将应用渲染为 Stream 流,能够显著下降首字节响应时间(TTFB)。
实现一个同构的 React 应用,须要如下几个步骤(下文均以字符串渲染为例):
renderToString
方法,将应用渲染成字符串;这是实现同构的通用思路,Next.js 框架也是这种思路。
以上步骤的第一步,是获取匹配当前 URL 的路由。不一样的路由对应不一样的数据获取方法,这是后续步骤的前提。
React Router v4 提供了 [React Router Config](
https://github.com/ReactTrain... 实现中心化的静态路由配置,用于获取 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 文件,是接下来要解决的问题。
;
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 进行服务端渲染,须要执行如下步骤:
上述过程,流程以下:
 { // 使用 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> );
完整流程以下:
); 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 模式,只需在第一次请求时进行一次预渲染。以后再次请求,使用缓存数据便可。
均之外链形式列出
本文发布自 网易云音乐前端团队,文章未经受权禁止任何形式的转载。咱们一直在招人,若是你刚好准备换工做,又刚好喜欢云音乐,那就 加入咱们!