一文吃透 React SSR 服务端渲染和同构原理

写在前面

前段时间一直在研究react ssr技术,而后写了一个完整的ssr开发骨架。今天写文,主要是把个人研究成果的精华内容整理落地,另外经过再次梳理但愿发现更多优化的地方,也但愿可让更多的人少踩一些坑,让跟多的人理解和掌握这个技术。javascript

相信看过本文(前提是能对你的胃口,也能较好的消化吸取)你必定会对 react ssr 服务端渲染技术有一个深刻的理解,能够打造本身的脚手架,更能够用来改造本身的实际项目,固然这不只限于 react ,其余框架都同样,毕竟原理都是类似的。php

为何要服务端渲染(ssr)

至于为何要服务端渲染,我相信你们都有所闻,并且每一个人都能说出几点来。css

首屏等待

在 SPA 模式下,全部的数据请求和 Dom 渲染都在浏览器端完成,因此当咱们第一次访问页面的时候极可能会存在“白屏”等待,而服务端渲染全部数据请求和 html内容已在服务端处理完成,浏览器收到的是完整的 html 内容,能够更快的看到渲染内容,在服务端完成数据请求确定是要比在浏览器端效率要高的多。html

没考虑SEO的感觉

有些网站的流量来源主要仍是靠搜索引擎,因此网站的 SEO 仍是很重要的,而 SPA 模式对搜索引擎不够友好,要想完全解决这个问题只能采用服务端直出。改变不了别人(搜索yinqing),只能改变本身。前端

SSR + SPA 体验升级

只实现 SSR 其实没啥意义,技术上没有任何发展和进步,不然 SPA 技术就不会出现。 vue

可是单纯的 SPA又不够完美,因此最好的方案就是这两种体验和技术的结合,第一次访问页面是服务端渲染,基于第一次访问后续的交互就是 SPA 的效果和体验,还不影响SEO 效果,这就有点完美了。java

单纯实现 ssr 很简单,毕竟这是传统技术,也不分语言,随便用 php 、jsp、asp、node 等均可以实现。node

可是要实现两种技术的结合,同时能够最大限度的重用代码(同构),减小开发维护成本,那就须要采用 react 或者 vue 等前端框架相结合 node (ssr) 来实现。react

本文主要说 React SSR 技术 ,固然 vue 也同样,只是技术栈不一样而已。webpack

核心原理

总体来讲 react 服务端渲染原理不复杂,其中最核心的内容就是同构。

node server 接收客户端请求,获得当前的req url path,而后在已有的路由表内查找到对应的组件,拿到须要请求的数据,将数据做为 props
context或者store 形式传入组件,而后基于 react 内置的服务端渲染api renderToString() or renderToNodeStream() 把组件渲染为 html字符串或者 stream 流, 在把最终的 html 进行输出前须要将数据注入到浏览器端(注水),server 输出(response)后浏览器端能够获得数据(脱水),浏览器开始进行渲染和节点对比,而后执行组件的componentDidMount 完成组件内事件绑定和一些交互,浏览器重用了服务端输出的 html 节点,整个流程结束。

技术点确实很多,但更多的是架构和工程层面的,须要把各个知识点进行连接和整合。

这里放一个架构图

react ssr

从 ejs 开始

实现 ssr 很简单,先看一个 node ejs的栗子。

// index.html
<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <meta http-equiv="X-UA-Compatible" content="ie=edge">
   <title>react ssr <%= title %></title>
</head>
<body>
   <%=  data %>
</body>
</html>
//node ssr
 const ejs = require('ejs');
 const http = require('http');

http.createServer((req, res) => {
    if (req.url === '/') {
        res.writeHead(200, {
            'Content-Type': 'text/html' 
        });
        // 渲染文件 index.ejs
        ejs.renderFile('./views/index.ejs', {
            title: 'react ssr', 
            data: '首页'}, 
            (err, data) => {
            if (err ) {
                console.log(err);
            } else {
                res.end(data);
            }
        })
    }
}).listen(8080);

jsx 到字符串

