相信大家對于這兩個名詞并不陌生,最知名的變化就是vue從2到3底層數據響應使用的變化。今天得幸有時間,讓我們重新溫故一下,這方面的知識可能還需要大家對于JS的原型鏈有一定的認知。
Object.defineProperty()
首先我們來學習一下Object.defineProperty()方法。大家想要更加深入可以直接閱讀MDN官網。
構造器上的方法

從圖片可以看出,該方法是在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)。當屬性不可配置時,不能在數據和訪問器屬性類型之間切換。
當試圖改變不可配置屬性(除了 value 和 writable 屬性之外)的值時,會拋出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官網。


從圖片我們可以看出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 一個通常以函數作為屬性的對象,按照捕獲時機定義的方法。
捕獲時機都有哪些?

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的一大步進步。有興趣的同學,可以按照基于數據劫持的雙向綁死實現思路圖實現完整雙向綁定。