45
本篇文章主要是对Vue中的DOM异步更新策略和nextTick机制的解析,需要读者有一定的Vue使用经验并且熟悉掌握JavaScript事件循环模型。

引入:DOM的异步更新

<template>
  <div>
    <div ref="test">{{test}}</div>
    <button @click="handleClick">tet</button>
  </div>
</template>
export default {
    data () {
        return {
            test: 'begin'
        };
    },
    methods () {
        handleClick () {
            this.test = 'end';
            console.log(this.$refs.test.innerText);//打印“begin”
        }
    }
}

打印的结果是begin而不是我们设置的end。这个结果足以说明VueDOM的更新并非同步。

Vue官方文档中是这样说明的:

可能你还没有注意到,Vue异步执行DOM更新。只要观察到数据变化,Vue将开启一个队列,并缓冲在同一事件循环中发生的所有数据改变。如果同一个watcher被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和DOM操作上非常重要。然后,在下一个的事件循环“tick”中,Vue刷新队列并执行实际 (已去重的) 工作。

简而言之,就是在一个事件循环中发生的所有数据改变都会在下一个事件循环的Tick中来触发视图更新,这也是一个“批处理”的过程。(注意下一个事件循环的Tick有可能是在当前的Tick微任务执行阶段执行,也可能是在下一个Tick执行,主要取决于nextTick函数到底是使用Promise/MutationObserver还是setTimeout

Watcher队列

Watcher的源码中,我们发现watcherupdate其实是异步的。(注:sync属性默认为false,也就是异步)

update () {
    /* istanbul ignore else */
    if (this.lazy) {
        this.dirty = true
    } else if (this.sync) {
        /*同步则执行run直接渲染视图*/
        this.run()
    } else {
        /*异步推送到观察者队列中,下一个tick时调用。*/
        queueWatcher(this)
    }
}

queueWatcher(this)函数的代码如下:

 /*将一个观察者对象push进观察者队列,在队列中已经存在相同的id则该观察者对象将被跳过,除非它是在队列被刷新时推送*/
export function queueWatcher (watcher: Watcher) {
    /*获取watcher的id*/
    const id = watcher.id
    /*检验id是否存在,已经存在则直接跳过,不存在则标记哈希表has,用于下次检验*/
    if (has[id] == null) {
        has[id] = true
        if (!flushing) {
            /*如果没有flush掉,直接push到队列中即可*/
            queue.push(watcher)
        } else {
        ...
        }
        // queue the flush
        if (!waiting) {
            waiting = true
            nextTick(flushSchedulerQueue)
        }
    }
}

这段源码有几个需要注意的地方:

  1. 首先需要知道的是watcher执行update的时候,默认情况下肯定是异步的,它会做以下的两件事:

    • 判断has数组中是否有这个watcherid
    • 如果有的话是不需要把watcher加入queue中的,否则不做任何处理。
  2. 这里面的nextTick(flushSchedulerQueue)中,flushScheduleQueue函数的作用主要是执行视图更新的操作,它会把queue中所有的watcher取出来并执行相应的视图更新。
  3. 核心其实是nextTick函数了,下面我们具体看一下nextTick到底有什么用。

nextTick

nextTick函数其实做了两件事情,一是生成一个timerFunc,把回调作为microTaskmacroTask参与到事件循环中来。二是把回调函数放入一个callbacks队列,等待适当的时机执行。(这个时机和timerFunc不同的实现有关)

首先我们先来看它是怎么生成一个timerFunc把回调作为microTaskmacroTask的。

if (typeof Promise !== 'undefined' && isNative(Promise)) {
    /*使用Promise*/
    var p = Promise.resolve()
    var logError = err => { console.error(err) }
    timerFunc = () => {
        p.then(nextTickHandler).catch(logError)
        // in problematic UIWebViews, Promise.then doesn't completely break, but
        // it can get stuck in a weird state where callbacks are pushed into the
        // microTask queue but the queue isn't being flushed, until the browser
        // needs to do some other work, e.g. handle a timer. Therefore we can
        // "force" the microTask queue to be flushed by adding an empty timer.
        if (isIOS) setTimeout(noop)
    }
} else if (typeof MutationObserver !== 'undefined' && (
    isNative(MutationObserver) ||
    // PhantomJS and iOS 7.x
    MutationObserver.toString() === '[object MutationObserverConstructor]'
    )) {
    // use MutationObserver where native Promise is not available,
    // e.g. PhantomJS IE11, iOS7, Android 4.4
    /*新建一个textNode的DOM对象,用MutationObserver绑定该DOM并指定回调函数,在DOM变化的时候则会触发回调,该回调会进入主线程(比任务队列优先执行),即textNode.data = String(counter)时便会触发回调*/
    var counter = 1
    var observer = new MutationObserver(nextTickHandler)
    var textNode = document.createTextNode(String(counter))
    observer.observe(textNode, {
        characterData: true
    })
    timerFunc = () => {
        counter = (counter + 1) % 2
        textNode.data = String(counter)
    }
} else {
    // fallback to setTimeout
    /* istanbul ignore next */
    /*使用setTimeout将回调推入任务队列尾部*/
    timerFunc = () => {
        setTimeout(nextTickHandler, 0)
    }
}

值得注意的是,它会按照PromiseMutationObserversetTimeout优先级去调用传入的回调函数。前两者会生成一个microTask任务,而后者会生成一个macroTask。(微任务和宏任务)

之所以会设置这样的优先级,主要是考虑到浏览器之间的兼容性(IE没有内置Promise)。另外,设置Promise最优先是因为Promise.resolve().then回调函数属于一个微任务,浏览器在一个Tick中执行完macroTask后会清空当前Tick所有的microTask再进行UI渲染,把DOM更新的操作放在Tick执行microTask的阶段来完成,相比使用setTimeout生成的一个macroTask会少一次UI的渲染。

nextTickHandler函数,其实才是我们真正要执行的函数。

function nextTickHandler () {
    pending = false
    /*执行所有callback*/
    const copies = callbacks.slice(0)
    callbacks.length = 0
    for (let i = 0; i < copies.length; i++) {
        copies[i]()
    }
}

这里的callbacks变量供nextTickHandler消费。而前面我们所说的nextTick函数第二点功能中“等待适当的时机执行”,其实就是因为timerFunc的实现方式有差异,如果是Promise\MutationObservernextTickHandler回调是一个microTask,它会在当前Tick的末尾来执行。如果是setTiemoutnextTickHandler回调是一个macroTask,它会在下一个Tick来执行。

还有就是callbacks中的成员是如何被push进来的?从源码中我们可以知道,nextTick是一个自执行的函数,一旦执行是return了一个queueNextTick,所以我们在调用nextTick其实就是在调用queueNextTick这个函数。它的源代码如下:

return function queueNextTick (cb?: Function, ctx?: Object) {
    let _resolve
    /*cb存到callbacks中*/
    callbacks.push(() => {
        if (cb) {
            try {
            cb.call(ctx)
            } catch (e) {
            handleError(e, ctx, 'nextTick')
            }
        } else if (_resolve) {
            _resolve(ctx)
        }
    })
    if (!pending) {
        pending = true
        timerFunc()
    }
    if (!cb && typeof Promise !== 'undefined') {
        return new Promise((resolve, reject) => {
            _resolve = resolve
        })
    }
}

可以看到,一旦调用nextTick函数时候,传入的function就会被存放到callbacks闭包中,然后这个callbacksnextTickHandler消费,而nextTickHandler的执行时间又是由timerFunc来决定。

我们再回来看Watcher中的一段代码:

 /*将一个观察者对象push进观察者队列,在队列中已经存在相同的id则该观察者对象将被跳过,除非它是在队列被刷新时推送*/
export function queueWatcher (watcher: Watcher) {
  /*获取watcher的id*/
  const id = watcher.id
  /*检验id是否存在,已经存在则直接跳过,不存在则标记哈希表has,用于下次检验*/
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
        /*如果没有flush掉,直接push到队列中即可*/
        queue.push(watcher)
    } else {
      ...
    }
    // queue the flush
    if (!waiting) {
      waiting = true
      nextTick(flushSchedulerQueue)
    }
  }
}

