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

node server 接收客户端请求,得到当前的req url path,然后在已有的路由表内查找到对应的组件,拿到需要请求的数据,将数据作为 props 、context或者store 形式传入组件,然后基于 react 内置的服务端渲染api renderToString() or renderToNodeStream() 把组件转换为 html字符串或者 stream 流(脱水后输出覆盖到html), 在把最终的 html 进行输出前需要将数据注入到客户端(注水,将脱水后的数据重新转换格式变为客户端可用),浏览器开始进行渲染和节点对比,然后执行组件的componentDidMount 完成组件内事件绑定和一些交互,浏览器重用了服务端输出的 html 节点(因为初始的props值是服务端注水后传入的,与服务端使用的是同一份数据),整个流程结束。

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

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

路由同构

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

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

数据同构(预取同构)

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

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

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

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

比如 Index.getInitialProps

渲染同构

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

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

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

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

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

喝水(render)

首先要有水可脱,所以先要拉取数据(水),在服务端完成组件首次渲染(mount)的过程:

react java服务端渲染 react 服务端渲染原理_react java服务端渲染

也就是根据外部数据构建出初始组件树,过程中仅执行render及之前的几个生命周期,是为了尽可能缩短保命招数的前摇,尽快脱水

脱水(dehydrate)

接着对组件树进行脱水,使其在恶劣的环境同样能够以一种更简单的形态“生存”下来,比如禁用了 JavaScript 的客户端环境。

比组件树更简单的形态是 HTML 片段,脱去生命的水气(动态数据),成为风干标本一样的静态快照:内存里的组件树被序列化成了静态的 HTML 片段,还能看出来人样(初始视图),不过已经无法与之交互了,但这种便携的形态尤其适合运输,能够通过网络传输到地球上的某个客户端


注水(hydrate)

抵达客户端后,如果环境适宜(没有禁用 JavaScript),就立即开始“浸泡”(hydrate),组件随之复苏

客户端“浸泡”的过程实际上是重新创建了组件树,将新生的水(statepropscontext等)注入其中,并将鲜活的组件树塞进服务端渲染的干瘪躯壳里,使之复活。

CSR & SSR

CSR:Client Side Rendering 客户端渲染,流程如下:

SSR:Server Side Rendering 服务端渲染,流程如下:

三、SSR 的优缺点及使用场景

3.1 优点

  • 更快的首屏加载速度:无需等待 JS 完成下载且执行才显示内容,更快地看到完整渲染的页面,有更好的用户体验。
  • 更友好的 SEO
  • 爬虫可以直接抓取渲染之后的页面,CSR 首次返回的 HTML 文件中,root 节点为空,不包含内容;而 SSR 返回渲染之后的 HTML 片段,内容完整,能更好地被爬虫分析与索引

3.2 缺点

  • 对服务器性能消耗较高
  • 项目复杂度变高,多了一个 node 中间层
  • 需要考虑 SSR 及其的运维、申请、扩容,增加了运维成本

3.3 UmiJS 预渲染

服务端渲染,首先得有后端服务器(一般是 Node.js)才可以使用,而没有后端服务器的情况下,可以使用预渲染

预渲染与服务端渲染唯一的不同点在于 渲染时机,服务端渲染的时机是在用户访问时执行渲染(即实时渲染,数据一般是最新的),预渲染的时机是在项目构建时,当用户访问时,数据不一定是最新的(如果数据没有实时性,可以直接考虑预渲染)。

预渲染在构建时执行渲染,将渲染后的 HTML 片段生成静态 HTML 文件。无需使用 web 服务器实时动态编译 HTML,适用于 静态站点生成

四、Umi 服务端渲染

Umi3 在 SSR 上做了大量优化及开发体验的提升,具有以下特性:

  • 开箱即用:内置 SSR,一键开启,可在 umi dev 中预览,方便调试开发。
  • 服务端框架无关:Umi 不耦合服务端框架(如 Egg.js、Express、Koa),无论是哪种框架或者 Serverless 模式,都可以非常简单的进行集成。
  • 支持应用和页面级数据预获取
  • 支持按需加载:开启 dynamicImport (按需加载)后,Umi 3 会根据不同路由加载对应的资源文件(css/js)。
  • 内置预渲染功能:Umi 3 中内置了预渲染功能,不再通过安装额外插件使用,同时开启 ssrexportStatic,在 umi build 构建时会编译出渲染后的 HTML。
  • 支持渲染降级:优先使用 SSR,如果服务端渲染失败,自动降级为客户端渲染(CSR),不影响正常业务流程。
  • 支持流式渲染ssr: { mode: 'stream' } 即可开启流式渲染,流式 SSR 较正常 SSR 有更少的 TTFB(发送页面请求到接收到应用数据第一个字节所花费的毫秒数) 时间。
  • 兼容客户端动态加载:可同时使用 SSR 和 dynamicImport。
  • SSR 功能插件化:可通过提供的 API 来自定义 SSR 功能。

