Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

理解虚拟DOM #13

Open
phenomLi opened this issue Dec 23, 2017 · 0 comments
Open

理解虚拟DOM #13

phenomLi opened this issue Dec 23, 2017 · 0 comments

Comments

@phenomLi
Copy link
Owner

phenomLi commented Dec 23, 2017

这篇文章会解开虚拟DOM的神秘面纱,让你对React底层有一个大概的了解,或者对想实现一个虚拟DOM库的同学提供一个大概的思路。



刀耕火种的年代

在jquery盛行的年代(其实现在jquery依旧很盛行),基本都是对DOM结构直接进行操作,但是当代码量一大的时候,要维护起来就要变得很困难,因为数据,逻辑和视图都混淆在了一起。

其实当时前端还没有分层的概念,因为大多数业务逻辑都被放在了后端。但是随着时代发展,浏览器端需要承担的责任越来越大,慢慢地前端逻辑也就变得复杂起来,传统的命令式编程思维已经明显不适用于中大型项目,前端圈继续一些新的设计模式来革新传统的编码方式。

之后各种MVVM框架应运而生,有AngularJS、avalon、Vue1.等,MVVM使用数据双向绑定,使得我们完全不需要操作DOM了,更新了状态视图会自动更新,更新了视图数据状态也会自动更新,可以说MMVM使得前端的开发效率大幅提升,但是其大量的事件绑定使得其在复杂场景下的执行性能堪忧;有没有一种兼顾开发效率和执行效率的方案呢?React就是一种不错的方案,虽然其将JS代码和HTML代码混合在一起的设计有不少争议(JSX),但是其引入的虚拟DOM(Virtual DOM) 却是得到大家的一致认同的。


虽然React和Vue都是实现了虚拟DOM,但是毕竟React是先驱,所以下面我基本都会用React作为例子。



虚拟DOM的思想

其实我之前已经提到过,DOM操作是很慢的,其元素非常庞大,页面的性能问题鲜有由JS引起的,大部分都是由DOM操作引起的。

但是我们发现,虽然一个DOM节点有N多个属性,但是我们平时在对DOM进行操作时,通常只需要以下3个基本信息:

  • 标签名:tagName

  • HTML属性:attribute

  • 子节点:children

对于其他属性我们并不关心,于是,我们可以对一段DOM进行以下抽象:

可以看到,一段DOM片段被抽象成了JS对象,对应着节点的tagNameattributechildren


这就是虚拟DOM的思想:用JS对象表示DOM。


也就是说,当我们新建一个React组件的时候,在render方法中,return的是一个JS对象:

class NewComponent extends React.Component {
    ......

    //render方法返回的是JS对象
    render() {
        return (
            <li className="item">
                <a>link</a>
            </li>
        );
    }
}

那么既然我们得到的是对象而不是DOM,那么到底这些对象是在哪里转化成为DOM的呢?答案是在ReactDOM.render方法:

//将得到的虚拟DOM转化为真实DOM
ReactDOM.render(<NewComponent/>, $container);

可以看出,FaceBook将React拆分成了两个库,一个是React的核心,另一个是针对React运行平台的渲染库,这里运行平台是浏览器,所以渲染库就是ReactDOM

这样做的好处在哪?

  1. 将对DOM的操作提升到了对JS对象的操作,单纯操作JS对象的性能肯定会优于操作庞大的DOM对象的性能。
  2. React的设计思想是UI = F(State,props),在传统的前端开发思想中,这个UI可能就是DOM,现在React在开发者和DOM之间抽象了一层,这一层抽象可以被设计得十分强大:对于开发者可以有着相同的API,但是另一边不仅仅可以对接DOM,还可能时Native或者是服务端渲染(这也是React Native实现的基础),所以这个UI的概念就很自然地被扩大了。核心功能与渲染职责的拆分使得React变成了一个平台无关的UI库。


效率之道:diff与patch

如果说虚拟DOM是React的核心,那么diff算法就是虚拟DOM的核心。

那么diff算法究竟是干什么的呢?

通常情况下,对DOM结构进行修改,我们可以用jquery直接操作,现在有了虚拟DOM,事情反而变得复杂起来了。因为当你对虚拟DOM进行修改时,按理来说,React需要将你修改的虚拟DOM映射回真实的DOM上面去。但是问题是,React根本不知道你修改了哪个地方,那么现在办法有两个:

  1. 按照新的虚拟DOM结构,重新生成一个整个真实DOM。

  2. 将新,旧两个虚拟DOM进行对比,找出不同的地方,更新到真实DOM。

很显然第一个办法的效率是最低的(但是足够简单粗暴),React采用的是第二种方法,这个方法就是diff算法,顾名思义diff算法就是对比两个虚拟DOM差异的算法,而patch就是将差异更新到真实DOM的算法

