Gatsby Starter Blog

深入React: hydrate

January 22, 2019

上一篇讲到了如何通过 webpack 插件来实现文案的按页面和语言进行按需加载,如果页面仅仅通过客户端渲染,这种处理方式没有太大问题,然而当面临服务端渲染的时候,仍然会碰到这样那样的问题。

最近把项目里的 React 的版本升级到 16,React 的 15 到 16 的变动并不大,项目里主要需要处理如下几方面的变动

  1. React16 不再包含 propTypes,propTypes,必须使用第三方的 prop-types 库
  2. ReactDOM.render()ReactDOM.unstable_renderIntoContainer() 不再返回组件实例,而是返回 null,需要在 callback 里才能获取组件实例,我们使用的 react-portal 组件有部分代码依赖于返回值,改成回调即可,更好的方式是替换掉 react-portal 组件,使用最新的 ReactDOM.createPortal。
  3. React16 使用了 Map 和 Set 以及 requestAnimationFrame,在 IE11 上使用需要打 polyfill。

处理完上面三个变动,项目平稳的升级到 React16 版本,可以尽情的使用最新的特性了。

后来看到文档上如下的一句话:

v2 e81dc564fd409d636f11ee5c39081034 b

哇,ReactDOM.render 在下个大版本要废弃了啊,干脆一起升级了算了,于是乎把几个服务端渲染的页面的 ReactDOM.render 换成 ReactDOM.hydrate 算了。测了下好像没问题,OK。

俗话说,不作就不会死,过了几天接连发现各种诡异的问题。

  1. 页面加载后,无端的滚动到页面尾部
  2. 页面加载完,莫名其妙有的地方被 focus 了
  3. tooltip 有时莫名其妙失灵了
  4. 页面有时会闪烁
  5. 服务端渲染的页面出现了一些 warning

都是什么鬼,最后追查了半天才搞清楚,一切都是 hydrate 的锅,hydrate !== render !!!

更准确的说是 React16 的 hydrate 不等于 React15 的 render,因为 React16 的 render 和 React15 的 render 渲染结果也不一样呢,而且没写在文档中/(ㄒ o ㄒ)/~~。本文所说的 render 都是 React15 的 render 实现。

