youngwind / blog

梁少峰的个人博客
4.66k stars 385 forks source link

vue早期源码学习系列之八:列表渲染与diff算法 #91

Open youngwind opened 7 years ago

youngwind commented 7 years ago

前言

继上一篇 #90 实现了条件渲染之后,本篇我们来看看如何实现vue列表渲染功能

问题具象

考虑以下的情况

<div id="app">
    <ul>
        <li b-repeat="item:items">
             {{item.title}}
             {{user.name}}
        </li>
    </ul>
</div>
const app = new Bue({
    el: '#app',
    data: {
        user: {
            name: 'youngwind',
            age: 24
        },
        items: [
            {
                title: 'aaa'
            },
            {
                title: 'bbb'
            },
            {
                title: 'ccc'
            }
        ]
    },
    methods: {
        test: function () {
            //  [a, b, c]  => [b, c, e, f]
            this.items.shift();
            this.items.push({
                title: 'eee'
            });
            this.items.push({
                title: 'fff'
            });
        }
    }
});

我们的目标是:根据数组items循环渲染li标签,并且当items改变的时候能够最小化操作DOM完成重新渲染。

思路

我们先来思考一下,总共有几个难点:

  1. 回想实现条件渲染的时候,把b-if对应的DOM结构看成是一个vue实例。我们这次沿用这个思路,把每个b-repeat对应的li标签都当成一个vue实例来处理。
  2. 作用域问题。在实现条件渲染的时候,因为发现b-if子实例的作用域完全等价于父实例的作用域,所以暂时采取了直接引用父实例的$data和observer的方法,实属权宜之计。然而,在b-repeat里面不能继续采取这种粗糙的方法了。原因:每一个b-repeat子实例都有自己特有的变量:item。也就是说,每一个子实例都能访问到自己的item和父实例所有的变量,父实例无法访问子实例的item。父子实例的作用域关系是:子实例作用域严格包含父实例作用域,父子实例作用域再也不等价。有什么办法能够彻底解决作用域这个问题呢?
  3. 当items发生改变的时候,势必会引起对原有DOM列表的插入、删除或者移动,这往往容易触碰到性能问题,有什么办法能够确保最小化地操作DOM,从而提高列表渲染的性能?

    父子实例作用域

我们先来解决上面提到的第二个难点:作用域。有没有发现这样一个有趣的现象:父子实例的作用域极度类似于js对象的继承?子实例继承于父实例。比如说,当我们在子实例要找user.name的时候,由于在子实例中找不到user,所以就会沿着原型链跑到父实例上去找,在父实例中找到了user,然后终止查找。当我们在子实例中要找item的时候,因为item是子实例自己的属性,所以在子实例就可以找到,不会跑到父实例上面去找。然而当父实例想访问子实例的item的时候,是无法访问得到的。 综上:通过js的继承,可以解决父子实例的作用域嵌套问题。 具体的实现基本上就是下面这一行代码。

exports._init = function (options) {
    // ....
    this.__proto__ = this.$parent;    // this.$parent就是当前实例的父实例
    //....
};

由此导致的另一个相关的问题: 如果我们用继承来实现作用域嵌套,那么,当父实例的user.name改变的时候,如何将变化传导到各个子实例呢? 答案:把子实例存储到父实例的_children数组中,当父实例的data发生变动,触发父实例的updateBindingAt方法的时候,循环遍历_children,分别触发每个子实例的updateBindingAt方法。

那么,具体如何实现呢?

exports._updateBindingAt = function () {
    this._updateSelfBindingAt(...arguments); // 这个函数就是原来的_updateBindingAt
    this._updateChildrenBindingAt(...arguments);
};

/**
 * 触发本实例所有子实例的updateBindingAt
 */
exports._updateChildrenBindingAt = function () {
    if (!this.$children.length) return;
    this.$children.forEach((child) => {
        child._updateBindingAt(...arguments);
    });
};

diff算法

第三个难点。vue确保列表渲染性能的核心是:diff算法,整个算法分成两部分:初始化更新,如下图所示。 diff算法

初始化

  1. 假设一开始的items=[a,b,c];
  2. 构建vm实例数组Vms=[Vma,Vmb,Vmc];
  3. 依次将实例插入DOM中
  4. 结束初始化

    更新

  5. items发生改变,新的值为items=[b,c,e,f]
  6. 实例复用检查。在旧的实例数组OldVms里面发现Vmb和Vmc之前已经有了,给它们打上reused的标签。发现Vme和Vmf之前没有,新建这两个实例,最后构建的新的vm实例数组为Vms=[Vmb,Vmc,Vme,Vmf]。
  7. 循环旧实例数组OldVms,把其中没有打上reused(也就是不可复用)的实例都找出来,然后一一从DOM中删除。
  8. 将实例Vmf插入到DOM列表的最后
  9. 将实例Vme插入到Vmf的前面
  10. 判断Vmc正好处于Vme的前面,所以VMc不用动
  11. 判断Vmb正好处于Vmc的前面,所以Vmb也不用动
  12. 结束更新

说的比较抽象,更为直接的方式是直接去debug源码,我参考的版本vue版本是这个,自己实现的版本在这里,核心的代码在这里,实现的效果如下图所示。 demo

后话

关于列表渲染的性能优化,本篇实现的只是最简单的版本,我感觉还有很多其他的东西没有实现,有待研究,比如track

参考资料

  1. http://jiongks.name/blog/vue-code-review/ (有一部分讲到diff算法)
  2. https://github.com/banama/aboutVue/blob/master/diff.md
jiandan1989 commented 7 years ago

new Vue()

LiuMengzhou commented 6 years ago

更新

  1. items发生改变,新的值为items=[b,c,e,f]
  2. 实例复用检查。在旧的实例数组OldVms里面发现Vma和Vmb之前已经有了,给它们打上reused的标签。发现Vme和Vmf之前没有,新建这两个实例,最后构建的新的vm实例数组为Vms=[Vmb,Vmc,Vme,Vmf]。

应该是Vmb和Vmc吧。Correct me if I am wrong.

youngwind commented 6 years ago

@LiuMengzhou 是的,此处为笔误,多谢指出,已修正。

Geocld commented 6 years ago

diff算法那有一点死活想不明白,实例数组[b,c]生成后,为什么要先插入f再插入e,不能先插入e再插入f

zhoubhin commented 6 years ago

@Geocld 我也疑惑,关于diff算法,这篇文章写得很详细:https://github.com/aooy/blog/issues/2