写在前面
前段时间在写项目时对nextTick的使用有一些疑惑。在查阅各种资料之后,在这里总结一下Vue.js异步更新的策略以及nextTick的用途和原理。如有总结错误的地方,欢迎指出!
本文将从以下3点进行总结:
- 为什么Vue.js要异步更新视图?
- JavaScript异步运行的机制是怎样的?
- 什么情况下要使用nextTick?
先看一个例子
复制代码{ {message}}
export default { data () { return { message: 'begin' }; }, methods () { handleClick () { this.message = 'end'; console.log(this.$refs.message.innerText); //打印“begin” } }}复制代码
打印出来的结果是“begin”,我们在点击事件里明明将message赋值为“end”,而获取真实DOM节点的innerHTML却没有得到预期中的“begin”,为什么?
再看一个例子
复制代码{ {number}}click
export default { data () { return { number: 0 }; }, methods: { handleClick () { for(let i = 0; i < 10000; i++) { this.number++; } } }}复制代码
在点击click事件之后,number会被遍历增加10000次。在Vue.js响应式系统中,可以看一下我的前一篇文章。我们知道Vue.js会经历“setter->Dep->Watcher->patch->视图”这几个流程。。
根据以往的理解,每次number被+1的时候,都会触发number的setter按照上边的流程最后来修改真实的DOM,然后DOM被更新了10000次,想想都刺激!看一下官网的描述:Vue 异步执行 DOM 更新。只要观察到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据改变。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作上非常重要显然。
JavaScript的运行机制
为了方便理解Vue.js异步更新策略和nextTick,先介绍以下JS的运行机制,参考阮一峰老师的。摘取的关键部分如下:JS是单线程的,意思就是同一时间只能做一件事情。它是基于事件轮询的,具体可以分为以下几个步骤:
(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
(3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
(4)主线程不断重复上面的第三步。
上图就是主线程和任务队列的示意图。只要主线程空了,就会去读取"任务队列",这就是JavaScript的运行机制。这个过程会不断重复。主线程的执行过程是一个tick。所有的异步结果通过“任务队列”来被调度。任务队列中主要有两大类,“macrotask”和“microtask”,这两类task会进入任务队列。常见的 macrotask 有 setTimeout、MessageChannel、postMessage、setImmediate;常见的 microtask 有 MutationObsever 和 Promise.then。事件轮询
Vue.js在修改数据的时候,不会立马修改数据,而是要等同一事件轮询的数据都更新完之后,再统一进行视图更新。 上的例子:
//改变数据vm.message = 'changed'//想要立即使用更新后的DOM。这样不行,因为设置message后DOM还没有更新console.log(vm.$el.textContent) // 并不会得到'changed'//这样可以,nextTick里面的代码会在DOM更新后执行Vue.nextTick(function(){ console.log(vm.$el.textContent) //可以得到'changed'})复制代码
图解:
模拟nextTick
nextTick在官网当中的定义:
在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。
以下用setTimeout来模拟nextTick,先定义一个callbacks来存储nextTick,在下一个tick处理回调函数之前,所有的cb都会存储到这个callbacks数组当中。pending是一个标记位,代表等待的状态。接着setTimeout 会在 task 中创建一个事件 flushCallbacks ,flushCallbacks 则会在执行时将 callbacks 中的所有 cb 依次执行。
// 存储nextTicklet callbacks = [];let pending = false;function nextTick (cb) { callbacks.push(cb); if (!pending) { // 代表等待状态的标志位 pending = true; setTimeout(flushCallbacks, 0); }}function flushCallbacks () { pending = false; const copies = callbacks.slice(0); callbacks.length = 0; for (let i = 0; i < copies.length; i++) { copies[i](); }}复制代码
真实的代码比这儿复杂的多,在Vue.js源码当中,nextTick定义在一个单独的文件中来维护,在src/core/util/next-tick.js中:
/* @flow *//* globals MessageChannel */import { noop } from 'shared/util'import { handleError } from './error'import { isIOS, isNative } from './env'const callbacks = []let pending = falsefunction flushCallbacks () { pending = false const copies = callbacks.slice(0) callbacks.length = 0 for (let i = 0; i < copies.length; i++) { copies[i]() }}// Here we have async deferring wrappers using both microtasks and (macro) tasks.// In < 2.4 we used microtasks everywhere, but there are some scenarios where// microtasks have too high a priority and fire in between supposedly// sequential events (e.g. #4521, #6690) or even between bubbling of the same// event (#6566). However, using (macro) tasks everywhere also has subtle problems// when state is changed right before repaint (e.g. #6813, out-in transitions).// Here we use microtask by default, but expose a way to force (macro) task when// needed (e.g. in event handlers attached by v-on).let microTimerFunclet macroTimerFunclet useMacroTask = false// Determine (macro) task defer implementation.// Technically setImmediate should be the ideal choice, but it's only available// in IE. The only polyfill that consistently queues the callback after all DOM// events triggered in the same loop is by using MessageChannel./* istanbul ignore if */if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { macroTimerFunc = () => { setImmediate(flushCallbacks) }} else if (typeof MessageChannel !== 'undefined' && ( isNative(MessageChannel) || // PhantomJS MessageChannel.toString() === '[object MessageChannelConstructor]')) { const channel = new MessageChannel() const port = channel.port2 channel.port1.onmessage = flushCallbacks macroTimerFunc = () => { port.postMessage(1) }} else { /* istanbul ignore next */ macroTimerFunc = () => { setTimeout(flushCallbacks, 0) }}// Determine microtask defer implementation./* istanbul ignore next, $flow-disable-line */if (typeof Promise !== 'undefined' && isNative(Promise)) { const p = Promise.resolve() microTimerFunc = () => { p.then(flushCallbacks) // 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 { // fallback to macro microTimerFunc = macroTimerFunc}/** * Wrap a function so that if any code inside triggers state change, * the changes are queued using a (macro) task instead of a microtask. */export function withMacroTask (fn: Function): Function { return fn._withTask || (fn._withTask = function () { useMacroTask = true const res = fn.apply(null, arguments) useMacroTask = false return res })}export function nextTick (cb?: Function, ctx?: Object) { let _resolve callbacks.push(() => { if (cb) { try { cb.call(ctx) } catch (e) { handleError(e, ctx, 'nextTick') } } else if (_resolve) { _resolve(ctx) } }) if (!pending) { pending = true if (useMacroTask) { macroTimerFunc() } else { microTimerFunc() } } // $flow-disable-line if (!cb && typeof Promise !== 'undefined') { return new Promise(resolve => { _resolve = resolve }) }}复制代码
加上注释之后:
/** * 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 }) } }})()复制代码
关键在于timeFunc(),该函数起到延迟执行的作用。 从上面的介绍,可以得知timeFunc()一共有三种实现方式。
- Promise
- MutationObserver
- setTimeout
用途
nextTick的用途
应用场景:需要在视图更新之后,基于新的视图进行操作。
看一个例子: 点击show按钮使得原来v-show:false的input输入框显示,并获取焦点:
复制代码
new Vue({ el: "#app", data() { return { inputShow: false } }, methods: { show() { this.inputShow = true this.$nextTick(() => { this.$refs.input.focus() }) } }})复制代码