React服务端渲染的原理

a79fa9b32da7437f2f2af3de4c5d5469.jpeg

2f3eb12a0ba6376fcab4482f8dd95a4a.gif 

本文字数:21279

预计阅读时间:54分钟

1、前言

在前端项目需要首屏渲染速度优化或SEO的场景下,大家或多或少都听过到过服务端渲染( SSR ),但大多数人对服务端渲染具体实现和底层原理还是比较陌生的。本文基于公司官网开发时做了服务端渲染改造基础上,系统理解和梳理这套体系的模式和逻辑,并写了一些笔记和Demo(文后链接)便于深入理解。这次我们来以React为例,把服务端渲染彻底讲弄明白。本文主要有以下内容:

  • 什么是服务端渲染,与客户端渲染的区别是什么?

  • 为什么需要服务端渲染,服务端渲染的利弊是什么?

  • 服务端渲染的原理是什么?

  • 如何对React项目进行同构?

  • react服务端渲染的一些框架

  • 服务端渲染一些新的API

1.1 什么是服务端渲染?

服务端渲染, SSR (Server-side Rendering) ,顾名思义,就是在浏览器发起页面请求后由服务端完成页面的HTML结构拼接,返回给浏览器解析后能直接构建出有内容的页面。

用 node 实现一个简单的 SSR

我们使用Koa框架来创建node服务:

//  demo1
var Koa = require("koa");
var app = new Koa();

// 对于任何请求,app将调用该函数处理请求:
app.use(async (ctx) => {
  // 将HTML字符串直接返回 
  ctx.body = `
    <html>
      <head>
         <title>ssr</title>
        </head>
        <body>
        <div id="root">
            <h1>hello server</h1>
            <p>word</p>
        </div>
      </body> 
      </html>`;
});
//监听
app.listen(3001, () => {
  console.log("listen on 3001 port!");
});

启动服务后访问页面,查看网页源代码是这样:

5ffe99f53298380eeb3344f69f50b3cc.png
npx create-react-app my-app

上面的例子就是一个简单的服务端渲染,其服务侧直接输出了有内容的HTML,浏览器解析之后就能渲染出页面。与服务端渲染对应的是客户端渲染 ,CSR(Client Side Rendering),通俗的讲就是由客户端完成页面的渲染。其大致渲染流程是这样:在浏览器请求页面时,服务端先返回一个无具体内容的HTML,浏览器还需要再加载并执行JS,动态地将内容和数据渲染到页面中,才能完成页面具体内容的显示。目前主流的React ,Vue, Angular 等SPA页面未经特殊处理均采用客户端渲染。最常见脚手架create-react-app 生成的项目就是客户端渲染:

71003604293bb06dd695bbaad10d2101.jpeg e25cd7433ba2cbb5fb82ff005688192b.png

上面采用客户端渲染的HTML页面中<div id="root"></div>中无内容,需在浏览器端加载并执行bundle.js后才能构建出有内容页面。

1.2 为什么用服务端渲染?

1.2.1 服务端渲染的优势

相比于客户端渲染,服务端渲染有什么优势?我们可以从下图对比一下这两种不同的渲染模式。

f8d39511729001ddcf02d9739891daab.jpeg

首屏时间更短

采用客户端渲染的页面,要进行JS文件拉取和JS代码执行,动态创建 DOM 结构,客户端逻辑越重,初始化需要执行的 JS 越多,首屏性能就越慢;客户端渲染前置的第三方类库/框架、polyfill 等都会在一定程度上拖慢首屏性能。Code splitting、lazy-load等优化措施能够缓解一部分,但优化空间相对有限。相比而言,服务端渲染的页面直接拉取HTMl就能显示内容,更短的首屏时间创造更多的可能性。

利于SEO

在别人使用搜索引擎搜索相关的内容时,你的网页排行能靠得更前,这样你的流量就有越高,这就是SEO的意义所在。那为什么服务端渲染更利于爬虫爬你的页面呢?因为对于很多搜索引擎爬虫(非google)HTML返回是什么内容就爬什么内容,而不会动态执行JS代码内容。对客户端渲染的页面来说,简直无能为力,因为返回的HTML是一个空壳。而服务端渲染返回的HTML是有内容的。

SSR的出现,就是为了解决这些CSR的弊端。

1.2.2 权衡使用服务端渲染

并不是所有的WEB应用都必须使用SSR,这需要开发者来权衡,因为服务端渲染会带来以下问题:

代码复杂度增加。为了实现服务端渲染,应用代码中需要兼容服务端和客户端两种运行情况,部分代码只能在客户端运行,需要对其进行特殊处理,才能在服务器渲染应用程序中运行。

需要更多的服务器资源。由于服务器增加了渲染HTML的需求,使得原本只需要输出静态资源文件的nodejs服务,新增了数据获取的IO和渲染HTML的CPU占用,如果流量突然暴增,有可能导致服务器宕机,因此需要使用响应的缓存策略和准备相应的服务器负载。

涉及构建设置和部署的更多要求。与可以部署在任何静态文件服务器上的完全静态单页面应用程序 (SPA) 不同,服务器渲染应用程序,需要处于 Node.js server 运行环境。

