山寨 React 的一点心得

发表于更新于阅读时长 5 分钟

代码在Github

0. Fiber

virtual DOM 的基本思想是把 DOM 对象用 JS 对象表示, 从而尽量避免昂贵的 DOM 操作, 而具体实现则是在更新的时候 diff 新旧两颗 VDOM 树, 实现增量更新. 但这一操作当然并不便宜, React 采取的应对方式则是使用可中断的 diff 过程, 如果在 diff 过程中产生了需要立即响应的新事件(比如用户输入), 则可以中断当前任务转而优先执行更迫切的任务. 可以看到性质上这类似于操作系统中的调度器. 当然, 在实际实现的时候不能真像操作系统那样把上下文全部保存下来, 这非常不经济, 因此 React 会把 VDOM 树转换成 Fiber 树

fiber

可以看到, 和 DOM 树一一对应的 VDOM 树不同, Fiber 树多了子节点对父节点的指针, 而少了父节点到其他子结点的. 这样, 只要知道上一次执行的节点, 按照以下逻辑即可继续 diff

  1. 有子节点的去找子节点
  2. 没有的去找兄弟节点
  3. 都没有的去找有兄弟节点的父节点, 没找到就继续上溯直到找到过到达顶部为止

1. 两阶段更新

在做了 fiber 的改造之后 diff 过程就是可重入的, 随时可以被用户输入或者是更高优先级的 diff 打断, 然而这带来了另一个问题, 即过去一边 diff 一边更新 DOM 的做法会带来问题, 没人希望 UI 只更新一部分. 因此 React 选择记录每次 diff 的结果, 并且在整个 diff 结束之后集中更新.

2. 函数组件和类组件

在 JS 中, 类本身也是一个函数, 因此并不存在简单的方法来识别到底应该对一个函数使用 new 与否. 如果对所有函数都使用 new 来调用, 则会在遇到箭头函数时出错. 尽管通过判断prototype属性能解决这一问题, 但被转译过的代码并不一定能保持这样的性质. 因此 React 并不允许传入任意类来构造组件, 只允许普通函数和继承自React.Component的类. 通过判断该函数是否是Component的子类, 即可简单判断应该如何调用它.

3. 对象和闭包

曾经有人讲过这样的故事

The venerable master Qc Na was walking with his student, Anton. Hoping to prompt the master into a discussion, Anton said "Master, I have heard that objects are a very good thing - is this true?" Qc Na looked pityingly at his student and replied, "Foolish pupil - objects are merely a poor man's closures."

Chastised, Anton took his leave from his master and returned to his cell, intent on studying closures. He carefully read the entire "Lambda: The Ultimate..." series of papers and its cousins, and implemented a small Scheme interpreter with a closure-based object system. He learned much, and looked forward to informing his master of his progress.

On his next walk with Qc Na, Anton attempted to impress his master by saying "Master, I have diligently studied the matter, and now understand that objects are truly a poor man's closures." Qc Na responded by hitting Anton with his stick, saying "When will you learn? Closures are a poor man's object." At that moment, Anton became enlightened.

去掉上文中故弄玄虚的部分, 我也在 SICP 中读到过类似的语句. 总而言之, 对象和闭包常常可以起到相同的作用. 但我从来没想到能在 React 里也见到这个结论.

如前所述, React 组件分函数组件和类组件两种. 在组件更新时, 使用者常常希望不要更新整颗 Fiber 树, 而只更新和当前组件有关的子树. 对于类组件而言, 因为 React 需要保持组件实例, 因此简单地把这个组件的 Fiber 挂在实例上即可. 具体说来 React 会挂在_reactInternalFiber上.

对于函数组件呢? 在 React 16 之前, 所有函数组件都是无状态的, 因此它的更新只能由它的父组件导致. 但是这 React 16 引入 hook 之后, 函数组件自己的 setState 也能造成重渲染, 这就要求 setState 也知道当前组件对应的 Fiber 节点. 显而易见地, 应该把它存在 setState 函数的闭包里.

4. 事件代理

众所周知, 在 React 中无论哪种组件, 使用者都经常拿一个当时生成的函数当作 event listener. 这很方便, 但也带来了严重的性能问题: 两次渲染之间, 这个监听器无法做到保持不变, 因此 React 会比较它们的引用(显然不同)并相应地更新 DOM 树. 换言之, 这样会导致每次渲染时都会产生一次无用的removeEventListeneraddEventListener, 而这两个函数的性能并不高.

所以 React 选择了利用事件冒泡. React 只在根节点上施加监听器, 而通过冒泡上来的事件的target属性来分发事件. 当然, 还是要找个地方把监听器存起来. 自然而然地, 可以用一个Map<element,listeners>, 但在不支持 ES6 的浏览器里 polyfill 这个东西的成本比较高. 因此 React 选择把所有的监听器存在 DOM 节点的__reactEventHandlers属性上, 这样, 只要找到事件的target, 就自然知道该执行些什么.

5. 不要害怕全局状态

虽然有人讲过全局可变状态是万恶之源这种话, 但既然是 FB 人那就没有问题!

const ReactSharedInternals =
  React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;

怎么知道当前在 diff 哪个 fiber? 全局变量!

let workInProgress: Fiber | null = null;

怎么知道现在在处理哪个 hook? 全局变量!

let currentHook: Hook | null = null;
let workInProgressHook: Hook | null = null;

怎么知道现在引发的更新应该有怎样的更新优先级? 全局变量!

let currentUpdateLanePriority: LanePriority = NoLanePriority;

export function getCurrentUpdateLanePriority(): LanePriority {
  return currentUpdateLanePriority;
}

export function setCurrentUpdateLanePriority(newLanePriority: LanePriority) {
  currentUpdateLanePriority = newLanePriority;
}

6. 微优化

作为一个运行时, 做各种微优化方便上层应用自由发挥是很有必要的, 目前看到的:

  1. diff object 时返回一个数组, 奇数项是 key 偶数项是 value, 因为 JS 的数组和对象都不是无代价的
© 2016 - 2022Austaras Devas