對比Object.defineProperty()和Proxy()

相信大家對于這兩個名詞并不陌生,最知名的變化就是vue從2到3底層數據響應使用的變化。今天得幸有時間,讓我們重新溫故一下,這方面的知識可能還需要大家對于JS的原型鏈有一定的認知。

Object.defineProperty()

首先我們來學習一下Object.defineProperty()方法。大家想要更加深入可以直接閱讀MDN官網。

構造器上的方法

MDNapi截圖

從圖片可以看出,該方法是在Object直接調用,是直接在Object構造器上的,而不是在原型上。所以我們使用應當直接在構造器對象上調用此方法,而不是在任意一個Object類型的實例上調用。

語法

Object.defineProperty(obj, prop, descriptor)
// 參數
// obj 要定義屬性的目標對象
// prop 要定義或修改的屬性的名稱或Symbol,就像對象的鍵值
// descriptor 要定義或修改的屬性的描述符,用來描述該屬性是否可被枚舉,可被賦值等等,下面會講解

返回值:obj對象,被傳遞給方法經過處理的目標對象。

在ES6中,由于 Symbol類型的特殊性,用Symbol類型的值來做對象的key與常規(guī)的定義或修改不同,而Object.defineProperty 是定義key為Symbol的屬性的方法之一。

描述符

描述符主要分為三類:公共描述符(官網并沒有這個稱呼,這個是我自己取的,便于理解),數據描述符,存取描述符。

數據描述符鍵值:
  • value
    描述:該屬性對應的值??梢允侨魏斡行У?JavaScript 值(數值,對象,函數等)。
    默認值:undefined
  • writable
    描述:是否可以被賦值運算符改變
    默認值:false
存取運算符鍵值(這里不懂的朋友可以理解成屬性存值取值的過程,類似鉤子)
  • get
    描述:屬性的 getter 函數,如果沒有 getter,則為 undefined。當訪問該屬性時,會調用此函數。執(zhí)行時不傳入任何參數,但是會傳入 this 對象(由于繼承關系,這里的this并不一定是定義該屬性的對象)。該函數的返回值會被用作屬性的值。
    默認值:undefined
  • set
    描述:屬性的 setter 函數,如果沒有 setter,則為 undefined。當屬性值被修改時,會調用此函數。該方法接受一個參數(也就是被賦予的新值),會傳入賦值時的 this 對象。
    默認值:undefined
公共描述符鍵值(該名詞并不官方)

數據描述符可以看出,我們擁有了直接給屬性賦值的權利,還能控制屬性是否可以改寫。
存取描述符可以看出,屬性取值和賦值的操作完全掌握在我們自己手中。
所以不免有朋友好奇,這兩種描述符是否有些重疊,你的疑問是對的。數據描述符和存取描述符確實不能同時使用,它兩水火不容,同時使用會產生異常。但是我們下面要講的描述符確實可以和它們在一起配合使用,所以我給它們取名公共描述符。

  • configurable
    描述:該屬性的描述符是否能被修改,屬性是否能被刪除。
    默認值:false
    如果屬性已經存在,Object.defineProperty()將嘗試根據描述符中的值以及對象當前的配置來修改這個屬性。如果舊描述符將其configurable 屬性設置為false,則該屬性被認為是“不可配置的”,并且沒有屬性可以被改變(除了單向改變 writable 為 false)。當屬性不可配置時,不能在數據和訪問器屬性類型之間切換。

當試圖改變不可配置屬性(除了 valuewritable 屬性之外)的值時,會拋出TypeError,除非當前值和新值相同。

  • enumerable
    描述:enumerable 定義了對象的屬性是否可以在 for...in 循環(huán)和 Object.keys() 中被枚舉。
    默認值:false
var o = {};
Object.defineProperty(o, "a", { value : 1, enumerable: true });
Object.defineProperty(o, "b", { value : 2, enumerable: false });
Object.defineProperty(o, "c", { value : 3 }); // enumerable 默認為 false
o.d = 4; // 如果使用直接賦值的方式創(chuàng)建對象的屬性,則 enumerable 為 true
Object.defineProperty(o, Symbol.for('e'), {
  value: 5,
  enumerable: true
});
Object.defineProperty(o, Symbol.for('f'), {
  value: 6,
  enumerable: false
});

for (var i in o) {
  console.log(i);
}
// logs 'a' and 'd' (in undefined order)

Object.keys(o); // ['a', 'd']

o.propertyIsEnumerable('a'); // true
o.propertyIsEnumerable('b'); // false
o.propertyIsEnumerable('c'); // false
o.propertyIsEnumerable('d'); // true
o.propertyIsEnumerable(Symbol.for('e')); // true
o.propertyIsEnumerable(Symbol.for('f')); // false

var p = { ...o }
p.a // 1
p.b // undefined
p.c // undefined
p.d // 4
p[Symbol.for('e')] // 5
p[Symbol.for('f')] // undefined

記住,這些選項不一定是自身屬性,也要考慮繼承來的屬性。為了確認保留這些默認值,在設置之前,可能要凍結 Object.prototype (en-US),明確指定所有的選項,或者通過 Object.create(null)__proto__ (en-US) 屬性指向 null。

// 使用 __proto__
var obj = {};
var descriptor = Object.create(null); // 沒有繼承的屬性
// 默認沒有 enumerable,沒有 configurable,沒有 writable
descriptor.value = 'static';
Object.defineProperty(obj, 'key', descriptor);

// 顯式
Object.defineProperty(obj, "key", {
  enumerable: false,
  configurable: false,
  writable: false,
  value: "static"
});

繼承

如果訪問者的屬性是被繼承的,它的 get 和 set 方法會在子對象的屬性被訪問或者修改時被調用。如果這些方法用一個變量存值,該值會被所有對象共享。

function myclass() {
}

var value;
Object.defineProperty(myclass.prototype, "x", {
  get() {
    return value;
  },
  set(x) {
    value = x;
  }
});

var a = new myclass();
var b = new myclass();
a.x = 1;
console.log(b.x); // 1

這可以通過將值存儲在另一個屬性中解決。在 get 和 set 方法中,this 指向某個被訪問和修改屬性的對象。

function myclass() {
}

Object.defineProperty(myclass.prototype, "x", {
  get() {
    return this.stored_x;
  },
  set(x) {
    this.stored_x = x;
  }
});

var a = new myclass();
var b = new myclass();
a.x = 1;
console.log(b.x); // undefined

不像訪問者屬性,值屬性始終在對象自身上設置,而不是一個原型。然而,如果一個不可寫的屬性被繼承,它仍然可以防止修改對象的屬性。

function myclass() {
}

myclass.prototype.x = 1;
Object.defineProperty(myclass.prototype, "y", {
  writable: false,
  value: 1
});

var a = new myclass();
a.x = 2;
console.log(a.x); // 2
console.log(myclass.prototype.x); // 1
a.y = 2; // Ignored, throws in strict mode
console.log(a.y); // 1
console.log(myclass.prototype.y); // 1

Proxy

接下來我們來學習一下Proxy。同樣,大家想要更加深入可以直接閱讀MDN官網

defineProperty方法

Proxy對象

從圖片我們可以看出Proxy是JS的標準內置對象,和Object平級。
Proxy對象用于創(chuàng)建一個對象的代理,從而實現基本操作的攔截和自定義(如屬性查找、賦值、枚舉、函數調用等)。我理解這邊就像是給對象包了一層外衣,讓我們可以按照規(guī)定自己定義在不同捕捉的時機執(zhí)行的hooks。

術語

1.handler(en-US)
包含捕捉器(trap)的占位符對象,可以稱為處理器對象。
2.traps
提供屬性訪問的方法。這里指的其實就是根據時機我們自己定義要執(zhí)行的方法群。
3.target
被Proxy代理虛擬化的對象。這里其實就是目標對象。

語法

const p = new Proxy(target, handler)
// target 要使用Proxy的目標對象(可以是任何類型的對象,包括原生數組,函數,甚至另外一個代理)。
// handler 一個通常以函數作為屬性的對象,按照捕獲時機定義的方法。

捕獲時機都有哪些?

Proxy對象的捕捉時機

handler對象是一個容納一批特定屬性的占位符對象。它包含有 Proxy 的各個捕獲器(trap)。
所有的捕捉器是可選的。如果沒有定義某個捕捉器,那么就會保留源對象的默認行為。我們可以根據自己的需要去定義這個目標對象的操作方法,如果同學還想深入了解不同捕獲器請查看官網。

重點來啦

我們從一道面試題來對比兩者:實現雙向綁定Proxy與defineProperty的優(yōu)劣。

雙向綁定體系

雙向綁定其實已經是一個老掉牙的問題,只要涉及到MVVM框架就不得不涉及的知識點,大家熟知的vue三要素之一。

Vue三要素

  • 響應式:監(jiān)聽數據變化,實現方法就是響應式
  • 模板引擎:如何解析模板
  • 渲染:Vue如何將監(jiān)聽到的數據變化和解析后的HTML進行渲染。

可以實現雙向綁定的方法有很多:
1.KnockoutJs基于觀察者模式的雙向綁定
2.Ember基于數據模型的雙向綁定
3.Angular基于臟檢查的雙向綁定
4.面試中最常見的基于數據劫持的雙向綁定

最常見的基于數據劫持的雙向綁定有兩種實現:

  • Vue2 Object.defineProperty 實現
  • Vue3 Proxy 實現(嚴格來說Proxy被稱為代理,并非劫持,不過作用有很多相似之處)
雙向綁定體系

基于數據劫持的Object.observe方法,已被廢棄。

基于數據劫持實現的雙向綁定的特點

什么是數據劫持?

通常我們利用Object.defineProperty()劫持對象的訪問器,在屬性變化時我們可以獲取變化,從而進行下一步操作。使用過Vue2的小伙伴們都知道,Vue初始化實例時會把data中聲明的對象屬性進行getter/setter轉化,也就是使用了Object.defineProperty。

數據劫持的優(yōu)勢

目前主流框架可以分為兩個流派:一個是以React為首的單向數據綁定,另一個是以Angular、Vue為主的雙向數據綁定。

其實三大框架都是既可以雙向綁定也可以單項綁定,比如React可以手動綁定onChange和value實現雙向綁定,也可以調用可以雙向綁定庫。Vue也加入了props這種單向流的api,不過都并非主流賣點。

這里我們不討論單向或者雙向,我們來對比其他雙向綁定的實現方法,數據劫持的優(yōu)勢所在:
1.無需顯式調用:例如Vue運用數據劫持+發(fā)布訂閱,直接可以通知變化并驅動視圖。比如Angular則需要顯式調用markForCheck(這里可以使用zone.js庫避免顯示調用),react則需要顯式調用setState。
2.可精準得知變化數據:我們劫持了屬性的setter,當屬性變化,我們可以精確獲知變化的內容,因此這部分不需要額外的diff操作,否則我們只知道數據發(fā)生了變化而不知道具體哪些數據變化了,這個時候就需要大量的diff來找出變化值,這是額外的性能損耗。

基于數據劫持雙向綁定的實現思路

數據劫持是雙向綁定各種方案中比較流行的一種,最著名的實現就是Vue。
基于數據劫持的雙向綁定離不開Proxy與Object.defineProperty等方法對對象/對象屬性的“劫持”,我們要實現一個完整的雙向綁定需要以下幾個要點。
1.利用Proxy或Object.defineProperty生成的Observer針對對象/對象的屬性進行“劫持”,在屬性發(fā)生變化后通知訂閱者。(①)
2.解析器Compile解析模板中的Directive(指令),收集指令所依賴的方法和數據,等待數據變化進行渲染。(②)
3.Watcher屬于Observer和Compile橋梁,它將接收到的Observer產生的數據變化,并根據Compile提供的指令進行試圖渲染,是的數據變化促使視圖圖變化。(③)

基于數據劫持的雙向綁死實現思路

這里有人就好奇了,為什么我們不一個模塊直接在Dep數據變化的時候直接去更新視圖,還要用發(fā)布訂閱模式。我這里總結了兩個原因:

  • 大家如果了解開放封閉原則,就會知道這樣操作明顯違反了開放封閉原則
  • 代碼耦合嚴重,我們的數據,方法和DOM都是耦合在一起的,這就是傳說的面條代碼。

到這里,相信大家對于如何實現雙向綁定都有了相應的認知。接下來我們通過分析Vue2和Vue3響應式數據原理來對比Object.defineProperty到Proxy都有哪些的不同。

Vue2響應式數據原理

