通过 Preact 看 Diff 算法细节

Preact 是 React 的一个精简版,要读 React 源码,我们得先来理解一下 React 的设计原理和设计思想。在这一点上, Preact 是一个很不错的思路,我们可以从 Preact 这个简易版的框架出发。

在介绍 Preact 的主渲染流程之前,我们先来简单介绍一下什么是 jsx。

JSX 简介

在 React 中,jsx 是被推荐配合跟 React 同时使用的一个标签语法。它通常以以下的形式出现:

const element = <h1>Hello, world!</h1>;
复制代码

在页面上,上面的内容就会被渲染成为一个 h1 标签,其中的内容是 Hello,world! 就像下图中这样。

image.png

所以 jsx 实际上就是 js 表达式,可以使用所有 js 的语法规则。包括 for 循环、if 表达式等。我们就是在打包工具中将其变成 HTML 节点插入到页面当中的。

在 React 使用 jsx 的表达式时,HTML 标签或者 React 标签会通过 React.createElement 函数,将所有的 jsx 都转化为一个 js 对象,这个对象会记录当前 DOM 节点的 attr、events 等属性,最后渲染到页面上。

使用 jsx 可以避免在真正的 DOM 被渲染到页面上的多余操纵,提升了浏览器的渲染效率。

更详细的 jsx 介绍可以参考下文:

zh-hans.reactjs.org/docs/jsx-in…

从一个组件的渲染开始讲起

React 是一个以组件为基础的框架,我么最常用到的功能就是声明一个组件,并且在页面上渲染它。像下面这样:

class Home extends Component {
    constructor(props) {
        super(props);
        this.state = {
            title: 'Hello'
        }
    }

    changeTitle() {
        this.setState({title: 'changed'});
    }

    render() {
        return (
            <div>
                <button onClick={this.changeTitle.bind(this)}>改变页面标题</button>
                <h1>{this.state.title}</h1>
            </div>
        );
    }
}
复制代码

在这段代码中,我们用 class 的方式声明了一个组件,在这个组件中,我们声明了一个 render 方法来渲染当前组件的 DOM 节点,并且我们还给其中的 button 元素上绑定了一个 click 事件,这个事件的作用是去更改下面 h1 标签中的文案,这其中的文案使用的是一个在组件中声明的 state。

为了把这个组件渲染到页面上,我们还需要调用在 PReact 中声明的 render 函数,在这个函数中,需要传入三个参数,当前想要渲染的组件、组件的父节点和替代节点。我们一般使用下面的方式来调用渲染函数。

render(<Home />, document.body);
复制代码

可以见到,我们在这里传入了两个参数,React 组件 Home 作为子元素和一个原生的 DOM 节点 document.body。我们在 render 这个函数中做的主要功能就是把 Home 这个组件渲染到父节点上面。下面我们就来仔细看看这个过程是怎么完成的。

创建虚拟 DOM

未命名文件 (1).png

进入 render 函数之后,第一个任务就是检查传入的挂载目标的父节点的类型,parentNode。当前的父元素节点只能是一个原生的 HTML DOM。在这之中,作者自己定了三个等级,分别是 ELEMENT_NODE DOCUMENT_FRAGMENT_NODE 和 DOCUMENT_NODE。这三个类型分别代表了 HTML 中的元素节点、Fragment 片段节点和 document 节点。如果传入的父节点不符合要求,会直接抛出错误。我们这里传入的父节点是 body,在这里的类型是一个代码片段,因此它的类型就是 Fragment。

那么检查完之后,我们会将当前传入的这个 React 组件变成一个虚拟 DOM, 并且放到当前父节点的 _children 属性上,在这个时刻,使用 jsx 语法来写的 Home 组件已经被转化成为了一个 js 对象, 其中包含了它的组件名称、组件类型、子元素等属性,下一步我们就需要把当前的这个对象转化为一个虚拟 DOM。

生成虚拟 DOM

那么我们想把一个 Home 类型的对象转化成为虚拟 DOM 就需要看一下 createElement 这个函数,在这个函数中,createVNode 是其中的重点,这个是在 PReact 中声明的一个函数,专门用来将一个 React 组件对象转化为虚拟 DOM 节点。在这个函数中接受 5 个参数,分别是:

/**
 * Create a VNode (used internally by Preact)
 * @param {import('./internal').VNode["type"]} type The node name or Component
 * Constructor for this virtual node
 * @param {object | string | number | null} props The properties of this virtual node.
 * If this virtual node represents a text node, this is the text of the node (string or number).
 * @param {string | number | null} key The key for this virtual node, used when
 * diffing it against its children
 * @param {import('./internal').VNode["ref"]} ref The ref property that will
 * receive a reference to its created child
 * @returns {import('./internal').VNode}
 */
