Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

【看的见的思考】nextTick 的实现 #36

Open
cuixiaorui opened this issue Sep 26, 2021 · 0 comments
Open

【看的见的思考】nextTick 的实现 #36

cuixiaorui opened this issue Sep 26, 2021 · 0 comments

Comments

@cuixiaorui
Copy link
Owner

问题

  1. 为什么需要先执行父组件

看的见的思考

vue3 的 nextTick 的实现是在 scheduler.ts 实现的

scheduler 中文翻译过来是调度器,但是这个调度器这个词还是有点抽象啊。[[调度器]]

先找到核心执行逻辑

function flushJobs(seen?: CountMap) { isFlushPending = false
  isFlushing = true
  let job
  if (__DEV__) {
    seen = seen || new Map()
  }

  // 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)
  }
}

这里有几个关键的点,

  1. queue
  2. 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 的信息

  1. 组件更新从父级到孩子,因为父级总是在子组件之前创建好的,所以它渲染 effect 有较小的优先级(因为初始化的时候先创建的父组件,所以父组件的 id 是小的,换句话说也就是会先执行父组件)
  2. 如果一个组件在父级组件更新时是 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(), 在微任务执行的时候在去执行这个队列里面所有的函数。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant