youngwind/blog

vue早期源码学习系列之五:批处理更新DOM

youngwind opened this issue · 7 comments

前言

在上一篇 #87 中,我们最后谈到,有一个问题还没有解决,我们来看看是什么问题。如下图所示。
bug

我们可以看到,在函数test中,前后两次更改了user.name的值,对应的DOM元素的更新也执行了两次。(注意,这里的DOM元素更新指的是内存中DOM元素的更新,而非浏览器渲染的更新。因为你从视觉上应该也看得出来,虽然DOM元素更新了两次,但是网页上展示的效果却是只修改了一次。此处更多的资料可以参考这里。)

《高性能js》教导我们:“频繁操作DOM是低效率的”,所以,我们是否能做到说,不管user.name变化了多少次,对DOM的更新只进行一次呢?
答案是可以的,方法是批处理

批处理这个词我最早接触它是在大学计算机组成原理的课程上,当时觉得这个东西好晦涩难懂,跟自己没啥关系,所以就不管它。经过这次深入的学习,我发现批处理的应用是非常的广泛的,它代表的是对某一类问题的处理模式,不仅仅存在于计算机组成原理中。

我们来看看vue官方文档怎么描述它如何应用批处理:

Vue.js 默认异步更新 DOM。每当观察到数据变化时,Vue 就开始一个队列,将同一事件循环内所有的数据变化缓存起来。如果一个 watcher 被多次触发,只会推入一次到队列中。等到下一次事件循环,Vue 将清空队列,只进行必要的 DOM 更新。在内部异步队列优先使用 MutationObserver,如果不支持则使用 setTimeout(fn, 0)。

出处:https://vuejs.org.cn/guide/reactivity.html#异步更新队列

批处理原理

在我眼中的批处理是这样的:将一些事件放到一个任务队列中,等到合适的时机再全部拿出来执行。
js这门语言实现批处理的底层原理是“事件循环”,也就是所谓的"Event Loop"。
如果不清楚js事件循环,请务必搞明白这个概念再往下看。可以参考阮一峰的这篇文章

Batcher

好了,接下来我们正式来看看如何实现批处理更新DOM。
我们需要构造这样一个批处理“类”:Batcher

/**
 * 批处理构造函数
 * @constructor
 */
function Batcher() {
    this.reset();
}

/**
 * 批处理重置
 */
Batcher.prototype.reset = function () {
    this.has = {};
    this.queue = [];
    this.waiting = false;
};

/**
 * 将事件添加到队列中
 * @param job {Watcher} watcher事件
 */
Batcher.prototype.push = function (job) {
    if (!this.has[job.id]) {
        this.queue.push(job);
        this.has[job.id] = job;
        if (!this.waiting) {
            this.waiting = true;
            setTimeout(() => {
                this.flush();
            });
        }
    }
};

/**
 * 执行并清空事件队列
 */
Batcher.prototype.flush = function () {
    this.queue.forEach((job) => {
        job.cb.call(job.ctx);
    });
    this.reset();
};

下面我来说明一下几个关键点:

  1. has属性的作用是为了防止已经存在于任务队列中的事件被重复添加,这也是我们为了解决开头的重复修改DOM所必须的要素。
  2. queue是存放事件的任务队列。比方说,就像开头的函数app.test那样,修改了两次name和一次age,所以queue事件队列中就有两个事件,分别对应name和age。(请注意,事件是2个,而非3个)
  3. flush,顾名思义,是“冲洗”的意思。也就是把queue队列里面的事件都拿出来一一执行。
  4. waiting和reset非常关键。我们设想,如果没有这两个东西,随着程序的执行,会有一个name和age的事件被添加到queue中,等主线程空闲了,flush执行它们。然而,当我再次想添加name和age事件的时候却发现没响应?为什么?因为name和age已经存在has对象和queue里面了呀!所以在每次flush完都需要将整个任务队列的状态重置。waiting起的是保护的作用。当flush冲洗函数已经在任务列队时,那么我不再重复将flush函数添加到任务队列中。设想如果没有waiting的保护,在app.test执行完之后,flush函数会被执行两次,这显然不符合我们批处理的初衷。

最后一步,是修改原先watcher的update函数

Watcher.prototype.update = function () {
    //原先是watcher的update直接执行cb
    // this.cb.call(this.ctx, arguments);

   // 现在改成将watcher push到事件队列中,等待主线程空闲再一起执行
    batcher.push(this);
};

实现效果如下图所示:
demo

完整的源码请参考这个版本

React中的批处理

我们在开头提到,批处理是一个广泛应用的**,在研究vue的批处理的时候,我忽然想起了以前在用react的时候碰到的一个问题, #66 ,里面提到的this.setState是异步的,当时不理解。现在仔细想想,背后的本质也应该是批处理。也就是说,我们在调用setState的时候,并非同步修改了this.state,而是攒到一块再修改this.state。跟我们刚刚把对DOM的修改攒到一块,其实是异曲同工的。

参考资料

  1. Vue源码解读-Watchers(数据订阅者)异步执行队列
  2. https://segmentfault.com/q/1010000005813183
  3. http://jimliu.net/2016/04/29/a-brief-look-at-vue-2-reactivity/

后话

随着对vue早期源码的不断学习,我更加坚信从早期源码开始学习是一条走得通的道路。目前虽然已经初步具备动态数据绑定的能力,但是很多细节还有待打磨和推敲。比如对template的编译,比如构建缓存系统提升性能,我得好好想想接下来该往哪个方向走。

this.has = {};
this.queue = [];这里应该说下,用{},可以后者覆盖前者,push到【】里

Batcher.prototype.push = function (job) {
    if (!this.has[job.id]) {
        this.queue.push(job);
        this.has[job.id] = job;
        if (!this.waiting) {
            this.waiting = true;
            setTimeout(() => {
                this.flush();
            });
        }
    }
};

@youngwind 大佬,问一下,这样写的话,app.test中第二次更新的user.name如何覆盖前面的更新。
@william-xue 没看懂你说的,一定是我太笨了。

@LiuMengzhou 我的理解是,在例子中,第一第二次的name更新,都指向同一个job对象。第一次把job加入到queue中以后,第二次更新时,是把第一次的job.cb或者job.ctx给覆盖了。

@LiuMengzhou 最后更新的name是最终的name

个人觉得这技巧也是绝了, js对象的无重复key使用, setTimeout事件队列

mark

看Vue的源码一直不明白这个waiting到底有啥用,看到这里总算是懂了,真的是神奇的标志位!