Vue.nextTick实现及原理探究
一. nextTick() 使用场景/语法/分类
-
使用场景
当更新状态(数据)后,需要对新DOM做一些操作,但这时我们获取不到更新后的DOM,因为还没有重新渲染。nextTick接收一个回调函数作为参数,它的作用是将回调延迟到下次DOM更新周期之后执行。
-
语法
//实例方法 new Vue({ data:{ message:'oldValue' }, methods:{ fn:function(){ // 修改数据 this.message = 'newValue'; //DOM 还未更新 this.$nextTick(()=>{ // DOM更新了 doSomething... }) } } }) // 全局方法 Vue.nextTick(()=>{ //DOM更新 doSomething... }) // 作为一个 Promise 使用 Vue.nextTick().then(()=>{ //DOM更新 doSomething... })
-
分类
3.1
vm.$nextTick(fn)
:回调的this
自动绑定到调用它的实例上3.2
Vue.nextTick(fn)
:全局方法vm.$nextTick(fn)
和Vue.nextTick(fn)
实质是一样的,所以nextTick的具体实现并不是在Vue原型上的$nextTick
方法中,而是抽象成了nextTick
方法供两个方法共同调用。
二. nextTick()
实现原理
这里第一步我们需要思考的是:
nextTick
是将回调延迟到下次DOM更新之后执行,那么下次DOM更新具体是什么时候呢?如何得知?
我们知道,Vue的响应式原理是使用订阅-发布者模式,即通过收集某状态的使用者,在状态改变时,再遍历收集告知变化,然后触发虚拟DOM的渲染流程。这里引出一个概念:触发渲染是一个异步操作。每当需要重新渲染时,会将该任务推送至一个异步任务队列中,然后在下一次事件循环中执行更新DOM的操作。
第二步我们需要思考的是:何为任务队列及事件循环机制?
JavaScript是一门单线程且非阻塞的脚本语言
- JavaScript代码在执行的任何时候都只有一个主线程来处理所有任务
- 非阻塞是指当代码需要执行异步任务时,主线程会挂起(pending)这个任务,当同步任务执行完毕后,主线程再去执行挂起的任务。总结来非阻塞就是我在一刻也不停的做事儿。
例子:
console.log('time1:',new Date().getTime());
setTimeout(()=>{ // setTimeout相当于一个异步操作
console.log('time2:',new Date().getTime())
},1000)
console.log('time3',new Date().getTime())
// 输出: time1 time3 time2
任务队列
所有任务分为同步任务和异步任务
- 同步任务:可以立即执行的任务,直接进入js主线程中执行
- 异步任务:如
ajax
网络请求,setTimeout
定时器等,异步任务需要通过任务队列(Event Queue
)的机制来协调
同步任务与异步任务会进入不同的执行环境,同步的进入主线程,即执行栈,异步则进入任务队列。当主线程内的任务执行完毕,会去 Event Queue
读取对应任务,然后推入主线程执行。
异步任务
异步任务分为宏任务和微任务
宏任务(macrotask),主要包括:
- script
- setTimeout
- setInterval
- setImmediate
- MessageChannel
- requestAnimationFrame
- I/O
- UI交互事件
微任务(microtask),主要包括:
- Promise.then
- MutationObserver
- Object.observe
- process.nextTick
更新DOM的回调和vm.$nextTick注册的回调都是向微任务队列中添加任务
修改数据会默认将更新DOM的回调添加到微任务队列中
执行优先级:当执行栈中的任务执行完毕后,会去检查微任务队列中是否有事件存在,如果存在,则会依次执行微任务队列中事件对应的回调,直到为空。然后从宏任务队列中取出一个事件,把对应的回调推入当前执行栈。当执行栈中所有任务都执行完毕后,又去检查微任务队列是否有事件存在。
例子:
setTimeout(()=>{
console.log(1)
},0)
console.log(2)
new Promise(resolve=>{
console.log(3)
resolve()
}).then(()=>{
console.log(4)
}).then(()=>{
conlole.log(5)
})
// 输出:2 3 4 5 1
分析:
- 浏览器执行js进入第一个宏任务,遇到
setTimeout
,分发到宏任务Event Queue
中 - 遇到
console.log()
,直接打印 - 遇到
Promise
,new Promise
直接打印 - 遇到
then()
,分发到微任务Event Queue
中 - 第一轮宏任务执行结束,检查是否有微任务执行,有则打印
- 第一轮微任务执行结束,开启第二轮宏任务,打印
注意:宏任务是一个一个取出执行,而微任务是一个队列一个队列执行
事件循环机制(event loop
)
当执行栈中所有任务执行完毕,会去检查微任务队列是否有事件存在,有则执行,执行完毕再去宏任务队列取一个事件加入到当前执行栈,执行完毕再去检查微任务队列,上述过程的不断重复即事件循环
看到这里,可以解答上述遗留问题了,下次DOM更新 即是:下次微任务执行时更新DOM。
最后我们来讲nextTick方法的具体实现
const callbacks = [] // 定义存放回调任务的列表
let pending = false // 状态值,控制是否需要向任务队列中添加任务
function flushCallbacks (){
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0 // 清空任务队列
for(let i = 0; i < copies.length; i++){
copies[i]() // 遍历任务队列并执行
}
}
let microTimerFunc
const p = Promise.resolve()
microTimerFunc = () => {
p.then(flushCallbacks) // microTimerFunc函数封装Promise.then()将任务添加到微任务中
}
export function nextTick (cb, ctx){
callbacks.push(()=>{
if(cb){
cb.call(ctx)
}
})
if(!pending) { // 判断是否是第一次添加该任务
pending = true // 每当向队列中插入任务,更改状态值
microTimerFunc()
}
}
- 在一轮事件循环中,
nextTick
只会向任务队列添加一个任务,即flushCallbacks
只会执行一次 microTimerFunc
函数的作用是使用Promise.then
将flushCallbacks
添加到微任务中
流程图如下:
扩展
前面提到
microTimerFunc
函数封装Promise.then
将flushCallbacks
添加到微任务中,但Promise
是ES6新增的东西,存在兼容问题。这时为了解决一些特殊情况,Vue提出了降级策略
降级策略:即在特殊情况强制使用宏任务的方法
// 首先判断浏览器是否支持 Promise
if(typeof Promise !== 'undefined' && isNative(Promise)){
const p = Promise.resolve()
microTimerFunc = ()=>{
p.then(flushCallbacks)
}
}else{
microTimerFunc = macroTimerFunc
}
具体实现:
// 新增代码
let useMacroTask = false
export function withMacroTask (fn) {
return fn._withTask || (fn._withTask = function () {
useMacroTask = true
const res = fn.apply(null, arguments)
useMacroTask = false
return res
})
}
export function nextTick (cb,ctx){
callbacks.push(()=>{
if(cb){
cb.call(ctx)
}
})
if(!pending){
pending = true
if(useMacroTask){
macroTimerFunc()
} else {
microTimerFunc()
}
}
}
- 被
withMacroTask
包裹的函数所使用的nextTick
方法都会将回调添加到宏任务队列中,其中包括状态被修改后触发的更新DOM的回调和用户自己使用nextTick
注册的回调等