blog-md icon indicating copy to clipboard operation
blog-md copied to clipboard

san.nextTick与事件循环

Open jiangjiu opened this issue 7 years ago • 0 comments

san.nextTick与事件循环

在san框架中,发生数据变更时会更新视图,这个更新过程是异步执行的。

在读源码的过程中发现这个异步调用的san.nextTick方法使用了html5的API:MutationObserver,引发了对事件循环执行顺序的好奇。。

研究了一下,现在就来聊聊san.nextTick与事件循环。

事件循环中的microtask & macrotask

Tasks, microtasks, queues and schedules这篇chrome开发者的文章详细描述了event loop中的几种不同类型并提供了在线例子,强烈推荐详细阅读。

以下仅对上文做下简单介绍以及总结。

任务类型

简而言之,一个浏览器js主环境只可以有一个event loop,但是可以拥有多个任务队列。

任务队列包含两种类型,microtask和macrotask。

1. microtask: process.nextTick(nodejs) > promise.then/MutationObserver
2. macrotask: script、setTimeout、setInterval、setImmediate、I/O、UI rendering

执行顺序

所有的异步任务都会按类型推入不同的异步队列中,同步代码执行后开始执行异步任务。顺序如下:

  1. 首先取出macrotask中的第一个任务,js stack开始执行
  2. js stack中执行代码,同步代码直接执行,遇到异步任务放入相应的队列
  3. js stack执行完毕,开始microtask任务
  4. microtask任务队列执行完毕(1000个以内,多余的保留在microtask等待),如果macrotask中第一个任务已经执行完毕,就开始下一个任务,否则继续将此task中的代码加入js stack

高能部分

需要注意的是,鼠标键盘行为在事件循环中都是一个macrotask,如果事件有向上冒泡行为,那么每一次冒泡触发的回调都会在js stack中执行并清空调用栈,此时就会开始microtask任务队列。

如果是通过inner.click()方式进行调用,那么这一段script代码会被加入到事件循环的第一个macrotask,本次点击触发多次冒泡的所有回调都会直接进入js stack执行,如果js stack没有清空,是不会执行microtask的。

这部分有点绕,对着上面链接文章里的在线例子多看几遍吧。

process.nextTick

nodejs中的process.nextTick永远早于其他microtask,因为:

“process.nextTick 永远大于 promise.then,原因其实很简单。。。在Node中,_tickCallback在每一次执行完TaskQueue中的一个任务后被调用,而这个_tickCallback中实质上干了两件事:

  1. nextTickQueue中所有任务执行掉(长度最大1e4,Node版本v6.9.1)
  2. 第一步执行完后执行_runMicrotasks函数,执行microtask中的部分(promise.then注册的回调)所以很明显process.nextTick > promise.then”

MutationObserver

MutationObserver给开发者们提供了一种能在某个范围内的DOM树发生变化时作出适当反应的能力.该API设计用来替换掉在DOM3事件规范中引入的Mutation事件.

详细见 https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver

简单来说,MutationObserver是HTML5的新的API,当dom节点发生变化时触发回调。

需要注意的是,当一个microtask中已经存在一个MutationObserver任务时,会进入MutationObserver的pending状态,此后其他MutationObserver触发也不会再把回调塞到microtask中了。

为了解决这个问题,我们通过san.nextTick源码来看下是如何处理的。

san.nextTick源码解析

/**
 * @file 在下一个时间周期运行任务
 * @author errorrik([email protected])
 */

var bind = require('./bind');
var each = require('./each');

/**
 * 下一个周期要执行的任务列表
 *
 * @inner
 * @type {Array}
 */
 
 // 当MutationObserver检测到dom节点变动时(这个节点变动是nextTick手动触发的)
 // 并不会把回调塞到microtask中,而是把触发的回调塞到nextTasks中
 // 真正进入microtask队列的回调函数是nextHandler => 执行nextTasks里的所有函数
var nextTasks = [];

/**
 * 执行下一个周期任务的函数
 *
 * @inner
 * @type {Function}
 */