4.1 启用服务端渲染

默认情况下,服务端渲染时关闭的,可通过配置开启:

export default {
  ssr: {
    // 开发模式下的服务端渲染,默认为 true
    devServerRender: false,
  },
};

4.2 数据预获取

服务端渲染的数据获取方式与 SPA(单页应用) 有所不同,为了让客户端和服务端都能获取到同一份数据,Umi 提供了页面级数据的预获取。

页面级数据获取 - 使用

每个页面可能有单独的数据预获取逻辑,这里我们会获取页面组件上的 getInitialProps 静态方法,执行后将结果注入到该页面组件的 props 中,如:

// pages/index.tsx 函数组件
import { IGetInitialProps } from "umi";
import React from "react";

const Home = (props) => {
  const { data } = props;
  return <div>{data.title}</div>;
};

Home.getInitialProps = (async (ctx) => {
  return Promise.resolve({
    data: {
      title: "Hello World!",
    },
  });
}) as IGetInitialProps;

export default Home;
// pages/index.tsx 类组件
import { IGetInitialProps } from "umi";
import React from "react";

class Home extends React.Component {
  static getInitialProps = (async (ctx) => {
    return Promise.resolve({
      data: {
        title: "Hello World",
      },
    });
  }) as IGetInitialProps;

  render() {
    const { data } = props;
    return <div>{data.title}</div>;
  }
}

export default Home;

getInitialProps 有几个固定参数:

  • match:与客户端页面 props 中的 match 保持一致,保存当前路由的相关数据
  • isServer:是否为服务端在执行该方法
  • route:当前路由对象
  • history:history 对象

扩展 ctx 参数

为了结合数据流框架,我们提供了 modifyGetInitialPropsCtx 方法,由插件或应用来扩展 ctx 参数,以 dva 为例:

// plugin-dva/runtime.ts
export const ssr = {
  modifyGetInitialPropsCtx: async (ctx) => {
    ctx.store = getApp()._store;
  },
};

然后在页面中,可以获取到 store

// pages/index.tsx
const Home = () => <div />;

Home.getInitialProps = async (ctx) => {
  const state = ctx.store.getState();
  return state;
};

export default Home;

同时也可以在自身应用中进行扩展:

// app.ts
export const ssr = {
  modifyGetInitialPropsCtx: async (ctx) => {
    ctx.title = "params";
    return ctx;
  },
};

同时可以使用 getInitialPropsCtx 将服务端参数扩展到 ctx 中,例如:

app.use(async (req, res) => {
  // 或者从 CDN 上下载到 server 端
  const render = require("./dist/umi.server");
  res.setHeader("Content-Type", "text/html");

  const context = {};
  const { html, error, rootContainer } = await render({
    path: req.url,
    query: {},
    context,
    getInitialPropsCtx: {
      req,
    },
  });
});

在使用的时候,就有 req 对象,不过需要注意的是,只在服务端执行时才有此参数:

Page.getInitialProps = async (ctx) => {
  if (ctx.isServer) {
    // console.log(ctx.req);
  }
  return {};
};

则在执行 getInitialProps 方法时,除了以上两个固定参数外,还会获取到 titlestore 参数。

关于 getInitialProps 执行逻辑和时机,需要注意:

  • 开启 ssr,且执行成功
  • 未开启 forceInitial,首屏不触发 getInitialProps,切换页面时会执行请求,和客户端渲染逻辑保持一致。
  • 开启 forceInitial,无论是首屏还是页面切换,都会触发 getInitialProps,目的是始终以客户端请求的数据为准。(有用在静态页面站点的实时数据请求上)
  • 未开启 ssr 时,只要页面中有 getInitialProps 静态方法,则会执行该方法。