下面这一段代码它的操作很简单:
页面渲染了一个复选框,当点击复选框框时阻止 click 事件的默认行为,并在 click 的回调的将对复选框的 checked 值置为 true,从而改变复选框的 checked 状态。
无奖竞猜:大家可以先猜一猜一下这个复选框最终会不会被勾选上呢?
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<!-- import CSS -->
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
</head>
<body>
<div id="app">
<input
type="checkbox"
:checked="checked"
@click="handleClick"> {{ checked }}
</div>
</body>
<!-- import Vue before Element -->
<script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.js"></script>
<!-- import JavaScript -->
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
<script>
new Vue({
el: '#app',
data: function() {
return {
checked: false
};
},
methods: {
handleClick ($event) {
$event.preventDefault()
this.checked = true
}
}
})
</script>
</html>
事实上同样的操作在 vue@2.5.17
版本与 vue@2.6.14
版本下表现并不一致:
2.5版本:
2.6版本:
可以看到,2.5 版本下复选框的勾选状态与 checked 的值是同步的;但在 2.6 版本下,checked 值虽然改变了,复选框的选中状态并没有发生变化;
源码分析
可以看到,2.5 版本下复选框的勾选状态与 checked 的值是同步的;但在 2.6 版本下,checked 值虽然改变了,复选框的选中状态并没有发生变化;
那么是什么原因导致的同一段代码在两个版本的 vue 下表现不一致呢?
我们来一起分析一下:
2.5 版本
-
首先是复选框勾选状态被改变,handleClick 回调被触发,checked 的值被改变:
methods: { handleClick () { this.checked = true } }
-
由于 checked 是个响应式数据,它的值被更新会导致 Object.defineProperty 上的 set 方法被触发,在这里会调用相关依赖的 notify 方法,通知依赖数据源被改变
// src\core\observer\index.js set: function reactiveSetter (newVal) { const value = getter ? getter.call(obj) : val /* eslint-disable no-self-compare */ if (newVal === value || (newVal !== newVal && value !== value)) { return } /* eslint-enable no-self-compare */ if (process.env.NODE_ENV !== 'production' && customSetter) { customSetter() } if (setter) { setter.call(obj, newVal) } else { val = newVal } childOb = !shallow && observe(newVal) // 调用相关依赖的 notify 方法,通知依赖数据源被改变 dep.notify() }
-
而 notify 方法会遍历调用 sub 上的 update 方法,这里的 sub 其实就是我们的 watcher 观察者;
// src\core\observer\dep.js notify () { const subs = this.subs.slice() for (let i = 0, l = subs.length; i < l; i++) { // 遍历调用 sub 上的 update 方法 subs[i].update() } }
-
接下来会命中 Watcher 实例上的 update() 方法,这个方法可以简单理解为:当 watcher 依赖的数据源发生改变时,就会执行这个方法,做一些组件重新渲染前的准备工作;
// src/core/observer/watcher.js export default class Watcher { // ......(省略) update () { if (this.lazy) { this.dirty = true } else if (this.sync) { this.run() } else { debugger // 在这里又调用了 queueWatcher 这一方法 queueWatcher(this) } } // ......(省略) }
而在 update() 中又调用了 queueWatcher 这一方法,下面来看一下 queueWatcher 的实现:
-
queueWatcher 这个函数使用了一个 queue 即先进先出的队列,并将我们刚刚传入的 watcher 实例 push 进队列,最后在 nextTick 的时候,执行 flushSchedulerQueue 函数;
// src/core/observer/scheduler.js export function queueWatcher (watcher: Watcher) { debugger const id = watcher.id if (has[id] == null) { has[id] = true if (!flushing) { queue.push(watcher) } else { let i = queue.length - 1 while (i > index && queue[i].id > watcher.id) { i-- } queue.splice(i + 1, 0, watcher) } // queue the flush if (!waiting) { waiting = true // 在 nextTick 的时候,执行 flushSchedulerQueue 函数 // 我们接着看一下 flushSchedulerQueue 和 nextTick 的实现 nextTick(flushSchedulerQueue) } } }
flushSchedulerQueue 这个函数主要做的就是循环 queue 这个队列,并依次调用我们推入队列里的 watcher 实例上的 run() 方法,去重新渲染组件:
// src/core/observer/scheduler.js function flushSchedulerQueue () { flushing = true let watcher, id queue.sort((a, b) => a.id - b.id) for (index = 0; index < queue.length; index++) { watcher = queue[index] id = watcher.id has[id] = null debugger // 循环 queue,依次调用 queue 里的 watcher 实例上的 run() 方法 watcher.run() } // ......(省略) }
-
那么 nextTick 函数又在这里起到了什么作用呢?
实际上 vue 并不会立即去执行 flushSchedulerQueue 函数从而重新渲染组件,而是在 nextTick 方法中将刚刚传入的 flushSchedulerQueue 以回调函数的形式推入 callbacks 数组;
// src/core/util/next-tick.js // nextTick 将刚刚传入的 flushSchedulerQueue 作为回调函数,放入 callbacks 数组中 // 并在下一个 tick 依次执行 flushSchedulerQueue 函数; // 我们刚刚说到 flushSchedulerQueue 函数的主要作用就是调用 watcher 实例上的 run() 方法,去重新渲染组件 // 由此可证,vue 组件的重新渲染是在下一个 tick 执行的 export function nextTick (cb?: Function, ctx?: Object) { debugger let _resolve // 将刚刚传入的 flushSchedulerQueue 作为回调函数,放入 callbacks 数组中 callbacks.push(() => { if (cb) { try { cb.call(ctx) } catch (e) { handleError(e, ctx, 'nextTick') } } else if (_resolve) { _resolve(ctx) } }) // ......(省略) }
那么 callbacks 里的函数又是什么时候被调用的呢?我们接着往下看:
下面的代码会去判断是使用宏任务还是微任务,如果是宏任务调用 macroTimerFunc 方法,否则调用 microTimerFunc 方法;
这两个方法做的就是:以微任务 / 宏任务的形式在下一个 tick 执行 callbacks 数组里的回调函数,也就是执行 flushSchedulerQueue;
我们前面说到 flushSchedulerQueue 函数的主要作用就是调用 watcher 实例上的 run() 方法,去重新渲染组件;由此可知,vue 组件的重新渲染也是在下一个 tick 执行的:
// src/core/util/next-tick.js export function nextTick (cb?: Function, ctx?: Object) { // ......(省略) if (!pending) { pending = true // 判断是使用宏任务还是微任务 if (useMacroTask) { // 2.5.17版本下,会命中这里的逻辑 // macroTimerFunc 是使用 MessageChannel 实现的宏任务 // macroTimerFunc/microTimerFunc 这两个函数被调用 // 就会去执行 callbacks 数组里的回调函数,也就是执行 flushSchedulerQueue // flushSchedulerQueue 被执行,最终就会调用 watcher 上的 run() 方法,重新渲染组件 macroTimerFunc() } else { microTimerFunc() } } // ......(省略) }
接下来我们来具体看一下 macroTimerFunc 方法和 microTimerFunc 方法的实现:
macroTimerFunc 的实现会先判断当前环境是否支持 setImmediate,如果不支持再去检测是否支持 MessageChannel,如果还不支持最后就会降级为 setTimeout;
而 microTimerFunc 的实现,则会先判断当前环境是否支持 Promise,如果不支持的话直接使用 macroTimerFunc;
// src/core/util/next-tick.js let microTimerFunc let macroTimerFunc let useMacroTask = false // macroTimerFunc 先判断当前环境是否支持 setImmediate if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { macroTimerFunc = () => { setImmediate(flushCallbacks) } } else if (typeof MessageChannel !== 'undefined' && ( isNative(MessageChannel) || // PhantomJS MessageChannel.toString() === '[object MessageChannelConstructor]' )) { // macroTimerFunc 如果不支持 setImmediate 再检测是否支持 MessageChannel const channel = new MessageChannel() const port = channel.port2 channel.port1.onmessage = flushCallbacks macroTimerFunc = () => { port.postMessage(1) } } else { // 如果 setImmediate 和 MessageChannel 都不支持最后就会降级为 setTimeout; macroTimerFunc = () => { setTimeout(flushCallbacks, 0) } } // microTimerFunc 判断是否支持 Promise if (typeof Promise !== 'undefined' && isNative(Promise)) { const p = Promise.resolve() microTimerFunc = () => { p.then(flushCallbacks) if (isIOS) setTimeout(noop) } } else { // 不支持的话直接使用 macroTimerFunc microTimerFunc = macroTimerFunc }
在 macroTimerFunc / microTimerFunc 方法中,又调用了 flushCallbacks 这一方法,我们之前 push 进 callback 的函数就是在这里被调用;
-
此外 next-tick 文件中还暴露了一个 withMacroTask 方法,这个方法是用来给
v-on
之类的 DOM 操作事件做一层包装,确保回调命中的是宏任务的逻辑:// src/core/util/next-tick.js export function withMacroTask (fn: Function): Function { return fn._withTask || (fn._withTask = function () { useMacroTask = true const res = fn.apply(null, arguments) useMacroTask = false return res }) }
因此对于我们的 change 回调,会强制走 macroTimerFunc 的逻辑,在 chrome 它是一个通过
MessageChannel
实现的宏任务;从下图调试工具截图中也可以看到 flushCallbacks 函数被调用时,this 指向了 MessagePort:
-
接着 vue 会对新旧虚拟节点进行比较,对 dom 元素的 attrs、class、props 等进行更新;对于我们这里的例子,就是去更新 dom 的 props,也就是 checked 属性;
-
在 updateDOMProps 方法中对 input 元素的 checked 属性进行更新,由于我们 this.checked = true 已经生效,因此 input 元素的 checked 属性最终为更新为 true。
2.6 版本
对于 2.6 版本的 vue,前面的派发更新逻辑与 2.5 版本 vue 基本一致,但在对 nextTick 的实现上有一些差别,我们来看一下 2.6 版本的 nextTick:
// src/core/util/next-tick.js
export function nextTick (cb?: Function, ctx?: Object) {
// ......(省略)
if (!pending) {
pending = true
debugger
timerFunc()
}
// ......(省略)
}
// src/core/util/next-tick.js
let timerFunc
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
if (isIOS) setTimeout(noop)
}
isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
isNative(MutationObserver) ||
// PhantomJS and iOS 7.x
MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
let counter = 1
const observer = new MutationObserver(flushCallbacks)
const textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
characterData: true
})
timerFunc = () => {
counter = (counter + 1) % 2
textNode.data = String(counter)
}
isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
// Fallback to setTimeout.
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
可以看到,代码中 withMacroTask 的逻辑被剔除了,而 timerFunc 的降级顺序依次为:
Promise(微任务) => MutationObserver(微任务) => setImmediate(宏任务)=> setTimeout (宏任务)
也就是说,对于 v-on
绑定的回调,2.6 版本是用 Promise 微任务来实现的;
在调试工具中也可以看到,timerFunc 的实现指向了 46 行,也就是通过 Promise 来实现。
现在我们知道了在 2.5 版本和 2.6 版本下最主要差异,就在于 2.5 版本 v-on
回调使用宏任务,而 2.6 版本的 v-on
回调使用微任务。
那么这个差异又是如何影响组件渲染的呢?这其实与我们 事件循环 机制有关,我们一起来回顾一下事件循环的基本知识:
事件循环
-
首先,我们的 js 代码都是在执行栈上被执行的;
-
当代码在执行的过程中,遇上异步任务的回调、或者触发的一些操作,会被放在任务队列里;
-
任务队列的话又分为两种,宏任务队列和微任务队列;
- 宏任务队列:scirpt、setTimeout、setImmediate、postMessage 等
- 微任务队列:promise.then、MutationObsever
-
一般执行栈中最开始执行的都是我们整体的 script ,当我们的执行栈中的宏任务代码都被执行完毕以后,会去微任务队列中读取最早加入的任务,放入执行栈中执行,重复读取执行这个过程,一直到本次微任务队列清空;
-
这个时候如果是浏览器环境的话,浏览器可能会进行一些重新渲染的操作;
-
那么这样就完成了一次事件循环;
-
接着开启下一次事件循环,又会先从宏任务队列中读取一个任务执行,然后再去清空微任务队列。
过程分析
我简单分析了一下复选框被勾选后的一个大致流程:
在 2.5 版本下:
- 复选框被点击选中,接着我们的 click 回调被执行
$event.preventDefault()
方法被调用,告诉我们的浏览器需要阻止默认事件,也就是在 未来的某一时刻 将我们的复选框恢复到未选中的状态,而根据我们刚刚 debugger 观察,$event.preventDefault()
的执行时机是在下一个 tick 开始之前; - 接下来
this.checked = !this.checked
语句执行,checked 值变为 true,由于 checked 值被绑定到了 input 元素上,因此我们需要对 input 元素进行更新;我们之前说过,出于性能考虑 vue 底层对 dom 的更新操作是异步的,因此这次更新会通过 nextTick 放入一个宏任务里,在下一个 tick 执行; - 当前执行栈任务执行完毕,由于没有微任务,准备进入下一个 tick;此时
$event.preventDefault()
生效,复选框恢复未选中状态,input 元素的 checked 属性值变为 false; - 下一个事件循环开始,在步骤 2 中被推入宏任务的 dom 更新操作被执行,vue 进行 patchVnode、updateDOMProps 等一系列操作;在 updateDOMProps 时,vue 会拿到 checked 值去对 input 元素上的 checked 属性做更新,由于 checked 值为 true,因此 input checked 属性也被设置为 true,表示在页面上就是复选框被重新勾选了。
在 2.6 版本下:
- 复选框被点击选中,接着我们的 click 回调被执行
$event.preventDefault()
方法被调用,告诉我们的浏览器需要阻止默认事件,也就是在 未来的某一时刻 将我们的复选框恢复到未选中的状态; - 接下来
this.checked = !this.checked
语句执行,checked 值变为 true,由于 checked 值被绑定到了 input 元素上,因此我们需要对 input 元素进行更新;我们之前说过,出于性能考虑 vue 底层对 dom 的更新操作是异步的,因此这次更新会通过 nextTick 放入微任务里; - 步骤 2 中被推入微任务的 dom 更新操作被执行,vue 进行 patchVnode、updateDOMProps 等一系列操作;在 updateDOMProps 时,vue 会拿到 checked 值去对 input 元素上的 checked 属性做更新,由于 checked 值为 true,因此 input checked 属性也被设置为 true,表示在页面上就是复选框被重新勾选了。
- 当前执行栈任务执行完毕,由于没有微任务,准备进入下一个 tick;此时
$event.preventDefault()
生效,复选框恢复未选中状态,input 元素的 checked 属性值变为 false;
可以看到造成影响的主要原因就是:2.6 版本的 nextTick 是用微任务实现,而 2.5 版本是用宏任务实现。
至此,我们就知道了 nextTick 对组件渲染结果的影响。