var nextHandler;

/**
 * 在下一个时间周期运行任务
 *
 * @inner
 * @param {Function} fn 要运行的任务函数
 * @param {Object=} thisArg this指向对象
 */
function nextTick(fn, thisArg) {
    // 如果传了第二个参数就绑定this 
    if (thisArg) {
        fn = bind(fn, thisArg);
    }
    // 每次的异步任务fn塞到数组里
    nextTasks.push(fn);
    
    // 初始化了没
    if (nextHandler) {
        return;
    }

    nextHandler = function () {
        // copy一份
        var tasks = nextTasks.slice(0);
        // 然后把tasks清空,handler销毁
        nextTasks = [];
        nextHandler = null;
        // 开始执行所有的tasks
        each(tasks, function (task) {
            task();
        });
    };
    // 浏览器支持MutationObserver不
    if (typeof MutationObserver === 'function') {
        var num = 1;
        var observer = new MutationObserver(nextHandler);
        var text = document.createTextNode(num);
        observer.observe(text, {
            characterData: true
        });
        // 手动触发文本节点的改变
        text.data = ++num;
    }
    
    // 高版本IE下回退
    else if (typeof setImmediate === 'function') {
        setImmediate(nextHandler);
    }
    // 都不支持,setTimeout吧
    else {
        setTimeout(nextHandler, 0);
    }
}

exports = module.exports = nextTick;

从源码中我们可以看到,san会根据浏览器支持的特性判断,现代浏览器就用MutationObserver,不支持会回退到setImmediate和setTimeout。

前面提到过,microtask中还有Promise.then方法,无论是MutationObserver还是Promise,谁先执行谁先进microtask队列。其实Vue的nextTick方法就把Promise放在了第一位。

ps,这里的Promise必须是浏览器原生支持的,polyfill之类的就不资磁了。

san.nextTick在框架中的调用

/**
 * 组件内部监听数据变化的函数
 *
 * @private
 * @param {Object} change 数据变化信息
 */
Component.prototype._dataChanger = function (change) {
    var len = this.dataChanges.length;

    if (!len) {
        // 当所有的数据改变行为结束后
        // 实例的更新视图函数通过san.nextTick推入异步队列
        nextTick(this.updateView, this);
    }

    while (len--) {
        switch (changeExprCompare(change.expr, this.dataChanges[len].expr)) {
            case 1:
            case 2:
                if (change.type === DataChangeType.SET) {
                    this.dataChanges.splice(len, 1);
                }
        }
    }

    this.dataChanges.push(change);
};

上述代码可以看到,所有的change都会push到实例的dataChanges属性中,updateView会在microtask队列完成,在一轮事件循环中,不管一个组件更新了多少次data数据,都只会触发一次dom更新,最大化的优化了性能。

这里要注意的是,如果是开发者手动更新dom节点,会立即触发repaint/reflow,以保证dom属性的实时性。

关于浏览器的渲染机制后续再写。

总结

对事件循环和san.nextTick已经做了简单的介绍,microtask和macrotask的执行顺序以及san对于异步更新视图的方式又有了新的认识。

如果开发者在生命周期内要做异步任务处理时,不妨考虑使用san.nextTick

课后题

const App = san.defineComponent ({
    template:`
        <div>{{name}}</div>
    `,
    attached() {
        this.data.set('name','zhangzhe');
        setTimeout(() => console.log('setTimeout'));
        Promise.resolve().then(() => console.log('promise'));
        san.nextTick(() => console.log('nextTick'));     
    }
})

new App().attach(document.body);

猜猜log顺序是怎样的?

友情提示,通常课上讲的是1+1得2的时候,课后题都不会太简单的。。

codepen.io/demo

参考

  1. Promise的队列与setTimeout的队列有何关联?
  2. Tasks, microtasks, queues and schedules
  3. https://www.w3.org/TR/html5/webappapis.html#event-loops
  4. https://github.com/ecomfe/san

jiangjiu avatar Sep 06 '17 11:09 jiangjiu