React SSR 服务端渲染和同构原理

1,627 阅读7分钟

为什么会出现同构?

1.由于现在前端在用reactvue等框架开发的时候,都是采用客户端渲染的模式,就是把所有资源通过webpack打包以后,通过浏览器加载资源,解析,渲染。但是,当随着项目越来越大,打包的资源越来越多,会使得解析打包资源的时间会变得越来越长,导致的结果是打开页面的白屏时间越来越长,用户体验变得很差。
2.客户端渲染是通过js解析,然后最终插入到一个指定DOM中,这个过程都是在js中进行的,当我们在浏览器中打开网页源代码,往往只能看到一个指定idDOM,这不利于SEO;
基于以上这两点,同构方案应运而生;

同构和服务端渲染(SSR)的区别?

服务端渲染

正常的服务端渲染都是通过模板(ejs,hbs,jade)绑定数据,在浏览器渲染就结束了。

同构

同构更像是在原有的react开发模式下,先把要渲染的页面通过renderToString转化成真实DOM,在浏览器中渲染了,然后通过加载打包的资源做后续处理;

同构开发模式难点

首先,这里需要明确的是,同构的客户端和服务端有很强的相关性,需要客户端和服务端有统一的路由,并针对同一路由渲染的dom结构必须一致,不然就会报错;你也可以理解为,在正常react项目开发的基础上,多了一层服务端通过react提供的renderToString将组件转成DOM,在浏览器中做了预渲染;

接下来,就来一一说明,如何在原有的react开发模式下,处理成同构的开发模式

难点1:服务端没有window,dom,bom这些概念,如何处理?

同构需要在服务端调用renderToString这个方法,而这个方法会执行react中的renderconstructor,由于同构的服务端和客户端用的是同一个组件,这就要求我们在开发页面的过程中,不能在renderconstructor中去使用window,dom,bom等这些服务端不认识的东西。虽然有这个限制,但也不能说开发的时候完全不能用,renderToString是不会执行componentDidMount这个生命周期,所以开发的时候可以在componentDidMount中使用这些浏览器特有的方法和属性。

难点2:静态资源管理

我这边的解决方法是把图片,js等静态资源都放在了static目录下,并在启动node服务的时候,配置该目录为资源目录,这样在开发的时候就可以统一静态资源的引用。

//我这边是用express起的服务
var express = require("express");
var app = express();
app.use(express.static(path.join(__dirname,'static')));

难点3:服务端和客户端路由的统一

来看一下,路由不统一会出现什么问题。

对于同一路由,假如说服务端和客户端对应的不是同一个页面,就会出现以上情况。所以,做同构方案的时候必须统一服务端和客户端的路由。

如何处理: 我这边的处理是把所有的页面都统一放在view目录下,目录结构如下

然后根据现有的目录结构生成一份服务端和客户端通用的路由配置文件

//文件目录
/config/routeConfig.js

module.exports = { 
  "/manage/admin/admin": "@page/manage/admin/admin.js", 
  "/manage/index/index": "@page/manage/index/index.js", 
  "/manage/menu/index": "@page/manage/menu/index.js", 
  "/manage/theme/index": "@page/manage/theme/index.js" 
}

客户端使用该路由文件

//全量加载的配置文件
import route from "../config/routeConfig";
let routeArr = [];
for(let key in route){
    //处理路由配置文件
    const context = require.context('../view/page', true, /\.jsx?$/);
    //const keys = context.keys();
    const filename = route[key].replace("@page",".");
    const Com = context(filename);
    routeArr.push({
        path:key,
        Com:Com.default
    })
}

{routeArr.map((item,index)=>{
    return <Route path={item.path} key={index} exact component={wrap(item.Com)}></Route>
})}

客户端使用就是遍历该路由文件导出的对象,配合react-route生成前端路由,都是常规操作,没有什么可以多讲的。

服务端使用该路由文件

//路由配置,完全匹配前端路由
for(let path in route){
    //添加服务端映射路由配置
    app.get(path, function (req, res){
        Com = require(route[path].replace("@page","./view/page"));
        res.render("index",{
            _html:renderToString(
               	<Com.default/>
            )
        });
    });
}

服务端使用该文件的方式也差不多,遍历该路由文件导出的对象,生成服务端路由。

当服务端和客户端都使用同一份路由文件的时候,路由对应的页面必定是统一的。

难点4:scss,less,jsx语法的处理

