vue源码解析——异步更新机制&nextTick

异步更新机制

是什么

vue中,数据更新引发页面更新时,页面并不会马上更新,而是将它们缓存在一个队列中,更新dom会延迟到下一个事件循环中执行。这是因为,vue组件更新,需要生成新虚拟dom与旧的进行对比,再操作真实节点进行页面更新,若采取同步更新,将会非常损耗性能,还可能会导致UI闪烁的问题。

js 复制代码
<template>
  <div id="app">
    <p>{{num}}</p>
    <button @click="addNum">num++</button>
  </div>
</template>

<script>
export default {
  name: 'App',
  data() {
      return {
          num: 1
      }
  },
  methods: {
    addNum() {
        // 只会更新一次
        for (let i = 0; i < 1000; i++) {
            this.num++;
        }
    }
  }
}
</script>

上面的例子中,点击num++按钮,num值一共改变了1000次,但是最终页面只会更新一次。

出现的问题

异步更新大大提高了页面更新的性能,但也带来了新的问题。如为一个响应式数组新增一项,添加后需要在代码中拿取新增项的真实节点。但按照下面的写法,是无法拿到的。

js 复制代码
<template>
  <div id="app">
    <ul>
      <li v-for="item of arr">{{ item }}</li>
    </ul>
    <button @click="addItem"></button>
  </div>
</template>

<script>
export default {
  name: 'App',
  data() {
      return {
          arr: [1, 2, 3, 4]
      }
  },
  methods: {
    addItem() {
        this.arr.push(5);
        // 需要在这里拿取元素为5的li
        console.log(this.$refs.nums.children[4]); // undefined
    }
  }
}
</script>

解决方法------nextTick出现原因

为了解决上述的问题,vue提供了nextTick这个api。它可以在下次 DOM 更新完成后执行回调函数。

nextTick() 可以在状态改变后立即使用,以等待 DOM 更新完成。你可以传递一个回调函数作为参数,或者 await 返回的 Promise。

js 复制代码
<template>
  <div id="app">
    <ul>
      <li v-for="item of arr">{{ item }}</li>
    </ul>
    <button @click="addItem"></button>
  </div>
</template>

<script>
export default {
  name: 'App',
  data() {
      return {
          arr: [1, 2, 3, 4]
      }
  },
  methods: {
    addItem() {
        this.arr.push(5);
        // 需要在这里拿取元素为5的li
        this.$nextTick(() => {
            console.log(this.$refs.nums.children[4]); // 5的真实dom
        })
    }
  }
}
</script>

源码分析

Vue 页面更新和watch回调执行是dep通过调用Watcherupdate方法通知数据的更新,在update方法中会调用queueWatcher方法。

queueWatcher

该方法主要作用就是收集需要updatewatcherqueue中。

  1. 如果更新队列存在了该watcher,则无需加入队列,直接返回;
  2. watcher加入更新队列:如果更新队列未开始执行,则直接放在队列尾;反之,则根据id按顺序插入相应位置。
  3. 判断是否有队列即将要执行(waiting状态),如果没有则调用nextTick方法执行队列。
js 复制代码
// watcher队列
const queue: Array<Watcher> = []
// 标记watcher是否已经存在于更新队列中 用watcher id作为key
let has: { [key: number]: true | undefined | null } = {}
// 判断是否已经在执行
let flushing = false

export function queueWatcher(watcher: Watcher) {
  const id = watcher.id
  // 在更新队列里面已经有了这个watcher 直接返回
  if (has[id] != null) {
    return
  }

  
  if (watcher === Dep.target && watcher.noRecurse) {
    return
  }

  has[id] = true
  // 如果未开始执行直接放到尾部
  if (!flushing) {
    queue.push(watcher)
  } else {
    // if already flushing, splice the watcher based on its id
    // if already past its id, it will be run next immediately.
    // 开始执行了 需要按照id将其附加到合适的位置
    let i = queue.length - 1
    while (i > index && queue[i].id > watcher.id) {
      i--
    }
    queue.splice(i + 1, 0, watcher)
  }
  // queue the flush
  // 是否有队列即将要执行
  if (!waiting) {
    waiting = true
    // 开发环境并且配置为同步
    if (__DEV__ && !config.async) {
      flushSchedulerQueue()
      return
    }
    nextTick(flushSchedulerQueue)
  }
}