上面咱们结合 ejs模板引擎 ,实现了一个服务端渲染的输出,html 和 数据直接输出到客户端。

参考以上,咱们结合 react组件 来实现服务端渲染直出,使用 jsx 来代替 ejs,以前是在 html 里使用 ejs 来绑定数据,如今改写成使用jsx 来绑定数据,使用 react 内置 api 来把组件渲染为 html 字符串,其余没有差异。

为何react 组件能够被转换为 html字符串呢?

简单的说咱们写的 jsx 看上去就像在写 html(其实写的是对象) 标签,其实通过编译后都会转换成React.createElement方法,最终会被转换成一个对象(虚拟DOM),并且和平台无关,有了这个对象,想转换成什么那就看心情了。

const  React  = require('react');

const { renderToString}  = require( 'react-dom/server');

const http = require('http');

//组件
class Index extends React.Component{
    constructor(props){
        super(props);
    }

    render(){
        return <h1>{this.props.data.title}</h1>
    }
}
 
//模拟数据的获取
const fetch = function () {
    return {
        title:'react ssr',
        data:[]
    }
}

//服务
http.createServer((req, res) => {
    if (req.url === '/') {
        res.writeHead(200, {
            'Content-Type': 'text/html'
        });

        const data = fetch();

        const html = renderToString(<Index data={data}/>);
        res.end(html);
    }
}).listen(8080);

ps:以上代码不能直接运行,须要结合babel 使用 @babel/preset-react 进行转换

npx babel script.js --out-file script-compiled.js --presets=@babel/preset-react

引出问题

在上面很是简单的就是实现了 react ssr ,把jsx做为模板引擎,不要小看上面的一小段代码,他能够帮咱们引出一系列的问题,这也是完整实现 react ssr 的基石。

  • 双端路由如何维护?

首先咱们会发现我在 server 端定义了路由 '/',可是在 react SPA 模式下咱们须要使用react-router来定义路由。那是否是就须要维护两套路由呢?

  • 获取数据的方法和逻辑写在哪里?

发现数据获取的fetch 写的独立的方法,和组件没有任何关联,咱们更但愿的是每一个路由都有本身的 fetch 方法。

  • 服务端 html 节点没法重用

虽然组件在服务端获得了数据,也能渲染到浏览器内,可是当浏览器端进行组件渲染的时候直出的内容会一闪而过消失。

好了,问题有了,接下来咱们就一步一步的来解决这些问题。

同构才是核心

react ssr 的核心就是同构,没有同构的 ssr 是没有意义的。

所谓同构就是采用一套代码,构建双端(server 和 client)逻辑,最大限度的重用代码,不用维护两套代码。而传统的服务端渲染是没法作到的,react 的出现打破了这个瓶颈,而且如今已经获得了比较普遍的应用。

路由同构

双端使用同一套路由规则,node server 经过req url path 进行组件的查找,获得须要渲染的组件。

//组件和路由配置 ,供双端使用 routes-config.js

class Detail extends React.Component{

    render(){
        return <div>detail</div>
    }
}

class Index extends React.Component {

    render() {
        return <div>index</div>
    }
}


const routes = [
  
            {
                path: "/",
                exact: true,
                component: Home
            },
            {
                path: '/detail', exact: true,
                component:Detail,
            },
            {
                path: '/detail/:a/:b', exact: true,
                component: Detail
            }
         
];

//导出路由表
export default routes;

//客户端 路由组件

import routes from './routes-config.js';

function App(){
    return (
        <Layout>
            <Switch>

                        {
                            routes.map((item,index)=>{
                                return <Route path={item.path} key={index} exact={item.exact} render={item.component}></Route>
                            })
                        }
            </Switch>
        </Layout>
    );
}

export default App;

node server 进行组件查找

路由匹配其实就是对 组件path 规则的匹配,若是规则不复杂能够本身写,若是状况不少种仍是使用官方提供的库来完成。

matchRoutes(routes, pathname)

//引入官方库
import { matchRoutes } from "react-router-config";
import routes from './routes-config.js';

const path = req.path;

