记一次React应用同构(SSR)

3,147 阅读8分钟

前言

React应用同构可以达到更好的用户体验以及SEO,但是过程也较为繁杂,代码难以解耦,至于项目是否需要同构我认为就看对应的场景及投入和产出了,本文不对此做赘述

关于同构(Isomorphic),网上说法颇多,我个人认为是一份代码,服务端进行渲染,拼接html及初始化数据,浏览器端拿到html及js后在浏览器插入初始化的数据和挂载相关监听事件

关于isomorphic,也有人认为应该为Universal Rendering(看自知乎),本文将均使用"同构"一词。

此文将记录本人React应用———一个个人博客进行同构的实践、SSR效果,以及过程中所遇到的问题及解决方案,此链接为同构后上线的个人博客周立涵的博客

在参考大量的文章之后做出了此次改写,React的生态确实繁荣,方案颇多,此次实践采用的方案是同构,同时写了两套webpack,即一套React代码,两套webpack配置(分别针对浏览器端与服务端),服务端使用express进行渲染,夹带浏览器端js代码返回前端本质上还是一套代码

由于是首次改写同构,有建议之处还请大佬指出

此文将按以下顺序进展:

  • 同构效果对比
  • React同构原理
  • 同构过程遇到的问题
  • 实践中的步骤或问题
    • 服务端webpack配置
    • server/index.js结构编写
    • 服务端renderToString,浏览器端hydrate
    • 定制主题的Ant Design处理
    • react-router处理
    • Ajax数据请求处理
    • DOM处理

同构前后的效果对比

为单纯对比性能,排除网络环境影响,测试在本地开发环境下进行
同构前


FMP指标截图:

具体指标:

FP FCP FMP DCL L
1766.5ms 1766.5ms 1950.1ms 1706.1ms 4400.2.5ms

同构后


FMP指标截图:
具体指标:

FP FCP FMP DCL L
403.8ms 403.8ms 403.8ms 1343.5ms 2442.5ms

同构前后FMP降低了1546.3ms!!!
同时也肉眼可见的首屏速度有显著的提升

在不同环境下测试的结果会有差异,但基本每次测试,同构后都比同构前的FMP降低了1000+ms!

React 同构原理

React 同构简单地说即是在服务端renderToString拼接HTML字符串,请求初始化数据,返回浏览器HTML及相关静态文件(如js、css等等),在浏览器端执行React代码,插入初始化数据及挂载相关的监听事件(hydrate)

此处强烈推荐此文,非常清晰地阐述了SSRServer-Side Rendering with React, Redux, and React-Router,下图也摘自文中

应用目录结构

本博客应用使用了React + React Router + Ant Design + CSS Module
同构前的React应用结构如下

│  .babelrc
│  package-lock.json
│  package.json
│  postcss.config.js
│  webpack.common.js
│  webpack.dev.js  //开发环境webpack
│  webpack.prod.js  //生产环境webpack
│              
├─public
│      favicon.ico
│      index.html   
└─src
    │  App.css
    │  App.jsx
    │  App.module.css
    │  index.css
    │  index.js  //浏览器端入口文件
    │  
    ├─components  //组件目录
    │          
    ├─container  //应用视图目录
    │          
    └─source  //项目静态资源

同构前通过src/index.js进行浏览器端的挂载

配合express进行同构后目录如下

│  .babelrc
│  package-lock.json
│  package.json
│  postcss.config.js
│  server.js  //编译后的服务端代码
│  webpack.common.js
│  webpack.dev.js      
│  webpack.prod.js    //浏览器端webpack
│  webpack.server.js  //服务端webpack
│      
├─dist  //编译后的客户端代码
│          
│          
└─src  //项目源码
    ├─browser  //浏览器端入口文件
    │      index.js
    │      
    ├─server  //服务端入口文件
    │      index.js
    │      
    └─shared  //共享目录
        │  App.css
        │  App.jsx
        │  App.module.css
        │  index.css
        │  routesConfig.js
        │  
        ├─components
        │          
        ├─container
        │          
        └─source

同构后,浏览器访问express接口,express返回html字符串及编译后的客户端代码(dist目录下的静态资源),浏览器端挂载事件及初始化数据
目录结构的文件关系可以如下表示:

因为每次访问端口都依赖于静态资源,所以每次修改代码查看效果都需要webpack重新编译,开发过程中相对麻烦。
目前的想法是通过配置webpackDevServe在本地服务器上打通资源访问,以支持热更新,后续再研究

本应用中将dist作为静态资源目录,其中包含webpack打包后的js、css,lessc编译后的css文件等等

同构过程遇到的问题

综上,本项目使用了express + React + React Router + Ant Design + CSS Module,再加上一些简单的逻辑代码(涉及DOM的懒加载、数据请求)
我同构的步骤及解决的问题如下:

  1. 服务端webpack配置——ESM、JSX、CSS Module
  2. server/index.js结构编写
  3. 服务端renderTostring,浏览器端hydrate
  4. 抽取自定义样式的Ant Design为单独的CSS文件
  5. react-router处理——使前后端均可路由
  6. Ajax数据请求处理——使后端返回已初始化数据的html
  7. DOM处理——Node端运行js无DOM(document)、Window(window, navigator等)、Storage(localStorage, sessionStorage等)对象

接下来将按照顺序阐述步骤缘由,及采用了什么方案

1.服务端webpack配置

js可以跑在浏览器和node上,但是也存在些许的环境差异(比如是否有DOM),所以需要针对服务端(即node)进行webpack的配置
本项目主要针对了以下内容进行配置

webpack target属性——说明打包后的js运行环境为node而不是浏览器
ESM——node(v10.15.3)原生不支持import及export
JSX
CSS Module——使得在node端完成对className的hash

前三项内容可以说是必须项,最后一项得看对应项目技术栈

对于说明打包后的js运行环境为node,直接在webpack配置中设置target属性即可:

    ...
    entry: "./src/server/index.js",
    target: "node",
    ...

对于ESM、JSX及CSS Module,node端同浏览器端一样根据需要配置webpack module

2.server/index.js结构编写

我们通过访问express的API,返回对应的html,在此写出server/index.js的基本结构

import express from "express";
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom";
import React from "react";
import App from "../shared/App";

const app = express();
app.use(
  express.static("dist", {
    index: false
  })
);//指定端口使用的静态资源目录,dist目录下放的是打包后的客户端静态资源;index为false表示关闭index.**文件索引

app.get("路径",  (req,res)=>{
    /**
     * 客户端访问接口,接口返回html
    **/
    const reactComhtml = ...;//react元素获取html字符串,reactComhtml变量值见下文
    const theHtml = ...;//拼接成完整的html,theHtml变量值见下文
    res.setHeader("Content-Type", "text/html");//设置响应头
    res.send(theHtml); //返回html
});

3.服务端renderToString,浏览器端hydrate

这里介绍两个API,renderToStringhydrate
renderToString在服务端将React元素渲染为初始的HTML

// server/index.js
  const reactComHtml = ReactDOMServer.renderToString(
    <StaticRouter>
      <App reqPathname={req.path} />
    </StaticRouter>
);
  const theHtml = `
      <html>
      <head>
       ...
      </head>
      <body>
          <div id="App">${reactComHtml}</div>    //拼接上html字符串
          <script src="{...静态资源目录}/main.js" charset="utf-8"></script> //携带webpack打包后的浏览器端静态资源,比如js文件
      </body>
      </html>
  `;

hydrate在浏览器端对存在元素挂载监听事件。React希望服务端渲染的内容与浏览器渲染的内容一致。同时,React可以对文字内容的不一致进行修补,但我们最好不要人为地引入不一致,而且这一修补机制并不能保证完全正确。在开发环境下,若发现内容不一致,React会发出警告,比如Warning: Expected server HTML to contain a matching <div> in <div>.。当然,造成这一警告的原因也有很多,比如本应用中使用了react-markdown组件,对markdown文本进行了处理,使得服务端与客户端部分内容不一致,开发环境下就抛出了警告,关于hydrate更多可查看官网说明hydrate

// brpwser/index.js
const app = document.getElementById("App");
app? ReactDOM.hydrate(
      <BrowserRouter>
        <App />
      </BrowserRouter>,app) : false;

由此,服务端可以输出初始化的HTML(此处还未填充初始化数据),浏览器挂载事件供用户进行交互操作,以此`提升了SEO`和`极大地缩短了首屏白屏时间`