很多人的开发习惯是在开发页面的时候,在js,jsx文件中直接引入scss,less文件,但是在服务端处理文件的时候,并不认识scss,less的语法,也不认识jsx的语法,这样会导致服务端处理的时候会报错。

解决方法:起node服务的时候,借助babel-node,具体的可以百度相关文档,这里就不展开讲了,并配置.babelrc文件如下:

{
    "presets": [
        "@babel/preset-env",
        "@babel/preset-react"
    ],
    "plugins": [
        "@babel/plugin-transform-runtime",
        ["@babel/plugin-proposal-decorators", { "legacy": true }],
        ["@babel/plugin-proposal-class-properties",{ "loose": true }],
        [
            "babel-plugin-transform-require-ignore",
            {
              "extensions": [".scss",".less"]
            }
        ]
    ]
}

这样,可以在node中实时的编译react组件,并且忽略文件中引入的scss,less文件,但是这种方案的代价也是很大的,实时编译会比较慢,开发环境可以这样,生产环境肯定需要先编译压缩咯!

难点5*:客户端和服务端渲染初始化数据的同步(也是最为重要的一点)

为什么要同步呢?刚刚上文也有提到过,同构的话,如果服务端通过renderToString处理的结果和客户端通过webpack打包的jsrender出来的结果不一致就会报错。所以就需要保证在服务端和客户端使用数据是保持一致的。

回顾下服务端是如何使用路由配置文件的。来看下服务端的数据是如何同步给客户端的:

服务端传递数据

//假设data是服务端通过某个接口获取的数据
let data = {
	desc:"react同构"
}

//路由配置,完全匹配前端路由
for(let path in route){
    //添加服务端映射路由配置
    app.get(path, function (req, res){
        Com = require(route[path].replace("@page","./view/page"));
        res.render("index",{
            _html:renderToString(
               	<Com.default PAGE_DATA={data}/>
            ),
            _reqData:encodeURIComponent(JSON.stringify(data)),
        });
    });
}

从上面这段代码来看,先是把data当一个props传给了页面,并通过_reqData把该数据传给了模板。来看下index.hbs模板文件

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title></title>
    <script>
        window._reqData = JSON.parse(decodeURIComponent('{{{_reqData}}}'));
    </script>
</head>
<body>
    <div id="contain">{{{_html}}}</div>
</body>
</html>

模板里面做了两件事情: 1.接收了_reqData数据,并且挂在window._reqData下,这样做的目是在入口文件中可以拿到该数据,做数据的同步。 2.接收了_html(组件通过renderToString后的DOM字符串);

客户端同步数据

function wrap(Com){
    return class extends React.Component{
        render(){
            let newProps = {
                ...this.props,
                PAGE_DATA:window._reqData
            }
            return <Com {...newProps}/>
        }
    }
}

客户端只需要用高阶组件对所有的react页面做一层包装,就能同步数据啦!

数据流程图如下:

大体的开发流程就是这样,整体流程没问题,在做项目中碰到的小问题就不怕啦!

对比next.js

next.js封装程度比较高,适合全新起的一个项目。而这篇文章说的整体思路其实和next.js差不多,只不过都是自己处理的,会比较灵活,可以对已有的项目继续改造。

next.js中getInitialProps钩子的实现

next.js中提供了一个getInitialProps的静态方法,这个方法是在服务端执行的。这个方法有什么用呢?来下面一段代码:

页面组件

export default class Index extends Component{
	render(){
    	return null
    }
    static async getInitialProps(req){
        var result = await axios.post('接口地址');
        return {
            data:result
        };
    }
}

服务端

for(let path in route){
    //添加服务端映射路由配置
    app.get(path, function (req, res){
        Com = require(route[path].replace("@page","./view/page"));
        //处理静态方法
        if(Com.getInitialProps){
        	//如果静态方法存在,就先处理静态方法,然后把数据同步给客户端
            ...
        }
        ...
    });
}

从上面可以看出,服务端在拿到页面组件的时候,先会去找这个方法,如果有就执行,然后把获取的数据同步给客户端。

总结

在整个同构开发模式中,并没有大幅度的改变原有的开发习惯,就是多了比较多的限制,我个人还是能够接受的,毕竟鱼与熊掌不可兼得。相信在一步步稳定的过程中问题会越来越少。整篇文章讲的是一个实践方案,也是一个同构思路,希望能给小伙伴带来帮助吧!