const branch = matchRoutes(routes, path);

//获得要渲染的组件
const Component = branch[0].route.component;
 

//node server 
http.createServer((req, res) => {
    
        const url = req.url;
        //简单容错,排除图片等资源文件的请求
        if(url.indexOf('.')>-1) { res.end(''); return false;}

        res.writeHead(200, {
            'Content-Type': 'text/html'
        });
        const data = fetch();

        //查找组件
        const branch =  matchRoutes(routes,url);
        
        //获得组件
        const Component = branch[0].route.component;

        //将组件渲染为 html 字符串
        const html = renderToString(<Component data={data}/>);

        res.end(html);
        
 }).listen(8080);

能够看下matchRoutes方法的返回值,其中route.component 就是 要渲染的组件

[
    { 
    
    route:
        { path: '/detail', exact: true, component: [Function: Detail] },
    match:
        { path: '/detail', url: '/detail', isExact: true, params: {} } 
        
    }
   ]

react-router-config 这个库由react 官方维护,功能是实现嵌套路由的查找,代码没有多少,有兴趣能够看看。

文章走到这里,相信你已经知道了路由同构,因此上面的第一个问题 : 【双端路由如何维护?】 解决了。

数据同构(预取同构)

这里开始解决咱们最开始发现的第二个问题 - 【获取数据的方法和逻辑写在哪里?】

数据预取同构,解决双端如何使用同一套数据请求方法来进行数据请求。

先说下流程,在查找到要渲染的组件后,须要预先获得此组件所须要的数据,而后将数据传递给组件后,再进行组件的渲染。

咱们能够经过给组件定义静态方法来处理,组件内定义异步数据请求的方法也合情合理,同时声明为静态(static),在 server 端和组件内都也能够直接经过组件(function) 来进行访问。

好比 Index.getInitialProps

//组件
class Index extends React.Component{
    constructor(props){
        super(props);
    }

    //数据预取方法  静态 异步 方法
    static async  getInitialProps(opt) {
        const fetch1 =await fetch('/xxx.com/a');
        const fetch2 = await fetch('/xxx.com/b');

        return {
            res:[fetch1,fetch2]
        }
    }

    render(){
        return <h1>{this.props.data.title}</h1>
    }
}


//node server 
http.createServer((req, res) => {
    
        const url = req.url;
        if(url.indexOf('.')>-1) { res.end(''); return false;}

        res.writeHead(200, {
            'Content-Type': 'text/html'
        });
        
        //组件查找
        const branch =  matchRoutes(routes,url);
        
        //获得组件
        const Component = branch[0].route.component;
    
        //数据预取
        const data = Component.getInitialProps(branch[0].match.params);
      
        //传入数据,渲染组件为 html 字符串
        const html = renderToString(<Component data={data}/>);

        res.end(html);

 }).listen(8080);

另外还有在声明路由的时候把数据请求方法关联到路由中,好比定一个 loadData 方法,而后在查找到路由后就能够判断是否存在loadData这个方法。

看下参考代码

const loadBranchData = (location) => {
  const branch = matchRoutes(routes, location.pathname)

  const promises = branch.map(({ route, match }) => {
    return route.loadData
      ? route.loadData(match)
      : Promise.resolve(null)
  })

  return Promise.all(promises)
}

上面这种方式实现上没什么问题,但从职责划分的角度来讲有些不够清晰,我仍是比较喜欢直接经过组件来获得异步方法。

好了,到这里咱们的第二个问题 - 【获取数据的方法和逻辑写在哪里?】 解决了。

渲染同构

假设咱们如今基于上面已经实现的代码,同时咱们也使用 webpack 进行了配置,对代码进行了转换和打包,整个服务能够跑起来。

路由可以正确匹配,数据预取正常,服务端能够直出组件的 html ,浏览器加载 js 代码正常,查看网页源代码能看到 html 内容,好像咱们的整个流程已经走完。

可是当浏览器端的 js 执行完成后,发现数据从新请求了,组件的从新渲染致使页面看上去有些闪烁。

