【VUE3源码学习】nextTick 实现原理

什么是nextTick?

定义: 在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM

看完这个定义不免心生疑问:

  • 下次 DOM 更新循环结束之后是什么时候?
  • 执行延迟回调?
  • 更新后的 DOM?

基于以上问题和平时的使用经验可以基本解答疑问:

  • vue 更新DOM的策略是异步更新
  • nextTick 可以接收一个函数做为入参
  • nextTick 后能拿到最新的数据

那么nextTick 是怎么实现的呢?既然是异步更新,这涉及到了 js 的执行机制,下面一起复习一下js执行机制。

JS 执行机制

我们都知道 JS 是单线程语言,即指某一时间内只能干一件事,即为同步

而JS为什么是单线程的呢?这就要提及JS的主要用途了。JS自诞生之日起,其主要用途是与用户互动和DOM操作,如果同一时间,一个添加了 DOM,一个删除了 DOM, 这个时候语言就不知道是该添还是该删了,所以从应用场景来看 JS 只能是单线程,否则会带来复杂的同步问题。

单线程就意味着所有的任务都需要排队,后面的任务需要等前面的任务执行完才能执行,如果前面的任务耗时过长,后面的任务就需要一直等,一些从用户角度上不需要等待的任务就会一直等待,这个从体验角度上来讲是不可接受的,所以JS中就出现了异步的概念。

概念

同步任务:指排队在主线程上依次执行的任务
异步任务:不进入主线程,而进入任务队列的任务,又分为宏任务和微任务
宏任务: 渲染事件、请求、script、setTimeout、setInterval、Node中的setImmediate 等
微任务: Promise.then、MutationObserver(监听DOM)、Node 中的 Process.nextTick等

执行机制

