从Preact中了解React组件和hooks基本原理

Posted 全栈前端精选

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从Preact中了解React组件和hooks基本原理相关的知识,希望对你有一定的参考价值。

https://juejin.im/post/5cfa29e151882539c33e4f5e

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

从Preact中了解React组件和hooks基本原理

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. hcreateElement 的别名, Vue 生态系统也是使用这个惯例, 具体为什么没作考究(比较简短?)。

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

    /**
* @jsx h
*/

render(<div>hello jsx</div>, el);

本文不是 React 或 Preact 的入门文章,所以点到为止,更多内容可以查看官方教程.

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

    // ⚛️type 节点的类型,有DOM元素(string)和自定义组件,以及Fragment, 为null时表示文本节点exportfunctioncreateElement(type, props, children) {
props.children = children;
// ⚛️应用defaultPropsif (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);
}

exportfunctioncreateVNode(type, props, key, ref) {
return { type, props, key, ref, /* ... 忽略部分内置字段 */constructor: undefined };
}

通过 JSX 和组件, 可以构造复杂的对象树:

    render(
<divclassName="container"><SideBar /><Body /></div>,
root,
);

Component 的实现

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

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

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

    functionComponent(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 = typeofPromise == 'function'
? Promise.prototype.then.bind(Promise.resolve()) // micro task
: setTimeout; // 回调到setTimeoutfunctionenqueueRender(c) {
// 不需要重复推入已经在队列的Componentif (!c._dirty && (c._dirty = true) && q.push(c) === 1)
defer(process); // 当队列从空变为非空时,开始调度
}

// 批量清空队列, 调用Component的forceUpdatefunctionprocess() {
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 容器:

    // 简化版exportfunctionrender(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);
}

梳理一下上面的流程:从Preact中了解React组件和hooks基本原理到目前为止没有看到组件的其他功能,如初始化、生命周期函数。这些特性在 diff 函数中定义,也就是说在组件挂载或更新的过程中被调用。下一节就会介绍 diff

diff 算法

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

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

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

![](https://gw.alicdn.com/tfs/TB1pr4MlHr1gK0jSZR0XXbP8XXa-1156-960.png)

在深入 diff 程序之前,先看一下基本的对象结构, 方便后面理解程序流程. 先来看下 VNode 的外形:
`
``js
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 列表.从Preact中了解React组件和hooks基本原理如上图, 首先这里需要维护一个表示当前插入位置的变量 oldDOM, 它一开始指向 DOM childrenNode 的第一个元素, 后面每次插入更新或插入 newDOM,都会指向 newDOM 的下一个兄弟元素.

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

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

来详细看看源码:

    exportfunctiondiffChildren(
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;
// ...// ⚛️遍历新childrenfor (i = 0; i < newChildren.length; i++) {
childVNode = newChildren[i] = coerceToVNode(newChildren[i]); // 规范化VNodeif (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>// <> <>

以上是关于从Preact中了解React组件和hooks基本原理的主要内容,如果未能解决你的问题,请参考以下文章

第1774期详细preact hook源码逐行解析

详细 preact hook 源码逐行解析

react---Hooks的基本使用---巷子

送书Preact(React)核心原理详解

React 进阶必备:从函数式组件看 Hooks 设计原理(收藏!)

为何要使用React Hooks?