问题
- 为什么需要先执行父组件
看的见的思考
vue3 的 nextTick 的实现是在 scheduler.ts 实现的
scheduler 中文翻译过来是调度器,但是这个调度器这个词还是有点抽象啊。[[调度器]]
先找到核心执行逻辑
function flushJobs(seen?: CountMap) { isFlushPending = false
isFlushing = true
let job
if (__DEV__) {
seen = seen || new Map()
}
G
// Sort queue before flush.
// This ensures that:
// 1. Components are updated from parent to child. (because parent is always
// created before the child so its render effect will have smaller
// priority number)
// 2. If a component is unmounted during a parent component's update,
// its update can be skipped.
// Jobs can never be null before flush starts, since they are only invalidated
// during execution of another flushed job.
queue.sort((a, b) => getId(a!) - getId(b!))
while ((job = queue.shift()) !== undefined) {
if (job === null) {
continue
}
if (__DEV__) {
checkRecursiveUpdates(seen!, job)
}
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
}
flushPostFlushCbs(seen)
isFlushing = false
// some postFlushCb queued jobs!
// keep flushing until it drains.
if (queue.length || postFlushCbs.length) {
flushJobs(seen)
}
}
这里有几个关键的点,
- queue
- flushPostFlushCbs
先看看 queue 这个队列是干什么的? 队列里面存的是什么?在什么时候存的
export function queueJob(job: Job) {
if (!queue.includes(job)) {
queue.push(job)
queueFlush()
}
}
是通过 queueJob 来收集 job 的,那接着看看 job 都是什么东西
const prodEffectOptions = {
scheduler: queueJob
}
function createDevEffectOptions(
instance: ComponentInternalInstance
): ReactiveEffectOptions {
return {
scheduler: queueJob,
onTrack: instance.rtc ? e => invokeArrayFns(instance.rtc!, e) : void 0,
onTrigger: instance.rtg ? e => invokeArrayFns(instance.rtg!, e) : void 0
}
}
instance.update = effect(function componentEffect() {
啊哦,原来是传给了做 update 时的 effect 的配置里面
注意这个 scheduler:queueJob
在回顾一下 如果给了 effect options 里面有 scheduler 的话,effect 会有什么行为
const run = (effect: ReactiveEffect) => {
if (__DEV__ && effect.options.onTrigger) {
effect.options.onTrigger({
effect,
target,
key,
type,
newValue,
oldValue,
oldTarget
})
}
if (effect.options.scheduler) {
effect.options.scheduler(effect)
} else {
effect()
}
}
啊哦,当响应式对象发生改变后,执行 effect 的时候,如果有 scheduler 这个 option 的话,会执行这个 scheduler 函数,并且把 effect 传入
那其实这里的 scheduler 函数就是 上面的 queueJob 函数,并且 effect 就是 update 函数
那也就是说,每次更新的时候都会把 update 这个函数推入到 queueJob 内
那推入后呢?
在回顾一下 queueJob 函数
export function queueJob(job: Job) {
if (!queue.includes(job)) {
queue.push(job)
queueFlush()
}
}
这里的 job 就是 update 函数,那么这里只会推入一次,也就是说,当后续执行 queueFlush 之后,才会执行 update 函数。
嗯,这样的话就可以避免修改了数据之后就会立马渲染页面了! 棒!
其实 nextTick 的目的也是如此,而且这个做法在游戏里面也有,我自己在写物理引擎的时候也用过,但是没有现在理解的更清晰
继续继续,看看接下来做什么了
function queueFlush() {
if (!isFlushing && !isFlushPending) {
isFlushPending = true
nextTick(flushJobs)
}
}
那其实接着就是执行 queueFlush 了,这里的关键是调用了 nextTick 给了 flushJobs 函数
而 flushJobs 函数就是我们最开始看到的入口函数,在那里会最终执行我们传入的 job 函数
在看 flushJobs 之前 ,先看看 nextTick 是怎么实现的
const p = Promise.resolve()
export function nextTick(fn?: () => void): Promise<void> {
return fn ? p.then(fn) : p
}
这里还挺简单的,就是用的 Promise.resolve() ,把要执行的函数延迟到 微任务队列里面执行。
接着我们看看在最终执行到微任务队列的时候是怎么执行 flushJobs 函数的把 这里暂时只关注和 queue 队列有关的逻辑点
queue.sort((a, b) => getId(a!) - getId(b!))
while ((job = queue.shift()) !== undefined) {
if (job === null) {
continue
}
if (__DEV__) {
checkRecursiveUpdates(seen!, job)
}
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
}
isFlushing = false
// some postFlushCb queued jobs!
// keep flushing until it drains.
if (queue.length || postFlushCbs.length) {
flushJobs(seen)
}
看起来很简单,先排序(为什么需要排序呢? 这里的 id 是什么时候给的,)
然后取队列的头部 job 执行就完事了
这里是个递归的操作
好了,那接着要搞懂的问题就是这个 id 的问题
在 sort 的逻辑上有详细的注释
// Sort queue before flush.
// This ensures that:
// 1. Components are updated from parent to child. (because parent is always
// created before the child so its render effect will have smaller
// priority number)
// 2. If a component is unmounted during a parent component's update,
// its update can be skipped.
// Jobs can never be null before flush starts, since they are only invalidated
// during execution of another flushed job.
尝试着翻译翻译,然后理解一下是啥意思
有可能会涉及到设置 id 的信息
- 组件更新从父级到孩子,因为父级总是在子组件之前创建好的,所以它渲染 effect 有较小的优先级(因为初始化的时候先创建的父组件,所以父组件的 id 是小的,换句话说也就是会先执行父组件)
- 如果一个组件在父级组件更新时是 unmounted 的,那么它的更新会被跳过(这个没太理解,找一找对应的 demo 来验证一下)
好,到这里的时候其实我们能理解调用的优先级是先调用父组件
因为创建 update = effect(fn) 的时候,这里的 effect 的 id 是从零开始计算的,又因为先初始化父级组件,所以后面更新的时候基于 id 排序,会先执行父级组件的 update
那其实我想知道的是,为什么需要先执行父组件?
暂时想不出来 先记录一下
还有一个队列就是 queuePostRenderEffect 的应用了
export function queuePostFlushCb(cb: Function | Function[]) {
if (!isArray(cb)) {
postFlushCbs.push(cb)
} else {
postFlushCbs.push(...cb)
}
queueFlush()
}
和 queue 队列不同,它把收集的 job 都添加到 postFlushCbs 数组里面去
queue.sort((a, b) => getId(a!) - getId(b!))
// 处理 queue 队列
while ((job = queue.shift()) !== undefined) {
if (job === null) {
continue
}
if (__DEV__) {
checkRecursiveUpdates(seen!, job)
}
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
}
// 处理完 queue 队列后处理 postFlushCbs 队列
flushPostFlushCbs(seen)
isFlushing = false
// some postFlushCb queued jobs!
// keep flushing until it drains.
if (queue.length || postFlushCbs.length) {
flushJobs(seen)
}
然后是在处理完 queue 之后再处理 postFlushCbs ,从命名上也能体现出来 post (后刷新)
接着的重点是看看都是把什么任务添加到这个 postFlushCbs 队列来呢?
export const queuePostRenderEffect = __FEATURE_SUSPENSE__
? queueEffectWithSuspense
: queuePostFlushCb
在 renderer.js 使用的时候给它改了个名称叫做 queuePostRenderEffect ,从命名上我们能猜到是在 渲染 effect 之后调用的队列
对 Suspense 组件的处理我们以后单独搞一个章节来分析
queuePostRenderEffect(() => {
vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode)
transition && !transition.persisted && transition.enter(el)
dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
}, parentSuspense)
if ((vnodeHook = newProps.onVnodeUpdated) || dirs) {
queuePostRenderEffect(() => {
vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, n2, n1)
dirs && invokeDirectiveHook(n2, n1, parentComponent, 'updated')
}, parentSuspense)
}
// mounted hook
if (m) {
queuePostRenderEffect(m, parentSuspense)
}
// onVnodeMounted
if ((vnodeHook = props && props.onVnodeMounted)) {
queuePostRenderEffect(() => {
invokeVNodeHook(vnodeHook!, parent, initialVNode)
}, parentSuspense)
}
// activated hook for keep-alive roots.
if (
a &&
initialVNode.shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
) {
queuePostRenderEffect(a, parentSuspense)
}
instance.isMounted = true
会发现在处理一些 hook 的时候都会用这个方法,也就是说 hook 的处理都要等到处理完 update 逻辑。
至于为什么,我还没有猜到
补充:
我发现如果是触发 beforeXxx 的逻辑的时候直接调用 hook 即可
而如果是触发 xxxed 的逻辑的时候需要等到渲染之后
那么这样就能理解了为什么要等到渲染之后
不渲染完的话,怎么能叫 xxxed 呢? 哈哈
这里的 xxxed 指的是 mounted updated 等等
到此为止,其实 nextTick 已经被分析的差不多了,还剩下几个为什么这么做的问题,当然了着就是思考的价值所在,也是看源码的意义,就是它是解决什么问题的,也就是 why 层面的东西。
总结
问题
我们先聊一个场景,当响应式对象发生改变会,会触发 update 逻辑,当触发 update 逻辑后立马重新渲染视图的话 ok 没有问题
但是我们需要考虑这么一个场景
var count = ref(10)
for(let i=0; i<100; i++){
count = i
}
我把响应式对象在 for 循环中(同一帧)更新了 100 次,如果按照我们上面的更新策略,那么就需要更新 100 次视图(响应式数据变更就会触发重新渲染视图)
那我们怎么去优化这个问题呢?
其实通过观察我们会发现,最后的结果就是渲染 count 为 100 的情况。
那么我们就要想办法做到响应式完全变更完之后再渲染视图了
那怎么做呢?
怎么做
我们可以利用 js 的事件循环机制,上面的这个 for 循环的操作是在当前执行栈内执行(同步的),当前执行栈执行完成后,js 会检查事件队列里面的异步任务 (会先执行 微任务,然后在执行宏任务),所以我们完全可以把渲染的逻辑延迟到微任务里执行
这样就可以解决上面说的问题了,而着其实就是 vue.nextTick 要解决的问题,以及它的解决方案
vue 中维护了一个队列,当响应式对象发生变更后,会把 update 函数 push 到队列内,在入队列的时候还做了个检查,如果添加过就不会在添加了。确保同一个 update 函数只执行一次。然后利用 Promise.resolve(), 在微任务执行的时候在去执行这个队列里面所有的函数。
- 这是我们团队的开源项目 element3
- 一个支持 vue3 的前端组件库