a653d0b768b9fbe29d67f52673865cc1.png

因此,在使用服务端渲染SSR之前,需要考虑投入产出比:是否真的需要SEO,是否需要将首屏时间提升到极致。如果都没有,使用SSR反而小题大做了。

1.3 服务端渲染的发展史

其实服务端渲染并不是什么新奇的概念,前后端分层之前很长的一段时间里都是以服务端渲染为主(JSP、PHP),那时后端一把梭哈,在服务端生成完整的 HTML 页面。但那时的服务端渲染和现在还是有本质的区别,存在比较多的弊端,每一个请求都要动态生成HTML,存在大量的重复,服务器机器成本也相对比较高,前后端代码完全掺杂在一起,开发维护难。

随着业务不断发展变化,后端要写的JS逻辑也越发复杂,而且JS有很多潜在的坑使后端越发觉得这是块烫手山芋,于是逐渐出现了前后端分层。伴随AJAX的兴起,浏览器可以做到了不再重现请求页面就可更新局部视图。还可以利用客户端免费的计算资源,后端侧逐渐演变成了提供数据支持。jquery的兴起,良好的客户端兼容性使JS不再受困于各种版本浏览器兼容问题,一统了前端天下。

此后伴随node的兴起,前后端分离越演越烈。前端能摆脱后端的依赖单独起服务,三大框架vue,react,angular也迅势崛起,以操作数据就能更新视图,前端开发人员逐渐摆脱了与烦人的Dom操作打交道,能够专心的关注业务和数据逻辑。前端同时探索出了功能插件,UI库,组件等多种代码复用方案,形成了繁荣的前端生态。

但是三大框架采用客户端渲染模式,随着代码逻辑的加重,首屏时间成了一个很大的问题,同时开发人员也发现SEO也出了问题,大多搜索引擎根本不会去执行JS代码。但是也不可能再回头走老路,于是前端又探索出了一套服务端渲染的框架来解决掉这些问题。此时的服务端渲染是建立在成熟的组组件,模块生态之上,基于Node.js的同构方案成为最佳实践。

2、React服务端渲染的原理

2.1 基本思路

React服务端渲染流程

React服务端渲染的基本思路,简单理解就是将组件或页面通过服务器生成html字符串,再发送到浏览器,最后将静态标记"混合"为客户端上完全交互的应用程序。因为要考虑React在服务端的运行情况,故相比之前讲的多了在浏览器端绑定状态与事件的过程。

我们可以结合下面的流程图来一览完整的 React服务端渲染的全貌:当浏览器去请求一个页面,前端服务器端接收到请求并执行 React组件代码,此时React代码中可能包含向后端服务器发起请求,待请求完成返回的数据后,前端服务器组装好有内容的HTML里返给浏览器,浏览器解析HTML后已具备展示内容,但页面并不具备交互能力。

6b848bf9d315f2920513c1c4f21db519.png

下一阶段,在返回的HTMl中还有script链接,浏览器再拉取JS并执行其包含的React 代码,其能在浏览器端执行完整的生命周期,并通过相关API实现复用此前返回 HTML节点并添加事件的绑定,此时页面才就具备完全交互能力。总的来说,react服务端渲染包含有两个过程:服务端渲染 + 客户端 hydrate 渲染。服务端渲染在服务端渲染出了首屏内容;客户端 hydrate 渲染复用服务端返回的节点,进行一次类似于 render 的 hydrate 渲染过程,把交互事件绑上去(此时页面可交互),并接管页面。

服务端处理后返回的

fe580b0cf309df08c6f0437549c8f3b3.png

客户端“浸泡”还原后的

91d891a933b3456071b264d6c22c7ad5.png
核心思想(同构)

从上面的流程中可以看到,客户端和服务端都要执行React代码完成渲染,那是不是就要写两份代码,供双端使用? 当然不需要,也完全不合理。所谓同构,就是让一份React代码,既可以在服务端中执行,也可以在客户端中执行。

869a64cd1485f654293f9e06bb9f99e4.jpeg
SSR技术栈

我们这里简单理了一下服务端渲染涉及到的技术栈:

知道了服务端渲染、同构的大概思路之后,下面从头开始,一步一步完成具体实践,深入理解其原理。

8ddea8a21aa40746925bca2fb1c92b58.png

2.2 服务端如何渲染React组件?

按照之前流程的大概思路,我们首先需要将React组件在服务端转换成HTML字符串,那怎么做呢?React 提供的面向服务端的API(react-dom/server),提供了相关方法能够将 React 组件渲染成静态的(HTML)标签。下面我们简单了解下react-dom/server。

react-dom/server

react-dom/server有renderToString、renderToStaticMarkup,renderToNodeStream、renderToStaticNodeStream四个方法能够将 React 组件渲染成静态的(HTML)标签,前两者能在客户端和服务端运行,后两者只能在服务端运行。