flushSchedulerQueue

该方法主要作用是watcher队列的执行。

  1. 对队列中的watcher,按照id从小到大排序。排序能够保证:
    • 父组件先于子组件更新,因为父组件肯定先于子组件创建;
    • 组件自定义的watcher将先于渲染watcher执行,因为自定义watcher先于渲染watcher创建。在new Vue()中先initRender()initWatcher()initRender()
    • 如果组件在父组件执行watcher期间被销毁了,该watcher可以直接被跳过。
  2. 遍历队列中所有的watcher,按顺序执行watcher.run()。开发环境下判断circularid是否超过最大限制的更新值(100),如果超过,抛出警告可能有循环更新。
js 复制代码
function flushSchedulerQueue() {
  currentFlushTimestamp = getNow()
  flushing = true
  let watcher, id

  /**
   * 排序能够保证:
   * 1. 父组件先于子组件更新,因为父组件肯定先于子组件创建
   * 2. 组件自定义的watcher将先于渲染watcher执行,因为自定义watcher先于渲染watcher创建
   * 3. 如果组件在父组件执行watcher期间destroyed了,它的watcher可以直接被跳过
   */

  // 为了让先创建的watcher先执行

  queue.sort(sortCompareFn)

  // do not cache length because more watchers might be pushed
  // as we run existing watchers
  // 
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    if (watcher.before) {
      // 如是页面更新,通过before触发hook beforeUpdated
      watcher.before()
    }
    id = watcher.id
    has[id] = null
    // 执行watcher更新 如是页面更新,最终执行vue._update()方法
    watcher.run()
    // in dev build, check and stop circular updates.
    if (__DEV__ && has[id] != null) {
      circular[id] = (circular[id] || 0) + 1
      if (circular[id] > MAX_UPDATE_COUNT) {
        warn(
          'You may have an infinite update loop ' +
            (watcher.user
              ? `in watcher with expression "${watcher.expression}"`
              : `in a component render function.`),
          watcher.vm
        )
        break
      }
    }
  }
  // keep copies of post queues before resetting state
  const activatedQueue = activatedChildren.slice()
  const updatedQueue = queue.slice()
  // 队列完成更新,重置初始化参数
  resetSchedulerState()

  // call component updated and activated hooks
  // 执行activted钩子
  callActivatedHooks(activatedQueue)
  // 执行updated钩子
  callUpdatedHooks(updatedQueue)
  // 清理dep 具体可看https://juejin.cn/post/7012885163344396324
  cleanupDeps()

  // devtool hook
  /* istanbul ignore if */
  if (devtools && config.devtools) {
    devtools.emit('flush')
  }
}

resetSchedulerState

重置相关数据为初始值。

js 复制代码
function resetSchedulerState() {
  index = queue.length = activatedChildren.length = 0
  has = {}
  if (__DEV__) {
    circular = {}
  }
  waiting = flushing = false
}

nextTick源码分析

nextTick

  1. 将 nextTick 回调函数放入队列 callbacks 中;
  2. 若未进入任务队列,将其推进任务队列并改变 pending 状态;
  3. 若当前环境支持 promise,返回一个待定的 promise,当回调函数 cb 执行完毕后,调用返回 promise 的 resolve 方法。
ts 复制代码
// vue2版本
// 源码地址 src\core\util\next-tick.ts

// 装nextTick所有回调函数
const callbacks: Array<Function> = [];
// 是否调用nextTick所有回调函数的方法已经进入任务队列
let pending = false;

