fi3ework / blog

📝
861 stars 51 forks source link

避免React中的 unmount & re-mount #19

Open fi3ework opened 6 years ago

fi3ework commented 6 years ago

前言

前段时间在看这篇文章 [译] React性能优化-虚拟Dom原理浅析 时发现了一些非常有意思的知识点,之前并没有考虑过,大家可以先看一下这篇文章,写的非常好,翻译的也很好。本文将对其中涉及到 unmount & re-mount 的地方展开分析。

JSX的作用

在展开讲之前,我们先重新了解 JSX,JSX 并不审美,它只是一个弱弱的语法糖,所谓语法糖:

语法糖(Syntactic sugar),也译为糖衣语法,是由英国计算机科学家彼得·蘭丁发明的一个术语,指计算机语言中添加的某种语法,这种语法对语言的功能没有影响,但是更方便程序员使用。 语法糖让程序更加简洁,有更高的可读性。

看下 Babel 对 JSX 的转义就知道了。

JSX 代码如下:

const Button = (props) => <div>btn</div>

let x = <Button />

被转义后的 JS 代码如下(其实老版本的 React 就是这么写的):

"use strict";

var Button = function Button(props) {
  return React.createElement(
    "div",
    null,
    "btn"
  );
};

var x = React.createElement(Button, null);

React.createElement 的函数签名如下

React.createElement(
  type,
  [props],
  [...children]
)

createElement 接受不限定个参数

针对可以传入 children 的子元素,可以有以下值:

这么一看,似乎 children 是很特殊的一个参数,但是我们经常会写出 this.props.children 这样的参数,也就是说在 React 内部,children 也是 props 的一部分,

为了简化理解,我们可以看 React 15.0 版本(之后的版本加入了 Fiber)的 VDOM 的结构,其中,绿色框内的就是一个 VDOM 的结构。

(这里要注意,最外面的那个 _MyNode 并不是 VDOM,它是组件中的 this,我们一般称之为实例化对象,它的 render 中返回的值将会被用来生成真正的 VDOM。如果你还不信,那么想一下,真正的 VDOM 会有 shouldComponentWillUpdate 这种生命周期函数吗?)

image

所以,一个简略版的 React 的 VDOM 模型大致可以描述为:

{
  type: 'div',
  props: {
    className: 'cn',
    children: [
      'Content 1!',
      'Content 2!'
    ]
  }
}

reconciler

再重复一下 React 的 reconciler 的原则,深入React技术栈 中这本书已经说得很好了,这里再整理一下。

key

我们都知道,在显示的返回一个数组时,数组中的每个子元素要有一个稳定的,独一无二的 key 来标明每个子元素的“身份”,标明身份是为了让 React 的 reconciler 的效率获得提升。

我们来看两个个例子:

同级变动,有 key

image

B C 是同级节点,并且都有独特的 key,如果我问从左到右真实 DOM 是如何变化的?那么你肯定能说出来 B 不变,C 被移除了。没错,VDOM 的变化最后会 patch 到真实 DOM 上,在这里由于有 key,所以 React 可以知道 B 是没有变化的,只需要移除一个 C 即可。

同级变动,无 key

还是上面的那个图,这一次,B 和 C 没有 key 了,真实 DOM 会如何变化?我改了一个小 DEMO,打印了它们的生命周期,我们先来看下打印的结果:

image

答案是:先创建一个新的 B,再将原来的 B 和 C 移除,最后将新的 B 放在 A 的下面,因为 B 和 C 对 React 来说根本就分别不出来,React 的 diff 算法就是会把他们先卸载掉再重新生成加载。

思考

你可能会说,我平时都会写 key 的。React 也会在控制台对数组形式的子元素提示需要添加 key。但是非数组形式的则完全不会提示,如下两个组件。可以是一模一样,从 Babel 转义来看也几乎相同,唯一的区别就是,Arr2 返回的子元素是以数组形式包裹的而 Arr1 则是一个个参数。但是没关系,他们最后都会被 打包成一个 children 的组件,数组形式的子元素一方面是方便我们的书写,一方面是可以让 React 提示我们要加 key。所以会不会有那么一种情况,就是虽然我们的本意不是写一个不带 key 的数组,但是无意中写出了这种反模式的代码呢?

image

避免 unmount & re-mount

不必要的 unmount & re-mount 不仅会让其丢失自身的 state,还会带来性能上的负担。因为它不仅会在 VDOM 上进行 diff(其实 VDOM 上 diff 的负担会变少,因为直接卸载再重装其实很简单,真正的负担是真实 DOM),还会 patch 到真实 DOM 上,违背了 diff 最小化更新的原则。试想,一个有 10000 个条目的列表的真实 DOM 在每次更新时被 unmount 和 re-mount 带来的开销有多可怕。