renderToStaticMarkup VS renderToString:renderToString 方法会在 React 内部创建的额外 DOM 属性,例如 data-reactroot, 在相邻文本节点之间生成<!-- -->,这些属性是客户端执行hydrate复用节点的关键所在,data-reactroot属性是服务端渲染的标志之一。如果你希望把 React 当作静态页面生成器来使用,renderToStaticMarkup方法会非常有用,因为去除额外的属性可以节省一些字节。

// Home.jsx
import React from "react";
const Home = () => {
  return (
    <div>
      <h2 onClick={() => console.log("hello")}>This is Home Page</h2>
      <p>Home is the page ..... more discribe</p>
    </div>
  );
};
export default Home;

我们使用React-dom/server下提供的renderToString方法,在服务端将其转换为html字符串:

//  server.js
import Koa from "koa";
import React from "react";
import { renderToString } from "react-dom/server";
import Home from "./containers/Home";

const app = new Koa();
app.use(async (ctx) => {
  // 核心api renderToString 将react组件转化成html字符串
  const content = renderToString(<Home />);
  ctx.body = `
    <html>
      <head>
        <title>ssr</title>
      </head>
      <body>
        <div id="root">${content}</div>
      </body>
    </html>
   `;
});
app.listen(3002, () => {
  console.log("listen:3002");
});

可以看到上面代码里有ES6的import 和jsx语法,不能直接运行在node环境,需要借助webpack打包, 构建目标是commonjs。新建webpack.server.js具体配置如下:

// webpack.server.js
const path = require("path");
const nodeExternals = require("webpack-node-externals");
module.exports = {
  mode: "development",
  target: "node",
  entry: "./server.js",
  resolve: {
    extensions: [".jsx", ".js", ".tsx", ".ts"],
  },
  module: {
    rules: [
        {
        test: /\.jsx?$/,
        loader: "babel-loader",
        exclude: /node_modules/,
        options: {
          presets: ["@babel/preset-react", "@babel/preset-env"],
          plugins: [
            [
              "@babel/plugin-transform-runtime",
              {
                absoluteRuntime: false,
                corejs: false,
                helpers: true,
                regenerator: true,
                version: "7.0.0-beta.0",
              },
            ],
          ],
        },
      },
    ],
  },
  output: {
    filename: "bundle.js",
    path: path.resolve(__dirname, "build"),
  },
  externals: [nodeExternals()],
};

在webpack构建完成后,可在Node环境运行build/bundle.js,访问页面后查看网页源代码,可以看到,React组件中的内容已经完整地包含在服务端返回到html里面。我们成功迈出了服务端渲染第一步。此时,我们也有必要再深入了解renderToString 到底做了什么,提前踩坑!

4c0d989bd9bbf176728c658699bec266.png

renderToString

除了将React组件转换成html字符串外,renderToString还有做了下面这些:

1. 会执行传入的React组件的代码,但是其只执行到React生命周期初始化过程的render及之前,即下面红框的部分,其余大部分生命周期函数在服务端都不执行;这也是服务端渲染的坑点之一。

1d8ad911ba952786361cf03582fadb34.jpeg

2. renderToString 生成的产物中会包含一些额外生成特殊标记,代码体积会有所增大,其中属性data-reactroot是服务端渲染的标志,便于后续客户端通过hydrate复用HTML节点。在React16前后其产物也有差距:在React 16 之前,服务端渲染采用的是基于字符串校验和(string checksum)的 HTML 节点复用方式, 会额外生成生成data-reactid、data-react-checksum等属性;React 16 改用单节点校验来复用(服务端返回的)HTML 节点,不再生成data-reactid、data-react-checksum等体积占用大户,只在空白节点间多了<!-- --> 这样的标记。

renderToString react16前
<div data-reactroot="" data-reactid="1" data-react-checksum="122239856">
  <span data-reactid="2">Welcome to React SSR!</span>  
  <!-- react-text: 3 --> Hello There! <!-- /react-text -->
</div>

// renderToString react16
<div data-reactroot=""><h1 class="here"><span>Welcome to React SSR!</span><!-- --> Hello There!</h1></div>

3.会被故意忽略掉的on开头的的属性,也就忽略掉了react代码中事件处理,这是也是坑点之一。服务端返回的html里没有处理事件点击,需要靠后续客户端js执行绑定事件。

function shouldIgnoreAttribute(
  name: string,
  propertyInfo: PropertyInfo | null,
  isCustomComponentTag: boolean,
): boolean {
  if (propertyInfo !== null) {
    return propertyInfo.type === RESERVED;
  }
  if (isCustomComponentTag) {
    return false;
  }
  if (
    name.length > 2 &&
    (name[0] === 'o' || name[0] === 'O') &&
    (name[1] === 'n' || name[1] === 'N')
  ) {
    return true;
  }
  return false;
}
67a7bdf10b9548b70e4e8d4df96bdab9.png

上面的例子我们可以看到React的代码里有点击事件,但点击后没有反应。需要靠后续客户端js执行绑定事件。如何实现?这就需要同构了。

2.3 实现基础的同构

