nextTick什么时候用
道理讲再多,不如带你真真切切的踩个坑。如下图所示,一个门店后厨的统计页面,在第一个“后厨出菜统计”模块,要绘制每个厨师出菜比例的饼图。
既然Vue是双向数据绑定的,我们直接把响应数据赋值给data后,视图理应就会更新,可我们紧接着通过DOM对象绘制图表的时候,却报错找不到DOM元素。显然Vue的数据赋值后,没有立即去更新DOM。
<template>
<div>
<p class="title">后厨出菜统计</p>
</div>
<div class="cook-item"
v-for="(cook,index) in cookList"
:key="'chef_list_'+cook.oid">
<!-- 出菜信息 -->
<div></div>
<!-- 出菜图表 -->
<div style="flex: 1;background-color: #f8f8f8;border-radius: 6px;">
<canvas :id="'pie_' + cook.oid"></canvas>
</div>
</div>
</template>
<script>
import F2 from '@antv/f2';
const axios = require('axios').default;
// 动态构造饼图
function makePieChart(list, containerId) {
// ....
const pieChart = new F2.Chart({
id: containerId,
pixelRatio: window.devicePixelRatio,
padding: [20, 'auto']
});
// ...
pieChart.render();
}
export default {
name: 'cookReport',
props: {
storeCode: String, // 门店编码
date: String // 统计日期
},
data() {
return {
cookList: []
}
},
mounted() {
// 挂载完成后,发起请求数据
axios.get(`${process.env.VUE_APP_BASE_URL}/report?date=${this.date}&storeCode=${this.storeCode}`).then(res => {
// 赋值
this.cookList = res.data
// 直接调用
this.cookList.forEach(cook => {
makePieChart(cook.pieDataList, `pie_${cook.oid}`)
})
})
}
}
</script>
F2图表绘制报错:
Uncaught (in promise) TypeError: Cannot read property 'currentStyle' of null
at getStyle (f2.js?e004:765)
at getWidth (f2.js?e004:769)
at Canvas._initCanvas (f2.js?e004:9848)
at new Canvas (f2.js?e004:9801)
at createCanvas (f2.js?e004:10031)
at Chart._initCanvas (f2.js?e004:10490)
at Chart._init (f2.js?e004:10567)
at new Chart (f2.js?e004:10606)
at makePieChart (StoreReport.vue?8e92:161)
at eval (StoreReport.vue?8e92:293)
这是因为Vue采取的是异步更新策略,我们把图表绘制的代码写在nextTick里,能正常运行了。
mounted() {
// 挂载完成后,发起请求数据
axios.get(`${process.env.VUE_APP_BASE_URL}/report?date=${this.date}&storeCode=${this.storeCode}`).then(res => {
// 赋值
this.cookList = res.data
// 等待DOM更新完成后,再调用
this.$nextTick(() => {
this.cookList.forEach(cook => {
makePieChart(cook.pieDataList, `pie_${cook.oid}`)
})
});
})
}
nextTick()
的本质
- 结合上面的例子,再理解官方文档的解释,就一目了然了:
在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。
那么问题来了,我们不禁要思考nextTick回调函数的本质是什么?
- nextTick,单词next我知道是"下一个",那什么是tick?
- nextTick明明是JS代码,它怎么就知道DOM什么时候更新呢?又是谁去通知它呢?
- nextTick是钩子函数嘛,能否像mounted/watch生命周期函数的方式来使用?反正DOM更新就会通知调用不是嘛?
结合EventLoop理解nextTick()
解答第一个问题,就不得不提事件循环机制。
众所周知,事件循环(EventLoop)是JS协调异步程序的机制,异步程序中又分为宏任务和微任务,宏任务通常是和浏览器级别的,比如定时器/轮询器/XHR等;微任务通常是ECMAScript级别的,比如promise/await+async/mutationObserver/processe.nextTick等; 宏任务和微任务都是以队列(queue)的数据结构存在,宿主环境轮询检查执行栈(CallStack)的清空情况,然后逐个放入宏任务执行,当前宏任务完后执行紧接着会执行微任务。
现在可以回答第一个问题了,每执行一个宏任务,就是一个tick。而nextTick()
在语境上,可以理解成"在下一个宏任务执行之前的回调函数"。
nextTick()
通过MutataionObsever监听DOM的更新
接着解答第二个问题,DOM的增删改查操作是同步执行的,对于DOM变化的监听提供的MutationObsever API是异步的,并且属于微任务。 一个简单的MutationObsever示例:
<body>
<div id="container"></div>
<script>
// 厨师列表
let cookList = [{
name: '斯蒂芬周',
oid: 10081
},{
name: '鸡姐',
oid: 10082
},{
name: '唐牛',
oid: 10083
}];
// 模板容器
let $container = document.getElementById('container');
// 当容器内的chart DOM插入后,打印日志
new MutationObserver(() => {
console.log('大厨已就绪,可以进行下一步图表绘制工作!')
}).observe($container, {
childList: true,
subtree: true
});
// 遍历数组,插入chart DIV
cookList.forEach(cook => {
let cookDiv = document.createElement('div');
cookDiv.setAttribute('id','cook_' + cook.oid);
cookDiv.textContent = cook.name;
$container.appendChild(cookDiv);
});
</script>
</body>
nextTick()
本质就是往微任务队列中追加执行函数
接下来看第三个问题:如果像生命周期函数一样,提前注册nextTick是否可行呢?答案是否定的
mounted() {
// 先注册nextTick
this.$nextTick(() => {
console.log('DOM ready')
});
axios.get(`${process.env.VUE_APP_BASE_URL}/report?date=${this.date}&storeCode=${this.storeCode}`).then(res => {
// 响应后对数据进行赋值
this.cookList = res.data
console.log('data assign')
})
}
输出的结果:没等数据赋值,nextTick()
就先执行了
> DOM ready
> data assign
结论:MutataionObsever
和nextTick
都是微任务,在队列中按顺序执行。所以在业务代码中,数据赋值与nextTick()
总是成对出现,并且nextTick
必须在数据赋值后面,不能像钩子函数一样提前注册。
通过对比,认清自己
nextTick
与process.nextTick()
的区别
process.nextTick
是node.js的API,和Vue.nextTick
一样,都是往微任务追加执行函数
nextTick
与jQuery.ready()
的区别
当我和后端小哥用厨师图表渲染的例子,讲解nextTick
时,他当下反应是,“哦!这个我知道,和jQuery.ready()一样嘛!”,我当时是一脸的黑人问号表情。这两者还是不一样的,jQuery.ready()
是jQuery实例挂载到window的回调函数,函数里面就可以通过jQuery来操作DOM了。而Vue.nextTick()
是响应数据绑定的视图更新后的回调函数,函数里面可以操作DOM。
全局的Vue.nextTick
与组件内this.nextTick
的区别
当前组件和与全局Vue实例都指向src/core/util/next-tick.js
同一个nextTick()
函数
nextTick
与requestAnimationFrame()
的关系
还是要回到EventLoop机制:
- 执行栈(Call Stack)清空后,放入宏任务队列排在最前面的task,并执行它;
- 执行当前宏任务后,检查是否存在微任务(
Microtask
),如果有,则轮询执行,直到清空微任务队列(包含嵌套产生的微任务); - 检查是否进行浏览器渲染;
- 如需渲染,先调用requestAnimationFrame函数;
- 然后执行浏览器更新渲染;
- 最后执行requestIdleCallback函数;
- 重复以上步骤
可见,nextTick只是微任务队列中普通的函数,就是按顺序执行。而requestAnimationFrame()
取决于本次轮询是否要进行更新渲染,在需要更新渲染前,调用执行。
nextTick实现原理
源码走读
/* @flow */
/* globals MutationObserver */
import { noop } from 'shared/util'
import { handleError } from './error'
import { isIE, isIOS, isNative } from './env'
// 标识-是否使用微任务执行回调函数
export let isUsingMicroTask = false
// 数组-回调函数列表
const callbacks = []
// 标识-是否有回调函数在执行中
let pending = false
// 轮询callbacks数组,执行每个回调函数,并清空数组
function flushCallbacks () {
pending = false
// 复制到局部变量
const copies = callbacks.slice(0)
// 清空原数组
callbacks.length = 0
// 轮询执行每个回调函数
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
// 定义主逻辑的timerFunc方法
let timerFunc
if (typeof Promise !== 'undefined' && isNative(Promise)) {
/**
* 判断是否支持Promise
*/
const p = Promise.resolve()
// 以Promise.then来处理flushCallbacks函数
timerFunc = () => {
p.then(flushCallbacks)
if (isIOS) setTimeout(noop)
}
// Promise属于微任务,进行标识
isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
isNative(MutationObserver) ||
MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
/**
* 判断是否支持MutationObsever
*/
let counter = 1
// 以MutationObsever的监听来处理flushCallbacks函数
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)
}
// MutationObsever也属于微任务,进行标识
isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
/**
* 判断是否支持setImmediate
*/
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
/**
* 以上方法都不支持,则通过定时器的宏任务来处理
*/
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
// 对外暴露nextTick方法,传递callback回调函数,以及执行环境
export function nextTick (cb?: Function, ctx?: Object) {
let _resolve
// 将回调函数包装后,放入callbacks数组
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
if (!pending) {
// 有微任务在执行时,先设置pending为true进行等待
pending = true
timerFunc()
}
// 如果支持Promise,对外返回Promise类型
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
总结概述
- 整个
next-tick.js
文件对外只暴露一个nextTick()
方法,方法的参数就是业务代码中的回调函数; - 外部每调用一次
nextTick()
,传入的回调函数都会放入一个callbacks
数组中,然后执行timerFun()
异步方法; timerFun()
对环境进行判断,是否支持微任务对象Promise/MutationObsever,如果不支持则通过setImmediate/setTimeout宏任务包装成异步方法,回调处理flushCallbacks()
;flushCallback()
如方法名所写,做的就是按顺序遍历执行每个回调函数,并清空数组;
参考
未解决的疑惑
- 既然tick是指宏任务之间的间隔,
nextTick
为什么不直接放到下一个宏任务,而是优先放入微任务队列? queueMicrotask()
也是往微任务队列中追加执行函数,nextTick
为什么要用Promise来实现?