4.定制主题的Ant Design处理

本项目使用了Ant Design,并且配置了babel-plugin-import进行按需加载,所以若进行定制主题,则需要将babel-plugin-import的style配置改为true,但是这就导致了antd样式没有作为单独的CSS文件抽取出来,而是通过js运行,在DOM中插入<style>标签形成样式。

这在CSR中是没有问题的,因为在插入<style>标签之前用户看不到网页内容,但是现在用户打开网页第一眼就能看到完整的HTML内容,js运行后才插入<style>样式,就会造成页面样式“突变”的效果。

于是我将babel-plugin-import中的style的值设置为false,样式将不会通过js生成style标签插入。
对于css样式,antd使用less作为开发语言,其中定义了一些样式变量,可以使用lessc生成.css文件,作为静态资源使用

//比如此处修改了两个样式变量@primary-color与@link-hover-color
lessc --js --modify-var=@primary-color=#ffc34e --modify-var=@link-hover-color=#ffc34e ./node_modules/antd/dist/antd.less > dist/ant.css

其中dist为静态资源目录
至此,自定义的样式文件在返回的html中引用即可

  const theHtml = `
      <html>
      <head>
      <title>${title}</title>
      <link rel="shortcut icon" href="${...静态资源目录}/favicon.ico">
      <link rel="stylesheet" type="text/css" href="${...静态资源目录}/ant.css"> //引用antd样式文件
      <link rel="stylesheet" type="text/css" href="${...静态资源目录}/main.css">
      ...
      </head>
      <body>
          <div id="App">${reactComHtml}</div>
          <script src="${...静态资源目录}/main.js" charset="utf-8"></script>
      </body>
      </html>
  `;

5.react-router

在CSR下,路由交给前端控制,后端没有对应的路由,所以如果前端进入了某个路径,刷新了网页,后台会返回404。举个例子:

某项目为CSR,使用了react-router,主页为www.somebody.com,我们浏览器进入了www.somebody.com/test,此时刷新,或者将此链接分享给好友,返回的都会是404,因为后台对应路径下并不存在html文件或者服务。StackOverflow上对此有多种解决方案

同构也能解决此问题。
在服务端,我们使用进行路由,浏览器同样是使用

// server/index.js
const reactComHtml = renderToString(
  <StaticRouter location={req.url}>
    <App />
  </StaticRouter>
);
const theHtml = `
      <html>
      <head>
      <title>Home</title>
      <link rel="shortcut icon" href="${...静态资源目录}/favicon.ico">
      <link rel="stylesheet" type="text/css" href="${...静态资源目录}/ant.css">
      <link rel="stylesheet" type="text/css" href="${...静态资源目录}/main.css">
      ...
      </head>
      <body>
          <div id="App">${reactComHtml}</div>
          <script src="/main.js" charset="utf-8"></script>
      </body>
      </html>
`;

// browser/index.js
const app = document.getElementById("App");
app ? ReactDOM.hydrate(
      <BrowserRouter>
        <App />
      </BrowserRouter>,app): false;

通过StaticRouter的location属性填入路径,我们能获得对应的路由html,拼接成html返回给浏览器

6.Ajax数据请求处理

前后端分离下前端基本通过ajax请求向后端拉取数据,而SSR下我们希望返回的html包含初始数据。
那这里有两个问题:a.如何在服务端请求数据?b.如何将数据进行呈现?

a.如何在服务端请求数据

同样是通过Ajax向端口请求数据,但是要注意异步与环境问题。
对于异步,当需要请求多个接口时,需要做好对异步的处理,保证能将请求的数据传给html,比如可以使用Promise进行处理
对于环境问题,要注意使用的Ajax库必须能跑在node上,而不是只能跑在浏览器环境上。我在这里使用的是axios

app.get("路径",(req, res)=>{
  axios.get(url)//ajax请求接口
      .then((response)=>{//获取数据
          const data = response.data;
          const context = data;
          //如何呈现见下文
          const reactComHtml = ...;
          const theHtml = ...;
          res.setHeader("Content-Type", "text/html");
          res.send(theHtml);
      })
})


在通过Ajax获取数据后,我们就要进行如何将数据呈现的步骤了

