Vue最独特的特性之一就是其非侵入性的响应性系统了,我们的数据通常在不知不觉中就得到了响应式的能力,非常神奇。那它到底是如何工作的呢?我们现在就要来探究一下它的原理了!
Vue是一个MVVM框架(M:数据模型,V:视图,VM:视图模型),数据模型其实是被代理过的 JavaScript 对象,当我们修改它们的值时,被绑定关联过的视图也会随之自动更新。这让状态管理变得非常简单直观,为我们的开发提供了巨大的便利。作为优秀的前端开发者,我们要知其然而更知其所以然,了解其中的工作原理,以便在今后的工作中踩了坑也能快速的爬出坑,甚至是能绕过坑那是更好的。
何为响应性
“响应性” 这个专业术语在最近几年变得非常的火热,但是它到底是什么含义呢?
响应性其实是一种允许我们 以声明式的方式去适应变化 的一种编程范例。
好像还是不怎么懂?来个例子,以我们经常使用的Excel表格软件为例:
在 Excel 表格的设计中,我们可以通过在某个单元格中 声明一个对指定2个单元格进行求和的公式,就可以对那2个单元格的值进行实时求值并显示在当前单元格中;而一旦2个单元格中的值有所变化,则立马会进行重新求值与显示。这就是一种响应式功能的体现。
然而我们的编程语言通常不是以这样的方式工作的,来看一段我们很熟悉的代码:
var num1 = 1var num2 = 2var sum = num1 + num2// 现在 sum 的结果为 3num1 = 5// 现在 sum 的结果还是 3
很明显在以上代码中,当我们改变 num1 的时候,并不会让 sum 发生任何变化。
那我们能不能让代码实现这种效果呢?如果能的话,要通过什么途径来实现呢?答案当然是肯定的!实现步骤大致如下:
检测某个变量值是否发生了变化(在此例中是检测 num1 或 num2 是否发生变化)
用追踪器函数来注册用于修改最终值的函数(也就是注册一个执行计算 sum = num1 + num2 的函数)
用触发函数来调用之前注册的函数以便更新最终值
Vue是如何追踪数据变化的?
当我们将一个普通的 JavaScript 对象设置为 Vue 应用或组件实例的 data
选项时,Vue 就开始了它的辛勤劳作,遍历该JavaScript 对象的所有属性,并将它们转化为带有 getter 和 setter 的 代理对象(Proxy),Proxy 的概念请参考 Mozilla 的官方文档:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
需要注意的是:Proxy
是自 ES6 才有的新特性,在 IE 中不支持。因此 Vue 3 提供了一个使用老式的 Object.defineProperty
实现的版本,以支持这些IE浏览器。使用 Proxy 和 Object.defineProperty 实现的接口在使用时是一致的,但在内部代码实现上 Proxy 版要更精简、且性能更好。
代理就像是为每个原始对象配了一个管家,任何对原始对象的访问都会被管家拦下,经过管家的处理再送往原始对象。因此我们可以这么定义:代理是一个对象,它封装了另一个对象或函数,并允许你对其进行拦截。
Proxy 的基本使用形式如下:
new Proxy(target, handler)
再来看一个更实际的示例代码:
const dinner = { meal: 'tacos'}const handler = { get(target, prop) { return target[prop] }}const proxy = new Proxy(dinner, handler)console.log(proxy.meal) // 输出结果:tacos
从以上代码可以看到:创建代理对象以后就可以通过代理对象来访问原始对象 dinner 了。不过,这段代码目前还看不出来有什么特别大的用处。让我们再加点东西吧:
const dinner = { meal: 'tacos'}const handler = { get(target, prop) { console.log('...intercepted...') return target[prop] }}const proxy = new Proxy(dinner, handler)console.log(proxy.meal) // 输出结果:// ...intercepted...// tacos
这段修改后的代码,展示了在通过代理对象访问原始对象的时候,额外的逻辑被执行了。这里的额外逻辑充其量只是打印了一段控制台输出,但其实我们应该可以想到,这边可以放任何的逻辑代码,甚至也可以不返回原始对象上的实际值。这就是作为 “管家” 这个角色的强大权利啊!
此外,Proxy 还提供了另一个特性,就是可以通过 Reflect
来获取原始对象的属性值,取代原先的 target[prop]
形式,这种方式能更好的处理 this
绑定。示例如下:
const dinner = { meal: 'tacos'}const handler = { get(target, prop, receiver) { return Reflect.get(...arguments) // 或者: // return Reflect.get(target, prop, receiver) }}const proxy = new Proxy(dinner, handler)console.log(proxy.meal) // 输出结果:tacos
之前已经提到过:我们需要为变化的变量或对象属性使用追踪函数来注册用于修改最终值的函数(也叫做:收集依赖),那在本示例代码的 handler 中,我们就可以使用名为 track
的追踪函数,并为它传入 target
和 prop
:
const dinner = { meal: 'tacos'}const handler = { get(target, prop, receiver) { // 当属性被访问时,就使用追踪函数收集依赖(内部会防止重复收集同一个依赖) track(target, prop) return Reflect.get(...arguments) }}const proxy = new Proxy(dinner, handler)console.log(proxy.meal)
最后,当被监听的值发生变化时就需要触发调用注册过的函数(执行依赖),完成对最终值的更新操作:
const dinner = { meal: 'tacos'}const handler = { get(target, prop, receiver) { track(target, prop) return Reflect.get(...arguments) }, set(target, key, value, receiver) { // 当属性被重新设值时,就使用触发函数执行依赖 trigger(target, key) return Reflect.set(...arguments) }}const proxy = new Proxy(dinner, handler)console.log(proxy.meal)
回过头来回顾一下我们之前列下的步骤清单:
检测某个变量值是否发生了变化
【实现方法】当我们使用 Proxy 的时候就不再需要做这件事情了,因为 Proxy 允许我们使用拦截
用追踪器函数来注册用于修改最终值的函数
【实现方法】我们在 Proxy 的 getter 中完成这件事,追踪函数叫做 track,用来收集依赖函数;依赖函数叫做 effect
用触发函数来调用之前注册的函数以便更新最终值
【实现方法】我们在 Proxy 的 setter 中完成这件事,触发函数叫做 trigger,它用来调用 effect
代理对象对 Vue 框架的使用者来说其实是透明的,但框架内部时刻在使用代理对象,当数据对象的属性值被访问或修改时,能对它们进行依赖跟踪和变更通知。
从 Vue 3 开始,响应性功能不再和Vue 框架紧耦合,我们可以通过引入一个独立的包,在任何JS代码中获得响应性能力。
https://github.com/vuejs/vue-next/tree/master/packages/reactivity
另外值得注意的一些事情是:当你在浏览器控制台中打印出经过响应性转换后的数据对象时,它的格式看起来比较奇怪,不方便我们阅读和调试。这个时候你可能需要安装和使用 vue-devtools,它可以能让我们更直观的去查看代理对象。
好了,其实阅读至此有的人可能还是觉得有点懵,不能完全理解上文中出现的一些东西,比如追踪器(track)和触发器(trigger)这两个函数内的实现逻辑到底是什么样的。Vue 3 官方文档中其实也并没有深入的讲解响应性的实现细节。所以,在此我推荐几篇很不错的文章,作为补充阅读材料:
1. Vue 3 响应式原理及实现
https://segmentfault.com/a/1190000022871354
2. 浅析Vue3中的响应式原理
https://segmentfault.com/a/1190000020637178
代理对象
Vue 在其框架的内部会跟踪所有已被设置为响应式的对象,因此它始终会返回同一个对象的代理版本。而当访问一个响应式代理对象上的嵌套对象时,该嵌套对象会在被返回之前先转换为代理对象。
以下是这个过程的示意代码:
const handler = { get(target, prop, receiver) { track(target, prop) const value = Reflect.get(...arguments) if (isObject(value)) { // 如果访问的属性值也是一个对象,则将这个对象变为响应式对象 return reactive(value) } else { // 如果访问的属性值不是对象,则直接返回该值 return value } } // ...}
代理 vs. 原始身份
代理的引入和使用,也带来了一些新的需要警惕的地方:如果将代理对象和原始对象使用 ===
进行比较的话,肯定是不相等的:
const obj = {}const wrapped = new Proxy(obj, handlers)console.log(obj === wrapped) // 结果为:false
虽然,原始对象和通过代理包装过的对象在大多数情况下的行为是一致的,但需要注意的方面是:它们在依赖严格比对的操作中将会失败,例如 .filter()
或 .map()
这些方法的调用中。
以上的这种问题,在使用选项式API (Options API)时,是不太可能出现的。因为在这种情况下,所有响应式状态数据都是从 this
引用上进行访问的,它们在使用的时候已被框架层面自动处理,确保一定是代理对象了。但在使用组合式API(Composition API)时,我们都是自己来手动创建响应式对象的,这也就使得出错的几率上升了。
这里有一个最佳实践:永远不要创建对原始对象的引用,而只使用响应式版本的对象!
根据这个最佳实践,我们来看一段示例代码:
不推荐形式:
const raw = { count: 0 } // 这里创建了对原始对象的引用!const obj = reactive(raw)
推荐的形式:
const obj = reactive({ count: 0 })
原始数据对象是 { count: 0 }
,我们避免去创建一个额外的变量引用原始对象,而是直接使用通过原始对象创建的响应式对象。
侦听器
每个 Vue 的组件实例都会伴生一个的侦听器实例,该侦听器实例会把在组件渲染期间“触碰”到的所有数据属性记录为依赖项。之后,当触发依赖项的 setter 时(也就是重新赋值时),setter 就会发通知给侦听器,让侦听器重新渲染组件。
当我们将数据对象传递给组件实例时,Vue 框架就会自动将这个数据对象转换为一个对应的代理对象:
数据对象中的每个属性都会被视为一个依赖项。当访问或修改数据对象的属性时,代理对象能跟踪依赖项的使用及通知依赖项的变更。
当一个组件在进行首次渲染之后,该组件就会跟踪一组依赖列表 —— 即在首次渲染的过程中被访问到的所有数据属性。反过来说也就是:组件成了每个数据属性的订阅者。当代理对象拦截到 set 操作时,该属性将会通知所有订阅了它的组件进行重新渲染。