阅读 6942

关于React Hooks和Immutable性能优化的实践,我写了一本掘金小册

最近,我的第一本小册《React Hooks 与 Immutable 数据流实战》在掘金成功上线。各位期待的粉丝朋友久等了,两个月之前的计划一直拖到了现在,也经常在 GitHub 的 issue 区也能感受到大家焦急的心情,实在非常抱歉,不过万幸的是,它终于成功地问世了。

上线了不到 5 天,没有任何推文介绍的情况下,销量已经超过 400,这个是我万万没想到的,不过这也侧面反映了各位掘友对我的信任。在后台大概看了一下 ID 名单,其中不乏熟悉的面孔,但更多的是几乎没什么印象甚至完全陌生的 ID,确实,回头看看在掘金这些日子的成长,写作思考挣扎的过程是极其痛苦的,但正是因为你偶然看到了文章,不经意点了赞、给了一些反馈,才让我有足够的斗志和毅力坚持下去。可能我们从未谋面,甚至互相连微信都没有,但就偏偏在一个叫"掘金"的地方,我收到了来自一个陌生人的认可,这种感觉从未有过,也是一直激励我不断坚持的动力。各位无论是期待已久还是偶尔打开这篇文章,请让我非常真诚地说上一声: 非常感谢!

回到小册本身,目前已经有不少的小伙伴加入了学习。尽管如此,我想我仍然有必要正式地介绍一下这本小册,因为我觉得这是作为小册作者的责任所在。

缘起

小册本身的性质算是一个项目教程,那为什么我要去做这样一个项目?

其实说来也挺好笑的,我仅仅只是想做一个精致的项目罢了。记得慕课网的名师七月曾经说过一句话: 技术这东西其实很纯粹,最后无非两点:一是打工赚钱,二是做自己想做的事情。而我后来所做的事情,恰好印证了后者。很多时候把事情做成,做成 60 分,是相对轻松且常人所能及的,但是要做到 90 分甚至更高,往往需要异常的刻苦,甚至需要恰当的机遇和天赋。这也是为什么类似题材的项目网上一大堆,我仍然坚持要做这个项目的原因。我想要靠自己独立做完成一个项目,它必须足够的精致,同时不是为了应付任何人。

接着,我试着去整合之前一段时间学到的知识,打算用 React 来搭配Immutable(不可变)数据,并且用上 React 界炽手可热的hooks来作为整个项目的基础技术栈。

为什么要用 hooks ?

我想说,React Hooks如今可以说是前端界"当红小生", 因其API简洁性、逻辑复用性等特性逐渐被开发者所应用,vue3.0也是采用类似的Function Based的模式,因此学习React Hooks也是未来的大趋势。在这里我也不想再重复都xxx年了,再不学xxx就要被淘汰了之类贩卖焦虑的话,其实并没有什么技术是必须要学的,如果它足够好,我愿意将它分享给各位,让更多的人享受到其带来的便利和效率上的提升。对于hooks而言,作为一个深度使用过的玩家,我觉得我是非常乐意给大家来分享的。而通过一个具体的项目来实践、应用hooks特性,我觉得比干啃文档要强太多,并且在实践的过程中会遇到一些坑,通过坑驱动来学习,可以加深我们对于hooks原理的理解。

为什么用 Immutable 数据?

这就比较复杂了。我想我首先得介绍一下 React 的渲染机制——Reconciliation 过程 (很多人翻译成 "一致化处理过程",个人觉得不太贴切,直译为 "协调" 反而更好,且看下面分解)。

渲染机制

如上图所示,React 采用的是虚拟 DOM (即 VDOM ),每次属性 (props) 和状态 (state) 发生变化的时候,render 函数返回不同的元素树,React 会检测当前返回的元素树和上次渲染的元素树之前的差异,然后针对差异的地方进行更新操作,最后渲染为真实 DOM,这就是整个 Reconciliation 过程,其核心就是进行新旧 DOM 树对比的 diff 算法。

为了获得更优秀的性能,首当其冲的工作便是 减少 diff 的过程,那么在保证应该更新的节点能够得到更新的前提下,这个 diff 的过程如何来避免呢?

答案是利用 shouldComponentUpdate 这个声明周期函数。这个函数做了什么事情呢?

默认的 shouldComponentUpdate 会在 props 和 state 发生变化时返回 true, 表示组件会重新渲染,从而调用 render 函数,进行新旧 DOM 树的 diff 比对。但是我们可以在这个生命周期函数里面做一些判断,然后返回一个布尔值,并且返回 true 表示即将更新当前组件,false 则不更新当前组件。换句话说,我们可以通过 shouldComponentUpdate 控制是否发生 VDOM 树的 diff 过程。

关键的知识点已经做好了铺垫。现在我们以 React 官方的一个图为例,完整地分析一下 Reconciliation 的流程:

SCU 即 shouldComponentUpdate 的简写,图中的红色节点表示 shouldComponentUpdate 函数返回 true ,需要调用 render 方法,进行新旧 VDOM 树的 diff 过程,绿色节点表示此函数返回 false ,不需要进行 DOM 树的更新。

从 C1 开始,C1 为红色节点,shouldComponentUpdate 返回 true,需要进行进一步的新旧 VDOM 树的比对,假设现在两棵树上的 C1节点类型相同,则递归进入下一层节点的比较,首先进入 C2,绿色节点,表示 SCU 返回 false,不需要对 C2 的 VDOM 节点进行比对,同时 C2 下面所有的后代节点 都不需要比对。

现在进入 C3,C3 为红色节点,表示 SCU 为 true,需要在该节点上进行比对,假设两棵树的 C3 节点类型相同,则继续进入到下一层的比对中。其 r 中 C6 为红色节点,进行相应的 diff 操作,C7、C8 都为绿色节点,都不需要更新。

当然可能你会有疑问,上面都是在 diff 的时候假设节点类型相同,那如果节点类型不相同的时候会怎样呢?这里 React 的做法非常简单粗暴,直接将 原 VDOM 树上该节点以及该节点下所有的后代节点 全部删除,然后替换为新 VDOM 树上同一位置的节点,当然这个节点的后代节点也全都跟着过来了。这属于 diff 算法的实现细节,我们在文末的彩蛋中会对于 diff 更全面和细致的拆解:)

因此我们可以发现,如果能够合理利用 shouldComponentUpdate,从而能避免不必要的 Reconciliation 过程,使得应用性能可以更加优秀。

一般 shouldComponentUpdate 会比较 props 和 state 中的属性是否发生改变 (浅比较) 来判定是否返回 true,从而触发 Reconciliation 过程。典型的应用就是 React 中推出的 PureComponent 这个 API,会在 props 或者 state 改变时对两者的数据进行浅层比较。

但是这个项目全面拥抱函数式组件,不再用类组件了,因此 shouldComponentUpdate 就不能再用了。用了函数组件后,是不是就没有了浅比较的方案了呢?并不是。React 为函数组件提供了一个 memo 方法,它和 PureComponent 在数据比对上唯一的区别就在于 只进行了 props 的浅比较,因为函数组件是没有 state 的。而且它的用法很简单,直接将函数传入 memo 中导出即可。形如:

function Home () {
    //xxx
} 
export default memo (Home);
复制代码

这也就解释了为什么我们需要用在每个组件导出时都要加 memo 包裹。

现在就有了一系列的优化方案了。

优化方案一:PureComponent (memo) 进行浅层比较

上一节我埋下了一个伏笔,就是 PureComponent 或者 memo 将会进行新旧数据的浅层比对。你可能会比较好奇,浅层比较是怎么比较的呢?口说无凭,我觉得让大家直观地感受一下比较重要,所以我暂且扒出 PureComponent 浅比较部分的核心源码让大家体会一下,大家不用紧张,其实逻辑非常简单。

function shallowEqual (objA: mixed, objB: mixed): boolean {
  // 下面的 is 相当于 === 的功能,只是对 + 0 和 - 0,以及 NaN 和 NaN 的情况进行了特殊处理
  // 第一关:基础数据类型直接比较出结果
  if (is (objA, objB)) {
    return true;
  }
  // 第二关:只要有一个不是对象数据类型就返回 false
  if (
    typeof objA !== 'object' ||
    objA === null ||
    typeof objB !== 'object' ||
    objB === null
  ) {
    return false;
  }

  // 第三关:在这里已经可以保证两个都是对象数据类型,比较两者的属性数量
  const keysA = Object.keys (objA);
  const keysB = Object.keys (objB);

  if (keysA.length !== keysB.length) {
    return false;
  }

  // 第四关:比较两者的属性是否相等,值是否相等
  for (let i = 0; i < keysA.length; i++) {
    if (
      !hasOwnProperty.call (objB, keysA [i]) ||
      !is (objA [keysA [i]], objB [keysA [i]])
    ) {
      return false;
    }
  }

  return true;
}
复制代码

从我写的注释可以看出,在这里开启了四道关卡,但终究还是浅层比较。在下面的情况会判断失灵。

state: {a: ["1"]} -> state: {a: ["1", "2"]}
复制代码

其实 a 数组已经改变了,但是浅层比较会表示没有改变,因为数组的引用没有变。看到没有?一旦属性的值为引用类型的时候浅比较就失灵了。

这就是这种方式最大的弊端,由于 JS 引用赋值的原因,这种方式仅仅适用于无状态组件或者状态数据非常简单的组件,对于大量的应用型组件,它是无能为力的。

优化方案二:shouldComponentUpdate 中进行深层比对

为了解决方案一带来的问题,我们现在不做浅层比对了,我们把 props 中所有的属性和值进行递归比对。