[ Document that you can’t rely on React 16 SSR patching up differences · Issue

25 · reactjs/reactjs.org

](https://link.zhihu.com/?target=https%3A//github.com/reactjs/reactjs.org/issues/25) 实际上新文档在 todo 中,但是距离完成似乎遥遥无期。

我们直接想用 hydrate 替换 render,需要满足一个十分重要的前提条件:

在服务端渲染和客户端首次渲染完全一致的情况下,才能使用 hydrate 替换 render,否则自求多福吧!!!

如果说在 React15 里客户端渲染和服务端渲染不一致是 warning 的话,那么在 React16,如果你使用 hydrate,那么这些 warning 就不是 warning 而是 error 了 !!!

在 react15 中 ReactDOM.render 的使用分为三种场景,意义各不相同:

  1. 无服务端渲染情况下,首次调用,挂载组件到挂载点,是我们常见的使用 ReactDOM.render 的方式,在一个挂载点下初始化我们的应用其要完成所有的工作,包括创建 dom 节点,初始化节点属性,绑定事件等,对于比较大型的应用其执行速度对首屏加载的速度影响较大。
  2. 服务端渲染情况下,进行 hydrate,绑定事件到已存在的 dom 节点,相比于 1 其免去了创建 dom 节点的工作,但仍然需要完成 dom diff,和 dom patch 的工作。
  3. 后续调用,更新组件,其使用场景较为有限,主要适用于与跨节点渲染如 Modal/Tooltip 等需要挂载在 body 下的组件更新上,其和父组件更新子组件方式类似,ReactDOM.createPortal 的引入,可以减小此类场景的使用。

在服务端渲染的场景下,2 的执行时间一定程度上影响了首屏的可交互时间。我们需要尽可能的减小 2 的执行时间。

render === hydrate?

在 react15 中,当服务端和客户端渲染不一致时,render 会做 dom patch,使得最后的渲染内容和客户端一致,否则这会使得客户端代码陷入混乱之中,如下的代码就会挂掉。

 import React from 'react';

export default class Admin extends React.Component {
  componentDidMount() {
    const container = document.querySelector('.client');
    container.innerHTML = 'this is client';
  }
  render() {
    const content = __IS_CLIENT__ ? 'client' : 'server';
    return (
      <div className={content}>
        {content}
      </div>
    );
  }
}

render 遵从客户端渲染虽然保证了客户端代码的一致性,但是其需要对整个应用做 dom diff 和 dom patch,其花销仍然不小。在 React16 中,为了减小开销,和区分 render 的各种场景,其引入了新的 api,hydrate。

hydrate 的策略与 render 的策略不一样,其并不会对整个 dom 树做 dom patch,其只会对 text Content 内容做 patch,对于属性并不会做 patch。上面的代码在 hydrate 和 render 下会有两种不同的结果。

hydrate(React16)

v2 545f4af30927a41484535db24738335c b

render(React15)

v2 05dabc3d143cd6058e5d6f31a156061a b

我们发现在 render 彻底抛弃了服务端的渲染结果采用客户端的渲染结果,而 hydrate 则 textContent 使用了客户端渲染结果,属性仍然是服务端的结果(为啥这样设计,只能等 React 那篇文档了)。

不止如此,hydrate 还有个副作用,就是当发现服务端和客户端渲染结果不一致的时候,就会 focus 到不一致的节点上,这就导致了我们页面加载完后,页面自动滚动到了渲染不一致的节点上。

由此导致的结果就是,在 React16 中,我们必须保证服务端的渲染结果和客户端渲染的结果一致。同构的需求迫在眉睫。

客户端服务端同构

同构的最大难点在于服务端和客户端的运行环境不一致,其主要区别如下:

  1. 服务端和客户端的运行环境不一样,所支持的语法也不一样。
  2. 服务端无法支持图片、css 等资源文件。
  3. 服务端缺乏 BOM 和 DOM 环境,服务端下无法访问 window,navigator 等对象。
  4. 服务端中所有用户公用一个 global 环境,客户端每个用户都有自己的 global 环境。

对于 1 和 2,客户端通常使用 webpack 进行编译,资源的加载通过各种 loader 进行处理,但这写 loader 只是针对于客户端环境的,编译生成的代码,无法应用于服务端。webpack 自带 import 实现不需要 babel- loader 处理,而 node 不支持 import 需要 babel-loader 进行处理。虽然有 webpack-isomorphic-tools 这样的项目,但配置起来仍然较为麻烦。为此我们考虑使用 babel-node 进行语法的转换支持 es- next 和 jsx,对于图片、css 等资源文件,通过忽略进行处理。

require.extensions['.svg'] = function() {
  return null;
};

我们在 node 中虽然忽略了 css 资源,但是首屏加载如果没有 css 文件,势必影响效果,为此我们通过编写 webpack 插件,将 ExtractTextPlugin 生成的 css 文件,内联插入页面的 pug 模板中,这样服务端首屏渲染就可以支持样式了。

对于 3 有两种解决方式,1 是 fake window 等对象如 window 等库,2 是延迟这些对象的调用,在 didMount 中才进行调用。

对于 4,由于 js 是单线程,无法像 flask 一样为每个请求构造出一个 request 对象,只能另寻他法。

客户端无可避免的需要访问服务端带过来的一些属性,例如用户信息,服务器信息等。在组件内如何访问这些信息就成了问题了。

server.js

const Koa = require('koa');
const Util = require('./util');
const app = new Koa();
...
app.use(async (ctx,next) => {
  const userInfo = Util.getUserInfo();
  const serverInfo = Util.getServerInfo();
  ctx.body = `
   <html>
  ...
  window.userInfo = ${userInfo}
  window.serverInfo = ${serverInfo}
  ...
  </html>
  `
});

client.js

// feedPage.js
render(<FeedContaienr />,root);
// feedContainer.js
export () => <FeedList />
// feedList.js
export () => <FeedCard />
// feedCard
export () => {
  const userInfo = window.userInfo;
  return (<div className="feed-card-container">{userInfo} />)
}

上面是一个简单的服务端渲染例子,在 FeedCard 里我们通过 window.userInfo 直接取出 userInfo 信息进行渲染,然而这是无法通过服务端渲染的。

方案 1: props drilling

我们可以把属性从根组件一层层的传递到子组件,对于一个大型应用组件树可能达到十几层,这样传下去太恶心了。

方案 2:old context

优点是,不用一层层传递,缺点是会被 shouldComponentUpdate 阻止更新

方案 3:new context

解决了 shouldComponentUpdate 阻止更新的问题了,但还未正式发布

方案 4:redux connect

导致组件依赖于 redux,不能用于无 redux 的页面了。

方案 5:服务端临时构造 window 对象

前面提到过服务端是单线程的,无法为每个请求构造一个 window 对象,但是由于服务端的 render 是同步的,我们可以在渲染前借用 window 对象,渲染后返还 window 对象。如下所示:

const feed = {
  *index() {
    const originWindow = global.window;
    global.window = createWindow(this.userInfo})
    try{
    const htmlContent = renderToString(<feedContainer />);
    console.log('html:', htmlContent);
    this.render('admin', {
      html: htmlContent
    });
   }catch(err){
   }
   global.window = originWindow;
  }
}

这样上面的客户端代码就能够通过服务端渲染了。这个方法着实有点 hack 了。