复制代码

将这些基本的组件信息传进去之后,首先会初始化一个基本的 vNode 对象,对象的各个值代表的含义如下:

const vnode = {
    type, // 当前组件的类型,一般为生成组件的构造函数或者名称
    props, // 组件的 props 属性,由上一级传入
    key, // 组件的 key 属性,用来标识组件的一致性,由上一级传入
    ref, // 组件的 ref 属性,用来标识组件中的不可变量,由上一级传入
    _children: null, // 当前节点的子节点
    _parent: null, // 当前节点的父节点
    _depth: 0, // 当前节点的 HTML 层级深度
    _dom: null, // 当前节点的最外层 HTML DOM
    _nextDom: undefined, // 当前节点的下一个 HTML DOM
    _component: null, 当前节点的组件相关信息
    _hydrating: null,
    constructor: undefined,
    _original: original == null ? ++vnodeId : original // 当前 vNode 的唯一标识
};
复制代码

添加初始属性

初始化好了这个对象之后,就是要为这个对象添加各种与 vNode 有关的属性,下一步是给当前的虚拟对象上添加 proto 属性,也就是隐式原型。添加的内容如下所示:

image.png

接下来会检测 vNode 的 type 属性,如果是非 string 类型,那么就说明它不是一个原生的 HTML DOM,而是一个 React 类型的节点,则将 vNode 的 $$typeof 属性设置为 REACT_ELEMENT_TYPE。走到这一步,一个 vNode 就生成好了,而当前的这个 vNode 其实是在一个 Fragment 元素中将真正要渲染的元素放在它的 props.children 上。

生成好了当前的 DOM 树的结构,我们最后就要去用 diff 算法将我们新生成的这个节点和原来的节点进行一个对比,通过最终对比的结果来进行渲染。但是其实这是我们组件的首次渲染,所以在 diff 的过程中和更新的 diff 算法还是有很多的不同的。

Diff 算法

diff 算法在 React 中可以算是一个核心的算法,其宗旨是为了让我们能够避免多余的 DOM 树的改动和渲染。通过将 diff 算法和 vNode 相结合的方式,我们可以提前得知是哪些 DOM 元素发生了改变,从而只更新这些已经发生了改变的元素,而不需要全局刷新,下面,我们会通过流程图的方式,来将这一步骤做一个更加详细的讲解, 这里有一个流程图,大家可以参照这个来看一下。

未命名文件 (6).png

diff 函数(标为蓝色线)

未命名文件 (10).png

首先,我们会进入到一个叫作 diff 的函数中,在这个函数中第一件事情,还是检查。在进入到这个检查逻辑中,首先一件事就是将这个 vNode 的 type 和父节点 parent 取出来,同时取出距离当前的这个 vNode 最近的父节点。拿到父节点之后就会去检查当前的父节点类型是否不合法,主要是检查是否为 table 相关的节点,从而判断其父节点是否合规,也会根据父元素节点不同的类型来采取对应的逻辑处理。

初始化 comp

做完这一系列的检查和操作之后,开始检查传入的新节点的类型,如果是 function,那么会当作一个 React 组件来处理。并且会在 globalContext 上寻找这个 vNode 原来的 context 的 id, 如果在 globalContext 中没有找到对应的 context,那么就把 globalContext 赋值给 vNode 的上下文。

接下来我们就来初始化这个新的组件,初始化一个新的组件首先就是要将当前的 vNode 的 props 和 context 传递给 Componnet 构造函数,然后初始化一个对象 comp。将当前 vNode 的 type 属性当作 comp 的constructor,并且初始化 comp 的 render 函数。

第一次调用生命周期

初始化 comp 的 state 属性为空对象,并且用 _dirty 来标记 comp 没有被更新过,是一个新组件。初始化_nextState 为 state 的值。接下来就是生命周期的调用,此时 comp 还没有进行渲染。首先检查的是 componentWillMount,如果这个生命周期函数在 comp 上面被定义了,那么就会调用这个函数。接下来会检查 componentDidMount 这个生命周期,如果这个生命周期被定义了,那么这个函数就会被放到 _renderCallbacks 中,在 render 之后调用。

进行完上述的操作之后,将 comp 的 _dirty 设置为 false,_parentDom 设置为传递进来的父元素。

获取组件的子元素

