6x0 精读Vue官方文档 - 深入响应式原理

681 阅读3分钟

精读 Vue 官方文档系列 🎉

介绍

Vue 的非侵入性响应式系统依赖于其特殊的“数据模型”。更具体点指的就是具有响应式(反应式)特性的普通 JavaScript 对象,当它被修改时,视图也会随着更新。

如何追踪变化

Vue 会使用 Object.defineProperty 将组件 data 选项上的所有 property 转换为带有 getter/setter 的 property。然后通过这些 getter/setter 来追踪依赖。

Object.defineProperty 是 ES5 中一个无法 shim 的特性,这也就是 Vue 不支持 IE8 以及更低版本浏览器的原因。

关于 shimpolyfill 的定义目前没有一个严格的标准。它们共同的目标都是希望抹平环境和 API 之间的差异。 Shim 的含义是 “垫片”。更多被认为是指提供一套代码库,在旧的环境(不一定是浏览器环境)之上覆盖一套新的环境来整体性的抹平所有差异,而落实到具体功能或 API 上则由 polyfill 去实现。 polyfill 的含义是“填充物;腻子”。它更多的被认为是提供一个插件来解决浏览器中某个 API 之间的差异,例如,通过引入 Array 的 polyfill 插件来使用 ES5 标准中定义的数组遍历方法。 总结一下,shim 更多是用来抹平环境之间的差异,是一个整体全面的概念,而 polyfill 更多指的是具体 API 或功能上的实现,因此 shim 包含着 polyfill。关于这一点,可以在 es5-shim 库中更清晰的看出,es5-shim 是一个整体,整体中的具体部分都是由像 Array.prototype.every/blob/main/polyfill.js 这样是具体的 polyfill 实现。

当带有 getter/setter 的 property 被访问或修改时就会通知变更,而通知的对象就是 watcher 实例。

每个 watcher 实例都对应着一个组件,它会在组件渲染的过程中把接触过的数据 property 记录为依赖,之后依赖项的 setter 触发时就会通知 watcher,从而使它关联的组件重新渲染。

image.png

由于不同浏览器的控制台在输出数据的格式上存在差异,所以为了能够获得统一友好的调试效果,建议使用 vue-devtools

检测变化的注意事项

由于 Vue 会使用 Object.defineProperty 方法在组件实例初始化时(beforeCreated 钩子之后)对 property 执行 getter/setter 转化,所以必须要保证 property 在组件实例初始化之前就已经存在在 data 选项上。

本质区别就是 对象.属性Object.defineProperty 方法在功能效果上的差别。

对于已经创建的实例,Vue 不允许动态添加根级别的响应式 property。

var vm = new Vue({
  data:{
    a:1
  }
})

// `vm.a` 是响应式的

vm.b = 2
// `vm.b` 是非响应式的
// 所谓“根级”指的就是组件实例本身这一层的 property。

但支持向已经是响应式的引用类型值中动态添加响应式的 property。

{
  data() {
    return {
      student: {
        name: "xiaoming",
      },
    };
  },
  mounted() {
    setTimeout(() => {
      Vue.set(this.student, "id", "0001");
      this.$set(this.student, "age", 18);
      this.student = Object.assign({}, this.student, { sex: "male" });
      this.student = { ...this.student, ...{ height: "tall" } };
    }, 1000);
  },
}

主要的方式有:

  1. 使用 Vue 构造函数上的 set 方法。
  2. 使用当前组件实例上的 $set 方法。
  3. 使用 ES6 中的 Object.assign 方法和扩展运算符。
  4. 借助其它工具库的 _.extend() 方法。

像 3,4 方法的基本原理就是为了产生一个新的对象来覆盖 student 的值,因为 student 本身也是一个 setter/getter 的 property 更改它就可以正常触发Vue的检测。

student: Object
get student: ƒ proxyGetter()
set student: ƒ proxySetter(val)

对于引用类型常见的另一种类型 —— “数组”,Vue 不能检测以下方式数组的变动:

  1. 利用索引直接设置一个数组元素时,例如 arr[arr.length] = newValue
  2. 修改数组的长度时,例如 arr.length = arr.length - 1

对于数组,既可以使用数组自带的操作方法,例如 splicepush 来触发 Vue 检测更新,也可以继续使用 set 方法,只是在用法上稍微有点不同,其第二个参数是数组元素的下标索引。

this.$set(this.arr, 0, 1);

声明根级响应式 property

Vue 不允许动态添加根级响应式 property,所以你必须在初始化实例前声明所有根级响应式 property,哪怕只是一个空值。

异步更新队列

Vue 是采用 异步 的方式来更新 DOM。

异步的方式,大大提高了性能,如果采用同步,必然会堵塞 UI 的渲染。

只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。

“事件循环” 是基于浏览器的 Event Loop。从同步代码执行 -> 微任务执行完成 -> 宏任务执行完成是为一个事件循环。 拥有相同 id 的 Watcher 不会被重复加入到队列中去,所以不会执行 1000 次 Watcher 的 run。最终的结果是直接把数据变化的值从 1 变成 1000,大大提升了性能。 tick 可以理解成步骤(数据图表的刻度概念🤔),Vue 的每个事件循环期间都会做很多工作,每个工作可以看成是一个 tick。例如,查找异步队列,推入异步队列是一个 tick,执行 DOM 更新是一个 tick,更新完成,执行回调是一个 tick。

Vue 在内部对异步队列尝试使用原生的 Promise.thenMutationObserversetImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。

这是一种逐渐降级的方案,其中 PromiseMutationObserver 属于微任务(Micro-task),而 setImmediate(IE 专有) 与 setTimeout 属于宏任务(Macro-task)。前者的执行优先级要大于后者。

例如,当你设置 vm.someData = 'new value' 该组件不会立即重新渲染。当刷新队列时,组件会在下一个事件循环的“tick”中更新。多数情况我们不需要关心这个过程,但是如果你想基于更新后的 DOM 状态来做点什么,这就可能会有些棘手。虽然 Vue.js 通常鼓励开发人员使用“数据驱动”的方式思考,避免直接接触 DOM,但是有时我们必须要这么做。为了在数据变化之后等待 Vue 完成更新 DOM,可以在数据变化之后立即使用 Vue.nextTick(callback)。这样回调函数将在 DOM 更新完成后被调用。例如:

vm.someData = 'new value' 的更改实际上是在同步环境下完成的。 Vue.nextTick(callback) 可以看成是 watcher 执行完成后的回调方法。

var vm = new Vue({
  el: '#example',
  data: {
    message: '123'
  }
})
vm.message = 'new message' // 更改数据
vm.$el.textContent === 'new message' // false
Vue.nextTick(function () {
  vm.$el.textContent === 'new message' // true
})

可以直接使用组件实例上的 $nextTick 方法更方便。

因为 $nextTick() 返回一个 Promise 对象,所以你可以使用新的 ES2017 async/await 语法完成相同的事情:

methods: {
  updateMessage: async function () {
    this.message = '已更新'
    console.log(this.$el.textContent) // => '未更新'
    await this.$nextTick()
    console.log(this.$el.textContent) // => '已更新'
  }
}