export function nextTick(cb?: (...args: any[]) => any, ctx?: object) {
  let _resolve
  // 保存nextTick更新方法
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e: any) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      // 已经执行完nexttick中的回调函数 nextTick().then(() => {/* 可执行 */})
      _resolve(ctx)
    }
  })
  // 若未进入任务队列 将其推进任务队列并改变pending状态
  if (!pending) {
    pending = true
    timerFunc()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    // 返回一个待定的promise
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

timerFunc

这个函数主要就是根据当前环境采取不同的异步方法。从代码可以看出,顺序是promise------>MutationObserver------>setImmediate------>setTimeout

js 复制代码
// vue2版本
// 源码地址 src\core\util\next-tick.ts

// 是否是微任务
export let isUsingMicroTask = false
let timerFunc

// 如果支持promise
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    // 在.then()中执行所有的更新函数
    p.then(flushCallbacks)
    // ios系统 使用setTimeout
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
} else if (/* 支持MutationObserver执行 */
  !isIE &&
  typeof MutationObserver !== 'undefined' &&
  (isNative(MutationObserver) ||
    // PhantomJS and iOS 7.x
    MutationObserver.toString() === '[object MutationObserverConstructor]')
) {
  // Use MutationObserver where native Promise is not available,
  // e.g. PhantomJS, iOS7, Android 4.4
  // (#6466 MutationObserver is unreliable in IE11)
  let counter = 1
  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)
  }
  isUsingMicroTask = true
  // 支持setImmediate
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // 最后setTimeout兜底
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

flushCallbacks

清空 callbacks,改变 pending 状态,执行所有 callbacks。

js 复制代码
// vue2版本
// 源码地址 src\core\util\next-tick.ts

const callbacks: Array<Function> = []
let pending = false

// 清空callbacks 并执行所有callbacks
function flushCallbacks() {
  // 开始执行了 所以pending改为false
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

vue3的nextTick原理

vue3nextTick主要就是通过了promise.then()将待执行函数放入任务队列中。详细的介绍可以看一次弄懂 Vue2 和 Vue3 的 nextTick 实现原理 - 掘金 (juejin.cn)

js 复制代码
const resolvedPromise = /*#__PURE__*/ Promise.resolve() as Promise<any>
let currentFlushPromise: Promise<void> | null = null

export function nextTick<T = void, R = void>(
  this: T,
  fn?: (this: T) => R
): Promise<Awaited<R>> {
  const p = currentFlushPromise || resolvedPromise
  // fn回调函数放入.then执行 推入任务队列
  return fn ? p.then(this ? fn.bind(this) : fn) : p
}

// 刷新任务队列
function queueFlush() {
  if (!isFlushing && !isFlushPending) {
    isFlushPending = true 
    // 创建微任务,把 flushJobs 推入任务队列等待执行
    currentFlushPromise = resolvedPromise.then(flushJobs)
  }
}

总结

vue页面更新以及watch的回调函数执行都是异步的。当数据发生变化时,会通过setter劫持,遍历deps中所有的watcher,调用watcherupdate方法,在update方法中,会调用queueWatcher方法,将watcher添加到更新队列中,并通过nextTick方法执行更新队列。

nextTick是下一次DOM更新完成后会执行该回调方法。便于我们在DOM更新完后,立即拿到新的DOM。在vue2nextTick方法中,首先将nextTick的回调方法保存到callbacks中,并判断是否执行callbacks的方法是否已经推入任务队列中了,如果还未则推入任务队列中。推入任务队列采用的异步方法,优先级如下:promiseMutationObserversetImmediatesetTimeout。在vue3中则是直接使用了promise.then()方法,将待执行回调函数推入任务队列中。

相关推荐
mldong3 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排3 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60614 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角5 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!5 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰5 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi7 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen7 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马8 小时前
AI源码分析:拆解模型底层实现逻辑
前端
BillKu8 小时前
全局样式变量:CSS 自定义属性(--border-color)和SCSS 变量($border-color)的说明
javascript·css·scss