手动辅助 reconciler

开头提到的那篇文章中有一个很有意思的案例,我在这里简单再重复一下:

就是有这么个组件

<div>
  <Message />
  <Table />
  <Footer />
</div>

Table 是个有很多项目的列表(其实是什么无所谓,只是为了强调 Table 如果被 unmount & re-mount 是开销很大的操作)。现在呢,比如我们的用户读完一个通知了,那么就可以移除 Message 这个组件。

React会怎么处理呢?它会看作是一个 array 类型的 children,现在少了第一项,从前第一项是 Message 现在是 Table 了,也没有 key 作为索引,比较 type 的时候又发现它们俩不是同一个 function 或者 class 的同一个实例,于是会把整个 Table unmount,然后再 re-mount 回去,重新渲染它大量的子数据。

我们上面的 key 的 demo 中,也印证了在没有 key 时 React 会“傻傻得”去 unmount & re-mount。

作者还很贴心的给出了解决方案:

  1. 主动添加 key 辅助 React 进行辨别元素身份。
  2. 使用基础类型占位,比如 isShown && <Message />,我们可以使用 true, false, null, undefined ,这些都是 JSX 允许的,它们本身不会被渲染什么,但是确实合格的 “占位符”。

但是我们平时在写类似这种需要移除的组建时,往往会写成一个表达式的形式,拿这个案例举例的话就是

<div>
    {this.state.doesShow ? <Message onClickHandler={this.remove}/> : null}
    <Table />
    <Footer />
</div>

这种表达式的写法自然而然的符合原文作者通过添加一个基础类型占位符来辅助 diff 的思路,笔者之前只是认为 null 表达的是不渲染任何东西(当然用 false 什么的也可以的,但是这里 null 更符合语意),其实 null 还有另一个重要作用就是辅助 diff。

既然如此,那么什么时候我们会陷入作者写的这个坑中的呢?

笔者能想到的一个点是"更换样式",比如很多网站都有,点击更换样式(不只是颜色,也会涉及到 DOM 的更改)。但是要注意,React 对相同位置不同类型的组件会直接卸载掉旧的组件然后加载一个新的组件,直接就没有子元素啥事了。

还是拿之前的那个例子举例,假设这是样式1

<Style1 data={...}>
  <Message />
  <Table />
  <Footer />
</Style1>

这是样式2

<Style2 data={...}>
  <Table />
  <Message />
</Style2>

如果直接替换掉,那么由于 Style1 和 Style2 类型不同,Style1 会被直接卸载掉,Style2 再重新加载。

如果不想被直接替换掉,我们可以剥离掉最外层的样式组件,在渲染时直接执行来展开子元素们:

  style1 = () => (
    <div data={...}>
      <Message key="message"/>
      <Table key="table"/>
      <Footer key="footer"/>
    </div>
  )

  style2 = () => (
    <div data={...}>
      <Table key="table"/>
      <Message key="message"/>
    </div>
  )

在父组件中:

{this.state.style === 'style1' ? this.style1() : this.style2() }

这样的确可以减少 unmount & re-mount 的次数,但是也会带来一些弊端:1. 如果嵌套的很复杂的组件将比较难分离出来。 2. 数据的传递变得更加复杂了。 3. 组件间的耦合变重了。

避免引用丢失

文中还提到了一个 HOC 的坑,这里再简要复述一下这个坑:

我们会经常这样写 HOC(事实上,这是很常见的一种 HOC,比如 react-router 的 withRouter 就是这样)

function withName(SomeComponent) {
  // Computing name, possibly expensive...
  return function(props) {
    return <SomeComponent {...props} name={name} />;
  }
}

然后在父组件中如此调用:

class App extends React.Component() {
  render() {
    // Creates a new instance on each render
    const ComponentWithName = withName(SomeComponent);
    return <ComponentWithName />;
  }
}

或者,如此调用:

// Creates a new instance just once
const ComponentWithName = withName(Component);

class App extends React.Component() {
  render() {
    return <ComponentWithName />;
  }
}

看起来差不多,但其实如果父组件 re-render,第一种方法创建的 HOC 是会 unmount & re-mount 的。有的朋友读到这里可能会说不对啊,根据前面的 component 的 diff 的原则,前后两次 render 在 React.createElement 中的 type 都是 ComponentWithName。但是仔细看啊,当 type 为一个自定义的组件的时候,type 将不是一个字符串,而是一个指向对应的函数的引用。所以虽然都是 ComponentWithName,但是 re-render 时的引用已经改变。这对 React 来说会认为是换了一个类型的节点,所以直接将旧节点 unmount,新节点 re-mount,突突突突,一堆老的真实 DOM 被卸载,一堆新的真实 DOM 被装载。

总结

还是那两句话。