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

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

Preact 是 React 的缩略版, 体积很是小, 但五脏俱全. 若是你想了解 React 的基本原理, 能够去学习学习 Preact 的源码, 这也正是本文的目的。node

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

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

注意:代码有所简化,忽略掉 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 变动的成本.babel

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

我以为两个可比性不大,从性能上看, 框架再怎么牛逼它也是须要操做原生 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 rel="nofollow" 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;
// ...

// ⚛️遍历新childrenfor (i = 0; i < newChildren.length; i++) {childVNode = newChildren[i] = coerceToVNode(newChildren[i]); // 规范化VNodeif (childVNode == null) continue// ⚛️查找oldChildren中是否有对应的元素,若是找到则经过设置为undefined,从oldChildren中移除// 若是没有找到则保持为nulloldVNode = 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,表示是一个新的}// ⚛️ 递归比对VNodenewDom = 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>// <> <>

相关文章
相关标签/搜索