从Preact中了解React组件和hooks基本原理
更新:HHH   时间:2023-1-7


React 的代码库现在已经比较庞大了,加上 v16 的 Fiber 重构,初学者很容易陷入细节的大海,搞懂了会让人觉得自己很牛逼,搞不懂很容易让人失去信心, 怀疑自己是否应该继续搞前端。那么尝试在本文这里找回一点自信吧(高手绕路).

Preact 是 React 的缩略版, 体积非常小, 但五脏俱全. 如果你想了解 React 的基本原理, 可以去学习学习 Preact 的源码, 这也正是本文的目的。

关于 React 原理的优秀的文章已经非常多, 本文就是老酒装新瓶, 算是自己的一点总结,也为后面的文章作一下铺垫吧.

文章篇幅较长,阅读时间约 20min,主要被代码占据,另外也画了流程图配合理解代码。

注意:代码有所简化,忽略掉 svg、replaceNode、context 等特性 本文代码基于 Preact v10 版本

Virtual-DOM
从 createElement 开始
Component 的实现
diff 算法
diffChildren
diff
diffElementNodes
diffProps
Hooks 的实现
useState
useEffect
技术地图
扩展

Virtual-DOM

Virtual-DOM 其实就是一颗对象树,没有什么特别的,这个对象树最终要映射到图形对象. Virtual-DOM 比较核心的是它的diff算法.

你可以想象这里有一个DOM映射器,见名知义,这个’DOM 映射器‘的工作就是将 Virtual-DOM 对象树映射浏览器页面的 DOM,只不过为了提高 DOM 的'操作性能'. 它不是每一次都全量渲染整个 Virtual-DOM 树,而是支持接收两颗 Virtual-DOM 对象树(一个更新前,一个更新后), 通过 diff 算法计算出两颗 Virtual-DOM 树差异的地方,然后只应用这些差异的地方到实际的 DOM 树, 从而减少 DOM 变更的成本.

Virtual-DOM 是比较有争议性,推荐阅读《网上都说操作真实 DOM 慢,但测试结果却比 React 更快,为什么?》 。切记永远都不要离开场景去评判一个技术的好坏。当初网上把 React 吹得多么牛逼, 一些小白就会觉得 Virtual-DOM 很吊,JQuery 弱爆了。

我觉得两个可比性不大,从性能上看, 框架再怎么牛逼它也是需要操作原生 DOM 的,而且它未必有你使用 JQuery 手动操作 DOM 来得'精细'. 框架不合理使用也可能出现修改一个小状态,导致渲染雪崩(大范围重新渲染)的情况; 同理 JQuery 虽然可以精细化操作 DOM, 但是不合理的 DOM 更新策略可能也会成为应用的性能瓶颈. 所以关键还得看你怎么用.

那为什么需要 Virtual-DOM?

我个人的理解就是为了解放生产力。现如今硬件的性能越来越好,web 应用也越来越复杂,生产力也是要跟上的. 尽管手动操作 DOM 可能可以达到更高的性能和灵活性,但是这样对大部分开发者来说太低效了,我们是可以接受牺牲一点性能换取更高的开发效率的.

所以说 Virtual-DOM 更大的意义在于开发方式的改变: 声明式、 数据驱动, 让开发者不需要关心 DOM 的操作细节(属性操作、事件绑定、DOM 节点变更),也就是说应用的开发方式变成了view=f(state), 这对生产力的解放是有很大推动作用的.

当然 Virtual-DOM 不是唯一,也不是第一个的这样解决方案. 比如 AngularJS, Vue1.x 这些基于模板的实现方式, 也可以说实现这种开发方式转变的. 那相对于他们 Virtual-DOM 的买点可能就是更高的性能了, 另外 Virtual-DOM 在渲染层上面的抽象更加彻底, 不再耦合于 DOM 本身,比如可以渲染为 ReactNative,PDF,终端 UI 等等。

从 createElement 开始
很多小白将 JSX 等价为 Virtual-DOM,其实这两者并没有直接的关系, 我们知道 JSX 不过是一个语法糖.

例如<a href="/"><span>Home</span></a>最终会转换为h('a', { href:'/' }, h('span', null, 'Home'))这种形式, h是 JSX Element 工厂方法.

h 在 React 下约定是React.createElement, 而大部分 Virtual-DOM 框架则使用h. h 是 createElement 的别名, Vue 生态系统也是使用这个惯例, 具体为什么没作考究(比较简短?)。

可以使用@jsx注解或 babel 配置项来配置 JSX 工厂:

/**

  • @jsx h
    */
    render(<div>hello jsx</div>, el);
    复制代码
    本文不是 React 或 Preact 的入门文章,所以点到为止,更多内容可以查看官方教程.

