可能会有理解存在偏差的地方,欢迎大神斧正,共同学习,共同进步。
操作DOM
在使用vue.js的时候,有时候因为一些特定的业务场景,不得不去操作DOM,比如这样:
export default { data () { return { test: 'begin' }; }, methods () { handleClick () { this.test = 'end'; console.log(this.$refs.test.innerText);//打印“begin” } }}复制代码{ { test}}
打印的结果是begin,为什么我们明明已经将test设置成了“end”,获取真实DOM节点的innerText却没有得到我们预期中的“end”,而是得到之前的值“begin”呢?
Watcher队列
带着疑问,我们找到了Vue.js源码的Watch实现。当某个响应式数据发生变化的时候,它的setter函数会通知闭包中的Dep,Dep则会调用它管理的所有Watch对象。触发Watch对象的update实现。我们来看一下update的实现。
update () { /* istanbul ignore else */ if (this.lazy) { this.dirty = true } else if (this.sync) { /*同步则执行run直接渲染视图*/ this.run() } else { /*异步推送到观察者队列中,下一个tick时调用。*/ queueWatcher(this) }}复制代码
我们发现Vue.js默认是使用。
当异步执行update的时候,会调用queueWatcher函数。/*将一个观察者对象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 { // if already flushing, splice the watcher based on its id // if already past its id, it will be run next immediately. let i = queue.length - 1 while (i >= 0 && queue[i].id > watcher.id) { i-- } queue.splice(Math.max(i, index) + 1, 0, watcher) } // queue the flush if (!waiting) { waiting = true nextTick(flushSchedulerQueue) } }}复制代码
查看queueWatcher的源码我们发现,Watch对象并不是立即更新视图,而是被push进了一个队列queue,此时状态处于waiting的状态,这时候会继续会有Watch对象被push进这个队列queue,等待下一个tick时,这些Watch对象才会被遍历取出,更新视图。同时,id重复的Watcher不会被多次加入到queue中去,因为在最终渲染时,我们只需要关心数据的最终结果。
那么,什么是下一个tick?
nextTick
vue.js提供了一个函数,其实也就是上面调用的nextTick。
nextTick的实现比较简单,执行的目的是在microtask或者task中推入一个funtion,在当前栈执行完毕(也行还会有一些排在前面的需要执行的任务)以后执行nextTick传入的funtion,看一下源码:
/** * Defer a task to execute it asynchronously. */ /* 延迟一个任务使其异步执行,在下一个tick时执行,一个立即执行函数,返回一个function 这个函数的作用是在task或者microtask中推入一个timerFunc, 在当前调用栈执行完以后以此执行直到执行到timerFunc 目的是延迟到当前调用栈执行完以后执行*/export const nextTick = (function () { /*存放异步执行的回调*/ const callbacks = [] /*一个标记位,如果已经有timerFunc被推送到任务队列中去则不需要重复推送*/ let pending = false /*一个函数指针,指向函数将被推送到任务队列中,等到主线程任务执行完时,任务队列中的timerFunc被调用*/ let timerFunc /*下一个tick时的回调*/ function nextTickHandler () { /*一个标记位,标记等待状态(即函数已经被推入任务队列或者主线程,已经在等待当前栈执行完毕去执行),这样就不需要在push多个回调到callbacks时将timerFunc多次推入任务队列或者主线程*/ pending = false /*执行所有callback*/ const copies = callbacks.slice(0) callbacks.length = 0 for (let i = 0; i < copies.length; i++) { copies[i]() } } // the nextTick behavior leverages the microtask queue, which can be accessed // via either native Promise.then or MutationObserver. // MutationObserver has wider support, however it is seriously bugged in // UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It // completely stops working after triggering a few times... so, if native // Promise is available, we will use it: /* istanbul ignore if */ /* 这里解释一下,一共有Promise、MutationObserver以及setTimeout三种尝试得到timerFunc的方法 优先使用Promise,在Promise不存在的情况下使用MutationObserver,这两个方法都会在microtask中执行,会比setTimeout更早执行,所以优先使用。 如果上述两种方法都不支持的环境则会使用setTimeout,在task尾部推入这个函数,等待调用执行。 参考:https://www.zhihu.com/question/55364497 */ 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) } } /* 推送到队列中下一个tick时执行 cb 回调函数 ctx 上下文 */ 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 }) } }})()复制代码
它是一个立即执行函数,返回一个queueNextTick接口。
传入的cb会被push进callbacks中存放起来,然后执行timerFunc(pending是一个状态标记,保证timerFunc在下一个tick之前只执行一次)。
timerFunc是什么?
看了源码发现timerFunc会检测当前环境而不同实现,其实就是按照Promise,MutationObserver,setTimeout优先级,哪个存在使用哪个,最不济的环境下使用setTimeout。
这里解释一下,一共有Promise、MutationObserver以及setTimeout三种尝试得到timerFunc的方法。
优先使用Promise,在Promise不存在的情况下使用MutationObserver,这两个方法的回调函数都会在microtask中执行,它们会比setTimeout更早执行,所以优先使用。如果上述两种方法都不支持的环境则会使用setTimeout,在task尾部推入这个函数,等待调用执行。为什么要优先使用microtask?我在顾轶灵在知乎的回答中学习到:
JS 的 event loop 执行时会区分 task 和 microtask,引擎在每个 task 执行完毕,从队列中取下一个 task 来执行之前,会先执行完所有 microtask 队列中的 microtask。setTimeout 回调会被分配到一个新的 task 中执行,而 Promise 的 resolver、MutationObserver 的回调都会被安排到一个新的 microtask 中执行,会比 setTimeout 产生的 task 先执行。要创建一个新的 microtask,优先使用 Promise,如果浏览器不支持,再尝试 MutationObserver。实在不行,只能用 setTimeout 创建 task 了。为啥要用 microtask?根据 HTML Standard,在每个 task 运行完以后,UI 都会重渲染,那么在 microtask 中就完成数据更新,当前 task 结束就可以得到最新的 UI 了。反之如果新建一个 task 来做数据更新,那么渲染就会进行两次。
首先是Promise,(Promise.resolve()).then()可以在microtask中加入它的回调,
MutationObserver新建一个textNode的DOM对象,用MutationObserver绑定该DOM并指定回调函数,在DOM变化的时候则会触发回调,该回调会进入microtask,即textNode.data = String(counter)时便会加入该回调。
setTimeout是最后的一种备选方案,并且还有4ms延时,setTimeout延时0不会老老实实立即执行:
setTimeout(function(){ console.log("我不是立即执行的,我会延时4ms,哈哈");},0);复制代码
它会将回调函数加入task中,等到执行。
setTimeout(function(){console.log(4)},0);new Promise(function(resolve){ console.log(1) for( var i=0 ; i<10000 ; i++ ){ i==9999 && resolve() } console.log(2)}).then(function(){ console.log(5)});console.log(3);结果是:1,2,3,5,4复制代码
综上,nextTick的目的就是产生一个回调函数加入task或者microtask中,当前栈执行完以后(可能中间还有别的排在前面的函数)调用该回调函数,起到了异步触发(即下一个tick时触发)的目的。
setImmediate(function(){ console.log(1);},0);setTimeout(function(){ console.log(2);},0);new Promise(function(resolve){ console.log(3); resolve(); console.log(4);}).then(function(){ console.log(5);});console.log(6);process.nextTick(function(){ console.log(7);});console.log(8);结果是:3 4 6 8 7 5 2 1复制代码
flushSchedulerQueue
/*Github:https://github.com/answershuto*//** * Flush both queues and run the watchers. */ /*nextTick的回调函数,在下一个tick时flush掉两个队列同时运行watchers*/function flushSchedulerQueue () { flushing = true let watcher, id // Sort queue before flush. // This ensures that: // 1. Components are updated from parent to child. (because parent is always // created before the child) // 2. A component's user watchers are run before its render watcher (because // user watchers are created before the render watcher) // 3. If a component is destroyed during a parent component's watcher run, // its watchers can be skipped. /* 给queue排序,这样做可以保证: 1.组件更新的顺序是从父组件到子组件的顺序,因为父组件总是比子组件先创建。 2.一个组件的user watchers比render watcher先运行,因为user watchers往往比render watcher更早创建 3.如果一个组件在父组件watcher运行期间被销毁,它的watcher执行将被跳过。 */ queue.sort((a, b) => a.id - b.id) // do not cache length because more watchers might be pushed // as we run existing watchers /*这里不用index = queue.length;index > 0; index--的方式写是因为不要将length进行缓存,因为在执行处理现有watcher对象期间,更多的watcher对象可能会被push进queue*/ for (index = 0; index < queue.length; index++) { watcher = queue[index] id = watcher.id /*将has的标记删除*/ has[id] = null /*执行watcher*/ watcher.run() // in dev build, check and stop circular updates. /* 在测试环境中,检测watch是否在死循环中 比如这样一种情况 watch: { test () { this.test++; } } 持续执行了一百次watch代表可能存在死循环 */ if (process.env.NODE_ENV !== 'production' && has[id] != null) { circular[id] = (circular[id] || 0) + 1 if (circular[id] > MAX_UPDATE_COUNT) { warn( 'You may have an infinite update loop ' + ( watcher.user ? `in watcher with expression "${watcher.expression}"` : `in a component render function.` ), watcher.vm ) break } } } // keep copies of post queues before resetting state /**/ /*得到队列的拷贝*/ const activatedQueue = activatedChildren.slice() const updatedQueue = queue.slice() /*重置调度者的状态*/ resetSchedulerState() // call component updated and activated hooks /*使子组件状态都改编成active同时调用activated钩子*/ callActivatedHooks(activatedQueue) /*调用updated钩子*/ callUpdateHooks(updatedQueue) // devtool hook /* istanbul ignore if */ if (devtools && config.devtools) { devtools.emit('flush') }}复制代码
flushSchedulerQueue是下一个tick时的回调函数,主要目的是执行Watcher的run函数,用来更新视图
为什么要异步更新视图
来看一下下面这一段代码
export default { data () { return { test: 0 }; }, created () { for(let i = 0; i < 1000; i++) { this.test++; } }}复制代码{ { test}}
现在有这样的一种情况,created的时候test的值会被++循环执行1000次。
每次++时,都会根据响应式触发setter->Dep->Watcher->update->patch。如果这时候没有异步更新视图,那么每次++都会直接操作DOM更新视图,这是非常消耗性能的。所以Vue.js实现了一个queue队列,在下一个tick的时候会统一执行queue中Watcher的run。同时,拥有相同id的Watcher不会被重复加入到该queue中去,所以不会执行1000次Watcher的run。最终更新视图只会直接将test对应的DOM的0变成1000。保证更新视图操作DOM的动作是在当前栈执行完以后下一个tick的时候调用,大大优化了性能。访问真实DOM节点更新后的数据
所以我们需要在修改data中的数据后访问真实的DOM节点更新后的数据,只需要这样,我们把文章第一个例子进行修改。
export default { data () { return { test: 'begin' }; }, methods () { handleClick () { this.test = 'end'; this.$nextTick(() => { console.log(this.$refs.test.innerText);//打印"end" }); console.log(this.$refs.test.innerText);//打印“begin” } }}复制代码{ { test}}
使用Vue.js的global API的$nextTick方法,即可在回调中获取已经更新好的DOM实例了。