综合技术

React填坑记(四):render !== hydrate

微信扫一扫,分享到朋友圈

React填坑记(四):render !== hydrate
0

上一篇讲到了如何通过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版本,可以尽情的使用最新的特性了。

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


哇,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 实际上新文档在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 (
      
{content}
); } }

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

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

hydrate(React16)


render(React15)


我们发现在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 = `
  
  ...
  window.userInfo = ${userInfo}
  window.serverInfo = ${serverInfo}
  ...
  
  `
});

client.js

// feedPage.js
render(,root);
// feedContainer.js
export () => 
// feedList.js
export () => 
// feedCard
export () => {
  const userInfo = window.userInfo;
  return (
{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();
    console.log('html:', htmlContent);
    this.render('admin', {
      html: htmlContent
    });
   }catch(err){
   }
   global.window = originWindow;
  }
}

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

展开阅读全文

微信扫一扫,分享到朋友圈

React填坑记(四):render !== hydrate
0

漫谈公链发展

上一篇

帐号泄露事件频发,到底什么样的密码才安全?

下一篇

你也可能喜欢

评论已经被关闭。

插入图片