这是由于在浏览器端,双端节点对比失败,致使组件从新渲染,也就是只有当服务端和浏览器端渲染的组件具备相同的props 和 DOM 结构的时候,组件才能只渲染一次。

刚刚咱们实现了双端的数据预取同构,可是数据也仅仅是服务端有,浏览器端是没有这个数据,当客户端进行首次组件渲染的时候没有初始化的数据,渲染出的节点确定和服务端直出的节点不一样,致使组件从新渲染。

数据注水

在服务端将预取的数据注入到浏览器,使浏览器端能够访问到,客户端进行渲染前将数据传入对应的组件便可,这样就保证了props的一致。

//node server  参考代码
http.createServer((req, res) => {
    
        const url = req.url;
        if(url.indexOf('.')>-1) { res.end(''); return false;}

        res.writeHead(200, {
            'Content-Type': 'text/html'
        });

        console.log(url);
       
        //查找组件
        const branch =  matchRoutes(routes,url);
        //获得组件
        const Component = branch[0].route.component;

        //数据预取
        const data = Component.getInitialProps(branch[0].match.params);

        //组件渲染为 html
        const html = renderToString(<Component data={data}/>);

        //数据注水
        const propsData = `<textarea style="display:none" id="krs-server-render-data-BOX">${JSON.stringify(data)}</textarea>`;

        // 经过 ejs 模板引擎将数据注入到页面
        ejs.renderFile('./index.html', {
            htmlContent: html,  
            propsData
        },  // 渲染的数据key: 对应到了ejs中的index
            (err, data) => {
                if (err) {
                    console.log(err);
                } else {
                    console.log(data);
                    res.end(data);
                }
            })

 }).listen(8080);
 
 //node ejs html
 
 <!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
</head>

<body>
    <div id="rootEle">
        <%- htmlContent %> //组件 html内容
    </div>
    
    <%- propsData %> //组件 init  state ,如今是个字符串
</body>

</html>
</body>

须要借助 ejs 模板,将数据绑定到页面上,为了防止 XSS攻击,这里我把数据写到了 textarea 标签里。

下图中,我看着明文数据难受,对数据作了base64编码 ,用以前须要转码,看我的须要。

数据脱水

上一步数据已经注入到了浏览器端,这一步要在客户端组件渲染前先拿到数据,而且传入组件就能够了。

客户端能够直接使用id=krs-server-render-data-BOX 进行数据获取。

第一个方法简单粗暴,可直接在组件内的constructor 构造函数 内进行获取,若是怕代码重复,能够写一个高阶组件。

第二个方法能够经过 context 传递,只须要在入口处传入,在组件中声明 static contextType 便可。

我是采用context 传递,为了后面方便集成 redux 状态管理 。

// 定义 context 生产者 组件

import React,{createContext} from 'react';
import RootContext from './route-context';

export default class Index extends React.Component {
    constructor(props,context) {
        super(props);
    }

    render() {
        return <RootContext.Provider value={this.props.initialData||{}}>
            {this.props.children}
        </RootContext.Provider>
    }
}

//入口  app.js
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import Routes from '../';
import Provider from './provider';


//渲染入口  接收脱水数据
function renderUI(initialData) {
    ReactDOM.hydrate(<BrowserRouter><Provider initialData={initialData}>
        <Routes />
    </Provider>
    </BrowserRouter>, document.getElementById('rootEle'), (e) => {
    });
}

//函数执行入口
function entryIndex() {
    let APP_INIT_DATA = {};
    let state = true;

    //取得数据
    let stateText = document.getElementById('krs-server-render-data-BOX');

    if (stateText) {
        APP_INIT_DATA = JSON.parse(stateText.value || '{}');
    }


    if (APP_INIT_DATA) {//客户端渲染
        
        renderUI(APP_INIT_DATA);
    }
}

//入口执行
entryIndex();

行文至此,核心的内容已经基本说完,剩下的就是组件内如何使用脱水的数据。

下面经过 context 拿到数据 , 代码仅供参考,可根据本身的需求来进行封装和调整。

