# 正文
上一节我们实现了应用骨架的路由同构,这一节我们来实现非常重要的一个环节 - 数据同构。
# 什么是数据同构
整体来说,组件的一些数据需要从接口异步获取后进行渲染,数据同构就是服务端和客户端能够使用同一个数据请求处理方法(一套代码),同一份数据进行组件的渲染。
我们前面实现的组件直出只是将组件转换为了 html字符串,但是并没有具体的数据,顶多就是个静态页。
比如现在有这么一个需求,要从接口获取数据并且渲染到页面上。
以往在单页应用中,我们一般都将数据的数据的请求处理放在compoentDidMount生命周期内,得到数据后更改状态,随之渲染。
异步获取数据
componentDidMount(){
...
fetchData().then(res=>{
this.setState({
list:res.list
});
})
}
在 render 方法内组织数据
render() {
....
let {list} = this.state;
.....
return <>
{list&&list.map(item=>{
return <div>{item.title}</div>
})}
</>
}
上面的代码我们都非常熟悉,以上代码也能在 ssr 模式中执行,但是无法得到我们期望的效果,数据只能在客户端得到,达不到数据直出的效果,查看网页源代码也没有我们想要的数据。

开始的时候我们介绍过一些原理,componentDidMount生命周期只会在浏览器端执行,所以如果想让数据也能在服务端渲染就需要做一些特殊的处理。
接下来我们来实现 react ssr 本应用骨架内的数据同构。
# 数据预取
在客户端,我们在componentDidMount生命周期内执行数据请求方法从接口拿到数据。
在服务端渲染组件的时候要想在直出的组件内容也包含数据,那就需要提前得到数据,然后将数据作为属性传递给组件,在constructor内对组件 state 进行初始化。
当组件有了数据,服务端渲染直出的时候自然就会有数据。
以上这个在服务端渲染前得到数据的过程就是数据预取。
思考两个问题:
问题1:客户端和服务端组件渲染执行的声明周期不同,双端如何使用一套代码,代码如何组织呢?
问题2:真实开发中,浏览器的 fetch api 无法在node 端使用,如何统一呢?
以上两个问题都可以通过同构来解决。
先说问题2,因为比较简单,现在已经有很多同构的库来解决。
比如:isomorphic-fetch,axios,这里我推荐使用axios,对开发者非常友好,可以无差别使用。
那现在回到问题1,解决这个问题前需要回顾下以往的知识。
js里无论是函数还是类,到底都是函数,同时都是特殊的对象。
所以我们可以为这些函数添加属性,这个属性也可以被称作为类的静态方法。
静态方法有什么特点?
不需要实例化就可以访问,像下面这样。
class Foo {
run(){
.....
console.log('hello');
}
}
Foo.method=function(){
console.log('hello method');
}
这有什么作用呢?
上面的代码可以在浏览器端执行,当然也可在 node 端执行。
其实以上思路就是解决问题2的办法,可以把Foo想象成我们的react 组件。
我们可以在 node 端找到这个路由对应的组件,然后调用这个组件的静态方法来实现数据的预取。
梳理下完整的思路
- 约定并为组件添加数据预取的静态方法
- 在服务端查找到当前路由对应的组件
- 调用组件的数据预取方法得到数据
- 将数据作为属性传入组件
- 组件内render做相应的处理
- 服务端直出组件
- 浏览器接管页面,完成渲染
# 手膜手实现数据同构
# 约定数据预取方法
首先我们模拟一个异步获取数据的方法,返回一个列表数据。
我这里准备了一份从掘金采集的信息,作为假数据。
// ./src/client/pages/list/data.js
const data = [{
"title": "深入浅出TypeScript:从基础知识到类型编程",
"desc": "Vue3 源码及开发必备基础,从基础知识到类型工具设计,从理论到实战,手把手让你从零基础成为进阶使用者。",
"img": "https://user-gold-cdn.xitu.io/2019/11/8/16e4ab5d6aff406a?imageView2/1/w/200/h/280/q/95/format/webp/interlace/1"
}, {
"title": "SVG 动画开发实战手册",
"desc": "从0到1,学习SVG动画开发知识,快速高效完成SVG动画效果开发。",
"img": "https://user-gold-cdn.xitu.io/2019/9/26/16d6bda264ac27e4?imageView2/1/w/200/h/280/q/95/format/webp/interlace/1"
}, {
"title": "预售JavaScript 设计模式核⼼原理与应⽤实践",
"desc": "通俗易懂的编程“套路“学。带你深入看似高深实则接地气的设计模式原理,在实际场景中内化设计模式的”道“与”术“。学会驾驭代码,而非被其奴役。",
"img": "https://user-gold-cdn.xitu.io/2019/9/16/16d382e623923d91?imageView2/1/w/200/h/280/q/95/format/webp/interlace/1"
}
]
另外我们约定所有页面组件内的数据预取方法为getInitialProps,用于双端调用。
//src/client/pages/list/index.js
//List 页面 组件
import React from 'react';
import {Link} from 'react-router-dom';
//导入 - 假数据
import tempData from './data';
//组件
export default class Index extends React.Component {
constructor(props) {
super(props);
}
//静态方法 数据预取方法
static async getInitialProps() {
//模拟数据请求方法
const fetchData=()=>{
return new Promise(resolve=>{
setTimeout(() => {
resolve({
code:0,
data: tempData
})
}, 100);
})
}
let res = await fetchData();
return res;
}
handlerClick(){
alert('一起来玩 react 服务端渲染');
}
render() {
return <div onClick={this.handlerClick}>hello world。</div>
}
}
数据预取方法设置已完成,下一步需要在服务端调用这个方法。
# 服务端数据预取实现
server 端接到客户端的请求,通过req url path 来进行路由匹配,然后得到需要渲染的组件后调用数据预取方法。
# 路由如何匹配?
到这里我们又遇到了个问题 - 路由如何匹配。
每个路由都有 path 属性,所以完全可以根据路由的 path 去匹配。
最简单的方式无疑就是遍历路由配置,对比 req path 和路由path 。
参考代码
//路由配置文件
import Index from '../pages/index';
import List from '../pages/list';
export default [
{
path:'/index',
component:Index
},
{
path: '/list',
component: List
}
]
//根据请求 path 查找路由
const matchRoute=(path,routeList)=>{
let route;
for(var item of routeList){
if(item.path===path){//路由匹配
route = item;
}
break;
}
return route;
}
上面的代码看着没什么问题,但只能处理静态路由,如果是动态路由的话上面的方法就无能为力了。
静态路由
<Route path="/item" exact={true} component={Item}></Route>
动态路由
<Route path="/item/:id" exact={true} component={Root}></Route>
当然我们都知道这种动态 path 就需要正则来进行匹配了。
path-to-regexp
此时我们就需要使用工具来处理了。
该工具库用来处理 url 中地址与参数,可以将动态路径转换为所对应的正则。
const keys = [];
const regexp = pathToRegexp("/foo/:bar", keys);
// regexp = /^\/foo\/([^\/]+?)\/?$/i
// keys = [{ name: 'bar', prefix: '/', suffix: '', pattern: '[^\\/#\\?]+?', modifier: '' }]
还可以直接用于路径匹配
const regexp = pathToRegexp("/:foo/:bar");
// keys = [{ name: 'foo', prefix: '/', ... }, { name: 'bar', prefix: '/', ... }]
regexp.exec("/test/route");
//=> [ '/test/route', 'test', 'route', index: 0, input: '/test/route', groups: undefined ]
说到这里相信我们的问题已经解决了。
不过上面只是介绍下原理,具体的应用其实react-router内已经内置了,而且内部处理机制也是利用pathToRegexp这个库。
matchPath 方法
import { matchPath } from "react-router";
该方法主要就是用于路由的匹配。
const match = matchPath("/users/123", {
path: "/users/:id",
exact: true,
strict: false
});
完善下组件匹配方法
//根据请求 path 匹配路由,结果返回该路由
const matchRoute=(opt)=>{
let {path} = opt;
let route;
for(var item of routeList){
if(matchPath(path,item)){
route = item;
break;
}
}
return route;
}
完成数据预取
- 查找到组件后,调用组件的数据预取方法得到数据
- 得到数据后,将数据传递给组件
export default async (ctx,next)=>{
const path = ctx.request.path;
//查找到的目标路由对象
let targetRoute = matchRoute(path,routeList);
//数据预取 -> fetchResult
let fetchDataFn = targetRoute.component.getInitialProps;
let fetchResult = {};
if(fetchDataFn){
fetchResult = await fetchDataFn();
}
//将预取数据在这里传递过去 组内通过props.staticContext获取
const context = {
initialData: fetchResult
};
html = renderToString(<StaticRouter location={path} context={context}>
<App routeList={routeList}></App>
</StaticRouter>);
//....
await next();
}
# 组件 render 逻辑处理
组件从props.staticContext.initialData得到数据。
render方法增加渲染逻辑
//list 页面 组件
export default class Index extends React.Component {
constructor(props) {
super(props);
//得到初始化数据
+ initialData = props.staticContext.initialData||{};
+ this.state=initialData;
}
static async getInitialProps() {
//...
}
render() {
//渲染逻辑
+ const {code,data}=this.state;
return <div>
+ {data && data.map((item,index)=>{
return <div key={index}>
<h3>{item.title}</h3>
<p>{item.desc}</p>
</div>
})}
{!data&&<div>暂无数据</div>}
</div>
}
}
到这里,服务端的数据直出就处理完成了,查看网页源代码已经能看到直出的数据。