接下来进行 doRender 操作,这一步的主要目的是获取到 comp 的子元素。而 comp 的 render 函数就是返回当前的 props.children。而由于我们的 comp 是一个 Fragment 类型的组件,因此这一步返回的就是 Home 组件。

随后会检查当前的这个要渲染的节点是不是顶部的 React 节点来再次判断当前拿到的是不是子节点,那么拿到子节点之后,就要 diff 子节点了。

当前的栈帧如下:

image.png

Diff Children(标为绿色线)

未命名文件 (9).png

对比 newChildren 和 oldChildren

我们 diff 完了第一层节点之后,我们就要来 diff 子节点了。diff 子节点的第一步就是要通过原来的 oldNode 来取到上面的 oldChildren,并且和上面收集到的当前组件的子节点进行对比,从而起到更新的作用。然而我们当前是第一次渲染这个子节点,也就意味着没有 oldNode,那么其实也就是比真正的更新要少一些步骤。

我们从上面可以看到 Fragment 节点的子节点是 Home 元素,那么我们这里的 newOldChildren 列表里的内容就只有当前的这个 Home 元素。

那么我们首先遍历新的子节点的数据,拿到一个子节点 newOldChildren,也就是 Home 元素。给当前子节点的 _parent 属性上赋值为当前的父节点,将当前的子节点的节点深度 _depth + 1, 来标记当前的节点是在哪一层。

接下来我们拿到相同下标的旧的子节点 oldChildren,如果 oldChildren 和 newChildren 的 key 及 type 完全一致,那么就直接使用新的节点,在旧的子节点列表中,将此节点的值设置为 undefined, 当前栈帧如下:

image.png

接下来会再次进入 diff 函数,将当前的 oldChildren 和 newChildren 再次重复上面 diff 函数的逻辑。在调用当前的 newChildren 的 render 函数时,就相当于调用我们 Home 组件的 render 函数,那么这段函数返回的就是一段 jsx。

render() {
    return (
        <div>
            <button onClick={this.changeTitle.bind(this)}>改变页面标题</button>
            <h1>{this.state.title}</h1>
        </div>
    );
}
复制代码

image.png

根据这段 jsx, 我们使用 createElement 函数,将其中的 div 标签的 vNode 创建出来,也就是取到 Home 的子元素,然后再次进入 diffChildren 内。

image.png

在 diffChildren 中进行上面相同的操作之后,进入 diff 函数内,此时 diff 函数检测到,当前的 vNode 已经是一个原生的 HTML 标签了,而不是 React 元素,那么就会进入到 diffElementNodes 内部。

当前的栈帧

image.png

DiffElementNodes (标为橙色线)

未命名文件 (8).png

diffElementNodes 这个函数主要是用来 diff 原生的 HTML 节点的,在这个函数中需要传入 oldNode 的真正元素节点,也就是 oldNode 的 HTML 节点。

创建 div 节点

首先来检查一下当前这个标签节点的类型,如果这个节点不是 svg 类型,也不为空,那么会根据当前的元素类型通过 document.createElement 创建一个对应的节点。这里我们就创建第一层的父元素 div 节点。

接下来我们会去检查当前节点是否有子元素以及是否有 dangerouslySetInnerHTML 这个属性。dangerouslySetInnerHTML 是在 DOM 上直接插入 HTML 片段的属性,如果有这个属性,那么我们要为它赋值一个 _children 属性。

判断是否为最底部节点

如果没有,我们需要再判断一下,当前的节点是不是已经是一个 “最底部” 的节点了。因为这关系到我们下一步该如何操作,如果当前的页面已经是 “最底部” 节点,那么意味着它不会再有 DOM 子节点了,但是如果是 DOM 节点或者 React 节点,就先 diff 新旧两个节点上的 props, 并且将其中不是 children 和 key 的 props 进行更新。随后继续通过 diffChildren 重复上面的操作。

在当前的 div 元素中,还有 button 元素和 h1 元素,因此会再次进入 diffChildren 中进行对比。在这一步完成后最后将我们在这个函数中创建的新节点 div 返回给 diff 函数。

返回到 diff 函数之后,再次检查当前生成的新 DOM 是否符合规范,再回到 diffChildren 中。在 diffChildren 中我们拿到新的 DOM 元素之后,就需要找一个合适的位置放置当前的 DOM 元素,就 div 这个元素来说,我们会将它插入到 parent 节点,也就是 Home 中,并且在这里我们还会找到下一个即将渲染的节点。

接下来,我们将由 oldChildren 组成的列表中的每个引用都设置成 undefined,并且调用 unmount 生命周期。

