likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 23

异步更新机制

是什么

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

<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次,但是最终页面只会更新一次。

出现的问题

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

<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。

<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方法执行队列。
// 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()。开发环境下判断circular[id]是否超过最大限制的更新值(100),如果超过,抛出警告可能有循环更新。

resetSchedulerState

重置相关数据为初始值。

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 方法。
// 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

// 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。

// 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原理

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()方法,将待执行回调函数推入任务队列中。

转载自:https://juejin.cn/post/7287495466513711138
评论
请登录