这里面的nextTick(flushSchedulerQueue)中的flushSchedulerQueue函数其实就是watcher的视图更新。调用的时候会把它pushcallbacks中来异步执行。

另外,关于waiting变量,这是很重要的一个标志位,它保证flushSchedulerQueue回调只允许被置入callbacks一次。

也就是说,默认waiting变量为false,执行一次后waitingtrue,后续的this.xxx不会再次触发nextTick的执行,而是把this.xxx相对应的watcher推入flushSchedulerQueuequeue队列中。

所以,也就是说DOM确实是异步更新,但是具体是在下一个Tick更新还是在当前Tick执行microTask的时候更新,具体要看nextTcik的实现方式,也就是具体跑的是Promise/MutationObserver还是setTimeout

附:nextTick源码带注释,有兴趣可以观摩一下。

这里面使用PromisesetTimeout来执行异步任务的方式都很好理解,比较巧妙的地方是利用MutationObserver执行异步任务。MutationObserverH5的新特性,它能够监听指定范围内的DOM变化并执行其回调,它的回调会被当作microTask来执行,具体参考MDN

var counter = 1
var observer = new MutationObserver(nextTickHandler)
var textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
    characterData: true
})
timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
}

可以看到,通过借用MutationObserver来创建一个microTasknextTickHandler作为回调传入MutationObserver中。
这里面创建了一个textNode作为观测的对象,当timerFunc执行的时候,textNode.data会发生改变,便会触发MutatinObservers的回调函数,而这个函数才是我们真正要执行的任务,它是一个microTask