卸载完之前的同级旧节点,我们就可以返回到第一层的 diff 函数中,给当前以 div 为基础的组件的 _base 属性设置为当前的 DOM节点。并且在最后拿出 commitQueue 中的渲染之后待执行的函数来执行。

最终的栈帧和执行的过程如下图所示:

image.png

最终我们的 diff 过程就完成啦!

setState 之后会怎样

未命名文件 (7).png

下面我们来对比一下改变状态,也就是在 setState 中这个过程有什么区别。

首先我们在 setState 中会传入一个需要 update 的参数,一般来讲是一个对象,里面是要改变的 key: value 值。

this.setState({title: 'changed'});
复制代码

首先在 setState 中会将 nextState 深拷贝一份出来。update state 的方式是遍原来的 state 并且直接将新的 state 赋值给原来的 state,因此 state 是一个引用值的要小心。在这一步,如果没有传入要更改的 update,则直接返回,不重新渲染。

export function assign(obj, props) {
    for (let i in props) obj[i] = props[i];
    return obj;
}
复制代码

根据 depth 进行排序

接下来是将这个任务放入渲染队列中,放入队列之后,代码就会根据当前的任务队列,执行渲染任务。执行渲染任务的时候,并不是按照放入渲染队列的顺序来执行的,而是先将渲染队列按照当前的 node 深度层次升序排列,也就是 _depth 属性,然后将渲染队列清空,并遍历之前队列的副本,也就是先渲染外层的,再渲染里面的节点。如果遍历到当前的 component 的 _dirty 值为 true,那么会去执行当前组件的渲染。

进行 diff

组件渲染的第一步,依旧是进新旧组件的 diff。在这里进行 diff 之前,我们首先要做一系列的准备工作。在准备工作中,我们将当前状态改变的 component 的 _vnode 属性拿出来, 当作新节点。然后将新节点的 _original 属性加一,当作旧节点。并且保存旧节点上最外层的 dom 节点作为 oldDom。

同样的进入 diff 函数之后会进行和上面一样的操作,但是在 comp 的生成过程是和上面不一样的。上面初次渲染的时候 comp 是使用 Component 构造函数生成的一个实例,但是在有 oldNode 的情况下,comp 当前是 oldNode,需要更新的 state 会赋值到 _nextState 上面,并且保存旧组件的 props 和 state。

执行生命周期

接下来会对 comp 上定义的生命周期进行检查,如果新旧 props 有更新并且定义 componentWillReceiveProps,就会调用当前的这个函数。并且在之后会检查当前的生命周期是否被定义,其中 componentWillUpdate 会在此刻调用。而 componentDidUpdate 则会放入渲染后的回调函数中,也就是 commitQueue 中。

在调用完 componentWillUpdate 这个生命周期之后,comp 的 props 会被赋值成为新组件的值,而 state 也会被赋值为新组件上的 _nextState。当上面的准备工作就绪之后,我们就可以开始最外层组件的渲染了。

因为我们当前的组件是 update 而不是初始化挂载组件, 因此在最外层不需要套一个 Fregment 的元素,而是直接更新目标组件,因此我们可以直接调用组件的 render 函数,也就是:

render() {
   return (
        <div>
            <button onClick={this.changeTitle.bind(this)}>改变页面标题</button>
            <h1>{this.state.title}</h1>
        </div>
    );
}
复制代码

在执行这个渲染函数的时候,依旧是利用 diffChildren 和 diffElementNodes 这两个函数,由最上层的 div 节点递归遍历至 h1 节点,并且在每个节点便利的时候,都会对比节点上的 props 来进行更新。在遍历到 h1 节点之后,我们会发现它的子节点是一个 text 类型,那么在这种节点上,我们如果发现它的 props 和之前的旧节点不一样,我们会直接把新的 props 赋值给新节点的 data 属性。也就是说 diffElementNodes 节点会一直 diff 到最底层的 text 节点为止,然后更新 text 节点。

最终渲染

到 h1 中,我们会发现新的文案和旧的文案不一致,那么我们此时就会将新的文案更新到节点上,从而触发页面对于这个节点的重新渲染,最后返回到 diff 函数中,将 commitQueue 中的函数全部执行完毕,当前的这个 setState 任务就执行完毕啦。

以上的文章是我们通过 PReact 对于 React 最核心的逻辑和代码细节有了一个了解。当然现在 React 的代码细节要复杂得多了,尤其是加了 Fiber 之后。那么在这个系列接下来的文章里,我会继续深入到 React 的源码里面,带大家看到更多的细节。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享