现在来看看createElement, createElement 不过就是构造一个对象(VNode):

// ⚛️type 节点的类型,有DOM元素(string)和自定义组件,以及Fragment, 为null时表示文本节点
export function createElement(type, props, children) {
props.children = children;
// ⚛️应用defaultProps
if (type != null && type.defaultProps != null)
for (let i in type.defaultProps)
if (props[i] === undefined) props[i] = type.defaultProps[i];
let ref = props.ref;
let key = props.key;
// ...
// ⚛️构建VNode对象
return createVNode(type, props, key, ref);
}

export function createVNode(type, props, key, ref) {
return { type, props, key, ref, / ... 忽略部分内置字段 / constructor: undefined };
}
复制代码
通过 JSX 和组件, 可以构造复杂的对象树:

render(
<div className="container">
<SideBar />
<Body />
</div>,
root,
);
复制代码

Component 的实现
对于一个视图框架来说,组件就是它的灵魂, 就像函数之于函数式语言,类之于面向对象语言, 没有组件则无法组成复杂的应用.

组件化的思维推荐将一个应用分而治之, 拆分和组合不同级别的组件,这样可以简化应用的开发和维护,让程序更好理解. 从技术上看组件是一个自定义的元素类型,可以声明组件的输入(props)、有自己的生命周期和状态以及方法、最终输出 Virtual-DOM 对象树, 作为应用 Virtual-DOM 树的一个分支存在.

Preact 的自定义组件是基于 Component 类实现的. 对组件来说最基本的就是状态的维护, 这个通过 setState 来实现:

function Component(props, context) {}

// ⚛️setState实现
Component.prototype.setState = function(update, callback) {
// 克隆下一次渲染的State, _nextState会在一些生命周期方式中用到(例如shouldComponentUpdate)
let s = (this._nextState !== this.state && this._nextState) ||
(this._nextState = assign({}, this.state));

// state更新
if (typeof update !== 'function' || (update = update(s, this.props)))
assign(s, update);

if (this._vnode) { // 已挂载
// 推入渲染回调队列, 在渲染完成后批量调用
if (callback) this._renderCallbacks.push(callback);
// 放入异步调度队列
enqueueRender(this);
}
};
复制代码

enqueueRender 将组件放进一个异步的批执行队列中,这样可以归并频繁的 setState 调用,实现也非常简单:

let q = [];
// 异步调度器,用于异步执行一个回调
const defer = typeof Promise == 'function'
? Promise.prototype.then.bind(Promise.resolve()) // micro task
: setTimeout; // 回调到setTimeout

function enqueueRender(c) {
// 不需要重复推入已经在队列的Component
if (!c._dirty && (c._dirty = true) && q.push(c) === 1)
defer(process); // 当队列从空变为非空时,开始调度
}

// 批量清空队列, 调用Component的forceUpdate
function process() {
let p;
// 排序队列,从低层的组件优先更新?
q.sort((a, b) => b._depth - a._depth);
while ((p = q.pop()))
if (p._dirty) p.forceUpdate(false); // false表示不要强制更新,即不要忽略shouldComponentUpdate
}
复制代码

Ok, 上面的代码可以看出 setState 本质上是调用 forceUpdate 进行组件重新渲染的,来往下挖一挖 forceUpdate 的实现.

这里暂且忽略 diff, 将 diff 视作一个黑盒,他就是一个 DOM 映射器, 像上面说的 diff 接收两棵 VNode 树, 以及一个 DOM 挂载点, 在比对的过程中它可以会创建、移除或更新组件和 DOM 元素,触发对应的生命周期方法.

Component.prototype.forceUpdate = function(callback) { // callback放置渲染完成后的回调
let vnode = this._vnode, dom = this._vnode._dom, parentDom = this._parentDom;

if (parentDom) { // 已挂载过
const force = callback !== false;
let mounts = [];
// 调用diff对当前组件进行重新渲染和Virtual-DOM比对
// ⚛️暂且忽略这些参数, 将diff视作一个黑盒,他就是一个DOM映射器,
dom = diff(parentDom, vnode, vnode, mounts, this._ancestorComponent, force, dom);
if (dom != null && dom.parentNode !== parentDom)
parentDom.appendChild(dom);
commitRoot(mounts, vnode);
}
if (callback) callback();
};
复制代码

在看看 render 方法, 实现跟 forceUpdate 差不多, 都是调用 diff 算法来执行 DOM 更新,只不过由外部指定一个 DOM 容器:

// 简化版
export function render(vnode, parentDom) {
vnode = createElement(Fragment, null, [vnode]);
parentDom.childNodes.forEach(i => i.remove())
let mounts = [];
diffChildren(parentDom, null oldVNode, mounts, vnode, EMPTY_OBJ);
commitRoot(mounts, vnode);
}
复制代码

梳理一下上面的流程:

到目前为止没有看到组件的其他功能,如初始化、生命周期函数。这些特性在 diff 函数中定义,也就是说在组件挂载或更新的过程中被调用。下一节就会介绍 diff

diff 算法
千呼万唤始出来,通过上文可以看出,createElement 和 Component 逻辑都很薄, 主要的逻辑还是集中在 diff 函数中. React 将这个过程称为 Reconciliation, 在 Preact 中称为 Differantiate.

为了简化程序 Preact 的实现将 diff 和 DOM 杂糅在一起, 但逻辑还是很清晰,看下目录结构就知道了:

src/diff
├── children.js # 比对children数组
├── index.js # 比对两个节点
└── props.js # 比对两个DOM节点的props
复制代码

在深入 diff 程序之前,先看一下基本的对象结构, 方便后面理解程序流程. 先来看下 VNode 的外形:

type ComponentFactory<P> = preact.ComponentClass<P> | FunctionalComponent<P>;

interface VNode<P = {}> {
// 节点类型, 内置DOM元素为string类型,而自定义组件则是Component类型,Preact中函数组件只是特殊的Component类型
type: string | ComponentFactory<P> | null;
props: P & { children: ComponentChildren } | string | number | null;
key: Key
ref: Ref<any> | null;

/**

  • 内部缓存信息
    */
    // VNode子节点
    _children: Array<VNode> | null;
    // 关联的DOM节点, 对于Fragment来说第一个子节点
    _dom: PreactElement | Text | null;
    // Fragment, 或者组件返回Fragment的最后一个DOM子节点,
    _lastDomChild: PreactElement | Text | null;
    // Component实例
    _component: Component | null;
    }
    复制代码

diffChildren
先从最简单的开始, 上面已经猜出 diffChildren 用于比对两个 VNode 列表.

如上图, 首先这里需要维护一个表示当前插入位置的变量 oldDOM, 它一开始指向 DOM childrenNode 的第一个元素, 后面每次插入更新或插入 newDOM,都会指向 newDOM 的下一个兄弟元素.

在遍历 newChildren 列表过程中, 会尝试找出相同 key 的旧 VNode,和它进行 diff. 如果新 VNode 和旧 VNode 位置不一样,这就需要移动它们;对于新增的 DOM,如果插入位置(oldDOM)已经到了结尾,则直接追加到父节点, 否则插入到 oldDOM 之前。

最后卸载旧 VNode 列表中未使用的 VNode.

来详细看看源码:

export function diffChildren(
parentDom, // children的父DOM元素
newParentVNode, // children的新父VNode
oldParentVNode, // children的旧父VNode,diffChildren主要比对这两个Vnode的children
mounts, // 保存在这次比对过程中被挂载的组件实例,在比对后,会触发这些组件的componentDidMount生命周期函数
ancestorComponent, // children的直接父'组件', 即渲染(render)VNode的组件实例
oldDom, // 当前挂载的DOM,对于diffChildren来说,oldDom一开始指向第一个子节点
) {
let newChildren = newParentVNode._children || toChildArray(newParentVNode.props.children, (newParentVNode._children = []), coerceToVNode, true,);
let oldChildren = (oldParentVNode && oldParentVNode._children) || EMPTY_ARR;
// ...

// ⚛️遍历新children
for (i = 0; i < newChildren.length; i++) {
childVNode = newChildren[i] = coerceToVNode(newChildren[i]); // 规范化VNode
if (childVNode == null) continue
// ⚛️查找oldChildren中是否有对应的元素,如果找到则通过设置为undefined,从oldChildren中移除
// 如果没有找到则保持为null
oldVNode = oldChildren[i];
for (j = 0; j < oldChildrenLength; j++) {
oldVNode = oldChildren[j];
if (oldVNode && childVNode.key == oldVNode.key && childVNode.type === oldVNode.type) {
oldChildren[j] = undefined;
break;
}
oldVNode = null; // 没有找到任何旧node,表示是一个新的
}
// ⚛️ 递归比对VNode
newDom = diff(parentDom, childVNode, oldVNode, mounts, ancestorComponent, null, oldDom);
// vnode没有被diff卸载掉
if (newDom != null) {
if (childVNode._lastDomChild != null) {
// ⚛️当前VNode是Fragment类型
// 只有Fragment或组件返回Fragment的Vnode会有非null的_lastDomChild, 从Fragment的结尾的DOM树开始比对:
// <A> <A>
// <> <>

返回开发技术教程...