Vue 中 $set 和 nextTick 方法的实现

153 阅读4分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 8 天,点击查看活动详情

Vue.set 方法实现

我们开发过程,常遇到一种情况,需要在对象上添加一个属性, 如果我们直接用 test.a = 1 的方式进行添加,这个过程 Vue 是无法检测到的。 因为 Vue 在对对象进行依赖收集是,会对每个对象属性都进行依赖收集,而通过 test.b 的方式添加的属性没有在依赖收集的过程,那么属性 b 也就无法进行响应式化。

为了解决这一问题, Vue 提供了 Vue.set(target, property, value) 的静态方法和 vm.$set(target, property, value) 的实例方法来往对象上添加属性,来看一下 set 的具体实现

function set (target: Array<any> | Object, key: any, val: any): any {
  // target 必须是非空对象
  if (process.env.NODE_ENV !== 'production' && (isUndef(target) || isPrimitive(target))) {
    warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
  }
  // 对于 target 是数组的情况下,调用重写的 splice 方法,对新添加的元素进行依赖收集
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    return val
  }
  // 新增对象的属性已经存在时,直接返回
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  const ob = (target: any).__ob__

  if (!ob) {
    target[key] = val
    return val
  }
  // 调用 defineReactive , 为新增的属性设置 getter setter
  defineReactive(ob.value, key, val)
  ob.dep.notify()
  return val
}

对上面代码进行总结

  • 目标对象必须是非空对象,可以是数组
  • 如果目标对象是数组时,调用重写的 splice 方法
  • 如果新增的属性在目标对象中已经存在,则手动访问新的属性值,这一过程或触发依赖收集
  • 调用 defineReactive 方法,为新增的属性设置 setter getter

nextTick

在前面的分析中,我们知道,当数据发生改变时,会触发 setter 方法进行依赖的派发更新,更新时会将 watcher 推到一个队列中,等待下一个 tick 到来时在执行 DOM 的渲染更新操作。现在我们来了解一下 nextTick 方法的实现。

事件循环机制

首先先来了解一下浏览器的事件循环机制

  • 完整的事件循环机制包括两种异步队列 macro-taskmicro-task
  • macro-task 成为宏观任务队列,常见的有 setTimeout , setInterval , setImmediate, script 脚本 , I/O操作 , UI渲染
  • micro-task 称为微观异步队列,常见的有 promise , process.nextTick , MutationObserver
  • 完整的事件循环流程为:
  • micro-task 为空,macro-task 队列只有 script 脚本,推出 macro-taskscript 任务执行,脚本执行期间产生的 macro-task micro-task 推到对应的队列中
  • 执行 micro-task 里面的微任务事件
  • 执行 DOM 操作,更新渲染页面
  • 执行 web worker 等相关任务
  • 执行 macro-task 队列中的宏任务

从上面的流程中可以看出,最好的渲染过程发生在微任务执行过程中,因此我们可以借助微任务队列来实现异步更新,这样可以让复杂的运算操作运行在 JS 层面,而视图层只关心最终的结果。

nextTick 基本实现

function renderMixin() {
  Vue.prototype.$nextTick = function (fn: Function) {
    return nextTick(fn, this)
  }
}

const callbacks = []
let pending = false
function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  // callbacks 是维护微任务的数组
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    // 将维护的队列推送到微任务队列中维护
    timerFunc()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

nextTick 定义为一个函数,通过 Vue.nextTick(callback, ctx) 的方式进行使用,当 callback 经过 nextTick 封装之后, callback 会在下一个 tick 中执行调用。 callbacks 维护的是一个需要在下一个 tick 中执行的任务队列,它的每个元素都是需要执行的函数。 pending 是判断是否在等到执行微任务队列的标志。 timerFunc 是真正将任务队列推入微任务队列中的函数。

timerFunc 函数根据不同浏览器对 API 的支持不同,具有不同的实现

  • 使用 Promise 实现

上面关于浏览器的事件循环机制中提到, promise 属于微任务,因此可以通过 promise.then(callback) 的方式,将任务推入到微任务队列中。

let timerFunc

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
    if (isIOS) setTimeout(noop)
  }
  // 使用为任务队列的标志
  isUsingMicroTask = true
} 

// 取出 callbacks 中的每一个任务函数并执行
function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}
  • 使用 MutationObserver 实现
else if (!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)) {
  // Fallback to setImmediate.
  // Techinically it leverages the (macro) task queue,
  // but it is still a better choice than setTimeout.
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
}
  • 上述方法都不适用,则使用宏任务的中 setTimeout
else {
  // Fallback to setTimeout.
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

nextTick 使用场景

由于异步更新的原理,当我们在更新数据之后,并不会立即更新视图,需要等到下一次 tick 到来才会更新视图

<input v-if="show" type="text" ref="myInput">
// js
data() {
  show: false
},
mounted() {
  this.show = true;
  this.$refs.myInput.focus();// 报错
}

数据发生变化时,视图并不会同时改变,因此需要使用 nextTick

mounted() {
  this.show = true;
  this.$nextTick(function() {
    this.$refs.myInput.focus();// 正常
  })
}