學習每一門語言,通常都是從其數據結構開始,JavaScript也是同樣,而JavaScript的數據結構中對象(Object)是最基礎也是使用最頻繁的概念和語法,坊間有言,JavaScript中,一切皆對象,基本能夠描述對象在JavaScript中的地位,並且JavaScript中對象的強大也使其地位名副其實,本篇介紹JavaScript對象屬性描述器接口及其在數據視圖綁定方向的實踐,而後對Vue.js的響應式原理進行剖析。javascript
能夠先看一個應用實例,點擊此處html
JavaScript的對象,是一組鍵值對的集合,能夠擁有任意數量的惟一鍵,鍵能夠是字符串(String)類型或標記(Symbol,ES6新增的基本數據類型)類型,每一個鍵對應一個值,值能夠是任意類型的任意值。對於對象內的屬性,JavaScript提供了一個屬性描述器接口PropertyDescriptor
,大部分開發者並不須要直接使用它,可是不少框架和類庫內部實現使用了它,如avalon.js,Vue.js,本篇介紹屬性描述器及相關應用。vue
在介紹對象屬性描述以前,先介紹一下如何定義對象屬性。最經常使用的方式就是使用以下方式:java
var a = {
name: 'jh'
};
// or
var b = {};
b.name = 'jh';
// or
var c = {};
var key = 'name';
c[key] = 'jh';複製代碼
本文使用字面量方式建立對象,可是JavaScript還提供其餘方式,如,new Object(),Object.create(),瞭解更多請查看對象初始化。react
上面一般使用的方式不能實現對屬性描述器的操做,咱們須要使用defineProperty()
方法,該方法爲一個對象定義新屬性或修改一個已定義屬性,接受三個參數Object.defineProperty(obj, prop, descriptor)
,返回值爲操做後的對象:git
var x = {};
Object.defineProperty(x, 'count', {});
console.log(x); // Object {count: undefined}複製代碼
因爲傳入一個空的屬性描述對象,因此輸出對象屬性值爲undefined,當使用defineProperty()
方法操做屬性時,描述對象默認值爲: github
不使用該方法定義屬性,則屬性默認描述爲:web
默認值都可被明確參數值設置覆蓋。express
固然還支持批量定義對象屬性及描述對象,使用`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.
指定對象屬性值:
var x = {};
Object.defineProperty(x, 'count', {
value: 0
});
console.log(x); // Object {count: 0}複製代碼
指定對象屬性是否可變:
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
。
對象屬性能夠設置存取器函數,使用get
聲明存取器getter函數,set
聲明存取器setter函數;若存在存取器函數,則在訪問或設置該屬性時,將調用對應的存取器函數:
讀取該屬性值時調用該函數並將該函數返回值賦值給屬性值;
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複製代碼
當設置函數值時調用該函數,該函數接收設置的屬性值做參數:
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
描述。
咱們發現,設置屬性存取器函數後,咱們能夠實現對該屬性的實時監控,這在實踐中頗有用武之地,後文會印證這一點。
指定對象內某屬性是否可枚舉,即便用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複製代碼
該值指定對象屬性描述是否可變:
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支持咱們經過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對象和屬性的處理開始:
首先,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()
方法設置屬性描述對象,邏輯集中在屬性存取器函數內:
watcher
存在,則遞歸記錄依賴;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
方法返回值。
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屬性又有不一樣,該屬性是變量或表達式,並且與計算屬性不一樣的是,它須要指定一個變動事件發生後的回調函數:
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
實例,綁定到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響應式視圖