前文已经大概讲了同构的概念,那为什么需要同构?之前的服务端代码在处理点击事件时故意忽略掉了这类属性,在服务端执行的生命周期也是不完整的,此时的页面是不具备交互能力的。同构,正是解决这些问题的关键,React代码在服务器上执行一遍之后,浏览器再去加载JS后又运行了一遍React代码,完成事件绑定和完整生命周期的执行,从而才能成为完全可交互页面。

bc6134fdc7500a933616b9e8f83b66af.png

react-dom:hydrate

实现同构的另一个核心API是React-dom下的hydrate,该方法能在客户端初次渲染的时候去复用服务端返回的原本已经存在的 DOM 节点,于渲染过程中为其附加交互行为(事件监听等),而不是重新创建 DOM 节点。需要注意是,服务端返回的 HTML 与客户端渲染结果不一致时,出于性能考虑,hydrate可以弥补文本内容的差异,但并不能保证修补属性的差异,而是将错就错;只在development模式下对这些不一致的问题报 Warning,因此必须重视 SSR HydrationWarning,要当 Error 逐个解决。

那具体实现同构?

c88c752aae832cb22b13e27fd4147c26.jpeg

上面这里我们提供了一个基本的架构图,可以看到,服务端运行React生成html代码我们已经基本实现,目前需要做的就是生产出客户端执行的index.js,那么这个index.js我们如何生产出来呢?

具体实践 

首先新建客户端代码client.js,引入React组件,通过ReactDom.hydrate处理挂载到Dom节点, hydrate是实现复用的关键。

// client.js
import React from "react";
import ReactDom from "react-dom";
import Home from "./containers/Home";

const App = () => {
  return <Home></Home>;
};

ReactDom.hydrate(<App />, document.getElementById("root"));

客户端代码也需要webpack打包处理,新建webpack.client.js具体配置如下,需要注意打包输出在public目录下,后续的静态资源服务也起在了这个目录下。

// webpack.client.js
const path = require("path");
const resolve = (dir) => path.resolve(__dirname, "./src", dir);
module.exports = {
  mode: "development",
  entry: "./client.js",
  output: {
    filename: "index.js",
    path: path.resolve(__dirname, "public"),
  },
  module: {
    rules: [
      // babel-loader处理js的一些配置
    ],
  },
};

服务端的静态资源服务使用koaStatic起在public目录,这样就能通过外链访问到打包出来的客户端js,同时我们在html中嵌入这个链接。

// server.js
import koaStatic from "koa-static";

const app = new Koa();
app.use(koaStatic("public"));

app.use(async (ctx) => {
  const content = renderToString(<Home />);
  console.log(content);
  ctx.body = `
    <html>
      <body>
        <div id="root">${content}</div>
            <script src="./index.js"></script>
      </body>
    </html>
   `;
});
app.listen(3003, () => {
  console.log("listen:3003");
});

简单看下此时的代码结构是这样:

├── package.json
├── webpack.client.js
├── webpack.server.js
├── server.js
├── client.js
└── containers
    └── Home.jsx

通过上面一番操作,简单的同构基本可以跑起来了,点击对应位置后看到有了反应,查看网页源代码如下。可以看到多了script标签的index.js 这段,这是在客户端执行的js代码。

0858814a036c3c579e496ccf3542c39b.png

ba5c3517a661399de21437cabb6f75ba.png以上我们仅仅是就完成了一个React基础的同构,但这还远不够,一个完整的前端页面还包含路由,状态管理,请求服务端数据等,这些也需要进行同构,且看下面为你一一道来。

2.4 路由的同构

我们之前页面只是一个页面, 但实际开发的应用用一般都是多个页面的,这就需要加入路由了。服务端渲染加入路由就涉及到同一份路由在不同端的执行,这就是路由的同构。

下面一步步来:首先加入About页面,并书写路由配置routes.js

// routes.js
import Home from "./containers/home";
import About from "./containers/about";
export default [
  { path: "/", component: Home, exact: true },
  {
    path: "/about",
    component: About,
    exact: true,
  },
];

在客户端侧加入路由的写法还是熟悉的写法,考虑到页面中可能涉及多级路由的渲染,这里直接引入react-router-config中来处理:

// client.js
import { BrowserRouter } from "react-router-dom";
import { renderRoutes } from "react-router-config";
import Routes from "./routes";

const App = () => {
  return (
    <BrowserRouter>
      <div>{renderRoutes(Routes)}</div>
    </BrowserRouter>
  );
};

react-router 为服务端提供了StaticRouter,需显式地向location传path。

// server.js
import { StaticRouter } from "react-router-dom";
import { renderToString } from "react-dom/server";
import Routes from "./routes";
import { renderRoutes } from "react-router-config";

const app = new Koa();
app.use(koaStatic("public"));

