最近被公众号各类推送关于 Vue 3 的文章(真是不想学都不行啊),由于如今 Vue 还处于 pre-alpha 状态,因此不少功能还没有实现(这就意味着源码量相对较少,阅读起来也相对比较容易)。这次版本中的重大改进之一是全新的响应式系统 - 基于 Proxy 的变动检测。因为在项目中几乎没有使用过 Proxy,出于盲区的补漏,就写下了这篇文章,才疏学浅,若有纰漏,欢迎指正。
10 月 5 日,尤雨溪在 GitHub 开放了 Vue 3.0 处于 pre-alpha 状态的源码,此次 Vue 3.0 Updates 版本的更新,将带来五项重大改进:html
截止目前,Vue 3.0 主要的架构改进、优化和新功能均已完成,剩下的主要任务是完成一些 Vue 2 现有功能的移植。vue
结合目前的 RFCs 和已经完成的改进,能够窥探到 Vue 3.0 将带来:react
看了这么多的改进和新功能的介绍,新版本到底会给性能带来多大的提高,真的很值得期待。git
因为 Vue 3 的变动检测是基于 Proxy 代理的,因此在理解 Vue 3 的响应系统以前,有必要先熟知 Proxy 具备哪些特性和它能解决什么问题。github
JavaScript 运行环境包含了一些不可枚举、不可写入的对象属性,然而在 ES5 以前开发者没法定义他们本身的不可枚举属性或不可写入属性。ES5 引入 Object.defineProperty()
方法以便开发者在这方面可以像 JS 引擎那样作。api
ES6 为了让开发者能进一步接近 JS 引擎的能力,推出了 Proxy,代理是一种封装,可以拦截并改变 JS 引擎的底层操做。简单的说,就是在目标对象上架设一层 “拦截”
,外界对该对象的访问,都必须先经过这层拦截,提供了一种改变 JS 引擎过滤和改写的能力。数组
let target = {}; let proxy = new Proxy(target, { get: function(target, property) { return 35; } }); proxy.time // 35 proxy.name // 35 proxy.title // 35
经过调用 new Proxy()
来建立一个代理时,须要传递两个参数:目标对象 target
以及一个处理器 handler
,handler
是一个对象,能够定义一个或多个陷阱函数 (可以响应特定操做的函数),来定制拦截行为。架构
若是未提供陷阱函数,代理会对全部操做采起默认行为。app
let target = {}; let proxy = new Proxy(target, {}); proxy.name = "proxy"; console.log(proxy.name); // "proxy" console.log(target.name); // "proxy" target.name = "target"; console.log(proxy.name); // "target" console.log(target.name); // "target"
咱们已经知道,经过调用 new Proxy()
能够建立一个代理用来替代目标对象 target
。这个代理对目标对象进行了虚拟,所以该代理与该目标对象表面上能够被看成同一个对象来对待。异步
Reflect 是 ES6 提供的一个内置的对象,它提供拦截 JavaScript 操做的方法。被 Reflect 对象所表明的反射接口,是给底层操做提供默认行为的方法的集合。
每一个陷阱函数均可以重写 JS 对象的一个特定内置行为,容许你拦截并修改它。若是你仍然须要使用原先的内置行为,则可以使用对应的 Reflect 方法。
简单的来说,Proxy 是拦截默认行为,Reflect 是恢复默认行。被 Proxy 拦截、过滤了一些默认行为以后,可使用 Reflect 恢复未被拦截的默认行为。一般它们两个会结合在一块儿使用。
到这里不明白不要紧,在下文会介绍的陷阱函数中,应该就会明白了。
let target = {}; let proxy = new Proxy(target, { get(target, name) { console.log('get', target, name); return Reflect.get(target, name); }, deleteProperty(target, name) { console.log('delete' + name); return Reflect.deleteProperty(target, name); }, has(target, name) { console.log('has' + name); return Reflect.has(target, name); } }); proxy.name = 'proxy'; delete proxy.name; name in proxy;
上面代码中,Proxy 对象设置了一些拦截操做(get
、delete
、has
),而且内部都调用了对应的 Reflect 方法,保证原生行为可以正常执行。
每一个陷阱函数都有一个对应的 Reflect 方法,每一个方法都与对应的陷阱函数同名,而且接收的参数也与之一致。
下表中列出了因此陷阱函数和 Reflect 方法对应的默认行为,在这里只介绍其中几个陷阱函数的用法,由于它们在 Vue 3 源码中有所涉及。
陷阱函数 | 被重写的行为 | 默认行为 |
---|---|---|
get | 读取一个属性的值 | Reflect.get() |
set | 写入一个属性 | Reflect.set() |
has | in 运算符 | Reflect.has() |
deleteProperty | delete 运算符 | Reflect.deleteProperty() |
getPrototypeOf | Object.getPrototypeOf() | Reflect.getPrototypeOf() |
setPrototypeOf | Object.setPrototypeOf() | Reflect.setPrototypeOf() |
isExtensible | Object.isExtensible() | Reflect.isExtensible() |
preventExtensions | Object.preventExtensions() | Reflect.preventExtensions() |
getOwnPropertyDescriptor | Object.getOwnPropertyDescriptor() | Reflect.getOwnPropertyDescriptor() |
defineProperty | Object.defineProperty() | Reflect.defineProperty |
ownKeys | Object.keys、Object.getOwnPropertyNames() 与 Object.getOwnPropertySymbols() | Reflect.ownKeys() |
apply | 调用一个函数 | Reflect.apply() |
construct | 使用 new 调用一个函数 | Reflect.construct() |
下文介绍到的陷阱函数,都会在 Vue 3 源码中出现,提早进行了解。
假设你想要建立一个对象,并要求其属性值只能是数值,而且在属性值不为数值类型时应当抛出错误。
可使用 set()
陷阱函数来重写设置属性值时的默认行为,该陷阱函数能接受四个参数:
target
:将接收属性的对象(即代理的目标对象);key
:须要写入的属性的键(字符串类型或符号类型);value
:将被写入属性的值;receiver
:操做发生的对象(一般是代理对象)。let target = { name: "target" }; let handler = { set(target, key, value, receiver) { // 拦截,忽略已有属性,避免影响它们 if (!target.hasOwnProperty(key)) { if (isNaN(value)) { throw new TypeError("Property must be a number."); } } // 知足条件的进行写入 等价于 target[key] = value; return Reflect.set(target, key, value, receiver); } } let proxy = new Proxy(target, handler); // 添加一个新属性 proxy.count = 1; console.log(proxy.count); // 1 console.log(target.count); // 1 // 你能够为 name 赋一个非数值类型的值,由于该属性已经存在 proxy.name = "proxy"; console.log(proxy.name); // "proxy" console.log(target.name); // "proxy" // 抛出错误 proxy.anotherName = "proxy";
set
陷阱函数容许你在写入属性值的时候进行拦截,而 get()
代理陷阱则容许你在读取属性值的时候进行拦截。
咱们知道,JavaScript 在读取对象不存在的属性时并不会抛出错误,而会把 undefined
看成该属性的值,例如:
let target = {}; console.log(target.name); // undefined
JS 的这种行为在很是大型的项目中,可能会致使严重的问题,尤为是当属性名称存在书写错误时。咱们可使用代理对访问不存在的属性时,抛出错误。
因为该属性验证只须在读取属性时被触发,所以只要使用 get()
陷阱函数。该陷阱函数会在读取属性时被调用,即便该属性在对象中并不存在,它能接受三个参数:
target
:将会被读取属性的对象(即代理的目标对象);key
:须要读取的属性的键(字符串类型或符号类型);receiver
:操做发生的对象(一般是代理对象)。Reflect.get()
方法一样接收这三个参数,而且默认会返回属性的值。
使用 get()
陷阱函数与 Reflect.get()
方法在目标属性不存在时抛出错误:
let proxy = new Proxy({}, { get(target, key, receiver) { // 读取属性时进行拦截 if (!(key in receiver)) { throw new TypeError("Property " + key + " doesn't exist."); } // 保持默认的读取行为 return Reflect.get(target, key, receiver); } }) // 添加属性的功能正常 proxy.name = "proxy"; console.log(proxy.name); // "proxy" // 读取不存在属性会抛出错误 console.log(proxy.nme); // 抛出错误
in
运算符用于判断指定对象中是否存在某个属性,若是对象的属性名与指定的字符串或符号值相匹配,那么 in
运算符应当返回 true
,不管该属性是对象自身的属性仍是其原型的属性。例如:
let target = { value: 42 } console.log("value" in target); // true console.log("toString" in target); // true
value
是对象自身的属性,而 toString
则是原型属性,可使用代理的 has()
陷阱函数来拦截这个操做,从而在使用 in
运算符时返回不一样的结果。
has()
陷阱函数会在使用 in
运算符的状况下被调用,而且会被传入两个参数:
target
:须要读取属性的对象(即代理的目标对象);key
:须要检查的属性的键(字符串类型或符号类型)。Reflect.has()
方法接受与之相同的参数,并向 in
运算符返回默认响应结果。
使用 has()
陷阱函数以及 Reflect.has()
方法,容许你修改部分属性在接受 in
检测时的行为,但保留其余属性的默认行为。
let target = { name: "target", value: 42 } let proxy = new Proxy(target, { has(target, key) { // 拦截操做 if (key === "value") { return false; } else { // 保持默认行为 return Reflect.has(target, key); } } }) console.log("value" in proxy); // false console.log("name" in proxy); // true console.log("toString" in proxy); // true
delete
运算符可以从指定对象上删除一个属性,在删除成功时返回 true
,不然返回 false
。若是试图用 delete
运算符去删除一个不可配置的属性,在严格模式下将会抛出错误;而非严格模式下只是单纯返回 false
。这里有个例子:
let target = { name: "target", value: 42 } Object.defineProperty(target, "name", {configurable: false}); console.log("value" in target); // true delete target.value; // true console.log("value" in target); // false delete target.name; // 非严格模式下返回false(在严格模式下会抛出错误) console.log("name" in target); // true
name
属性是不可配置的,所以对其使用 delete
操做符只会返回 false
(若是代码运行在严格模式下,则会抛出错误)。能够在代理对象中使用 deleteProperty()
陷阱函数以改变这种行为。
deleteProperty
陷阱函数会在使用 delete
运算符去删除对象属性时下被调用,而且会被传入两个参数:
target
:须要删除属性的对象(即代理的目标对象);key
:须要删除的属性的键(字符串类型或符号类型)。Reflect.deleteProperty()
方法也接受这两个参数,并提供了 deleteProperty()
陷阱函数的默认实现。
能够结合 Reflect.deleteProperty()
方法以及 deleteProperty()
陷阱函数,来修改 delete 运算符的行为。例如,能确保 value
属性不被删除:
let target = { name: "target", value: 42 } let proxy = new Proxy(target, { deleteProperty(target, key) { // 拦截行为 if (key === "value") { return false; } else { // 恢复行为 return Reflect.deleteProperty(target, key); } } }) console.log("value" in proxy); // true // 尝试删除 proxy.value delete proxy.value; // false // 不能删除,由于这个默认行为被拦截了 console.log("value" in proxy); // true console.log("name" in proxy); // true // 尝试删除 proxy.name delete proxy.name; // true console.log("name" in proxy); // false
value
属性是不能被删除的,由于该操做被 proxy
对象拦截。这么作容许你在严格模式下保护属性避免其被删除,而且不会抛出错误。
ownKeys()
代理陷阱拦截了内部方法 [[OwnPropertyKeys]]
,并容许你返回一个数组用于重写该行为。
可使用 ownKeys()
陷阱函数去过滤特定的属性,以免这些属性被 Object.keys()
、 Object.getOwnPropertyNames()
、Object.getOwnPropertySymbols()
或 Object.assign()
方法使用。
ownKeys()
陷阱函数的默认行为由 Reflect.ownKeys()
方法实现,会返回一个由所有自有属性的键构成的数组,不管键的类型是字符串仍是符号。
ownKeys()
陷阱函数接受单个参数,即目标对象,同时必须返回一个数组或者一个类数组对象,不合要求的返回值会致使错误。
假设你不想在结果中包含任何如下划线打头的属性(在 JS 的编码惯例中,这表明该字段是私有的),那么可使用 ownKeys()
陷阱函数来将它们过滤掉,就像下面这样:
let proxy = new Proxy({}, { ownKeys(target) { return Reflect.ownKeys(target).filter(key => { // 过滤掉一些特定属性 return typeof key !== "string" || key[0] !== "_"; }); } }); let nameSymbol = Symbol("name"); proxy.name = "proxy"; proxy._name = "private"; // 被过滤掉 proxy[nameSymbol] = "symbol"; let names = Object.getOwnPropertyNames(proxy); let keys = Object.keys(proxy); let symbols = Object.getOwnPropertySymbols(proxy); console.log(names); // ["name"] console.log(names[0]); // "name" console.log(keys); // ["name"] console.log(keys[0]); // "name" console.log(symbols); // [Symbol(name)] console.log(symbols[0]); // Symbol(name)
这个例子使用了一个 ownKeys
陷阱函数,作了以下操做:
Reflect.ownKeys()
方法来获取目标对象的键列表。filter()
方法被用于将全部下划线打头的字符串类型的键过滤出去。proxy
对象添加了三个属性: name
、 _name
与 nameSymbol
。所以在输出结果中 _name
属性则始终没有出如今结果里,由于它被过滤了。
ownKeys
陷阱函数也能影响 for-in
循环,由于这种循环调用了陷阱函数来决定哪些值可以被用在循环内。(Vue 源码会涉及这里)
到这里陷阱函数的介绍就告一段落了,下面咱们回到正题,一块儿来看下 Vue 3 是如何使用 Proxy 代理打造全新的响应系统的吧。
Vue 2 中响应系统是基于 Object.defineProperty
的,递归遍历 data 对象上的全部属性,将其转换为 getter/setter,当 setter 触发时,通知 watcher,来进行变动检测的。
... function proxy (target, sourceKey, key) { sharedPropertyDefinition.get = function proxyGetter () { return this[sourceKey][key] }; sharedPropertyDefinition.set = function proxySetter (val) { this[sourceKey][key] = val; }; Object.defineProperty(target, key, sharedPropertyDefinition); } ... for (const key in propsOptions) { ... if (!(key in vm)) { proxy(vm, `_props`, key); } }
这种变动检测机制存在一个限制,那就是 Vue 没法检测到对象属性的添加或删除。为此咱们须要使用 Vue.set
和 Vue.delete
来保证响应系统的运行符合预期。
// vue 2 Vue.set(vm.state, 'name', 'vue 2'); // vue 3 this.state.name = 'vue 3';
Vue 3 进行了全新改进,使用 Proxy 代理的做为全新的变动检测,再也不使用 Object.defineProperty
。
使用代理的好处是,对目标对象 target
架设了一层拦截,能够对外界的访问进行过滤和改写,不用再递归遍历对象的全部属性并进行 getter/setter
转换操做,这使得组件更快的初始化,运行时的性能上将获得极大的改进,据测试新版本的 Vue 比以前 速度快了 2
倍(很是夸张)。
Vue 3.0 建立响应式数据能够有三种方法:
data
选项( 兼容 2.x )。reactive API
。ref API
。// 根组件 <template> <div id="app"> <div>{{ name }}</div> </div> </template> <script> import { createApp } from Vue; export default { const App = { data: { name: 'Vue 3', // count: ref(0) } } createApp().mount(App, '#app') </script>
data
选项定义的数据,最终也会被 reactive
转换为响应式的 Proxy
代理。
// runtime-core > src > apiOptions.ts instance.data = reactive(data)
返回原始对象的响应式 Proxy
代理( 同 2.x 的 Vue.observate() )。
<template> <div>{{ state.name }}</div> </template> <script> import { reactive } from Vue; export default { setup() { const state = reactive({ name: "Vue 3" }) return { state } } } </script>
reactive()
函数最终返回一个可观察的响应式 Proxy
代理。
// reactivity > src > reactive.ts reactive(target) => observed => new Proxy(target, handlers)
获取一个内部值并返回一个响应式的可变 ref
对象。
<template> <div>{{ name }}</div> </template> <script> import { ref } from Vue; export default { setup() { return { name: ref('Vue 3') } } } </script>
ref
对象有一个指向内部值的单个属性 .value
。若是将一个值分配为 ref
对象,则 reactive()
方法会使该对象具备高度的响应性。
... const r = { _isRef: true, get value() { track(r, "get" /* GET */, 'value'); return raw; }, set value(newVal) { raw = convert(newVal); // trigger 方法扮演通讯员的角色,贯穿整个响应系统,使得 ref 具备高度的响应性 trigger(r, "set" /* SET */, 'value', { newValue: newVal } ); } }; return r ...
所以,无需在模版中追加 .value
。
const count = ref(0) console.log(count.value) // 0 count.value++ console.log(count.value) // 1
在 Vue 3 中,将 Vue 的核心功能(例如建立和观察响应状态)公开为独立功能,例如使用 reactive()
建立一个响应状态:
import { reactive } from 'vue' // reactive state const state = reactive({ name: "vue 3.0", count: ref(42) })
咱们向 reactive()
函数传入了一个 {name: "Vue 3.x", count: {…}}
,对象,reactive()
函数会将传入的对象进行 Proxy 封装,将其转换为"可观测"的对象。
//reactive f => createReactiveObject() function createReactiveObject(target, toProxy, toRaw, baseHandlers, collectionHandlers) { ... // 设置拦截器 const handlers = collectionTypes.has(target.constructor) ? collectionHandlers : baseHandlers; observed = new Proxy(target, handlers); ... return observed; }
传入的目标对象 target
最终会变成这样:
从打印的结果咱们能够得知,被代理的目标对象 target
设置了 get()
、set()
、deleteProperty()
、has()
、ownKeys()
,这几个陷阱函数,结合咱们上文介绍的内容,一块儿来看下它们都作了什么。
get()
会自动读取使用 ref
对象建立的响应数据,并进行 track
调用。
// get() => createGetter(false) function createGetter(isReadonly: boolean, unwrap: boolean = true) { return function get(target: object, key: string | symbol, receiver: object) { // 恢复默认行为 let res = Reflect.get(target, key, receiver) // 根据目标对象 key 类型进行的一些处理 if (isSymbol(key) && builtInSymbols.has(key)) { return res } // 若是目标对象存在使用 ref 建立的数据,直接获取内部值 if (unwrap && isRef(res)) { res = res.value // 案例中 这里是 42 } else { // 调用 track() 方法 track(target, OperationTypes.GET, key) } return isObject(res) ? isReadonly ? readonly(res) : reactive(res) : res } }
set()
陷阱函数,对目标对象上不存在的属性设置值时,进行 “添加” 操做,而且会触发 trigger()
来通知响应系统的更新。解决了 Vue 2.x 中没法检测到对象属性的添加的问题。
function set(target, key, value, receiver) { value = toRaw(value); // 获取修改以前的值,进行一些处理 const oldValue = target[key]; if (isRef(oldValue) && !isRef(value)) { oldValue.value = value; return true; } const hadKey = hasOwn(target, key); // 恢复默认行为 const result = Reflect.set(target, key, value, receiver); // //若是目标对象在原型链上,不要 trigger if (target === toRaw(receiver)) { /* istanbul ignore else */ { const extraInfo = { oldValue, newValue: value }; // 若是设置的属性不在目标对象上 就进行 Add // 这就解决了 Vue 2.x 中没法检测到对象属性的添加或删除的问题 if (!hadKey) { trigger(target, "add" /* ADD */ , key, extraInfo); } else if (hasChanged(value, oldValue)) { // trigger 方法进行一系列的调度工做,贯穿着整个响应系统,是变动检测的“通信员” trigger(target, "set" /* SET */ , key, extraInfo); } } } return result; }
deleteProperty()
陷阱函数关联 delete
操做,当目标对象上的属性被删除时,会触发 trigger()
来通知响应系统的更新。这也解决了 Vue 2.x 中没法检测到对象属性的删除的问题。
// 这里就没什么好说的 function deleteProperty(target, key) { const hadKey = hasOwn(target, key); const oldValue = target[key]; const result = Reflect.deleteProperty(target, key); if (result && hadKey) { /* istanbul ignore else */ { 发布通知 trigger(target, "delete" /* DELETE */ , key, { oldValue }); } } return result; }
function has(target, key) { const result = Reflect.has(target, key); track(target, "has" /* HAS */ , key); return result; } function ownKeys(target) { track(target, "iterate" /* ITERATE */ ); return Reflect.ownKeys(target); }
从源码能够看出,这个两个陷阱函数并无修改默认行为,可是它们都调用 track(...)
函数,回顾上文咱们可知,has()
会对应 in
操做的默认行为,ownKeys()
也会影响 for...in
循环。
梳理一下:
get()
并进行 track
调用。set()
并进行 trigger
调用,解决了 Vue 2.x 响应系统没法检测到对象属性的添加或删除的问题。deleteProperty()
并进行 trigger
调用,解决了 Vue 2.x 响应系统没法检测到对象属性的添加或删除的问题。in
操做符 或者 for...in
遍历数据时,会触发has()
和 ownKeys()
并进行 track
调用。最后,本文就不详细介绍 track()
和 trigger()
两个函数的内部细节的实现了,可是从上图咱们能够得知,track
是依赖收集阶段的核心函数,trigger
会对 getter
、effect
进行计算,贯穿 Vue 的整个响应系统,起到 调度
、协调
的做用。