Vue2的數據響應使用了ES5中的Object.defineProperty。
Object.defineProperty只能劫持對象的屬性,從而需要對每個對象,每個屬性進行遍歷。如果,屬性值是對象,還需要深度遍歷。

  • 優(yōu)點:
    兼容性好,IE9 。
  • 缺點:
    1.只能劫持對象的屬性,因此需要對每個對象的每個屬性進行遍歷,性能消耗大。
    2.Vue的文檔提到了Vue是可以檢測到數組變化的,但是只有以下七種方法。其實Vue2里面是通過重寫數據的操作方法(通過原型鏈進行攔截)來對數組進行監(jiān)聽的。但是對于數組長度變化下標值修改內容是無法監(jiān)聽的,Vue提供了Vue.set()進行響應式。
    3.不能監(jiān)聽對象屬性的新增和刪除。
    4..不能對es6新產生的Map,Set這些數據結構做出監(jiān)聽。
    因為Object.defineProperty會一開始就會遍歷data、methods、props、computed、watch、mixins… 里的一系列變量全都綁定在this上,當嵌套層次比較深時會影響性能和占內存比較大。
push()
pop()
shift()
unshift()
splice()
sort()
reverse()

模擬Vue2中用Object.defineProperty實現數據響應

let obj = {
  key:'cue'
  flags: {
    name: ['AAA', 'VVV', 'FFF']
  }
}
function observer(obj) {
  if (typeof obj == 'object') {
    for (let key in obj) {
      defineReactive(obj, key, obj[key])
    }
  }
}
function defineReactive(obj, key, value) {
  Object.defineProperty(obj, key, {
    get() {
      console.log('獲?。? + key)
      return value
    },
    set(val) {
      observer(val)
      console.log(key + "-數據改變了")
      value = val
    }
  })
}
observer(obj)

Vue3響應式數據原理

先來看下MND描述:

Proxy 對象用于創(chuàng)建一個對象的代理,從而實現基本操作的攔截和自定義(如屬性查找、賦值、枚舉、函數調用等)。

Proxy可以劫持整個對象,并返回一個新的對象。

  • 優(yōu)點:
    1.可以直接監(jiān)聽對象而非屬性
    2.可以直接監(jiān)聽數組的變化
    3.有多種攔截方式,不限于apply、ownKeys、has等是defineProperty不具備的
    4.返回的是一個新對象,我們可以只操作新的對象達到目的,而defineProperty只能遍歷對象屬性直接修改
    模擬Vue3種用Proxy實現數據響應
let obj = {
  key:'cue'
  flags: {
    name: ['AAA', 'VVV', 'FFF']
  }
}
function observerProxy(obj) {
  const handler = {
    get(target, key, receiver) {
      console.log("獲?。? + key);
      if (typeof target[key] === "object" && target[key] !== null) {
       // 如果是對象,就添加 proxy 攔截
        return new Proxy(target[key], handler);
      }
      return Reflect.get(target, key, receiver);
    },
    set(target, key, value, receiver) {
      console.log("設置:" + key);
      return Reflect.set(target, key, value, receiver);
    },
  };
  return new Proxy(obj, handler);
}
let newObj = observerProxy(obj);

vue3在開發(fā)中的一些具體使用

<script lang="ts">
import { defineComponent, setup } from'vue'
export default defineComponent({
   setup(props, context) {
       //props父組件傳的值
       //在setup()里我們不能用this
       //vue2.0里的 this.$emit, this.$psrent, this.$refs在這里都不能用了。
       //context就是對這些參數的集合
       //context.attrs
       //context.slots
       //context.parent 相當于2.0里 this.$psrent
       //context.root 相當于2.0里 this
       //context.emit 相當于2.0里 this.$emit
       //context.refs 相當于2.0里 this.$refs
       let data = reactive({ message: "hello world" });  //響應式對象
       let message = ref("hello world");  //響應式字符串
       let arr = ref(['hello','world']);  //響應式數組字符串
       let username = computed(() => user.firstname + " " + user.lastname); //計算屬性
       const copy = readonly(original);  //只讀代理
       ...
   }
})
</script>

總結

首先,我們先認識到了Object.defineProperty和Proxy的概念和使用。再圍繞著實現雙向綁定Proxy與defineProperty的優(yōu)劣面試題,了解雙向綁定體系,什么是數據劫持,數據劫持實現雙向綁定的思路。最后通過分析Vue2和Vue3響應式數據原理實現(Observer)的區(qū)別,更加深入了解了從Object.defineProperty到Proxy的一大步進步。有興趣的同學,可以按照基于數據劫持的雙向綁死實現思路圖實現完整雙向綁定。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容