import React from 'react';
import './css/index.scss';

export default class Index extends React.Component {

    constructor(props, context) {
        super(props, context);

        //将context 存储到 state 
        this.state = {
            ... context
        }

    }

    //设置此参数 才能拿到 context 数据
    static contextType = RootContext;

    //数据预取方法
    static async getInitialProps(krsOpt) {

        if (__SERVER__) {
            //若是是服务端渲染的话  能够作的处理,node 端设置的全局变量
        }

        const fetch1 = fetch.postForm('/fe_api/filed-manager/get-detail-of-type', {
            data: { ofTypeId: 4000 }
        });

        const fecth2 = fetch.postForm('/fe_api/filed-manager/get-detail-of-type', {
            data: { ofTypeId: 2000 }
        });

        const resArr = await fetch.multipleFetch(fetch1, fecth2);
        //返回全部数据
        return {
            page: {},
            fetchData: resArr
        }
    }

    componentDidMount() {
        if (!this.isSSR) { //非服务端渲染须要自身进行数据获取
            Index.getInitialProps(this.props.krsOpt).then(data => {
                this.setState({
                    ...data
                }, () => {
                   //可有的一些操做
                });
            });
        }
    }

    render() {

        //获得 state 内的数据,进行逻辑判断和容错,而后渲染
        const { page, fetchData } = this.state;
        const [res] = fetchData || [];

        return <div className="detailBox">
            {
                res && res.data.map(item => {
                    return <div key={item.id}>{item.keyId}:{item.keyName}---{item.setContent}</div>
                })
            }
        </div>
    }
}

到此咱们的第三个问题:【服务端 html 节点没法重用 】已经解决,但人不够完美,请继续看。

css 过滤

咱们在写组件的时候大部分都会导入相关的 css 文件。

import './css/index.scss';//导入css

//组件
class Index extends React.Component{
    constructor(props){
        super(props);
    }


    static async  getInitialProps() {
        const fetch1 =await fetch('/xxx.com/a');
        const fetch2 = await fetch('/xxx.com/b');

        return {
            res:[fetch1,fetch2]
        }
    }

    render(){
        return <h1>{this.props.data.title}</h1>
    }
}

可是这个 css 文件在服务端没法执行,其实想一想在服务端原本就不须要渲染 css 。为何不直接干掉? 因此为了方便,我这里写了一个babel 插件,在编译的时候干掉 css 的导入代码。

/**
 * 删除 css 的引入
 * 可能社区已经有现成的插件可是不想费劲儿找了,仍是本身写一个吧。 
 */
module.exports = function ({ types: babelTypes }) {
    return {
        name: "no-require-css",
        visitor: {
            ImportDeclaration(path, state) {
                let importFile = path.node.source.value;
                if(importFile.indexOf('.scss')>-1){
                    // 干掉css 导入
                    path.remove();
                }
            }
        }
    };
};

//.babelrc 中使用

 "plugins": [
                "./webpack/babel/plugin/no-require-css"  //引入        
            ]

动态路由的 SSR

如今要说一个更加核心的内容,也是本文的一个压轴亮点,能够说是全网惟一,我以前也看过不少文章和资料都没有细说这一起的实现。

不知道你有没有发现,上面咱们已经一步一步的实现了 React SSR 同构 的完整流程,可是总感受少点什么东西。

SPA模式下大部分都会实现组件分包和按需加载,防止全部代码打包在一个文件过大影响页面的加载和渲染,影响用户体验。

那么基于 SSR 的组件按需加载如何实现呢?

固然咱们所限定按需的粒度是路由级别的,请求不一样的路由动态加载对应的组件。

如何实现组件的按需加载?

webpack2 时期主要使用require.ensure方法来实现按需加载,他会单独打包指定的文件,在当下 webpack4,有了更加规范的的方式实现按需加载,那就是动态导入 import('./xx.js'),固然实现的效果和 require.ensure是相同的。

我们这里只说如何借助这个规范实现按需加载的路由,关于动态导入的实现原理先按下不表。

咱们都知道 import 方法传入一个js文件地址,返回值是一个 promise 对象,而后在 then 方法内回调获得按需的组件。他的原理其实就是经过 jsonp 的方式,动态请求脚本,而后在回调内获得组件。

import('../index').then(res=>{
    //xxxx
});

那如今咱们已经获得了几个比较有用的信息。

  • 如何加载脚本 - import 结合 webpack 自动完成
  • 脚本是否加载完成 - 经过在 then 方法回调进行处理
  • 获取异步按组件 - 经过在 then 方法回调内获取

咱们能够试着把上面的逻辑抽象成为一个组件,而后在路由配置的地方进行导入后,那么是否是就完成了组件的按需加载呢?

先看下按需加载组件, 目的是在 import 完成的时候获得按需的组件,而后更改容器组件的 state,将这个异步组件进行渲染。

/**
 * 按需加载的容器组件
 * @class Bundle
 * @extends {Component}
 */
export default class Async extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            COMPT: null
        };
    }

    UNSAFE_componentWillMount() {
        //执行组件加载
        if (!this.state.COMPT) {
            this.load(this.props);
        }
    }


    load(props) {
        this.setState({
            COMPT: null
        });
        //注意这里,返回Promise对象; C.default 指向按需组件
        props.load().then((C) => {
            this.setState({
                COMPT: C.default ? C.default : COMPT
            });
        });
    }

    render() {
        return this.state.COMPT ? this.props.children(this.state.COMPT) : <span>正在加载......</span>;
    }
}

Async 容器组件接收一个 props 传过来的 load 方法,返回值是 Promise类型,用来动态导入组件。

在生命周期 UNSAFE_componentWillMount 获得按需的组件,并将组件存储到 state.COMPT内,同时在 render 方法中判断这个状态的可用性,而后调用this.props.children 方法进行渲染。

//调用
const LazyPageCom = (props) => (
    <Async load={() => import('../index')}>
        {(C) => <C {...props} />}//返回函数组件
    </Async>
);

固然这只是其中一种方法,也有不少是经过 react-loadable 库来进行实现,可是实现思路基本相同,有兴趣的能够看下源码。

//参考代码
import React from 'react';
import Loadable from 'react-loadable';

//loading 组件
const Loading =()=>{
    return (
        <div>loading</div>
    ) 
}

//导出组件
export default Loadable({
    loader:import('../index'),
    loading:Loading
});

到这里咱们已经实现了组件的按需加载,剩下就是配置到路由。

看下伪代码

//index.js

class Index extends React.Component {

    render() {
        return <div>detail</div>
    }
}


//detail.js

class Detail extends React.Component {

    render() {
        return <div>detail</div>
    }
}

//routes.js

//按需加载 index 组件
const AyncIndex = (props) => (
    <Async load={() => import('../index')}>
        {(C) => <C {...props} />}
    </Async>
);

//按需加载 detai 组件
const AyncDetail = (props) => (
    <Async load={() => import('../index')}>
        {(C) => <C {...props} />}
    </Async>
);

const routes = [

    {
        path: "/",
        exact: true,
        component: AyncIndex
    },
    {
        path: '/detail', exact: true,
        component: AyncDetail,
    }
];

结合路由的按需加载已经配置完成,先无论 server端 是否须要进行调整,此时的代码是能够运行的,按需也是 ok 的。

可是ssr无效了,查看网页源代码无内容。

动态路由 SSR 双端配置

ssr无效了,这是什么缘由呢?

上面咱们在作路由同构的时候,双端使用的是同一个 route配置文件routes-config.js,如今组件改为了按需加载,因此在路由查找后获得的组件发生改变了 - AyncDetail,AyncIndex,根本没法转换出组件内容。

ssr 模式下 server 端如何处理路由按需加载

其实很简单,也是参考客户端的处理方式,对路由配置进行二次处理。server 端在进行组件查找前,强制执行 import 方法,获得一个全新的静态路由表,再去进行组件的查找。

//得到静态路由

import routes from 'routes-config.js';//获得动态路由的配置

