# 正文

上一节我们实现了应用骨架的路由同构,这一节我们来实现非常重要的一个环节 - 数据同构

# 什么是数据同构

整体来说,组件的一些数据需要从接口异步获取后进行渲染,数据同构就是服务端和客户端能够使用同一个数据请求处理方法(一套代码),同一份数据进行组件的渲染。

我们前面实现的组件直出只是将组件转换为了 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 模式中执行,但是无法得到我们期望的效果,数据只能在客户端得到,达不到数据直出的效果,查看网页源代码也没有我们想要的数据。

image-20210214214245297

开始的时候我们介绍过一些原理,componentDidMount生命周期只会在浏览器端执行,所以如果想让数据也能在服务端渲染就需要做一些特殊的处理。

接下来我们来实现 react ssr 本应用骨架内的数据同构。

# 数据预取

在客户端,我们在componentDidMount生命周期内执行数据请求方法从接口拿到数据。

在服务端渲染组件的时候要想在直出的组件内容也包含数据,那就需要提前得到数据,然后将数据作为属性传递给组件,在constructor内对组件 state 进行初始化。

当组件有了数据,服务端渲染直出的时候自然就会有数据。

以上这个在服务端渲染前得到数据的过程就是数据预取。

思考两个问题:

问题1:客户端和服务端组件渲染执行的声明周期不同,双端如何使用一套代码,代码如何组织呢?

问题2:真实开发中,浏览器的 fetch api 无法在node 端使用,如何统一呢?

以上两个问题都可以通过同构来解决。

先说问题2,因为比较简单,现在已经有很多同构的库来解决。

比如:isomorphic-fetchaxios,这里我推荐使用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>
    }
}

到这里,服务端的数据直出就处理完成了,查看网页源代码已经能看到直出的数据。

image-20210214214310986

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

image-20210214214329679

image-20210214214352550

# 数据脱水

继续分析,出现以上问题的原因。

导致这个问题的原因是因为在浏览器端进行渲染的时候,没有该数据。

结果导致双端节点对比失败,最终采用的是客户端的渲染结果。

所以,浏览器端也需要有相同的数据,使组件可以渲染出和服务端相同的结构,才能够通过双端节点对比。才不会被客户端的结构覆盖,从而使用服务端直出的 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页面,它的渲染结果已经正常,数据也能够正常的显示。

image-20210214214418209

这个将数据和组件调和渲染的过程就是数据注水

# 彻底解决问题

到这里,首次访问的结果是正常了,但是仍然有问题,在这里我们彻底解决它。

在上图中我们页面中有两个链接,分别是首页和列表页。

上面访问的是/list列表页,但是如果我们第一次就访问/index路由,再点击列表页链接,列表页的数据竟然消失了。

image-20210214214433204

image-20210214214442417

这是什么原因?

我们都知道首次进入页面走服务端 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生命周期内请求一次数据

本节内容较多,但并不复杂,重要的是理清思路。

github.com/Bigerfe/koa… (opens new window)

阅读全文