b.如何将数据进行呈现

如何将数据进行呈现有非常多的方案,此处本人的方案是StaticRouter的context属性 + 自定义window.__ROUTE_DATE__属性进行(这里的__ROUTE_DATA__属性是自定义的,你可以使用任何与window对象不冲突的属性)
补充上文的代码

  // server/index.js
  const reactComHtml = renderToString(
    <StaticRouter location={req.url} context={context}>
      <App reqPathname={req.path} />
    </StaticRouter>
  );
  const theHtml = `
      <html>
      <head>
      <title>${title}</title>
      <link rel="shortcut icon" href="${...静态资源目录}/favicon.ico">
      <link rel="stylesheet" type="text/css" href="${...静态资源目录}/ant.css">
      <link rel="stylesheet" type="text/css" href="${...静态资源目录}/main.css">
      <script>window.__ROUTE_DATA__ = ${JSON.stringify(context)}</script> //以字符串的形式传递给html,在浏览器端使用此数据
      </head>
      <body>
          <div id="App">${reactComHtml}</div>
          <script src="${...静态资源目录}/main.js" charset="utf-8"></script>
      </body>
      </html>
  `;


  //  举例:shared/container/Article/index.jsx  /article/:articleId路由的对应组件
  class Article extends Component {
  constructor(props) {
    super(props);
    const staticContext = props.staticContext;

    if (staticContext) {
      //S端运行
      const { title, content, time, tag } = staticContext;
      this.state = {
        title: title,
        content: content,
        time: time,
        tag: tag
      };
    } else {
      //B端运行
      const data = window.__ROUTE_DATA__;
      this.state = {
        content: data.content,
        title: data.title,
        time: data.time,
        tag: data.tag
      };
      delete window.__ROUTE_DATA__;
    }
  }
  ...
  ...

运行于服务端,props.staticContext也仅存于服务端
服务端上通过staticContext取值,renderToString输出HTML字符串
浏览器端上通过window.__ROUTE_DATA__取值,hydrate对已存在的元素挂载监听事件

7.对DOM

由于node上不存在DOM、Window、Storage对象,所以需要进行判断处理,本应用举例对DOM的处理
封装函数判断是否在浏览器环境

const isBrowser = ()=>{
    return typeof window !== "undefined";
}
export default isBrowser;

在对应的涉及到DOM的代码中,通过isBrowser判断当前是否为浏览器环境,是则执行相应操作,不是则不执行

  constructor(props) {
    super(props);
    //举例
    if (isBrowser() === true) {
      this.handleDom = this.handleDom.bind(this);
      this.handleDom();
    }
  }

结语

经过此次实践后,对同构、服务端渲染、node、React、react router等有了更清楚的认识和了解,也着实体会到了SSR带来的首屏速度提升。但是同构过程也相对繁琐,本次改写仅仅是一个非常简单的博客应用,也暂时不存在性能问题,若是复杂应用则必定会是大工程。

SSR带来的好处就是SEO首屏速度的提升,对原应用进行同构的过程也着实繁琐。现阶段,需不需要将CSR改为SSR私以为得看具体场景,得看投入和产出是否值得。

未来,随着浏览器的升级、搜索引擎爬虫的进步、网络的提升,是否能直接解决CSR在SEO和首屏速度的问题呢?可是这未来会不会特别远呢?会不会又有新的方案解决呢?未来可真是令人好奇
以上有不当之处请大佬指出。

参考资料

本次学习参考了比较多的文章,很多实现方法各有不同,也存在着对应的可以优化的问题。
什么是前端同构——知乎
Server-Side Rendering with React, Redux, and React-Router(强烈推荐此文)
User-centric Performance Metrics(包含性能指标阐述)
Ant Design定制主题
react-router官网——Server Rendering
Stack Overflow React-router urls don't work when refreshing or writing manually
An Introduction to React Server-Side Rendering
Using React Router 4 with Server-Side Rendering(浏览器端上,数据初始化放在了不是特别恰当的生命周期)
YouTube——ReactCasts #12 - Server Side Rendering
React Server Side Rendering with Express(比较推荐的入门实践文章,但是中间多了不必要的大胡子语法)
Tips for server-side rendering with React