blog
blog copied to clipboard
vue早期源码学习系列之八:列表渲染与diff算法
前言
继上一篇 #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完成重新渲染。
思路
我们先来思考一下,总共有几个难点:
- 回想实现条件渲染的时候,把b-if对应的DOM结构看成是一个vue实例。我们这次沿用这个思路,把每个b-repeat对应的li标签都当成一个vue实例来处理。
- 作用域问题。在实现条件渲染的时候,因为发现b-if子实例的作用域完全等价于父实例的作用域,所以暂时采取了直接引用父实例的$data和observer的方法,实属权宜之计。然而,在b-repeat里面不能继续采取这种粗糙的方法了。原因:**每一个b-repeat子实例都有自己特有的变量:item。也就是说,每一个子实例都能访问到自己的item和父实例所有的变量,父实例无法访问子实例的item。父子实例的作用域关系是:子实例作用域严格包含父实例作用域,父子实例作用域再也不等价。**有什么办法能够彻底解决作用域这个问题呢?
- 当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算法,整个算法分成两部分:初始化和更新,如下图所示。
初始化
- 假设一开始的items=[a,b,c];
- 构建vm实例数组Vms=[Vma,Vmb,Vmc];
- 依次将实例插入DOM中
- 结束初始化
更新
- items发生改变,新的值为items=[b,c,e,f]
- 实例复用检查。在旧的实例数组OldVms里面发现Vmb和Vmc之前已经有了,给它们打上reused的标签。发现Vme和Vmf之前没有,新建这两个实例,最后构建的新的vm实例数组为Vms=[Vmb,Vmc,Vme,Vmf]。
- 循环旧实例数组OldVms,把其中没有打上reused(也就是不可复用)的实例都找出来,然后一一从DOM中删除。
- 将实例Vmf插入到DOM列表的最后
- 将实例Vme插入到Vmf的前面
- 判断Vmc正好处于Vme的前面,所以VMc不用动
- 判断Vmb正好处于Vmc的前面,所以Vmb也不用动
- 结束更新
说的比较抽象,更为直接的方式是直接去debug源码,我参考的版本vue版本是这个,自己实现的版本在这里,核心的代码在这里,实现的效果如下图所示。
后话
关于列表渲染的性能优化,本篇实现的只是最简单的版本,我感觉还有很多其他的东西没有实现,有待研究,比如track
参考资料
- http://jiongks.name/blog/vue-code-review/ (有一部分讲到diff算法)
- https://github.com/banama/aboutVue/blob/master/diff.md
new Vue()
更新
- items发生改变,新的值为items=[b,c,e,f]
- 实例复用检查。在旧的实例数组OldVms里面发现Vma和Vmb之前已经有了,给它们打上reused的标签。发现Vme和Vmf之前没有,新建这两个实例,最后构建的新的vm实例数组为Vms=[Vmb,Vmc,Vme,Vmf]。
应该是Vmb和Vmc吧。Correct me if I am wrong.
@LiuMengzhou 是的,此处为笔误,多谢指出,已修正。
diff算法那有一点死活想不明白,实例数组[b,c]生成后,为什么要先插入f再插入e,不能先插入e再插入f
@Geocld 我也疑惑,关于diff算法,这篇文章写得很详细:https://github.com/aooy/blog/issues/2