從 Proxy 到 Vue 源碼,深刻理解 Vue 3.0 響應系統

最近被公衆號各類推送關於 Vue 3 的文章(真是不想學都不行啊),由於如今 Vue 還處於 pre-alpha 狀態,因此不少功能還沒有實現(這就意味着源碼量相對較少,閱讀起來也相對比較容易)。這次版本中的重大改進之一是全新的響應式系統 - 基於 Proxy 的變動檢測。因爲在項目中幾乎沒有使用過 Proxy,出於盲區的補漏,就寫下了這篇文章,才疏學淺,若有紕漏,歡迎指正。

新版本前瞻

10 月 5 日,尤雨溪在 GitHub 開放了 Vue 3.0 處於 pre-alpha 狀態的源碼,此次 Vue 3.0 Updates 版本的更新,將帶來五項重大改進:html

  1. 速度
  2. 體積
  3. 可維護性
  4. 面向原生
  5. 易用性

截止目前,Vue 3.0 主要的架構改進、優化和新功能均已完成,剩下的主要任務是完成一些 Vue 2 現有功能的移植。vue

vue 3

結合目前的 RFCs 和已經完成的改進,能夠窺探到 Vue 3.0 將帶來:react

  • 模塊化架構,支持 tree-shaking。
  • API 暴露爲函數。
  • Composition API + Options API。
  • 基於 Proxy 的變動檢測。
  • 支持 Fragments。
  • 支持 Portals。
  • 支持 Suspense w/ async setup()。
  • 全局掛載/配置 API 更改(createApp().mount(...))。
  • Component v-model API 更改。
  • Custom Directive API 更改。
  • 函數組件和異步組件 API 更改。
  • Render 函數 API 更改。
  • ...

看了這麼多的改進和新功能的介紹,新版本到底會給性能帶來多大的提高,真的很值得期待。git

從 Proxy 開始

因爲 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 以及一個處理器 handlerhandler 是一個對象,能夠定義一個或多個陷阱函數 (可以響應特定操做的函數),來定製攔截行爲。架構

若是未提供陷阱函數,代理會對全部操做採起默認行爲。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 對象設置了一些攔截操做(getdeletehas),而且內部都調用了對應的 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

假設你想要建立一個對象,並要求其屬性值只能是數值,而且在屬性值不爲數值類型時應當拋出錯誤。

可使用 set() 陷阱函數來重寫設置屬性值時的默認行爲,該陷阱函數能接受四個參數:

  1. target:將接收屬性的對象(即代理的目標對象);
  2. key:須要寫入的屬性的鍵(字符串類型或符號類型);
  3. value:將被寫入屬性的值;
  4. 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() 代理陷阱則容許你在讀取屬性值的時候進行攔截。

陷阱函數 get

咱們知道,JavaScript 在讀取對象不存在的屬性時並不會拋出錯誤,而會把 undefined 看成該屬性的值,例如:

let target = {};
console.log(target.name); // undefined

JS 的這種行爲在很是大型的項目中,可能會致使嚴重的問題,尤爲是當屬性名稱存在書寫錯誤時。咱們可使用代理對訪問不存在的屬性時,拋出錯誤。

因爲該屬性驗證只須在讀取屬性時被觸發,所以只要使用 get() 陷阱函數。該陷阱函數會在讀取屬性時被調用,即便該屬性在對象中並不存在,它能接受三個參數:

  1. target:將會被讀取屬性的對象(即代理的目標對象);
  2. key:須要讀取的屬性的鍵(字符串類型或符號類型);
  3. 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); // 拋出錯誤

陷阱函數 has

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 運算符的狀況下被調用,而且會被傳入兩個參數:

  1. target:須要讀取屬性的對象(即代理的目標對象);
  2. 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

陷阱函數 deleteProperty

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 運算符去刪除對象屬性時下被調用,而且會被傳入兩個參數:

  1. target:須要刪除屬性的對象(即代理的目標對象);
  2. 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

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_namenameSymbol

所以在輸出結果中 _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);
  }
}

data

這種變動檢測機制存在一個限制,那就是 Vue 沒法檢測到對象屬性的添加或刪除。爲此咱們須要使用 Vue.setVue.delete 來保證響應系統的運行符合預期。

// vue 2
Vue.set(vm.state, 'name', 'vue 2');

// vue 3
this.state.name = 'vue 3';

Vue 3 進行了全新改進,使用 Proxy 代理的做爲全新的變動檢測,再也不使用 Object.defineProperty

屏幕快照 2019-11-14 下午5.37.00.png

使用代理的好處是,對目標對象 target 架設了一層攔截,能夠對外界的訪問進行過濾和改寫,不用再遞歸遍歷對象的全部屬性並進行 getter/setter 轉換操做,這使得組件更快的初始化,運行時的性能上將獲得極大的改進,據測試新版本的 Vue 比以前 速度快了 2 倍(很是誇張)。

image

建立響應式數據

Vue 3.0 建立響應式數據能夠有三種方法:

  1. data 選項( 兼容 2.x )。
  2. reactive API
  3. ref API

data 選項

// 根組件
<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)

reactive 函數

返回原始對象的響應式 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 函數

獲取一個內部值並返回一個響應式的可變 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 最終會變成這樣:

image

從打印的結果咱們能夠得知,被代理的目標對象 target 設置了 get()set()deleteProperty()has()ownKeys(),這幾個陷阱函數,結合咱們上文介紹的內容,一塊兒來看下它們都作了什麼。

get() - 讀取屬性值

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() - 設置屬性值

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()

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;
}

has() 和 ownKeys()

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 調用。

vue 3 響應系統

總結

最後,本文就不詳細介紹 track()trigger() 兩個函數的內部細節的實現了,可是從上圖咱們能夠得知,track 是依賴收集階段的核心函數,trigger 會對 gettereffect 進行計算,貫穿 Vue 的整個響應系統,起到 調度協調的做用。

相關文章
相關標籤/搜索