export async function getStaticRoutes() {

    const staticRoutes = [];//存放新路由

    for (; i < len; i++) {
        let item = routes[i];
       
        //存放静态路由
        staticRoutes.push({
            ...item,
            ...{
                component: (await item.component().props.load()).default
            }
        });
  
    }
    return staticRoutes; //返回静态路由
}

现在咱们离目标更近了一步,server端已兼容了按需路由的查找。可是还没完!

咱们这个时候访问页面的话,ssr 生效了,查看网页源代码能够看到对应的 html 内容。

可是页面上会显示直出的内容,而后显示<span>正在加载......</span> ,瞬间又变成直出的内容。

### ssr 模式下 client 端如何处理路由按需加载

这个是为何呢?

是否是看的有点累了,再坚持一下就成功了。

其实有问题才是最好的学习方式,问题解决了,路就通了。

首先咱们知道浏览器端会对已有的节点进行双端对比,若是对比失败就会从新渲染,这很明显就是个问题。

咱分析一下,首先服务端直出了 html 内容,而此时浏览器端js执行完后须要作按需加载,在按需加载前的组件默认的内容就是<span>正在加载......</span> 这个缺省内容和服务端直出的 html 内容彻底不一样,因此对比失败,页面会渲染成 <span>正在加载......</span>,而后按需加载完成后组件再次渲染,此时渲染的就是真正的组件了。

如何解决呢?

其实也并不复杂,只是不肯定是否可行,试过就知道。

既然客户端须要处理按需,那么咱们等这个按需组件加载完后再进行渲染是否是就能够了呢?

答案是:能够的!

如何按需呢?

向“服务端同窗”学习,找到对应的组件并强制 执行import按需,只是这里不是转换为静态路由,只找到按需的组件完成动态加载便可。

既然有了思路,那就撸起代码。

import React,{createContext} from 'react';
import RootContext from './route-context';

export default class Index extends React.Component {
    constructor(props,context) {
        super(props);
    }

    render() {
        return <RootContext.Provider value={this.props.initialData||{}}>
            {this.props.children}
        </RootContext.Provider>
    }
}

//入口  app.js
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import Routes from '../';
import Provider from './provider';


//渲染入口
function renderUI(initialData) {
    ReactDOM.hydrate(<BrowserRouter><Provider initialData={initialData}>
        <Routes />
    </Provider>
    </BrowserRouter>, document.getElementById('rootEle'), (e) => {
    });
}

function entryIndex() {
    let APP_INIT_DATA = {};
    let state = true;

    //取得数据
    let stateText = document.getElementById('krs-server-render-data-BOX');
    
    //数据脱水
    if (stateText) {
        APP_INIT_DATA = JSON.parse(stateText.value || '{}');
    }


    if (APP_INIT_DATA) {//客户端渲染
        
        - renderUI(true, APP_INIT_DATA);
        //查找组件
        + matchComponent(document.location.pathname, routesConfig()).then(res => {
            renderUI(true, APP_INIT_DATA);
        });
    }
}

//执行入口
entryIndex();

matchComponent 是我封装的一个组件查找的方法,在文章开始已经介绍过相似的实现,代码就不贴了。

核心亮点说完,整个流程基本结束,剩下的都是些有的没的了,我打算要收工了。

其余

SEO 支持

页面的 SEO 效果取决于页面的主体内容和页面的 TDK(标题 title,描述 description,关键词 keyword)以及关键词的分布和密度,如今咱们实现了 ssr因此页面的主体内容有了,那如何设置页面的标题而且让每一个页面(路由)的标题都不一样呢?

只要咱们每请求一个路由的时候返回不一样的 tdk 就能够了。

这里我在所对应组件数据预取的方法内加了约定,返回的数据为固定格式,必须包含 page 对象,page 对象内包含 tdk 的信息。

看代码瞬间就明白。

import './css/index.scss';

//组件
class Index extends React.Component{
    constructor(props){
        super(props);
    }