注:2.5+版本的VuenextTick进行了修改,具体参考下面“版本升级”一节。

关于Vue暴露的全局nextTick

继续来看下面的这段代码:

<div id="example">
    <div ref="test">{{test}}</div>
    <button @click="handleClick">tet</button>
</div>
var vm = new Vue({
    el: '#example',
    data: {
        test: 'begin',
    },
    methods: {
        handleClick() {
            this.test = 'end';
            console.log('1')
            setTimeout(() => { // macroTask
                console.log('3')
            }, 0);
            Promise.resolve().then(function() { //microTask
                console.log('promise!')
            })
            this.$nextTick(function () {
                console.log('2')
            })
        }
    }
})

Chrome下,这段代码执行的顺序的1、2、promise、3

可能有同学会以为这是1、promise、2、3,其实是忽略了一个标志位pending

我们回到nextTick函数returnqueueNextTick可以发现:

return function queueNextTick (cb?: Function, ctx?: Object) {
    let _resolve
    /*cb存到callbacks中*/
    callbacks.push(() => {
        if (cb) {
        try {
            cb.call(ctx)
        } catch (e) {
            handleError(e, ctx, 'nextTick')
        }
        } else if (_resolve) {
        _resolve(ctx)
        }
    })
    if (!pending) {
        pending = true
        timerFunc()
    }
    if (!cb && typeof Promise !== 'undefined') {
        return new Promise((resolve, reject) => {
        _resolve = resolve
        })
    }
}

这里面通过对pending的判断来检测是否已经有timerFunc这个函数在事件循环的任务队列等待被执行。如果存在的话,那么是不会再重复执行的。

最后异步执行nextTickHandler时又会把pending置为false

function nextTickHandler () {
    pending = false
    /*执行所有callback*/
    const copies = callbacks.slice(0)
    callbacks.length = 0
    for (let i = 0; i < copies.length; i++) {
        copies[i]()
    }
}

所以回到我们的例子:

handleClick() {
    this.test = 'end';
    console.log('1')
    setTimeout(() => { // macroTask
        console.log('3')
    }, 0);
    Promise.resolve().then(function() { //microTask
        console.log('promise!')
    });
    this.$nextTick(function () {
        console.log('2')
    });
}