但是如果查看页面效果的话,页面内容会一闪而过,最终页面只显示一个暂无数据 。


# 数据脱水
继续分析,出现以上问题的原因。
导致这个问题的原因是因为在浏览器端进行渲染的时候,没有该数据。
结果导致双端节点对比失败,最终采用的是客户端的渲染结果。
所以,浏览器端也需要有相同的数据,使组件可以渲染出和服务端相同的结构,才能够通过双端节点对比。才不会被客户端的结构覆盖,从而使用服务端直出的 html 结构。
浏览器端组件渲染前如何才能得到服务端的数据呢?
得到了数据如何传递给组件呢?
第一排除通过接口请求,那就是重复请求了,没意义。
服务端返回相应数据后页面就被浏览器接管了,所以只能在接管之前做一些操作。
我们可以直接把数据也吐给浏览器,将数据序列化后作为字符串直出到页面,这样在浏览器端就可以在组件渲染前很方便的得到数据。
为了防止 xss 攻击,咱们这里将数据放到了textarea标签内。
//...
ctx.body=`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>my react ssr</title>
</head>
<body>
<div id="root">
${html}
</div>
+ <textarea id="ssrTextInitData" style="display:none;">
${JSON.stringify(fetchResult)}
</textarea>
</body>
</html>
</body>
//.....
ok,经过我们上面的分析和实现,我们在直出组件的时候同时将数据源也输出给浏览器,而这个过程就叫做数据脱水。
# 数据注水
现在还差最后一步,浏览器端得到了数据后,如何使用该数据呢?
- 浏览器端在组件渲染前,得到初始化数据
- 将数据作为属性传递给组件
# 得到初始化数据
这个很简单了,直接上代码
//初始数据
let initialData =JSON.parse( document.getElementById('ssrTextInitData').value);
# 将数据作为属性传递给组件
如何将数据作为属性传递给组件呢?
方法其实有很多种,下面算是其中一个方法。
可以根据当前的 path匹配到目标路由,然后在路由的render方法内将数据传递给组件即可。
ps:因为在服务端渲染的时候我们传入初始数据的属性为initialData,所以客户端最好使用同一个属性来传递。
// ./src/client/app/index.js
//浏览器端页面结构渲染入口
import React from 'react';
import ReactDom from 'react-dom';
import App from '../router/index';
import { BrowserRouter} from 'react-router-dom';
import routeList from '../router/route-config';
function clientRender() {
//初始数据
let initialData =JSON.parse( document.getElementById('ssrTextInitData').value);
//查找路由
let route = matchRoute(document.location.pathname,routeList);
//设置组件初始化数据 [关键点]
route.initialData =initialData;
//渲染index
ReactDom.hydrate(<BrowserRouter>
<App routeList={routeList}/>
</BrowserRouter>
, document.getElementById('root'))
}
//渲染入口
clientRender();
然后看下在App组件内做的一些处理。
function App({routeList}) {
return (
<Layout>
<Switch>
{
routeList.map(item=>{
//判断是否有初始数据
return item.initialData ? <Route key={item.path} exact={item.exact} path={item.path} render={(props)=>{
props.initialData = item.initialData;
return <item.component {...props}></item.component>
}}></Route> : <Route key={item.path} {...item}></Route>
})
}
<Route to="*" component={Page404}></Route>
</Switch>
</Layout>
);
}
到这里,我们进入到/list页面,它的渲染结果已经正常,数据也能够正常的显示。

这个将数据和组件调和渲染的过程就是数据注水。
# 彻底解决问题
到这里,首次访问的结果是正常了,但是仍然有问题,在这里我们彻底解决它。
在上图中我们页面中有两个链接,分别是首页和列表页。
上面访问的是/list列表页,但是如果我们第一次就访问/index路由,再点击列表页链接,列表页的数据竟然消失了。


这是什么原因?
我们都知道首次进入页面走服务端 ssr,后续访问就spa。
现在列表页的数据只能在ssr 模式下才能拿到,如果是 spa 就拿不到了。
如何处理?
这个就比较简单了,和我们平时开发spa一样。
我们可以在componentDidMount内获取数据然后更新 state。
ps: 实现比较简单,但是需要做个容错,判断下是否有初始化数据,以免重复请求,浪费资源。
componentDidMount(){
if(!this.state.data){//判断是否有初始化数据
//进行数据请求
Index.getInitialProps().then(res=>{
this.setState({
data:res.data||[]
})
})
}
}
到这里,页面的整体数据同构渲染已经完成,效果已经达到预期。
# 公共方法
上面的实现过程中,服务端和浏览器端都用到了路由的匹配,所以我们可以将这个方法提出来,供双端调用。
// src/share/match-route.js
// 根据 path, 匹配路由
import { matchPath} from 'react-router';
export default (path,routeList)=>{
let route;
for (var item of routeList) {
if (matchPath(path, item)) {
route = item;//查找到第一个路由后停止查找
break;
}
}
return route;
}
# 小结
本节主要使用一个小需求来抛砖引玉,带出来了一系列的问题,让我们逐步的分析和实现了数据同构。
关键步骤如下:
- 方法同构: 为组件声明
getInitialProps静态方法,这是一个同构方法,用于双端的数据获取 - 数据预取:在服务端通过路由匹配找到目标的组件,然后调用组件的数据预取方法得到数据
- 将初始化数据作为属性传递给组件
- 数据脱水:将数据序列化,和
html字符串 一起直出返回给浏览器端 - 数据注水:浏览器端得到服务端直出的数据,也通过属性将数据传给组件
- 如果初始化数据不存在,则可以在
componentDidMount生命周期内请求一次数据
本节内容较多,但并不复杂,重要的是理清思路。
← 双端路由同构 SEO TDK 支持 →