    static async  getInitialProps() {
        const fetch1 =await fetch('/xxx.com/a');
        const fetch2 = await fetch('/xxx.com/b');

        return {
            page:{
                tdk:{
                    title:'标题',
                    keyword:'关键词',
                    description:'描述'
                }
            }
            res:[fetch1,fetch2]
        }
    }

    render(){
        return <h1>{this.props.data.title}</h1>
    }
}

这样你的 tdk 能够根据你的须要设置成静态仍是从接口拿到的。而后能够在 esj 模板里进行绑定,也能够在 componentDidMount经过 js
document.title=this.state.page.tdk.title设置页面的标题。

<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <meta http-equiv="X-UA-Compatible" content="ie=edge">
   <meta name="keywords" content="<%=page.tdk.keyword%>" />
   <meta name="description" content="content="<%=page.tdk.description%>" />
   <title><%=page.tdk.title%></title>
</head>
<body>
   <div id="rootEle">
       <%- htmlContent %>
   </div>
   <%- propsData %>
</body>
</html>
</body>
<%page.staticSource.js.forEach(function(item){%>

fetch 同构

可使用isomorphic-fetchaxios或者whatwg-fetch + node-fetch 等库来实现支持双端的 fetch 数据请求,这里推荐使用axios 主要是比较方便。

TODO 和 思考

没有介绍结合 redux 状态管理的 ssr 实现,其实也不复杂,关键仍是看业务中是否须要使用redux,由于文中已经实现了使用 context 传递数据,直接改为按store 传递也很容易,可是更多的仍是对 react-redux 的应用。

//渲染入口 代码仅供参考 
function renderUI(initialData) {
   ReactDOM.hydrate(<BrowserRouter><Provider store={initialData}>
       <Routes />
   </Provider>
   </BrowserRouter>, document.getElementById('rootEle'), (e) => {
   });
}

服务端同构渲染虽然能够提高首屏的出现时间,利于 SEO,对低端用户友好,可是开发复杂度有所提升,代码须要兼容双端运行(runtime),还有一些库只能在浏览器端运行,在服务端加载会直接报错,这种状况就须要进行作一些特殊处理。

同时也会大大的增长服务端负载,固然这都容易解决,能够改用renderToNodeStream() 方法经过流式输出来提高服务端渲染性能,能够进行监控和扩容,因此是否须要 ssr 模式,还要看具体的产品线和用户定位。

最后

本文最初从 react ssr 的总体实现原理上进行说明,而后逐步的抛出问题,按部就班的逐步解决,最终完成了整个React SSR 所须要处理的技术点,同时对每一个技术点和问题作了详细的说明。

但实现方式并不惟一,还有不少其余的方式, 好比 next.js, umi.js,可是原理类似,具体差别我会接下来进行对比后输出。

源码参考

因为上面文中的代码较为零散,恐怕不能直接运行。为了方便你们的参考和学习,我把涉及到代码进行整理、完善和修改,增长了一些基础配置和工程化处理,目前已造成一个完整的开发骨架,能够直接运行看效果,全部的代码都在这个骨架里,欢迎star 欢迎 下载,交流学习。

项目代码地址: https://github.com/Bigerfe/koa-react-ssr

说点感想

不少东西均可以基于你现有的知识创造出来。

只要明白了其中的原理,而后梳理出实现的思路,剩下的就是撸代码了,期间会大量的自动或被动的从你现有的知识库里进行调取,一步一步的,只要不怕麻烦,都能搞得定。

这也是我为何上来先要说下reac ssr 原理 的缘由,由于它指导了个人实践。

全文都是本身亲手一个一个码出,也所有都是出自本人的理解,但我的文采有限,因此致使不少表达说的都是大白话,表达不够清楚的地方还请指出和斧正,可是真正的核心已所有涵盖。

但愿本文的内容对你有所帮助,也能够对得住我这个自信的标题。

参考资料

https://github.com/ReactTrain...
https://reacttraining.com/rea...
https://blog.seosiwei.com/det...
https://www.jianshu.com/p/47c...


更多精彩好玩有用的前端内容,请关注公众号《前端张大胖》

相关文章
相关标签/搜索