app.use(async (ctx) => {
  const content = renderToString(
    <StaticRouter location={ctx.request.path}>
      <div>{renderRoutes(Routes)}</div>
    </StaticRouter>
  );
}

以上就完成了路由的配置,还比较简单吧,此时页面的路由跳转基本没问题了。

2.5 Redux的同构

如何让前端页面的应用状态可控、让协作开发高效也是我们必须考虑的问题。Redux作为React最常见的状态管理方案被很多项目引入来解决这一问题。那引入Redux如何被到同构项目中?这里,我们还是简单回顾一下redux运作流程,不熟悉的可以移步redux熟悉下。接下来开启Redux的同构之旅。

5719d67e984a87b76682be87ef204e84.jpeg
第一步:创建全局STORE

首先我们创建了一个store.js,初始化配置并导出一个函数用来实例化store,以提供给客户端和服务端同时使用。为什么store要导出一个函数?因为这段代码后面服务端使用时,如果下面的store导出的是一个单例,所有的用户用的是同一份store,那将是灾难性的结果。

// store.js
import { createStore, applyMiddleware, combineReducers } from "redux";
import thunk from "redux-thunk";
// 这里提前引入了About组件下的store
import { reducer as AboutReducer } from "../containers/store";

const reducer = combineReducers({
  about: AboutReducer,
});

// 导出成函数的原因
export default () => {
  return createStore(reducer, applyMiddleware(thunk));
};
第二步:连接全局STORE

客户端的写法还是熟悉的样子:

//client.js
import { Provider } from "react-redux";
import getStore from "./store";

const App = () => {
  return (
    <Provider store={getStore()}>
      <BrowserRouter>
        <div>{renderRoutes(Routes)}</div>
      </BrowserRouter>
    </Provider>
  );
};

服务端server.js的写法也是类似:

// server.js
import { Provider } from "react-redux";
import getStore from "./store";

const app = new Koa();
app.use(koaStatic("public"));

app.use(async (ctx) => {
  const content = renderToString(
    <Provider store={getStore()}>
      <StaticRouter location={ctx.request.path}>
        <div>{renderRoutes(Routes)}</div>
      </StaticRouter>
    </Provider>
  );
}
第三步:组件的store

新建About 组件使用的store,其action和reducer的写法如下,注意此时我们在action里发起了一个异步请求。

// containers/store/reduccer.js
import { CHANGE_LIST } from "./constants";

const defaultState = { name: "panpan", age: 18, list: [] };
export default (state = defaultState, action) => {
  switch (action.type) {
    case CHANGE_LIST:
      return { ...state, list: action.payload };
    default:
      return state;
  }
};
// containers/store/action.js
import axios from "axios";
import { CHANGE_LIST } from "./constants";

const changeAction = (payload) => ({
  type: CHANGE_LIST,
  payload,
});

const getHomeList = () => {
  return (dispatch) => {
    return axios.get("http://localhost:3008/mock/1").then((res) => {
      dispatch(changeAction(res.data.data || []));
    });
  };
};

export default {
  getHomeList,
};

About组件连接Redux:

// containers/About.js
import { connect } from "react-redux";
import { action } from "./store";

const About = () => {
  useEffect(() => {
    props.getList();
  }, []);
  // ...
}
const mapStateToProps = (state) => ({
  name: state.about.name,
  age: state.about.age,
  list: state.about.list,
});
const mapDispatchToProps = (dispatch) => ({
  getList() {
    dispatch(action.getHomeList());
  },
});

export default connect(mapStateToProps, mapDispatchToProps)(About);

经过一番改造后,项目变成了这样:

├── package.json
├── mock.server.js
├── webpack.client.js
├── webpack.server.js
├── routes.js
├── server.js
├── client.js
└── store
    └── index.js
└── containers
    ├── Home.js
    ├── About.js 
    └── store
        ├── index.js
        ├── action.js
        ├── reducer.js
        └── constants.js

通过上述操作Redux 基本可以跑起来了,可以发现写法跟熟悉的客户端渲染大体差不多,只多了引入server端的步骤。但是目前的redux还存在一定的问题,我们一起再来看。

服务端没数据问题

上面的redux在同构项目中跑起来咋一看是没什么问题,但当我们把js禁用或直接查看源代码时,就会发现About组件内并不存在异步请求的数据列表,换句话说服务器端的store的list始终是空的,服务端并没有发起相应的数据请求。为什么会这样呢?

分析一下:当浏览器发起页面请求后,服务器接收到请求,此时服务器和客户端的store的list都为空, 接着服务端开始渲染执行React代码,根据此前讲rendertostring坑之一,服务端调用React代码时里面的生命周期的只到初始化时的render及之前,而About组件内发起的异步请求是在useEffect内,相当于是在ComponentDidMount阶段发起请求,所以服务器端渲染时根本不会执行里面的异步请求,因此服务器端的store的list始终是空的,所以我们看不到列表数据。之后客户端拉取了JS并执行React代码,React在客户端能够执行完整的生命周期,故可以执行useEffect里的函数获取到数据并渲染到页面。换而言之,目前获取异步数据只是进行了后期的客户端渲染阶段。

如何让服务端将获得数据的操作执行一遍,以达到真正的服务端渲染的效果?这就是接下来要讲的服务端渲染异步数据。

2.6 服务端渲染异步数据

上文的同构项目中跑起来后,我们是在组件的useEffect中发起的异步请求,服务端并不能执行到这一块。那能不能在其他生命周期获取异步请求数据?答案是不推荐,因为React16采用了Fiber架构后,render之前的生命周期都可能被中断而执行多次,类似getDerivedStateFromProps(静态方法), ComponentWillMount(废弃), UNSAFE_componentWillMount的生命周期钩子都有可能执行多次,所以不建议在这些生命周期中做有请求数据之类副作用的操作。而componentDidMount在render之后是确定被执行一次的,所以React开发中建议在componentDidMount生命周期函数进行异步数据的获取。那有没有其他的解决方案呢? React Router 恰好也考虑到了这点,提供了这样一种解决方案,需要我们对路由进行一些改造。

React Router 解决方案

React Router 解决方案的基本思路:

f7577297b9863ea33c8173be4ee27f9f.jpeg

首先,改造原有的路由,配置了一个loadData参数,这个参数代表要在服务端获取数据的函数:

// router.js
import Home from "./containers/home";
import About from "./containers/about";
export default [
  { path: "/", component: Home, exact: true },
  {
    path: "/about",
    component: About,
    exact: true,
    loadData: About.loadData,
  },
];

在服务端匹配路径对应的路由,如果这个路由对应的组件有loadData方法,那么就执行一次,并将store传进loadData里面去,注意loadData函数调用后需要返回Promise对象,等待Promise.all都resolve后,此时传过去的store已经完成了更新,便可以在renderToString时放心使用:

// server.js
import { renderRoutes, matchRoutes } from "react-router-config";
import { Provider } from "react-redux";
import getStore from "./store";

app.use(async (ctx) => {
  const store = getStore();
  // 匹配到路径对应的路由
  const matchArr = matchRoutes(Routes, ctx.request.path);
  let promiseArr = [];
  matchArr.forEach((item) => {
    // 判断有没有 loadData
    if (item.route.loadData) {
      // 要将store传递过去 
      // item.route.loadData() 返回的是一个promise
      promiseArr.push(item.route.loadData(store));
    }
  });
  // 等待异步完成,store已完成更新
  await Promise.all(promiseArr);
  const content = renderToString(
    <Provider store={store}>
      <StaticRouter location={ctx.request.path}>
        <div>{renderRoutes(Routes)}</div>
      </StaticRouter>
    </Provider>
  );
}

接下来是组件内loadData函数,发起异步请求,并返回一个Promise

// containers/About.js
import { connect } from "react-redux";
import { action } from "./store";

const About = (props) => {
  useEffect(() => {
    props.getList();
  }, []);
  // ...
};
About.loadData = (store) => {
  //可能存在多个数据请求,所以用promise.all包一层 
  return Promise.all([store.dispatch(action.getHomeList())]);
};
const mapStateToProps = (state) => ({
  name: state.about.name,
  age: state.about.age,
  list: state.about.list,
});
const mapDispatchToProps = (dispatch) => ({
  getList() {
    dispatch(action.getHomeList());
  },
});

export default connect(mapStateToProps, mapDispatchToProps)(About);

通过以上改造,服务端可以获取到异步数据了。但是眼尖的朋友可能注意到,页面此时还存在问题,页面中还不时存在list闪烁问题,这是什么原因导致的呢?这就涉及到数据的同步问题。

数据的注水和脱水

让我们来分析一下客户端和服务端的运行流程:

f1ec9ba44cd1e2ae87b119a07f44f47c.jpeg可以看到客户端和服务端的store 都经历了初始化置空的问题,导致store不同步, 那如何才能让这两个store的数据同步变化呢? 这就涉及到数据的注水和脱水。“注水”:在服务端获取获取之后,在返回的html代码中加入这样一个script标签,这样就将服务端store数据注入到了客户端全局的window.context对象中。

// server.js
app.use(async (ctx) => {
  // ...
  ctx.body = `
    <html>
      <head>
        <title>ssr</title>
        
      </head>
      <body>
        <div id="root">${content}</div>
        <script>
         window.context = {
          state: ${JSON.stringify(store.getState())}
        }
        </script>
        <script src="./index.js"></script>
      </body>
    </html>
   `;
});

“脱水”处理:把window上绑定的数据给到客户端的store,因此在store.js区分了客户端和服务端不同的导出函数。

// store.js
// 客户端使用
export const getClientStore = () => {
  const defaultState = window.context ? window.context.state : {};
  return createStore(reducer, defaultState, applyMiddleware(thunk));
};
// 服务端使用
export const getServerSore = () => {
  return createStore(reducer, applyMiddleware(thunk));
};

至此redux 包含异步数据的获取的同构就完成了。

2.7 css 的服务端渲染

为什么需要做css要服务端渲染?主要是解决页面的FOUC 闪屏问题。页面如果没做css的服务服务端渲染,我们一开始拉取到的HTML页面是没有样式的,页面的样式正常显示主要依赖于后面的客户端渲染,我们知道客户端渲染的时间相对要长很多,如果渲染前后存在较大的样式差距,就会引起闪屏。

还是以About组件为例,页面中加入样式:

.title {
  color: aqua;
  background: #999;
  height: 100px;
  line-height: 100px;
  font-size: 40px;
}
// containers/About.js
import styles from "./about.css";

const About = (props) => {
  // ...
  return (
    <h3 className={styles.title}>List Content</h3>
  );
};

需要webpack中相应的配置处理css,我们先处理客户端打包

{
    test: /\.css?$/,
    use: [
      "style-loader",
      {
        loader: "css-loader",
        options: {
          modules: true,
        },
      },
    ],
 }

上面的代码跑起来,就会发现有明显的闪烁,复盘一下:页面一开始html是没样式的,待到客户端js执行完成后,页面才突然有了样式显示正常。为了避免这种闪烁带来的不愉快体验,服务端也需要进行css的渲染。

在服务端如何处理css?

客户端webpack采用css-loader处理后,style-loader直接将样式通过DOM操作进行插入,这对于浏览器环境很好很方便,但是对于服务端的Node环境,这就没法愉快的玩耍了。Node环境下可将样式插入到生成的html字符串中,而不是进行DOM操作。这时就需要用到另外一个跨平台的loader:isomorphic-style-loader,在服务端的webpack配置是这样:

// webpack.server.js
{
    test: /\.css?$/,
    use: [
      "isomorphic-style-loader",
      {
        loader: "css-loader",
        options: {
          modules: true,
        },
      },
    ],
  },

通过isomorphic-style-loader处理,我们可以在组件内直接通过styles._getCss即可拿到CSS代码,但这还不够,如何将拿到的css传回到服务端sever.js里从而塞入返回体呢?

// containers/About.js
import styles from "./about.css";

const About = (props) => {
  console.log(styles._getCss && styles._getCss());
  // ...
}
CSS的服务端渲染

CSS服务端渲染还需要借助StaticRouter中已经帮我们准备的一个钩子变量context,传入外部对象变量到StaticRouter到context里。路由配置对象routes中的组件都能在服务端渲染的过程中拿到这个context,这个context对于组件来说相当于props.staticContext。将获取到的css推入到staticContext.css里,这样服务端的context对象就完成了改变,我们便可以拼接css到head中。

1edac75ac838a9de5243380fdc09ccc2.jpeg
// server.js
app.use(async (ctx) => {
  // 初始化 context
  let context = { css: [] };
  const content = renderToString(
    <Provider store={store}>
      // StaticRouter 传入context,组件接收到的props为staticContext
      <StaticRouter location={ctx.request.path} context={context}>
        <div>{renderRoutes(Routes)}</div>
      </StaticRouter>
    </Provider>
  );
  // 将css插入到head里面
  ctx.body = `
    <html>
      <head>
        <title>ssr</title>
        <style>${context.css.join("\n")}</style>
      </head>
      ...
    </html>
   `;
});
// containers/About.js
import styles from "./about.css";

const About = (props) => {
   // 只有服务端才传过来了context,组件中props为staticContext
  if (props.staticContext) {
    // 将css推入数组,改变了传入的context
    props.staticContext.css.push(styles._getCss());
  }
  // ...
}

通过上面的操作,css的服务端渲染基本能正常工作。需要注意的是React-router传过来的context只有配置对象routes中的组件才能拿到,如果组件里面再嵌入子组件,需要把staticContext透传过去,才能对子组件props.staticContext进行相应操作。当然这里更推荐官方demo里的另一种写法,且看下面。

更推荐写法

我们可以查看isomorphic-style-loader的demo,更推荐写法是:客户端、服务端都用isomorphic-style-loader,webpack处理客户端css是这样配置的:

//  webpack.client.js
{
    test: /\.css?$/,
    use: [
      "isomorphic-style-loader",
      {
        loader: "css-loader",
        options: {
          modules: true,
        },
      },
    ],
},

组件内的写法也有相应改变,isomorphic-style-loader 提供了hooks:useStyles

// containers/About.js
import useStyles from "isomorphic-style-loader/useStyles";
import styles from "./about.css";

const About = (props) => {
  useStyles(styles);
  // ...
}

在服务端的代码里是这样的:

// server.js
import StyleContext from "isomorphic-style-loader/StyleContext";
// ...

app.use(async (ctx) => {
  const css = new Set();
  const insertCss = (...styles) =>
    styles.forEach((style) => css.add(style._getCss()));
  const content = renderToString(
    <Provider store={store}>
      <StyleContext.Provider value={{ insertCss }}>
        <StaticRouter location={ctx.request.path} context={context}>
          <div>{renderRoutes(Routes)}</div>
        </StaticRouter>
      </StyleContext.Provider>
    </Provider>
  );
  ctx.body = `
    <html>
      <head>
        <title>ssr</title>
        <style>${[...css].join("")}</style>
      </head>
      <body>
        <div id="root">${content}</div>
        <script>
         window.context = {
          state: ${JSON.stringify(store.getState())}
        }
        </script>
        <script src="./index.js"></script>
      </body>
    </html>
  `;
})

类似的,客户端也需要做下面的调整:

// client.js
import StyleContext from "isomorphic-style-loader/StyleContext";
// ...

const App = () => {
  const insertCss = (...styles) => {
    const removeCss = styles.map((style) => style._insertCss());
    return () => removeCss.forEach((dispose) => dispose());
  };
  return (
    <Provider store={getStore()}>
      <StyleContext.Provider value={{ insertCss }}>
        <BrowserRouter>
          <div>{renderRoutes(Routes)}</div>
        </BrowserRouter>
      </StyleContext.Provider>
    </Provider>
  )
}

2.8 优化title和description

页面中的title,keywords和description在SEO中具有举足轻重的地位。上面的React项目中初始只有一份title和description,虽然不同页面可使用js生成的动态title和descroption,但这类信息搜索引擎是没办法抓取到的。为了更好的SEO,我们需要根据不同的页面组件显示来对应不同的网站标题和描述,这如何实现的呢?我们可以引入react-helmet来解决这个问题。

引入react-helmet

组件内:

// containers/About.js
 import { Helmet } from "react-helmet";
 // ...
 return (
    <div>
      <Helmet>
        <meta charSet="utf-8" />
        <title>SSR About Page</title>
        <meta name="description" content="this is panpan about page" />
      </Helmet>
      <div>
  )
  // ..

服务端html部分:

// server.js
const html = `
    <!doctype html>
    <html >
        <head>
            ${helmet.title.toString()}
            ${helmet.meta.toString()}
        </head>
    </html>
`;


3、开箱即用的SSR框架


Next.js

Next.js是一款面向生产使用的 React 框架,提供了好些开箱即用的特性,支持静态渲染/服务端渲染混用、支持 TypeScript、支持打包优化、支持按路由预加载等等:其中,完善的静态渲染/服务端渲染支持让 Next.js 在 React 生态中独树一帜。

faedd678f58143905494c943e9a31d02.jpeg

Next.js 中的预渲染(Pre-rendering),具体的分为两种方式:

SSG(Static Site Generation):也叫 Static Generation,在编译时生成静态 HTML

SSR(Server-Side Rendering):也叫 Server Rendering,用户请求到来时动态生成 HTML

与 SSR 相比,Next.js 更推崇的是 SSG,因为其性能优势更大(静态内容可托管至 CDN,性能提升立竿见影)。因此建议优先考虑 SSG,只在 SSG 无法满足的情况下(比如一些无法在编译时静态生成的个性化内容)才考虑 SSR、CSR。

UmiJS

Umi 很多功能是参考next.js做的,要说有哪些地方不如Umi,可能是不够贴近业务,不够接地气。Umi 3 结合自身业务场景,在 SSR 上做了大量优化及开发体验的提升,内置 SSR,一键开启,开发调试方便。Umi 不耦合服务端框架,无论是哪种框架或者 Serverless 模式,都可以非常简单进行集成。

24c025c0c061d5d79178566c6ca48b22.png

icejs

icejs是淘系前端飞冰团队开发的一个基于React 的渐进式框架。支持服务端渲染(即 SSR)能力,开发者可以按需一键开启 SSR 的模式。

73e1f429fd3714689385344977be50b1.png

4、一些新的API

新Hook:useId

服务端、客户端无法简单生成稳定、唯一的id是个由来已久的问题,早在多年前就有人提过issue。直到最近React conf 2021 上再次提出这个问题,推出了官方Hook——useId,可在服务端、客户端生成唯一的id,其背后的原理—— 每个id代表该组件在组件树中的层级结构,具体的就不展开了,有兴趣的可以去了解一下。

服务端suspense

React 18 带来了内置支持了 React.lazy 的 全新 SSR 架构, 性能优化的利器。这个架构能很大程度上提升用户体验:对比React18之前对整个应用hydrate,现在可以做到对单个组件hydrate,带来的一个好处,就是可以设置组件的渲染优先级。对比code splitting的优势在于如果同时设置了多个suspense组件,但是用户点击了之中某个组件,会优先hydrate那个被点击的组件。

d497314fb1d175bb4f7d83897c204d0a.png

5、结语

以上就是本文关于React服务端渲染 ( SSR )的全部内容, 内容还是比较复杂的 。对于服务端渲染原理的学习可以帮助更好借鉴优秀的程序写法和激发对日常代码编程架构的思考,如果你更倾向箱即用的解决方案,那可以使用现有的 SSR 框架来搭建项目,这些框架的模版抽象和额外的功能扩展可以提供平滑的开箱体验。

附Demo地址:https://github.com/hellopanpan/my-ssr-react

参考:

https://juejin.cn/post/6844904017487724557

https://juejin.cn/post/6844903881390964744

https://zhuanlan.zhihu.com/p/90746589

https://www.jianshu.com/p/3aa991ac3ce7

也许你还想看

(▼点击文章标题或封面查看)

小程序项目框架迁移实践

2022-02-17

bc6f88cb3c722b4bfb82a8bdd3f8985a.jpeg

子线程更新UI全解

2022-04-07

8ad3b3ac9796c28825f7f306d4c4a58c.jpeg

探秘AutoreleasePool实现原理

2022-05-26

fd8cd13e891a9741b22c07fac26d3299.jpeg

前端工程化-打造企业通用脚手架

2022-01-13

09b853dfbb500da8a6e6e75a03323ddd.jpeg

前端通用SEO技术优化指南

2021-10-07

bfadbddffa3740eaff8e7a917b09ffad.jpeg
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值