SSR的注水和脱水

5,992 阅读8分钟

Web rendering

「干货」你需要了解的六种渲染模式

先从网页的浏览说起

以下是一些网页性能相关的名词,以后会引用:

  • TTFB: Time to First Byte - 被视为点击链接和第一部分内容之间的时间。
  • FP: First Paint - 任何像素第一次对用户可见的时间。
  • FCP: First Contentful Paint - 请求的内容(文章正文等)变得可见的时间。
  • TTI: Time To Interactive - 页面变得可交互的时间(连接的事件等)。

SSR

首先就是SSR:Server-Side Rendering

SSR有两种模式,单页面非单页面模式,第一种是后端首次渲染的单页面应用,是在首次加载的时候,后端进行当前路径页面的组件渲染和数据请求,组装成HTML返回给前端,用户就能很快看到看到页面,当HTML中的JS资源加载完成后,剩下执行和运行的就是一般的单页面应用。

第二种是完全使用后端路由的后端模版渲染模式。他们区别在于使用后端路由的程度。

SSR的两个明显的优势:首次加载快SEO

为什么说首次加载快呢。 一个普通的单页面应用,首次加载的时候需要把所有相关的静态资源加载完毕,然后核心JS才会开始执行,这个过程就会消耗一定的时间,接着还会请求网络接口,最终才能完全渲染完成。

SSR模式下,后端拦截到路由,找到对应组件,准备渲染组件,所有的JS资源在本地,排除了JS资源的网络加载时间,接着只需要对当前路由的组件进行渲染,而页面的ajax请求,可能在同一台服务器上,如果是的话速度也会快很多。最后后端把渲染好的页面反回给前端。

注意:页面能很快的展示出来,但是由于当前返回的只是单纯展示的DOMCSS,其中的JS相关的事件等在客户端其实并没有绑定,所以最终还是需要JS加载完以后,对当前的页面再进行一次渲染,称为同构。 所以SSR就是更快的先展示出页面的内容,先让用户能够看到。

为什么SEO友好呢,因为搜索引擎爬虫在爬取页面信息的时候,会发送HTTP请求来获取网页内容,而我们服务端渲染首次的数据是后端返回的,返回的时候已经是渲染好了title,内容等信息,便于爬虫抓取内容。

SSR可能需要一点时间来准备内容(较长的TTFB ),但是有很快的FCP以及TTI

CSR

随着前端功能和互动越来越复杂,我们需要Client-Side RenderingSPA的架构来构建富有互动性的网页:AngularReactVue等等。

Server只回传没有内容的html,等到JS加载完成再根据不同的url渲染页面,后续的页面的都是在前端,用JS来渲染页面。

CSRSPA大幅增进了前端开发的体验,以及页面的互动性,但是问题也随之而出:

  • 越来越大的Javascript Bundle Size
  • 一开始的页面空白,需要等JS加载执行才有内容,有可能不利于SEO

其中肥大的Javascript Code,造成加载和执行的速度变慢。

FCPTTI时间变长,意味着使用者有很长时间看到的是空白或者不完整、还无法互动的页面。

关于SSR

单纯的SSR只是展示静态内容的传统技术,我们仍需要 SPA 交互体验。

最好的方案就是SSR+SPA相结合,即在实现服务端渲染的时候,还要实现客户端渲染,首次访问页面是服务端渲染,基于首次访问的后续的交互就是SPA的效果,这样就保留了两个技术的优点。

两种技术有大量可重用的代码,客户端路由、服务器端路由、客户端Redux、服务器端Redux等,最大程度的复用这些代码,就是同构。

现在所说的服务端渲染基本上都是SSR+SPA的同构渲染,不是传统上的服务端渲染。

简单来看一下ssr-core-react这种框架会是什么样子:

import { render } from 'ssr-core-react'

@Get('/')
@Get('/detail/:id')
async handler (): Promise<void> {
  try {
    this.ctx.apiService = this.apiService
    this.ctx.apiDeatilservice = this.apiDeatilservice
    const stream = await render<Readable>(this.ctx, {
      stream: true
    })
    this.ctx.body = stream
  } catch (error) {
    console.log(error)
    this.ctx.body = error
  }
}

当访问http://localhost:3000或者http://localhost:3000/detail/xxx时,请求会首先经过在Controller中注册的路由。并且交由对应的函数进行处理。

示例函数的处理逻辑,调用了ssr-core-xxx模块提供的render方法,来渲染当前请求所对应的前端组件。并且将返回的结果是一个包含html, meta标签的完整html文档结构。返回的文档结构中已经包含了script标签加载客户端资源的相关代码。

render方法:

数据请求将会在服务端渲染执行的过程中被调用。在客户端激活的过程中会复用服务端获取并注入到 window中的数据来进行初始化。不会在客户端再次获取。当客户端进行前端路由切换时会调用将要前往的页面对应的fetch

实现思想

核心实现分为以下几步:

  1. 后端拦截路由,根据路径找到需要渲染的react页面组件X
  2. 调用组件X初始化时需要请求的接口,同步获取到数据后,使用reactrenderToString方法对组件进行渲染,使其渲染出节点字符串。
  3. 后端获取基础HTML文件,把渲染出的节点字符串插入到body之中,同时也可以操作其中的titlescript等节点。返回完整的HTML给客户端。
  4. 客户端获取后端返回的HTML,展示并加载其中的JS,最后完成react同构。

  1. 注册路由

import Index from "../pages/index"; 
import List from "../pages/list"; 

const routers = [
  { exact: true, path: "/", component: Index },
  { exact: true, path: "/list", component: List }
];

配置路由路径和组件的映射,使其能被客户端路由和服务端路由同时使用。

import Index from "../pages/index";
import List from "../pages/list";