(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
(3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
(4)主线程不断重复上面的第三步。
只要主线程空了,就会去读取"任务队列",这就是JavaScript的运行机制。

当执行栈中的同步任务执行完后,就会去任务队列中拿一个宏任务放到执行栈中执行,执行完该宏任务中的所有微任务,再到任务队列中拿宏任务,即一个宏任务、所有微任务、渲染、一个宏任务、所有微任务、渲染…(不是所有微任务之后都会执行渲染),如此形成循环,即事件循环(EventLoop)

nextTick 就是创建一个异步任务,那么它自然要等到同步任务执行完成后才执行。

nextTick 用法

先看个例子,点击按钮更新 DOM 内容,并获取最新的 DOM 内容

 <template>
     <div ref="test">{{name}}</div>
     <el-button @click="handleClick">按钮</el-button>
 </template>
 <script setup>
     import { ref, nextTick } from 'vue'
     const name = ref("initName")
     const test = ref(null)
     async function handleClick(){
         name.value = 'newName'
         console.log(test.value.innerText) // initName
         await nextTick()
         console.log(test.value.innerText) // newName
     }
     return { name, test, handleClick }
 </script>

nextTick 源码剖析

nextTick实现完全基于语言执行机制实现,直接创建一个异步任务,那么nextTick自然就达到在同步任务后执行的目的

源码版本:3.2.22,源码地址:packages/runtime-core/src/sheduler.ts

const resolvedPromise: Promise<any> = Promise.resolve()
let currentFlushPromise: Promise<void> | null = null

export function nextTick<T = void>(this: T, fn?: (this: T) => void): Promise<void> {
  const p = currentFlushPromise || resolvedPromise
  return fn ? p.then(this ? fn.bind(this) : fn) : p
}

可以看出 nextTick 接受一个函数为参数,同时会创建一个Promise微任务。所以,页面调用 nextTick 的时候,会把的参数 fn 赋值给 p.then(fn),在队列currentFlushPromise || resolvedPromise的任务完成后,执行fn

vue3nextTick的队列由几个方法维护,基本执行顺序是这样的:queueJob -> queueFlush -> flushJobs -> nextTick参数的 fn

先有个印象即可,后面按照执行顺序依次分析。

nextTick调用位置

入口函数 queueJob 是在renderer函数中调用:

// packages/runtime-core/src/renderer.ts - 1555行
function baseCreateRenderer(){
  const setupRenderEffect: SetupRenderEffectFn = (...) => {
    const effect = new ReactiveEffect(
      componentUpdateFn,
      () => queueJob(instance.update), // 当作参数传入
      instance.scope
    )
  }
}

这里先看一下ReactiveEffect类的构造函数:

// packages/reactivity/src/effect.ts - 53行
export class ReactiveEffect<T = any> {
  // ...
  constructor(
    public fn: () => T,
    public scheduler: EffectScheduler | null = null,
    scope?: EffectScope | null
  ) {
    recordEffectScope(this, scope)
  }
 // ...
}

ReactiveEffect 这边接收过来的第二个形参就是 scheduler,最终被用到响应式源码的派发更新。

当响应式对象发生改变后,如果执行 effectscheduler 这个参数,会执行这个 scheduler 函数,并且把 effect 当做参数传入

// packages/reactivity/src/effect.ts - 330行
export function triggerEffects(
  // ...
  if (effect.scheduler) {
    effect.scheduler()
  } else {
    effect.run()
  }
}

然后看 queueJob具体做了什么。

queueJob()

该方法负责维护主任务队列,接受一个函数作为参数,为待入队任务,会将参数 pushqueue 队列中,有唯一性判断。会在当前宏任务执行结束后,清空队列

const queue: SchedulerJob[] = []
let isFlushing = false // 是否正在执行
let isFlushPending = false // 是否正在等待执行

export function queueJob(job: SchedulerJob) {
  // 判断条件:主任务队列为空 或者 有正在执行的任务且没有在主任务队列中  && job 不能和当前正在执行任务及后面待执行任务相同
  // 重复数据删除:
  // - 使用Array.includes(Obj, startIndex) 的 起始索引参数:startIndex
  // - startIndex默认为包含当前正在运行job的index,此时,它不能再次递归触发自身
  // - 如果job是一个watch()回调函数或者当前job允许递归触发,则搜索索引将+1,以允许他递归触发自身-用户需要确保回调函数不会死循环
  if (
    (!queue.length ||
      !queue.includes(
        job,
        isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex
      )) &&
    job !== currentPreFlushParentJob
  ) {
    // 判断当前job id 是否存在 不存在则添加到主任务队列
    if (job.id == null) {
      queue.push(job)
    } else {
      // 存在则从当前任务队列中查到位置并删除替换
      queue.splice(findInsertionIndex(job.id), 0, job)
    }
    // 创建微任务
    queueFlush()
  }
}

queueFlush()

该方法负责尝试创建微任务,等待任务队列执行

let isFlushing = false // 是否正在执行
let isFlushPending = false // 是否正在等待执行
const resolvedPromise: Promise<any> = Promise.resolve() // 微任务创建器
let currentFlushPromise: Promise<void> | null = null // 当前任务

function queueFlush() {
  // 当前没有微任务
  if (!isFlushing && !isFlushPending) {
    // 避免在事件循环周期内多次创建新的微任务
    isFlushPending = true
    // 创建微任务,把 flushJobs 推入任务队列等待执行
    currentFlushPromise = resolvedPromise.then(flushJobs)
  }
}

flushJobs()

该方法负责处理队列任务,主要逻辑如下:

  • 先处理前置任务队列
  • 根据 Id 进行队列排序,并遍历执行队列任务,执行完毕后清空并重置队列
  • 执行后置队列任务
  • 如果队列没有被清空会递归调用flushJobs清空队列
function flushJobs(seen?: CountMap) {
  isFlushPending = false // 是否正在等待执行
  isFlushing = true // 正在执行
  if (__DEV__) {
    seen = seen || new Map() // 开发环境下
  }

  flushPreFlushCbs(seen) // 执行前置任务队列

  // 根据 id 排序队列,这是为了一下两点:
  // 1. 组件更新顺序为:父到子,因为父级总是在子级前面先创建,它的渲染效果具有较小的优先级数
  // 2. 如果父组件更新期间卸载了子组件,则改子组件更新将跳过
  queue.sort((a, b) => getId(a) - getId(b))


  // checkRecursiveUpdate 的条件使用必须在 try ... catch 块外中确定,因为 Rollup 默认会在 try-catch 中取消优化 treeshaking。
  const check = __DEV__
    ? (job: SchedulerJob) => checkRecursiveUpdates(seen!, job)
    : NOOP

  try {
    // 遍历主任务队列,批量执行更新任务
    for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
      const job = queue[flushIndex]
      if (job && job.active !== false) {
        if (__DEV__ && check(job)) {
          continue
        }
        callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
      }
    }
  } finally {
    flushIndex = 0 // 队列任务执行完,重置队列索引
    queue.length = 0 // 清空队列

    flushPostFlushCbs(seen) // 执行后置队列任务

    isFlushing = false  // 重置队列执行状态
    currentFlushPromise = null // 重置当前微任务为 Null
    // some postFlushCb queued jobs!
    // keep flushing until it drains.
    // 如果主任务队列、前置和后置任务队列还有没被清空,就继续递归执行
    if (
      queue.length ||
      pendingPreFlushCbs.length ||
      pendingPostFlushCbs.length
    ) {
      flushJobs(seen)
    }
  }
}

flushPreFlushCbs()

该方法负责执行前置任务队列,说明都写在注释里了

  • 待处理前置任务队列不为空是,备份并清空前置任务队列
  • 并遍历执行待处理前置队列任务,执行完毕后当前任务队列
  • 如果队列没有被清空会递归调用flushJobs清空队列
const pendingPreFlushCbs: SchedulerJob[] = []
let activePreFlushCbs: SchedulerJob[] | null = null
let preFlushIndex = 0

export function flushPreFlushCbs(
  seen?: CountMap,
  parentJob: SchedulerJob | null = null
) {
  // 待处理前置任务队列不为空
  if (pendingPreFlushCbs.length) {
    currentPreFlushParentJob = parentJob
    activePreFlushCbs = [...new Set(pendingPreFlushCbs)] // 待处理前置任务队列去重备份为activePreFlushCbs
    pendingPreFlushCbs.length = 0 // 待处理前置任务队列重置
    if (__DEV__) {
      seen = seen || new Map()
    }
    // 遍历执行队列里的任务
    for (
      preFlushIndex = 0;
      preFlushIndex < activePreFlushCbs.length;
      preFlushIndex++
    ) {
      if (
        __DEV__ &&
        checkRecursiveUpdates(seen!, activePreFlushCbs[preFlushIndex])
      ) {
        continue
      }
      // 任务执行
      activePreFlushCbs[preFlushIndex]()
    }
    // 清空当前活动的任务队列
    activePreFlushCbs = null 
    preFlushIndex = 0
    currentPreFlushParentJob = null
    // 递归执行,直到清空前置任务队列
    flushPreFlushCbs(seen, parentJob)
  }
}

flushPostFlushCbs()

该方法负责执行后置任务队列,说明都写在注释里了

const pendingPostFlushCbs: SchedulerJob[] = []
let activePostFlushCbs: SchedulerJob[] | null = null
let postFlushIndex = 0

export function flushPostFlushCbs(seen?: CountMap) {
  // 待处理后置任务队列队列不为空
  if (pendingPostFlushCbs.length) {
    // 待处理后置任务队列去重备份为deduped
    const deduped = [...new Set(pendingPostFlushCbs)]
    pendingPostFlushCbs.length = 0 // 待处理后置任务队列重置

    // #1947 already has active queue, nested flushPostFlushCbs call
    // 如果当前已经有活动的队列,就添加到执行队列的末尾,并返回
    if (activePostFlushCbs) {
      activePostFlushCbs.push(...deduped)
      return
    }
    // 赋值为当前活动队列
    activePostFlushCbs = deduped
    if (__DEV__) {
      seen = seen || new Map()
    }

    // 队列排序
    activePostFlushCbs.sort((a, b) => getId(a) - getId(b))
    // 遍历执行队列里的任务
    for (
      postFlushIndex = 0;
      postFlushIndex < activePostFlushCbs.length;
      postFlushIndex++
    ) {
      if (
        __DEV__ &&
        checkRecursiveUpdates(seen!, activePostFlushCbs[postFlushIndex])
      ) {
        continue
      }
      activePostFlushCbs[postFlushIndex]()
    }
    // 清空当前活动的任务队列
    activePostFlushCbs = null
    postFlushIndex = 0
  }
}

整个 nextTick 的源码到这就解析完啦

为什么要nextTick

一个例子让大家明白,如果没有 nextTick 更新机制,那么 num 每次更新值都会触发视图更新,有了nextTick机制,只需要更新一次。

{{num}}
for(let i=0; i<100000; i++){
	num = i
}

总结

nextTickvue 中的更新策略,也是性能优化手段,基于JS执行机制实现

vue 中我们改变数据时不会立即触发视图,如果需要实时获取到最新的DOM,可以手动调用 nextTick

你可能感兴趣的:(VUE源码学习记录,javascript,前端,vue.js)