代码中,this.test = 'end'必然会触发watcher进行视图的重新渲染,而我们在文章的Watcher一节中就已经有提到会调用nextTick函数,一开始pending变量肯定就是false,因此它会被修改为true并且执行timerFunc。之后执行this.$nextTick其实还是调用的nextTick函数,只不过此时的pendingtrue说明timerFunc已经被生成,所以this.$nextTick(fn)只是把传入的fn置入callbacks之中。此时的callbacks有两个function成员,一个是flushSchedulerQueue,另外一个就是this.$nextTick()的回调。

因此,上面这段代码中,在Chrome下,有一个macroTask和两个microTask。一个macroTask就是setTimeout,两个microTask:分别是VuetimerFunc(其中先后执行flushSchedulerQueuefunction() {console.log('2')})、代码中的Promise.resolve().then()

版本升级带来的变化

上面讨论的nextTick实现是2.4版本以下的实现,2.5以上版本对于nextTick的内部实现进行了大量的修改。

独立文件

首先是从Vue 2.5+开始,抽出来了一个单独的文件next-tick.js来执行它。

microTask与macroTask

在代码中,有这么一段注释:

其大概的意思就是:在Vue 2.4之前的版本中,nextTick几乎都是基于microTask实现的(具体可以看文章nextTick一节),但是由于microTask的执行优先级非常高,在某些场景之下它甚至要比事件冒泡还要快,就会导致一些诡异的问题;但是如果全部都改成macroTask,对一些有重绘和动画的场景也会有性能的影响。所以最终nextTick采取的策略是默认走microTask,对于一些DOM的交互事件,如v-on绑定的事件回调处理函数的处理,会强制走macroTask

具体做法就是,在Vue执行绑定的DOM事件时,默认会给回调的handler函数调用withMacroTask方法做一层包装,它保证整个回调函数的执行过程中,遇到数据状态的改变,这些改变而导致的视图更新(DOM更新)的任务都会被推到macroTask

function add$1 (event, handler, once$$1, capture, passive) {
    handler = withMacroTask(handler);
    if (once$$1) { handler = createOnceHandler(handler, event, capture); }
    target$1.addEventListener(
        event,
        handler,
        supportsPassive
        ? { capture: capture, passive: passive }
        : capture
    );
}

/**
 * Wrap a function so that if any code inside triggers state change,
 * the changes are queued using a Task instead of a MicroTask.
 */
function withMacroTask (fn) {
    return fn._withTask || (fn._withTask = function () {
        useMacroTask = true;
        var res = fn.apply(null, arguments);
        useMacroTask = false;
        return res
    })
}

而对于macroTask的执行,Vue优先检测是否支持原生setImmediate(高版本IE和Edge支持),不支持的话再去检测是否支持原生MessageChannel,如果还不支持的话为setTimeout(fn, 0)

最后,写一段demo来测试一下:

<div id="example">
    <span>{{test}}</span>
    <button @click="handleClick">change</button>
</div>
var vm = new Vue({
    el: '#example',
    data: {
        test: 'begin',
    },
    methods: {
        handleClick: function() {
            this.test = end;
            console.log('script')
            this.$nextTick(function () { 
                console.log('nextTick')
            })
            Promise.resolve().then(function () {
                console.log('promise')
            })
        }
    }
})

Vue 2.5+中,这段代码的输出顺序是scriptpromisenextTick,而Vue 2.4输出scriptnextTickpromisenextTick执行顺序的差异正好说明了上面的改变。

MessageChannel

Vue 2.4版本以前使用的MutationObserver来模拟异步任务。而Vue 2.5版本以后,由于兼容性弃用了MutationObserver

Vue 2.5+版本使用了MessageChannel来模拟macroTask。除了IE以外,messageChannel的兼容性还是比较可观的。

const channel = new MessageChannel()
const port = channel.port2
channel.port1.onmessage = flushCallbacks
macroTimerFunc = () => {
port.postMessage(1)
}

可见,新建一个MessageChannel对象,该对象通过port1来检测信息,port2发送信息。通过port2的主动postMessage来触发port1onmessage事件,进而把回调函数flushCallbacks作为macroTask参与事件循环。

MessageChannel VS setTimeout

为什么要优先MessageChannel创建macroTask而不是setTimeout

HTML5中规定setTimeout的最小时间延迟是4ms,也就是说理想环境下异步回调最快也是4ms才能触发。

Vue使用这么多函数来模拟异步任务,其目的只有一个,就是让回调异步且尽早调用。而MessageChannel的延迟明显是小于setTimeout的。对比传送门

为什么要异步更新视图

看下面的代码:

<template>
  <div>
    <div>{{test}}</div>
  </div>
</template>
export default {
    data () {
        return {
            test: 0
        };
    },
    mounted () {
      for(let i = 0; i < 1000; i++) {
        this.test++;
      }
    }
}

现在有这样的一种情况,mounted的时候test的值会被++循环执行1000次。 每次++时,都会根据响应式触发setter->Dep->Watcher->update->run。 如果这时候没有异步更新视图,那么每次++都会直接操作DOM更新视图,这是非常消耗性能的。 所以Vue实现了一个queue队列,在下一个Tick(或者是当前Tick的微任务阶段)的时候会统一执行queueWatcherrun。同时,拥有相同idWatcher不会被重复加入到该queue中去,所以不会执行1000Watcherrun。最终更新视图只会直接将test对应的DOM0变成1000。 保证更新视图操作DOM的动作是在当前栈执行完以后下一个Tick(或者是当前Tick的微任务阶段)的时候调用,大大优化了性能。

应用场景

在操作DOM节点无效的时候,就要考虑操作的实际DOM节点是否存在,或者相应的DOM是否被更新完毕。

比如说,在created钩子中涉及DOM节点的操作肯定是无效的,因为此时还没有完成相关DOM的挂载。解决的方法就是在nextTick函数中去处理DOM,这样才能保证DOM被成功挂载而有效操作。

还有就是在数据变化之后要执行某个操作,而这个操作需要使用随数据改变而改变的DOM时,这个操作应该放进Vue.nextTick

之前在做慕课网音乐webApp的时候关于播放器内核的开发就涉及到了这个问题。下面我把问题简化:

现在我们要实现一个需求是点击按钮变换audio标签的src属性来实现切换歌曲。

<div id="example">
    <audio ref="audio"
           :src="url"></audio>
    <span ref="url"></span>
    <button @click="changeUrl">click me</button>
</div>
const musicList = [
    'http://sc1.111ttt.cn:8282/2017/1/11m/11/304112003137.m4a?tflag=1519095601&pin=6cd414115fdb9a950d827487b16b5f97#.mp3',
    'http://sc1.111ttt.cn:8282/2017/1/11m/11/304112002493.m4a?tflag=1519095601&pin=6cd414115fdb9a950d827487b16b5f97#.mp3',
    'http://sc1.111ttt.cn:8282/2017/1/11m/11/304112004168.m4a?tflag=1519095601&pin=6cd414115fdb9a950d827487b16b5f97#.mp3'
];
var vm = new Vue({
    el: '#example',
    data: {
        index: 0,
        url: ''
    },
    methods: {
        changeUrl() {
            this.index = (this.index + 1) % musicList.length
            this.url = musicList[this.index];
            this.$refs.audio.play();
        }
    }
});

毫无悬念,这样肯定是会报错的:

Uncaught (in promise) DOMException: The element has no supported sources.

原因就在于audio.play()是同步的,而这个时候DOM更新是异步的,src属性还没有被更新,结果播放的时候src属性为空,就报错了。

解决办法就是在play的操作加上this.$nextTick()

this.$nextTick(function() {
    this.$refs.audio.play();
});

参考链接

https://github.com/youngwind/...

https://github.com/answershut...

https://juejin.im/post/5a1af8...


绪北
1.8k 声望159 粉丝

偏后前端开发攻城狮 Tencent FE