我们把上面浅层比对的代码进行一些魔改:

 function deepEqual (objA: mixed, objB: mixed): boolean {
  // 下面的 is 相当于 === 的功能,只是对 + 0 和 - 0,以及 NaN 和 NaN 的情况进行了特殊处理
  // 第一关:保证两者都是基本数据类型。基础数据类型直接比较出结果。
  // 对象类型咱就不比了
  if (objA == null && objB == null) return true;
  if (typeof objA !== 'object' &&
      typeof objB !== 'object' &&
      is (objA, objB)) {
    return true;
  }
  // 第二关:只要有一个不是对象数据类型就返回 false
  if (
    typeof objA !== 'object' ||
    objA === null ||
    typeof objB !== 'object' ||
    objB === null
  ) {
    return false;
  }

  // 第三关:在这里已经可以保证两个都是对象数据类型,比较两者的属性数量
  const keysA = Object.keys (objA);
  const keysB = Object.keys (objB);

  if (keysA.length !== keysB.length) {
    return false;
  }

  // 第四关:比较两者的属性是否相等,值是否相等
  for (let i = 0; i < keysA.length; i++) {
    if (
      !hasOwnProperty.call (objB, keysA [i]) ||
      !is (objA [keysA [i]], objB [keysA [i]])
    ) {
      return false;
    } else {
        if (!deepEqual (objA [keysA [i]], objB [keysA [i]])){
            return false;
        }
    }
  }

  return true;
}
复制代码

当访问到对象的属性值的时候,将属性值再进行递归比对,这样就达到了深层比对的效果。但是想想一种极端的情况,就是在属性有一万条的时候,只有最后一个属性发生了变化,那我们就不得已将一万条属性都遍历。这是非常浪费性能的。

优化方案 3: immutable 数据结构 + SCU (memo) 浅层比对

回到问题的本质,无论是直接用浅层比对,还是进行深层比对,我们最终是想z知道组件的 props (或 state) 数据有无发生改变。

在这样的条件下,immutable 数据应运而生。

什么是 immutable 数据?它有什么优势?

immutable 数据一种利用结构共享形成的持久化数据结构,一旦有部分被修改,那么将会返回一个全新的对象,并且原来相同的节点会直接共享。

具体点来说,immutable 对象数据内部采用是多叉树的结构,凡是有节点被改变,那么它和与它相关的所有上级节点都更新。

用一张动图来模拟一下这个过程:

是吧!只更新了父节点,比直接比对所有的属性简直强太多,并且更新后返回了一个全新的引用,即使是浅比对也能感知到数据的改变。

因此,采用 immutable 既能够最大效率地更新数据结构,又能够和现有的 PureComponent (memo) 顺利对接,感知到状态的变化,是提高 React 渲染性能的极佳方案。

不过有一说一,immutable 也有一些被部分开发者吐槽的点,首先是 immutable 对象和 JS 对象要注意转换,不能混用,这个大家注意适当的时候调用 toJS 或者 fromJS 即可,问题并不大。

其次就是对于 immutable API 的学习成本的争议。我觉得这个问题见仁见智吧,我的观点是:如果你目前沉溺在已经运用得非常熟练的技术栈当中,不说深入学习新技术,连新的 API 都懒得学,我觉得对个人成长来说是一个不太好的征兆。

学习目标

估计有同学看完上面的还不过瘾,追问道:"学完这个有什么用啊?"现在就来好好梳理一下,学完这本小册可以达到的效果和目标:

  1. 熟练使用React Hooks进行业务开发,理解哪些场景产生闭包陷阱,如何避免掉坑。
  2. 手写近6000行代码,封装13个基础UI组件、12个业务组件,彻底掌握React + Redux的工程化编码的全流程。
  3. 封装常用的移动端组件,实现常见的需求,如封装滚动组件实现图片懒加载实现上拉/下拉刷新的功能、实现防抖功能、实现组件代码分割(CodeSpliting)等。
  4. 拥有实现前端复杂交互的实际项目经验,提升自己的内功,比如开发播放器内核就是其中一个很大的挑战。
  5. 掌握CSS中的诸多技巧,提升自己的CSS能力,无论布局还是动画,都有相当多的实践和探索,未使用任何UI框架,样式代码独立实现。
  6. 彻底理解redux原理,并能够独立开发redux的中间件。

小册展望

小册上线后,我也陆陆续续听到了各位掘友的反馈,有对文章进行勘误的,也有对项目代码提出修改意见的,大家积极的参与让我不敢有一丝的懈怠。项目的更新和维护仍然在不断地进行中,后期会根据和大家的沟通结果,对项目的部分细节进行重构,另外也会加上更多的彩蛋,目前的计划是将hooks源码解析的系列文章放在小册中,不断给这个小册增值。希望大家能够多多支持,也希望你能够通过这个项目得到充分的锻炼、吸取到足够的经验,关于项目更多的细节,这里就不赘述了,在小册的第一节已经有足够具体的介绍了。

最后奉上小册的地址, 祝学习愉快!

关注下面的标签,发现更多相似文章
评论