你不知道的 Proxy

阿寶哥將從 6 個方面入手,帶你一步一步揭開 Proxy 對象的神祕面紗。閱讀完本文,你將瞭解以下內容:

一、聊一聊代理

在日常工作中,相信挺多小夥伴都用過 Web 調試代理工具,比如 Fiddler 或 Charles。通過使用 Web 調試代理工具,我們可以抓取 HTTP/HTTPS 協議請求,還可以手動修改請求參數和響應結果。不僅如此,在調試線上問題時,利用 Web 調試代理工具,你還可以把線上 壓縮混淆過 的 JS 文件映射成本地 未壓縮混淆過 的 JS 文件。

在簡單介紹了 Web 調試代理工具的基本功能之後,我們來看一下使用 Web 調試代理工具的 HTTP 請求流程:

通過上圖可知,在引入 Web 調試代理工具之後,我們發起的 HTTP 請求都會通過 Web Proxy 進行轉發和處理。增加了 Web Proxy 代理層,讓我們能夠更好地控制 HTTP 請求的流程。對於單頁應用程序來說,當從服務器獲取數據之後,我們就會讀取相應的數據在頁面上顯示出來:

以上流程與瀏覽器直接從服務器獲取數據類似:

爲了能夠靈活控制 HTTP 請求的流程,我們增加了的 Web Proxy 層。那麼我們能否控制數據對象的讀取流程呢?答案是可以的,我們可以利用 Web API,比如 Object.defineProperty 或 Proxy API。在引入 Web API 之後,數據的訪問流程如下圖所示:

接下來,阿寶哥將重點介紹 Proxy API,它可是 Vue3 實現數據響應式幕後的 “功臣” 喲。對它感興趣的小夥伴,跟阿寶哥一起學起來吧。

二、Proxy 對象簡介

Proxy 對象用於創建一個對象的代理,從而實現基本操作的攔截和自定義(如屬性查找、賦值、枚舉、函數調用等)。Proxy 的構造函數語法爲:

1const p = new Proxy(target, handler)
2
3

相關的參數說明如下:

在介紹 Proxy 對象的使用示例前,我們先來了解一下它的兼容性:

(圖片來源:https://caniuse.com/?search=Proxy)

由上圖可知,Proxy API 的兼容性並不是很好,所以大家在使用的時候要注意其兼容性問題。

2.1 Proxy 對象使用示例

瞭解完 Proxy 構造函數,我們來看一個簡單的例子:

 1const man = {
 2  name: "阿寶哥",
 3};
 4
 5const proxy = new Proxy(man, {
 6  get(target, property, receiver) {
 7    console.log(`正在訪問${property}屬性`);
 8    return target[property];
 9  },
10});
11
12console.log(proxy.name);
13console.log(proxy.age);
14
15

在以上示例中,我們使用了 Proxy 構造函數爲 man 對象,創建了一個代理對象。在創建代理對象時,我們定義了一個 get 捕獲器,用於捕獲屬性讀取的操作。 捕獲器的作用就是用於攔截用戶對目標對象的相關操作,在這些操作傳播到目標對象之前,會先調用對應的捕獲器函數,從而攔截並修改相應的行爲。

在設置了 get 捕獲器之後,當成功運行以上的示例代碼,控制檯會輸出以下結果:

1正在訪問name屬性
2阿寶哥
3正在訪問age屬性
4undefined
5
6

通過觀察以上輸出結果,我們可以發現 get 捕獲器 不僅可以攔截已知屬性的讀取操作,也可以攔截未知屬性的讀取操作。在創建 Proxy 對象時,除了定義 get 捕獲器 之外,我們還可以定義其他的捕獲器,比如 has、set、delete、apply 或 ownKeys 等。

2.2 handler 對象支持的捕獲器

handler 對象支持 13 種捕獲器,這裏阿寶哥只列舉以下 5 種常用的捕獲器:

需要注意的是,所有的捕獲器是可選的。如果沒有定義某個捕獲器,那麼就會保留源對象的默認行爲。 看完上面的捕獲器介紹,是不是覺得 Proxy 對象很強大。

三、Reflect 對象簡介

Reflect 是一個內置的對象,它提供攔截 JavaScript 操作的方法。這些方法與 proxy handlers 的方法相同。Reflect 不是一個函數對象,因此它是不可構造的。

在介紹 Reflect 對象的使用示例前,我們先來了解一下它的兼容性:

(圖片來源:https://caniuse.com/?search=Reflect)

3.1 Reflect 對象使用示例

 1const man = {
 2  name: "阿寶哥",
 3  city: "Xiamen",
 4};
 5
 6console.log(Reflect.set(man, "sex", 1)); // true
 7console.log(Reflect.has(man, "name")); // true
 8console.log(Reflect.has(man, "age")); // false
 9console.log(Reflect.ownKeys(man)); // [ 'name', 'city', 'sex' ]
10
11

除了示例中介紹的 sethasownKeys 方法之外,Reflect 對象還支持 getdefinePropertydeleteProperty 等方法。下面阿寶哥將簡單介紹 Reflect 對象所支持的一些靜態方法。

3.2 Reflect 對象支持的靜態方法

Reflect 的所有屬性和方法都是靜態的,該對象提供了與 Proxy handler 對象相關的 13 個方法。同樣,這裏阿寶哥只列舉以下 5 個常用的方法:

在實際的 Proxy 使用場景中,我們往往會結合 Reflect 對象提供的靜態方法來實現某些特定的功能。爲了讓大家能夠更好地理解並掌握 Proxy 對象,接下來的環節,阿寶哥將列舉 Proxy 對象的 6 個使用場景。

四、Proxy 使用場景

這裏我們先來介紹 Proxy 對象的第一個使用場景 —— 增強型數組

4.1 增強型數組

定義 enhancedArray 函數
 1function enhancedArray(arr) {
 2  return new Proxy(arr, {
 3    get(target, property, receiver) {
 4      const range = getRange(property);
 5      const indices = range ? range : getIndices(property);
 6      const values = indices.map(function (index) {
 7        const key = index < 0 ? String(target.length + index) : index;
 8        return Reflect.get(target, key, receiver);
 9      });
10      return values.length === 1 ? values[0] : values;
11    },
12  });
13
14  function getRange(str) {
15    var [start, end] = str.split(":").map(Number);
16    if (typeof end === "undefined") return false;
17
18    let range = [];
19    for (let i = start; i < end; i++) {
20      range = range.concat(i);
21    }
22    return range;
23  }
24
25  function getIndices(str) {
26    return str.split(",").map(Number);
27  }
28}
29
30
使用 enhancedArray 函數
1const arr = enhancedArray([1, 2, 3, 4, 5]);
2
3console.log(arr[-1]); //=> 5
4console.log(arr[[2, 4]]); //=> [ 3, 5 ]
5console.log(arr[[2, -2, 1]]); //=> [ 3, 4, 2 ]
6console.log(arr["2:4"]); //=> [ 3, 4]
7console.log(arr["-2:3"]); //=> [ 4, 5, 1, 2, 3 ]
8
9

由以上的輸出結果可知,增強後的數組對象,就可以支持負數索引、分片索引等功能。除了可以增強數組之外,我們也可以使用 Proxy API 來增強普通對象。

4.2 增強型對象

創建 enhancedObject 函數
 1const enhancedObject = (target) =>
 2  new Proxy(target, {
 3    get(target, property) {
 4      if (property in target) {
 5        return target[property];
 6      } else {
 7        return searchFor(property, target);
 8      }
 9    },
10  });
11
12let value = null;
13function searchFor(property, target) {
14  for (const key of Object.keys(target)) {
15    if (typeof target[key] === "object") {
16      searchFor(property, target[key]);
17    } else if (typeof target[property] !== "undefined") {
18      value = target[property];
19      break;
20    }
21  }
22  return value;
23}
24
25
使用 enhancedObject 函數
 1const data = enhancedObject({
 2  user: {
 3    name: "阿寶哥",
 4    settings: {
 5      theme: "dark",
 6    },
 7  },
 8});
 9
10console.log(data.user.settings.theme); // dark
11console.log(data.theme); // dark
12
13

以上代碼運行後,控制檯會輸出以下代碼:

1dark
2dark
3
4

通過觀察以上的輸出結果可知,使用 enhancedObject 函數處理過的對象,我們就可以方便地訪問普通對象內部的深層屬性。

4.3 創建只讀的對象

創建 Proxy 對象
 1const man = {
 2  name: "semlinker",
 3};
 4
 5const handler = {
 6  set: "Read-Only",
 7  defineProperty: "Read-Only",
 8  deleteProperty: "Read-Only",
 9  preventExtensions: "Read-Only",
10  setPrototypeOf: "Read-Only",
11};
12
13const proxy = new Proxy(man, handler);
14
15
使用 proxy 對象
1console.log(proxy.name);
2proxy.name = "kakuqo";
3
4

以上代碼運行後,控制檯會輸出以下代碼:

1semlinker
2proxy.name = "kakuqo";
3           ^
4TypeError: 'Read-Only' returned for property 'set' of object '#<Object>' is not a function
5
6

觀察以上的異常信息可知,導致異常的原因是因爲 handler 對象的 set 屬性值不是一個函數。如果不希望拋出運行時異常,我們可以定義一個 freeze 函數:

 1function freeze (obj) {
 2  return new Proxy(obj, {
 3    set () { return true; },
 4    deleteProperty () { return false; },
 5    defineProperty () { return true; },
 6    setPrototypeOf () { return true; }
 7  });
 8}
 9
10

定義好 freeze 函數,我們使用數組對象來測試一下它的功能:

1let frozen = freeze([1, 2, 3]);
2frozen[0] = 6;
3delete frozen[0];
4frozen = Object.defineProperty(frozen, 0, { value: 66 });
5console.log(frozen); // [ 1, 2, 3 ]
6
7

上述代碼成功執行後,控制檯會輸出 [ 1, 2, 3 ],很明顯經過 freeze 函數處理過的數組對象,已經被 “凍結” 了。

4.4 攔截方法調用

定義 traceMethodCalls 函數
 1function traceMethodCalls(obj) {
 2  const handler = {
 3    get(target, propKey, receiver) {
 4      const origMethod = target[propKey]; // 獲取原始方法
 5      return function (...args) {
 6        const result = origMethod.apply(this, args);
 7        console.log(
 8          propKey + JSON.stringify(args) + " -> " + JSON.stringify(result)
 9        );
10        return result;
11      };
12    },
13  };
14  return new Proxy(obj, handler);
15}
16
17
使用 traceMethodCalls 函數
 1const obj = {
 2  multiply(x, y) {
 3    return x * y;
 4  },
 5};
 6
 7const tracedObj = traceMethodCalls(obj);
 8tracedObj.multiply(2, 5); // multiply[2,5] -> 10
 9
10

上述代碼成功執行後,控制檯會輸出 multiply[2,5] -> 10,即我們能夠成功跟蹤 obj 對象中方法的調用過程。其實,除了能夠跟蹤方法的調用,我們也可以跟蹤對象中屬性的訪問,具體示例如下:

 1function tracePropAccess(obj, propKeys) {
 2  const propKeySet = new Set(propKeys);
 3  return new Proxy(obj, {
 4    get(target, propKey, receiver) {
 5      if (propKeySet.has(propKey)) {
 6        console.log("GET " + propKey);
 7      }
 8      return Reflect.get(target, propKey, receiver);
 9    },
10    set(target, propKey, value, receiver) {
11      if (propKeySet.has(propKey)) {
12        console.log("SET " + propKey + "=" + value);
13      }
14      return Reflect.set(target, propKey, value, receiver);
15    },
16  });
17}
18
19const man = {
20  name: "semlinker",
21};
22const tracedMan = tracePropAccess(man, ["name"]);
23
24console.log(tracedMan.name); // GET name; semlinker
25console.log(tracedMan.age); // undefined
26tracedMan.name = "kakuqo"; // SET name=kakuqo
27
28

在以上示例中,我們定義了一個 tracePropAccess 函數,該函數接收兩個參數:obj 和 propKeys,它們分別表示需跟蹤的目標和需跟蹤的屬性列表。調用 tracePropAccess 函數後,會返回一個代理對象,當我們訪問被跟蹤的屬性時,控制檯就會輸出相應的訪問日誌。

4.5 隱藏屬性

創建 hideProperty 函數
 1const hideProperty = (target, prefix = "_") =>
 2  new Proxy(target, {
 3    has: (obj, prop) => !prop.startsWith(prefix) && prop in obj,
 4    ownKeys: (obj) =>
 5      Reflect.ownKeys(obj).filter(
 6        (prop) => typeof prop !== "string" || !prop.startsWith(prefix)
 7      ),
 8    get: (obj, prop, rec) => (prop in rec ? obj[prop] : undefined),
 9  });
10
11
使用 hideProperty 函數
 1const man = hideProperty({
 2  name: "阿寶哥",
 3  _pwd: "www.semlinker.com",
 4});
 5
 6console.log(man._pwd); // undefined
 7console.log('_pwd' in man); // false
 8console.log(Object.keys(man)); // [ 'name' ]
 9
10

通過觀察以上的輸出結果,我們可以知道,利用 Proxy API,我們實現了指定前綴屬性的隱藏。除了能實現隱藏屬性之外,利用 Proxy API,我們還可以實現驗證屬性值的功能。

4.6 驗證屬性值

創建 validatedUser 函數
 1const validatedUser = (target) =>
 2  new Proxy(target, {
 3    set(target, property, value) {
 4      switch (property) {
 5        case "email":
 6          const regex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
 7          if (!regex.test(value)) {
 8            console.error("The user must have a valid email");
 9            return false;
10          }
11          break;
12        case "age":
13          if (value < 20 || value > 80) {
14            console.error("A user's age must be between 20 and 80");
15            return false;
16          }
17          break;
18      }
19
20      return Reflect.set(...arguments);
21    },
22  });
23
24
使用 validatedUser 函數
 1let user = {
 2  email: "",
 3  age: 0,
 4};
 5
 6user = validatedUser(user);
 7user.email = "semlinker.com"; // The user must have a valid email
 8user.age = 100; // A user's age must be between 20 and 80
 9
10

上述代碼成功執行後,控制檯會輸出以下結果:

1The user must have a valid email
2A user's age must be between 20 and 80
3
4

介紹完 Proxy 對象的使用場景之後,我們來繼續介紹與 Proxy 對象相關的一些問題。

五、Proxy 相關問題

5.1 this 的指向問題

 1const target = {
 2  foo() {
 3    return {
 4      thisIsTarget: this === target,
 5      thisIsProxy: this === proxy,
 6    };
 7  },
 8};
 9
10const handler = {};
11const proxy = new Proxy(target, handler);
12console.log(target.foo()); // { thisIsTarget: true, thisIsProxy: false }
13console.log(proxy.foo()); // { thisIsTarget: false, thisIsProxy: true }
14
15

上述代碼成功執行後,控制檯會輸出以下結果:

1{ thisIsTarget: true, thisIsProxy: false }
2{ thisIsTarget: false, thisIsProxy: true }
3
4

通過以上輸出的結果,foo 方法中的 this 指向與當前的調用者有關。看起來挺簡單的,但在一些場景下如果稍不注意的話,就會出現問題,比如以下這個示例:

 1const _name = new WeakMap();
 2
 3class Person {
 4  constructor(name) {
 5    _name.set(this, name);
 6  }
 7  
 8  get name() {
 9    return _name.get(this);
10  }
11}
12
13

在以上示例中,我們使用 WeakMap 對象來存儲 Person 對象的私有信息。定義完 Person 類,我們就可以通過以下方式來使用它:

1const man = new Person("阿寶哥");
2console.log(man.name); // 阿寶哥
3
4const proxy = new Proxy(man, {});
5console.log(proxy.name); // undefined
6
7

對於以上的代碼,當我們通過 proxy 對象來訪問 name 屬性時,你會發現輸出的結果是 undefined。這是因爲當使用 proxy.name 的方式訪問 name 屬性時,this 指向的是 proxy 對象,而 _name WeakMap 對象中存儲的是 man 對象,所以輸出的結果是 undefined

然而,對於以上的問題,如果我們按照以下方式定義 Person 類,就不會出現以上問題:

 1class Person {
 2  constructor(name) {
 3    this._name = name;
 4  }
 5  get name() {
 6    return this._name;
 7  }
 8}
 9
10const man = new Person("阿寶哥");
11console.log(man.name); // 阿寶哥
12
13const proxy = new Proxy(man, {});
14console.log(proxy.name); // 阿寶哥
15
16

另外,如果你對 WeakMap 感興趣的話,可以閱讀 你不知道的 WeakMap 這篇文章。

5.2 get 捕獲器 receiver 參數是什麼

1const p = new Proxy(target, {
2  get: function(target, property, receiver) {
3    // receiver
4  }
5});
6
7

get 捕獲器用於攔截對象的讀取屬性操作,該捕獲器含有三個參數:

爲了更好地瞭解 receiver 參數的描述信息,我們來舉個具體的示例:

 1const proxy = new Proxy({},
 2  {
 3    get: function (target, property, receiver) {
 4      return receiver;
 5    },
 6  }
 7);
 8
 9console.dir(proxy.getReceiver === proxy); // true
10var inherits = Object.create(proxy);
11console.dir(inherits.getReceiver === inherits); // true
12
13

那麼我們能否改變 receiver 指向的對象呢?答案是可以的,通過 Reflect 對象提供的 get 方法,我們可以動態設置 receiver 對象的值,具體使用方式如下所示:

1console.dir(Reflect.get(proxy, "getReceiver", "阿寶哥"));
2
3

其實 receiver 的名稱是來源於 ECMAScript 規範:

以上的 [[Get]][[Set]] 被稱爲內部方法,ECMAScript 引擎中的每個對象都與一組內部方法相關聯,這些內部方法定義了其運行時行爲。

需要注意的是,這些內部方法不是 ECMAScript 語言的一部分。對於對象的訪問器屬性來說,在執行內部代碼時,Receiver 將被作爲 this 的值,同樣使用 Reflect 對象提供的 API,我們也可以通過設置 receiver 參數的值來改變 this 的值:

 1const obj = {
 2  get foo() {
 3    return this.bar;
 4  },
 5};
 6
 7console.log(Reflect.get(obj, "foo")); // undefined
 8console.log(Reflect.get(obj, "foo", { bar: 2021 })); // 2021
 9
10

5.3 包裝內置構造函數的實例

當使用 Proxy 包裝內置構造函數實例的時候,可能會出現一些問題。比如使用 Proxy 代理 Date 構造函數的實例:

1const target = new Date();
2const handler = {};
3const proxy = new Proxy(target, handler);
4
5proxy.getDate(); // Error
6
7

當以上代碼運行後,控制檯會輸出以下異常信息:

1proxy.getDate();
2      ^
3TypeError: this is not a Date object.
4
5

出現以上問題的原因是因爲有些原生對象的內部屬性,只有通過正確的 this 才能拿到,所以 Proxy 無法代理這些原生對象的屬性。那麼如何解決這個問題呢?要解決這個問題,我們可以爲 getDate 方法綁定正確的 this

 1const target = new Date();
 2const handler = {
 3  get(target, property, receiver) {
 4    if (property === "getDate") {
 5      return target.getDate.bind(target);
 6    }
 7    return Reflect.get(target, property, receiver);
 8  },
 9};
10
11const proxy = new Proxy(target, handler);
12console.log(proxy.getDate());
13
14

5.4 創建可撤銷的代理對象

通過 Proxy.revocable() 方法可以用來創建一個可撤銷的代理對象,該方法的簽名爲:

1Proxy.revocable(target, handler);
2
3

相關的參數說明如下:

調用 Proxy.revocable 方法之後,其返回值是一個對象,其結構爲:{"proxy": proxy, "revoke": revoke},其中:

瞭解完 revocable 方法之後,我們來舉一個具體的示例:

 1const target = {}; 
 2const handler = {};
 3const { proxy, revoke } = Proxy.revocable(target, handler);
 4
 5proxy.name = "阿寶哥";
 6console.log(proxy.name); // 阿寶哥
 7
 8revoke();
 9console.log(proxy.name); // TypeError: Revoked
10
11

當以上代碼成功運行之後,控制檯會輸出以下內容:

1阿寶哥
2Uncaught TypeError: Cannot perform 'get' on a proxy that has been revoked
3  at <anonymous>
4
5

通過觀察以上的結果,我們可知當 proxy 對象被撤銷之後,我們就沒有辦法對已撤銷的 proxy 對象執行任何操作。

六、Proxy 在開源項目中的應用

因爲 Proxy 對象能夠提供強大的攔截能力,所以它被應用在一些成熟的開源項目中,用於實現響應式的功能,比如 vue-next 和 observer-util 項目。對於 observer-util 這個項目,阿寶哥已經寫了一篇 從觀察者模式到響應式的設計原理 的文章來介紹該項目,感興趣的小夥伴可以自行閱讀。

而對於 vue-next 項目來說,響應式的功能被封裝在 @vue/reactivity 模塊中,該模塊爲我們提供了一個 reactive 函數來創建響應式對象。下面我們來簡單瞭解一下 reactive 函數的實現:

 1// packages/reactivity/src/reactive.ts
 2export function reactive(target: object) {
 3  if (target && (target as Target)[ReactiveFlags.IS_READONLY]) {
 4    return target
 5  }
 6  return createReactiveObject(
 7    target,
 8    false,
 9    mutableHandlers,
10    mutableCollectionHandlers
11  )
12}
13
14

reactive 函數內部,會繼續調用 createReactiveObject 函數來創建響應式對象,該函數也是被定義在 reactive.ts 文件中,該函數的的具體實現如下:

 1// packages/reactivity/src/reactive.ts
 2function createReactiveObject(
 3  target: Target,
 4  isReadonly: boolean,
 5  baseHandlers: ProxyHandler<any>,
 6  collectionHandlers: ProxyHandler<any>
 7) {
 8  // 省略部分代碼  
 9  const proxyMap = isReadonly ? readonlyMap : reactiveMap
10  const existingProxy = proxyMap.get(target)
11  if (existingProxy) {
12    return existingProxy
13  }
14  const proxy = new Proxy(
15    target,
16    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
17  )
18  proxyMap.set(target, proxy)
19  return proxy
20}
21
22

createReactiveObject 函數內部,我們終於見到了期待已久的 Proxy 對象。當 target 對象不是集合類型的對象,比如 Map、Set、WeakMap 和 WeakSet 時,在創建 Proxy 對象時,使用的是 baseHandlers,該 handler 對象定義了以下 5 種捕獲器:

1export const mutableHandlers: ProxyHandler<object> = {
2  get,
3  set,
4  deleteProperty,
5  has,
6  ownKeys
7}
8
9

其中 getset 捕獲器是分別用於收集 effect 函數和觸發 effect 函數的執行。好了,這裏阿寶哥只是介紹一下 @vue/reactivity 中的 reactive 函數,關於該模塊是如何實現響應式的細節,這裏就不展開介紹了,阿寶哥後續會單獨寫一篇文章來詳細分析該模塊的功能。

七、參考資源

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/UCgFeW9jHE2B_AhcRdGAtg