const routers = [
  { exact: true, path: "/", component: Index },
  { exact: true, path: "/list", component: List }
];

//注册页面和引入组件,存在对象中,server路由匹配后渲染
export const clientPages = (() => {
  const pages = {};
  routers.forEach(route => {
    pages[route.path] = route.component;
  });
  return pages;
})();
export default routers;

服务端处理

import { clientPages } from "./../../client/router/pages";

router.get("*", (ctx, next) => {
    let component = clientPages[ctx.path];
    if (component) {
        const data = await component.getInitialProps();
        //因为component是变量,所以需要create
        const dom = renderToString(
            React.createElement(component, {
                ssrData: data
            })
        )
    }
})

匹配到组件以后,执行了组件的getInitialProps方法,此方法是一个封装的静态方法,主要用于获取初始化所需要的ajax数据,在服务端会同步获取,而后通过ssrData参数传入组件props并执行组件渲染。 此方法在客户端依然是异步请求。

  1. 客户端渲染

import React from "react";
export default class Base extends React.Component {
  //override 获取需要服务端首次渲染的异步数据,也可以是api请求
  static async getInitialProps() {
    return null;
  }
  static title = "react ssr";
  //page组件中不要重写constructor
  constructor(props) {
    super(props);
    //如果定义了静态state,按照生命周期,state应该优先于ssrData 
   if (this.constructor.state) {
      this.state = {
        ...this.constructor.state
      };
    }
    //如果是首次渲染,会拿到ssrData
    if (props.ssrData) {
      if (this.state) {
        this.state = {
          ...this.state,
          ...props.ssrData
        };
      } else {
        this.state = {
          ...props.ssrData
        };
      }
    }
  }
  async componentWillMount() {
    //客户端运行时
    if (typeof window != "undefined") {
      if (!this.props.ssrData) {
        //静态方法,通过构造函数获取
        const data = await this.constructor.getInitialProps();
        if (data) {
          this.setState({ ...data });
        }
      }
      //设置标题
      document.title = this.constructor.title;
    }
  }
}

如果在客户端环境,分两种情况。

第一种:用户第一次进到页面,这时候是服务端去请求的数据,服务端获取到数据后在服务端渲染组件,同时也会把数据存放在HTMLscript代码中,定义一个全局变量ssrDatareact在注册单页面应用并且同构的时候会把全局ssrData传递给页面组件,这个时候页面组件在客户端同构渲染的时候,就可以延续使用服务端之前的数据,这样也保持了同构的一致性,也避免了一次重复请求。

第二种情况:就是当前用户在单页面之中切换路由,这样就没有服务端渲染,那么就执行 getInitialProps方法,把数据直接返回给state,几乎等同于在willmount中执行请求。

最重要的问题来了!!----怎么区分第一次和之后的渲染?就是什么时候传递ssrData, 逻辑如下:

import { BrowserRouter, Route, Switch, withRouter } from "react-router-dom";
import React from "react";
import routers from "./pages";

const router = ({ ssrData, ssrPath }) => {
  //把ssr数据注入到所有页面中,第一个路由的页面接收到以后,其他页面需要废弃使用当前数据,并且调用getInitialProps方法初始化
  routers.forEach(item => {
    let _ssrData = null;
    //如果当前路由注册并且首次渲染的路径匹配,给组件注入ssrData(这段代码只会在首次加载,路由注册时才执行)
    if (ssrPath == item.path) {
      _ssrData = ssrData;
    }
    item.render = () => {
      item.component = withRouter(item.component); //注入路由信息
      return <item.component ssrData={_ssrData} />;
    };
  });
  return (
    <BrowserRouter>
      <Switch>
        {routers.map((route, i) => (
          <Route
            key={i}
            exact={route.exact}
            path={route.path}
            render={route.render}
          />
        ))}
      </Switch>
    </BrowserRouter>
  );
};

export default router;
  1. 脱水(dehydrate)

准备index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>/*title*/</title>
  </head>
  <body>
    <div id="root">$$$$</div>
    <script>
      /*getInitialProps*/
    </script>
    <script src="/*app*/"></script>
    <script src="/*vendor*/"></script>
  </body>
</html>

替换节点

indexHtml = indexHtml.replace("/*title*/", component.title);
indexHtml = indexHtml.replace(
          "$$$$",
          renderToString(
            React.createElement(component, {
              ssrData: data
            })
          )
        );
indexHtml = indexHtml.replace(
  "/*getInitialProps*/",
  `window.ssrData=${JSON.stringify(data)};window.ssrPath='${ctx.path}'`
);
indexHtml = indexHtml.replace("/*app*/", bundles.app);
indexHtml = indexHtml.replace("/*vendor*/", bundles.vendor);
ctx.response.body = indexHtml;
next();

组件被序列化成了静态的 HTML 片段,还能看出来模样,不过已经无法与之交互了。

  1. 注水(hydrate)

客户端JS加载完成后,会运行react,并且执行同构方法ReactDOM.hydrate,而不是平时用的 ReactDOM.render

react-dom提供的hydrate方法类似render方法,用于二次渲染。

它在渲染的时候会复用原本已经存在的DOM节点,减少重新生成节点以及删除原本DOM节点的开销,只进行事件处理绑定。

hydraterender的区别就是hydrate会复用已有节点,render会重新渲染全部节点。

所以hydrate主要用于二次渲染服务端渲染的节点,提高首次加载体验。

import React from "react";
import { hydrate } from "react-dom";
import Router from "./router";

class App extends React.Component {
  render() {
    return <Router ssrData={this.props.ssrData} ssrPath={this.props.ssrPath} />;
  }}

hydrate(
  <App ssrData={window.ssrData} ssrPath={window.ssrPath} />,
  document.getElementById("root")
);