diff算法发生在setState方法里面:

   /*
    * setStete到底做了什么:
    * 1. 将新,旧state进行对比合并
    * 2. 生成一个新的虚拟DOM
    * 3. 将新,旧虚拟DOM进行对比,记录差异
    * 4. patch:将差异更新到真实DOM
    */
    handler() {
        this.setState();
    }

如下图所示,两个虚拟DOM之间的差异已经标红:

很显然,设计一个diff算法有两个要点:

  • 如何比较两个JS对象树

  • 如何记录对象之间的差异


<1> 如何比较两个两棵JS对象树

计算两棵树之间差异的常规算法复杂度为O(n3),一个文档的DOM结构有上百个节点是很正常的情况,这种复杂度无法应用于实际项目。针对前端的具体情况:我们很少跨级别的修改DOM节点,通常是修改节点的属性、调整子节点的顺序、添加子节点等。因此,我们只需要对同级别节点进行比较,避免了diff算法的复杂性。对同级别节点进行比较的常用方法是深度优先遍历:

//乞丐版的diff,React版本的要比这个复杂n倍,但是大体思路是一致的
const diff = function(newTree, oldTree) {

    //一个数组,用作记录差异信息
    const patchArr = [];

    //深度优先遍历
    dfsWalk(oldTree, newTree, patchArr); 
    
    //返回差异的信息
    return patchArr; 
}

dfsWalk的大概实现:

//深度优先遍历
const dfsWalk = function(oldTree, newTree, patchArr) {

    //对比当前两个节点
    compare(oldTree, newTree, patchArr);

    //遍历子节点
    for(let i = 0; i < newTree.children.length; i ++) {
        dfsWalk(oldTree.children[i], newTree.children[i], patchArr);
    }
}

<2>如何记录节点之间的差异

由于我们对JS对象树采取的是同级比较,因此节点之间的差异可以归结为4种类型:

  • 修改节点属性,用PROPS表示

  • 修改节点文本内容,用TEXT表示

  • 替换原有节点,,用REPLACE表示

  • 调整子节点,包括移动、删除等,用REORDER表示

按照这种思路,我们就可以在compare函数里面,记录下对象的差异信息:

const compare = function(oldTree, newTree, patchArr) {

    ......

    //比较两个节点的文本
    if(oldTree.textContent !== newTree.textContent) {
        patchArr.push({
            oldTree: oldTree,
            newTree: newTree,
            type: 'TEXT'
        });
    }

    ......
}

在diff完成后,我们得到所有差异信息,便可以调用patch将差异更新到真实DOM了:

const patch = function(patchArr) {

    //遍历差异队列
    patchArr.map(p => {

        switch(p.type) {
            case 'PROPS': {
                ......
            }

            //文本更新
            case 'TEXT': {
                //找到真实的DOM节点,将其文本改成新的文本
                $findNode(p.oldTree).textContent = p.newTree.textContent;
            }

            case 'REPLACE': {
                ......
            },

            case 'REORDER': {
                ......
            }
        }

    });

}

到此为止,一个
遍历 --> 记录 --> 更新
的流程就基本完成了。

总结

这篇对于虚拟DOM的文章基本就到这里了,我们来总结一下:

  • 虚拟DOM的思想:JS对象对DOM结构的映射

  • 虚拟DOM更新DOM:diff和patch,其中diff用作找出新旧两个虚拟DOM对象的差异,patch负责将差异更新到真实DOM

当然由于篇幅还有时间问题,有许多东西还没有提到,比如列表的对比listDiff,还有虚拟DOM事件的绑定等等,但是至少,核心的东西都提到了,希望通过这一篇文章,读者能够对React有一个更深的理解。



最后:一个误区

看到这里,很多人会问:那么虚拟DOM只是作为一个中间层,屏蔽了开发者对DOM的直接操作,但是操作DOM结构的脏活还是要干啊,只不过操作者从开发者变成了虚拟DOM而已,那么怎么会说虚拟DOM更快呢?

其实很多对React不够了解的人都会有这样一个误解:认为使用React就比直接操作DOM更快。
其实不是的,想要修改一个DOM,不可能有比直接修改更快的操作,何况React还要生成,对比,更新三步操作。React不可能比直接操作DOM快。

其实React官网已经说到了:

React makes it painless to create interactive UIs. Design simple views for each state in your application, and React will efficiently update and render just the right components when your data changes.


React从来没有说过比传统DOM操作要快,React只是efficiently update and render,什么意思?说的就是diff啊,更新你需要更新的地方,避免整个DOM的批量更新,指哪打哪。没有虚拟DOM能不能做diff?当然能啊!但是效率能一样吗?一个是遍历JS对象,一个是遍历DOM呀。


所以,我们说React快,是因为它在架构,可维护性与性能之间找到了一个最佳的平衡点。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant