最近被公衆號各類推送關於 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 的整個響應系統,起到 調度
、協調
的做用。