前言
JavaScript的對象,是一組鍵值對的集合,可以擁有任意數量的唯一鍵,鍵可以是字符串(String)類型或標記(Symbol,ES6新增的基本數據類型)類型,每個鍵對應一個值,值可以是任意類型的任意值。對於對象內的屬性,JavaScript提供了一個屬性描述器接口PropertyDescriptor
,大部分開發者並不需要直接使用它,但是很多框架和類庫內部實現使用了它,如avalon.js,Vue.js,本篇介紹屬性描述器及相關應用。
定義對象屬性
在介紹對象屬性描述之前,先介紹一下如何定義對象屬性。最常用的方式就是使用如下方式:
var a = {
name: 'jh'
};
// or
var b = {};
b.name = 'jh';
// or
var c = {};
var key = 'name';
c[key] = 'jh';
本文使用字面量方式創建對象,但是JavaScript還提供其他方式,如,new Object(),Object.create(),瞭解更多請查看對象初始化。
Object.defineProperty()
上面通常使用的方式不能實現對屬性描述器的操作,我們需要使用defineProperty()
方法,該方法爲一個對象定義新屬性或修改一個已定義屬性,接受三個參數Object.defineProperty(obj, prop, descriptor)
,返回值爲操作後的對象:
- obj, 待操作對象
- 屬性名
- 操作屬性的屬性描述對象
var x = {};
Object.defineProperty(x, 'count', {});
console.log(x); // Object {count: undefined}
由於傳入一個空的屬性描述對象,所以輸出對象屬性值爲undefined,當使用defineProperty()
方法操作屬性時,描述對象默認值爲:
- value: undefined
- set: undefined
- get: undefined
- writable: false
- enumerable: false,
- configurable: false
不使用該方法定義屬性,則屬性默認描述爲:
- value: undefined
- set: undefined
- get: undefined
- writable: true
- enumerable: true,
- configurable: true
默認值均可被明確參數值設置覆蓋。
當然還支持批量定義對象屬性及描述對象,使用``Object.defineProperties()`方法,如:
var x = {};
Object.defineProperties(x, {
count: {
value: 0
},
name: {
value: 'jh'
}
});
console.log(x); // Object {count: 0, name: 'jh'}
讀取屬性描述對象
JavaScript支持我們讀取某對象屬性的描述對象,使用Object.getOwnPropertyDescriptor(obj, prop)
方法:
var x = {
name: 'jh'
};
Object.defineProperty(x, 'count', {});
Object.getOwnPropertyDescriptor(x, 'count');
Object.getOwnPropertyDescriptor(x, 'name');
// Object {value: undefined, writable: false, enumerable: false, configurable: false}
// Object {value: "jh", writable: true, enumerable: true, configurable: true}
該實例也印證了上面介紹的以不同方式定義屬性時,其默認屬性描述對象是不同的。
屬性描述對象
PropertyDescriptor
API提供了六大實例屬性以描述對象屬性,包括:configurable, enumerable, get, set, value, writable.
value
指定對象屬性值:
var x = {};
Object.defineProperty(x, 'count', {
value: 0
});
console.log(x); // Object {count: 0}
writable
指定對象屬性是否可變:
var x = {};
Object.defineProperty(x, 'count', {
value: 0
});
console.log(x); // Object {count: 0}
x.count = 1; // 靜默失敗,不會報錯
console.log(x); // Object {count: 0}
使用defineProperty()
方法時,默認有writable: false
, 需要顯示設置writable: true
。
存取器函數(getter/setter)
對象屬性可以設置存取器函數,使用get
聲明存取器getter函數,set
聲明存取器setter函數;若存在存取器函數,則在訪問或設置該屬性時,將調用對應的存取器函數:
get
讀取該屬性值時調用該函數並將該函數返回值賦值給屬性值;
var x = {};
Object.defineProperty(x, 'count', {
get: function() {
console.log('讀取count屬性 +1');
return 0;
}
});
console.log(x); // Object {count: 0}
x.count = 1;
// '讀取count屬性 +1'
console.log(x.count); // 0
set
當設置函數值時調用該函數,該函數接收設置的屬性值作參數:
var x = {};
Object.defineProperty(x, 'count', {
set: function(val) {
this.count = val;
}
});
console.log(x);
x.count = 1;
執行上訴代碼,會發現報錯,執行棧溢出:
上述代碼在設置count
屬性時,會調用set
方法,而在該方法內爲count
屬性賦值會再次觸發set
方法,所以這樣是行不通的,JavaScript使用另一種方式,通常存取器函數得同時聲明,代碼如下:
var x = {};
Object.defineProperty(x, 'count', {
get: function() {
return this._count;
},
set: function(val) {
console.log('設置count屬性 +1');
this._count = val;
}
});
console.log(x); // Object {count: undefined}
x.count = 1;
// '設置count屬性 +1'
console.log(x.count); 1
事實上,在使用defineProperty()
方法設置屬性時,通常需要在對象內部維護一個新內部變量(以下劃線_
開頭,表示不希望被外部訪問),作爲存取器函數的中介。
注:當設置了存取器描述時,不能設置value
和writable
描述。
我們發現,設置屬性存取器函數後,我們可以實現對該屬性的實時監控,這在實踐中很有用武之地,後文會印證這一點。
enumerable
指定對象內某屬性是否可枚舉,即使用for in
操作是否可遍歷:
var x = {
name: 'jh'
};
Object.defineProperty(x, 'count', {
value: 0
});
for (var key in x) {
console.log(key + ' is ' + x[key]);
}
// name is jh
上面無法遍歷count
屬性,因爲使用defineProperty()
方法時,默認有enumerable: false
,需要顯示聲明該描述:
var x = {
name: 'jh'
};
Object.defineProperty(x, 'count', {
value: 0,
enumerable: true
});
for (var key in x) {
console.log(key + ' is ' + x[key]);
}
// name is jh
// count is 0
x.propertyIsEnumerable('count'); // true
configurable
該值指定對象屬性描述是否可變:
var x = {};
Object.defineProperty(x, 'count', {
value: 0,
writable: false
});
Object.defineProperty(x, 'count', {
value: 0,
writable: true
});
執行上述代碼會報錯,因爲使用defineProperty()
方法時默認是configurable: false
,輸出如圖:
修改如下,即可:
var x = {};
Object.defineProperty(x, 'count', {
value: 0,
writable: false,
configurable: true
});
x.count = 1;
console.log(x.count); // 0
Object.defineProperty(x, 'count', {
writable: true
});
x.count = 1;
console.log(x.count); // 1
屬性描述與視圖模型綁定
介紹完屬性描述對象,我們來看看其在現代JavaScript框架和類庫上的應用。目前有很多框架和類庫實現數據和DOM視圖的單向甚至雙向綁定,如React,angular.js,avalon.js,,Vue.js等,使用它們很容易做到對數據變更進行響應式更新DOM視圖,甚至視圖和模型可以實現雙向綁定,同步更新。當然這些框架、類庫內部實現原理主要分爲三大陣營。本文以Vue.js爲例,Vue.js是當下比較流行的一個響應式的視圖層類庫,其內部實現響應式原理就是本文介紹的屬性描述在技術中的具體應用。
可以點擊此處,查看一個原生JavaScript實現的簡易數據視圖單向綁定實例,在該實例中,點擊按鈕可以實現計數自增,在輸入框輸入內容會同步更新到展示DOM,甚至在控制檯改變data
對象屬性值,DOM會響應更新,如圖:
數據視圖單向綁定
現有如下代碼:
var data = {};
var contentEl = document.querySelector('.content');
Object.defineProperty(data, 'text', {
writable: true,
configurable: true,
enumerable: true,
get: function() {
return contentEl.innerHTML;
},
set: function(val) {
contentEl.innerHTML = val;
}
});
很容易看出,當我們設置data對象的text
屬性時,會將該值設置爲視圖DOM元素的內容,而訪問該屬性值時,返回的是視圖DOM元素的內容,這就簡單的實現了數據到視圖的單向綁定,即數據變更,視圖也會更新。
以上僅是針對一個元素的數據視圖綁定,但稍微有經驗的開發者便可以根據以上思路,進行封裝,很容易的實現一個簡易的數據到視圖單向綁定的工具類。
抽象封裝
接下來對以上實例進行簡單抽象封裝,點擊查看完整實例代碼。
首先聲明數據結構:
window.data = {
title: '數據視圖單向綁定',
content: '使用屬性描述器實現數據視圖綁定',
count: 0
};
var attr = 'data-on'; // 約定好的語法,聲明DOM綁定對象屬性
然後封裝函數批量處理對象,遍歷對象屬性,設置描述對象同時爲屬性註冊變更時的回調:
// 爲對象中每一個屬性設置描述對象,尤其是存取器函數
function defineDescriptors(obj) {
for (var key in obj) {
// 遍歷屬性
defineDescriptor(obj, key, obj[key]);
}
// 爲特定屬性設置描述對象
function defineDescriptor(obj, key, val) {
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function() {
var value = val;
return value;
},
set: function(newVal) {
if (newVal !== val) {
// 值發生變更才執行
val = newVal;
Observer.emit(key, newVal); // 觸發更新DOM
}
}
});
Observer.subscribe(key); // 爲該屬性註冊回調
}
}
管理事件
以發佈訂閱模式管理屬性變更事件及回調:
// 使用發佈/訂閱模式,集中管理監控和觸發回調事件
var Observer = {
watchers: {},
subscribe: function(key) {
var el = document.querySelector('[' + attr + '="'+ key + '"]');
// demo
var cb = function react(val) {
el.innerHTML = val;
}
if (this.watchers[key]) {
this.watchers[key].push(cb);
} else {
this.watchers[key] = [].concat(cb);
}
},
emit: function(key, val) {
var len = this.watchers[key] && this.watchers[key].length;
if (len && len > 0) {
for(var i = 0; i < len; i++) {
this.watchers[key][i](val);
}
}
}
};
初始化實例
最後初始化實例:
// 初始化demo
function init() {
defineDescriptors(data); // 處理數據對象
var eles = document.querySelectorAll('[' + attr + ']');
// 初始遍歷DOM展示數據
// 其實可以將該操作放到屬性描述對象的get方法內,則在初始化時只需要對屬性遍歷訪問即可
for (var i = 0, len = eles.length; i < len; i++) {
eles[i].innerHTML = data[eles[i].getAttribute(attr)];
}
// 輔助測試實例
document.querySelector('.add').addEventListener('click', function(e) {
data.count += 1;
});
}
init();
html代碼參考如下:
<h2 class="title" data-on="title"></h2>
<div class="content" data-on="content"></div>
<div class="count" data-on="count"></div>
<div>
請輸入內容:
<input type="text" class="content-input" placeholder="請輸入內容">
</div>
<button class="add" onclick="">加1</button>
Vue.js的響應式原理
上一節實現了一個簡單的數據視圖單向綁定實例,現在對Vue.js的響應式單向綁定進行簡要分析,主要需要理解其如何追蹤數據變更。
依賴追蹤
Vue.js支持我們通過data
參數傳遞一個JavaScript對象做爲組件數據,然後Vue.js將遍歷此對象屬性,使用Object.defineProperty
方法設置描述對象,通過存取器函數可以追蹤該屬性的變更,本質原理和上一節實例差不多,但是不同的是,Vue.js創建了一層Watcher
層,在組件渲染的過程中把屬性記錄爲依賴,之後當依賴項的setter
被調用時,會通知Watcher
重新計算,從而使它關聯的組件得以更新,如下圖:
組件掛載時,實例化watcher
實例,並把該實例傳遞給依賴管理類,組件渲染時,使用對象觀察接口遍歷傳入的data對象,爲每個屬性創建一個依賴管理實例並設置屬性描述對象,在存取器函數get函數中,依賴管理實例添加(記錄)該屬性爲一個依賴,然後當該依賴變更時,觸發set函數,在該函數內通知依賴管理實例,依賴管理實例分發該變更給其內存儲的所有watcher
實例,watcher
實例重新計算,更新組件。
因此可以總結說Vue.js的響應式原理是依賴追蹤,通過一個觀察對象,爲每個屬性,設置存取器函數並註冊一個依賴管理實例
dep
,dep
內爲每個組件實例維護一個watcher
實例,在屬性變更時,通過setter通知dep
實例,dep
實例分發該變更給每一個watcher
實例,watcher
實例各自計算更新組件實例,即watcher
追蹤dep
添加的依賴,Object.defineProperty()
方法提供這種追蹤的技術支持,dep
實例維護這種追蹤關係。
源碼簡單分析
接下來對Vue.js源碼進行簡單分析,從對JavaScript對象和屬性的處理開始:
觀察對象(Observer)
首先,Vue.js也提供了一個抽象接口觀察對象,爲對象屬性設置存儲器函數,收集屬性依賴然後分發依賴更新:
var Observer = function Observer (value) {
this.value = value;
this.dep = new Dep(); // 管理對象依賴
this.vmCount = 0;
def(value, '__ob__', this); // 緩存處理的對象,標記該對象已處理
if (Array.isArray(value)) {
var augment = hasProto
? protoAugment
: copyAugment;
augment(value, arrayMethods, arrayKeys);
this.observeArray(value);
} else {
this.walk(value);
}
};
上面代碼關注兩個節點,this.observeArray(value)
和this.walk(value);
:
-
若爲對象,則調用
walk()
方法,遍歷該對象屬性,將屬性轉換爲響應式:Observer.prototype.walk = function walk (obj) { var keys = Object.keys(obj); for (var i = 0; i < keys.length; i++) { defineReactive$$1(obj, keys[i], obj[keys[i]]); } };
可以看到,最終設置屬性描述對象是通過調用
defineReactive$$1()
方法。 -
若value爲對象數組,則需要額外處理,調用
observeArray()
方法對每一個對象均產生一個Observer
實例,遍歷監聽該對象屬性:Observer.prototype.observeArray = function observeArray (items) { for (var i = 0, l = items.length; i < l; i++) { observe(items[i]); } };
核心是爲每個數組項調用
observe
函數:function observe(value, asRootData) { if (!isObject(value)) { return // 只需要處理對象 } var ob; if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) { ob = value.__ob__; // 處理過的則直接讀取緩存 } else if ( observerState.shouldConvert && !isServerRendering() && (Array.isArray(value) || isPlainObject(value)) && Object.isExtensible(value) && !value._isVue) { ob = new Observer(value); // 處理該對象 } if (asRootData && ob) { ob.vmCount++; } return ob }
調用
ob = new Observer(value);
後就回到第一種情況的結果:調用defineReactive$$1()
方法生成響應式屬性。
生成響應式屬性
源碼如下:
function defineReactive$$1 (obj,key,val,customSetter) {
var dep = new Dep(); // 管理屬性依賴
var property = Object.getOwnPropertyDescriptor(obj, key);
if (property && property.configurable === false) {
return
}
// 之前已經設置了的get/set需要合併調用
var getter = property && property.get;
var setter = property && property.set;
var childOb = observe(val); // 屬性值也可能是對象,需要遞歸觀察處理
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
var value = getter ? getter.call(obj) : val;
if (Dep.target) { // 管理依賴對象存在指向的watcher實例
dep.depend(); // 添加依賴(記錄)
if (childOb) { // 屬性值爲對象
childOb.dep.depend(); // 屬性值對象也需要添加依賴
}
if (Array.isArray(value)) {
dependArray(value); // 處理數組
}
}
return value
},
set: function reactiveSetter (newVal) {
var value = getter ? getter.call(obj) : val;
/* eslint-disable no-self-compare */
if (newVal === value || (newVal !== newVal && value !== value)) {
return // 未發生變更不需要往後執行
}
/* eslint-enable no-self-compare */
if ("development" !== 'production' && customSetter) {
customSetter();
}
if (setter) {
setter.call(obj, newVal); // 更新屬性值
} else {
val = newVal; // 更新屬性值
}
childOb = observe(newVal); // 每次值變更時需要重新觀察,因爲可能值爲對象
dep.notify(); // 發佈更新事件
}
});
}
該方法使用Object.defineProperty()
方法設置屬性描述對象,邏輯集中在屬性存取器函數內:
- get: 返回屬性值,如果
watcher
存在,則遞歸記錄依賴; - set: 屬性值發生變更時,更新屬性值,並調用
dep.notify()
方法發佈更新事件;
管理依賴
Vue.js需要管理對象的依賴,在屬性更新時通知watcher
更新組件,進而更新視圖,Vue.js管理依賴接口採用發佈訂閱模式實現,源碼如下:
var uid$1 = 0;
var Dep = function Dep () {
this.id = uid$1++; // 依賴管理實例id
this.subs = []; // 訂閱該依賴管理實例的watcher實例數組
};
Dep.prototype.depend = function depend () { // 添加依賴
if (Dep.target) {
Dep.target.addDep(this); // 調用watcher實例方法訂閱此依賴管理實例
}
};
Dep.target = null; // watcher實例
var targetStack = []; // 維護watcher實例棧
function pushTarget (_target) {
if (Dep.target) { targetStack.push(Dep.target); }
Dep.target = _target; // 初始化Dep指向的watcher實例
}
function popTarget () {
Dep.target = targetStack.pop();
}
訂閱
如之前,生成響應式屬性爲屬性設置存取器函數時,get函數內調用dep.depend()
方法添加依賴,該方法內調用Dep.target.addDep(this);
,即調用指向的watcher
實例的addDep
方法,訂閱此依賴管理實例:
Watcher.prototype.addDep = function addDep (dep) {
var id = dep.id;
if (!this.newDepIds.has(id)) { // 是否已訂閱
this.newDepIds.add(id); // watcher實例維護的依賴管理實例id集合
this.newDeps.push(dep); // watcher實例維護的依賴管理實例數組
if (!this.depIds.has(id)) { // watcher實例維護的依賴管理實例id集合
// 調用傳遞過來的依賴管理實例方法,添加此watcher實例爲訂閱者
dep.addSub(this);
}
}
};
watcher
實例可能同時追蹤多個屬性(即訂閱多個依賴管理實例),所以需要維護一個數組,存儲多個訂閱的依賴管理實例,同時記錄每一個實例的id,便於判斷是否已訂閱,而後調用依賴管理實例的addSub
方法:
Dep.prototype.addSub = function addSub (sub) {
this.subs.push(sub); // 實現watcher到依賴管理實例的訂閱關係
};
該方法只是簡單的在訂閱數組內添加一個訂閱該依賴管理實例的watcher
實例。
發佈
屬性變更時,在屬性的存取器set函數內調用了dep.notify()
方法,發佈此屬性變更:
Dep.prototype.notify = function notify () {
// 複製訂閱者數組
var subs = this.subs.slice();
for (var i = 0, l = subs.length; i < l; i++) {
subs[i].update(); // 分發變更
}
};
觸發更新
前面提到,Vue.js中由watcher
層追蹤依賴變更,發生變更時,通知組件更新:
Watcher.prototype.update = function update () {
/* istanbul ignore else */
if (this.lazy) {
this.dirty = true;
} else if (this.sync) { // 同步
this.run();
} else { // 異步
queueWatcher(this); // 最後也是調用run()方法
}
};
調用run
方法,通知組件更新:
Watcher.prototype.run = function run () {
if (this.active) {
var value = this.get(); // 獲取新屬性值
if (value !== this.value || // 若值
isObject(value) || this.deep) {
var oldValue = this.value; // 緩存舊值
this.value = value; // 設置新值
if (this.user) {
try {
this.cb.call(this.vm, value, oldValue);
} catch (e) {
handleError(e, this.vm, ("callback for watcher \"" + (this.expression) + "\""));
}
} else {
this.cb.call(this.vm, value, oldValue);
}
}
}
};
調用this.get()
方法,實際上,後面會看到在該方法內處理了屬性值的更新與組件的更新,這裏判斷當屬性變更時調用初始化時傳給實例的cb
回調函數,並且回調函數接受屬性新舊值兩個參數,此回調通常是對於watch
聲明的監聽屬性纔會存在,否則默認爲空函數。
追蹤依賴接口實例化
每一個響應式屬性都是由一個Watcher
實例追蹤其變更,而針對不同屬性(data, computed, watch),Vue.js進行了一些差異處理,如下是接口主要邏輯:
var Watcher = function Watcher (vm,expOrFn,cb,options) {
this.cb = cb;
...
// parse expression for getter
if (typeof expOrFn === 'function') {
this.getter = expOrFn;
} else {
this.getter = parsePath(expOrFn);
}
this.value = this.lazy
? undefined
: this.get();
};
在初始化Watcher
實例時,會解析expOrFn
參數(表達式或者函數)成拓展getterthis.getter
,然後調用this.get()
方法,返回值作爲this.value
值:
Watcher.prototype.get = function get () {
pushTarget(this); // 入棧watcher實例
var value;
var vm = this.vm;
if (this.user) {
try {
value = this.getter.call(vm, vm); // 通過this.getter獲取新值
} catch (e) {
handleError(e, vm, ("getter for watcher \"" +
(this.expression) + "\""));
}
} else {
value = this.getter.call(vm, vm); // 通過this.getter獲取新值
}
if (this.deep) { // 深度遞歸遍歷對象追蹤依賴
traverse(value);
}
popTarget(); // 出棧watcher實例
this.cleanupDeps(); // 清空緩存依賴
return value // 返回新值
};
這裏需要注意的是對於data
屬性,而非computed
屬性或watch
屬性,而言,其watcher
實例的this.getter
通常就是updateComponent
函數,即渲染更新組件,get
方法返回undefined,而對於computed
計算屬性而言,會傳入對應指定函數給this.getter
,其返回值就是此get
方法返回值。
data普通屬性
Vue.jsdata屬性是一個對象,需要調用對象觀察接口new Observer(value)
:
function observe (value, asRootData) {
if (!isObject(value)) {
return
}
var ob;
ob = new Observer(value); // 對象觀察實例
return ob;
}
// 初始處理data屬性
function initData (vm) {
// 調用observe函數
observe(data, true /* asRootData */);
}
計算屬性
Vue.js對計算屬性處理是有差異的,它是一個變量,可以直接調用Watcher
接口,把其屬性指定的計算規則傳遞爲,屬性的拓展getter
,即:
// 初始處理computed計算屬性
function initComputed (vm, computed) {
for (var key in computed) {
var userDef = computed[key]; // 對應的計算規則
// 傳遞給watcher實例的this.getter -- 拓展getter
var getter = typeof userDef === 'function' ?
userDef : userDef.get;
watchers[key] = new Watcher(vm,
getter, noop, computedWatcherOptions);
}
}
watch屬性
而對於watch屬性又有不同,該屬性是變量或表達式,而且與計算屬性不同的是,它需要指定一個變更事件發生後的回調函數:
function initWatch (vm, watch) {
for (var key in watch) {
var handler = watch[key];
createWatcher(vm, key, handler[i]); // 傳遞迴調
}
}
function createWatcher (vm, key, handler) {
vm.$watch(key, handler, options); // 回調
}
Vue.prototype.$watch = function (expOrFn, cb, options) {
// 實例化watcher,並傳遞迴調
var watcher = new Watcher(vm, expOrFn, cb, options);
}
初始化Watcher與依賴管理接口的連接
無論哪種屬性最後都是由watcher
接口實現追蹤依賴,而且組件在掛載時,即會初始化一次Watcher
實例,綁定到Dep.target
,也就是將Watcher
和Dep
建立連接,如此在組件渲染時才能對屬性依賴進行追蹤:
function mountComponent (vm, el, hydrating) {
...
updateComponent = function () {
vm._update(vm._render(), hydrating);
...
};
...
vm._watcher = new Watcher(vm, updateComponent, noop);
...
}
如上,傳遞updateComponent
方法給watcher
實例,該方法內觸發組件實例的vm._render()
渲染方法,觸發組件更新,此mountComponent()
方法會在$mount()
掛載組件公開方法中調用:
// public mount method
Vue$3.prototype.$mount = function (el, hydrating) {
el = el && inBrowser ? query(el) : undefined;
return mountComponent(this, el, hydrating)
};
總結
到此爲止,對於JavaScript屬性描述器接口的介紹及其應用,還有其在Vue.js中的響應式實踐原理基本闡述完了,這次總結從原理到應用,再到實踐剖析,花費比較多精力,但是收穫是成正比的,不僅對JavaScript基礎有更深的理解,還更熟悉了Vue.js響應式的設計原理,對其源碼熟悉度也有較大提升,之後在工作和學習過程中,會進行更多的總結分享。
參考
本文鏈接地址: 從JavaScript屬性描述器剖析Vue.js響應式視圖