雙向系結(回應式)
了解Vue中如何實作資料的回應式系統,從而達到資料驅動視圖,
一、資料驅動視圖?
大膽的一句話概括:視圖因為它依賴的資料的變化二變化,即:
UI = render(state)
- state(狀態):輸入資料
- UI(頁面):輸出視圖
- render(驅動):由Vue扮演,當Vue發現state變化之后,經過一系列加工,最終將變化反應在UI上
那么第一個問題來了,Vue怎么知道state變化了呢?
二、資料檢測(Vue 2.x)
從上帝視角來講,我們知道了整個雙向系結是通過發布訂閱+資料代理(劫持)的方式實作的,即:

Object.defineProperty()方法會直接在一個物件上定義一個新屬性,或者修改一個物件的現有屬性,并回傳此物件,
2.1 了解Object.defineProperty
語法:
Object.defineProperty(obj, prop, descriptor)
引數說明:
- obj:必需,目標物件
- prop:必需,需定義或修改的屬性的名字
- descriptor:必需,目標屬性所擁有的特性
同時它提供了get和set兩個方法以方便我們查看該屬性的“操作日志”
基本使用:
let a = {};
let val = null
Object.defineProperty(a, 'name', {
enumerable: true,
configurable: true,
get() {
console.log('a的name屬性被獲取了:', val);
return val;
},
set(newVal) {
console.log('a的name屬性被修改了:', newVal);
val = newVal;
}
})
console.log('a:', a.name);
a.name = 100
通過這樣的手段,我們可以做到:
- 知道指定屬性被
獲取 - 知道指定屬性被
更新
介于我們擁有這兩種能力,我們可以做一個最簡單的雙向系結,
2.2 一個極簡的雙向系結
目標:
input輸入框輸入內容,同步更新到p標簽內
思路:
- 監測:定義obj變數,并指定“劫持”obj.text屬性
- 更新:input監聽鍵盤事件,輸入內容時,實時修改obj.text屬性
- 通知:觸發屬性set方法,通知p標簽進行內容更新
實作:
<input type="text" id="input">
<p id="text"></p>
<script>
const oInput = document.getElementById('input');
const oText = document.getElementById('text');
var obj = {}; // 定義變數
// 監測
Object.defineProperty(obj, 'text', {
get(e){
console.log(e)
},
set(newValue){
// 通知
oText.innerHTML = newValue;
}
})
// 更新
oInput.onkeyup = function(e){
obj.text = e.target.value;
}
</script>
圖解:

看了上面的demo,大家應該會有很多想法,這里面存在諸多的不方便,比如需要提前知道劫持哪個屬性,通知哪個物件等等,下一結我們繼續強大這個demo,
三、基于資料劫持的基本實作
簡單了解過后,我們很快會發現,2.2中所謂的雙向系結貌似并沒有實用價值,至少需要我們將發布訂閱應用進來,
3.1 實作思路
- 利用
Proxy或Object.defineProperty生成的Observer針對物件/物件的屬性進行"劫持",在屬性發生變化后通知訂閱者 - 決議器Compile決議模板中的Directive(指令),收集指令所依賴的方法和資料,等待資料變化然后進行渲染
- Watcher屬于Observer和Compile橋梁,它將接收到的Observer產生的資料變化,并根據Compile提供的指令進行視圖渲染,使得資料變化促使視圖變化
解決問題:
- Observer:靈活的劫持(代理)資料的變動
- Dep:依賴管理器,負責收益收集對資料的所有依賴(訂閱者),并且在特定的時候通知所有訂閱者資料已變動
- Watcher:訂閱者,負責接受變化,更新視圖
于是我們有了下圖:

3.2 依賴管理器——Dep
所以我們需要創建依賴管理器,負責:
- 收集依賴:誰依賴了這個資料,就收集起來,即Getter時
- 通知更新:什么時候資料變動了,就出發通知依賴者,即Setter時
總結一句話就是:在getter中收集依賴,在setter中通知依賴更新,
因此,我們給每個都建立一個依賴管理器,把這個資料所有的依賴都管理起來:
// 依賴管理器
let uid = 0;
class Dep {
constructor() {
this.id = uid++; // 作為標識區分
this.subs = []; // 誰依賴的這個資料,就保存進來,方便通知
}
// 添加依賴方法
addSub(sub) {
this.subs.push(sub)
}
// 觸發添加
depend() {
// 為什么要這樣操作?我們下一小結介紹
// 暫時理解為,我們獲得訂閱者的方法
if(Dep.target){
Dep.target.addDep(this) // 添加依賴管理器
}
}
// 通知所有的依賴更新
notify() {
const subs = this.subs.slice();
// 在這里我們觸發更新
subs.forEach(sub => sub.update())
}
}
// 為Dep類設定一個靜態屬性,默認為null,作業時指向當前的Watcher
// 這里我有看到利用window.target來保存臨時watcher的,是兩者均可還是另有玄機不知道有沒有大佬解惑
Dep.target = null; // 初始化為null
3.3 監聽者——Observer
在這里我們希望可以劫持資料的變化,并通知依賴管理器
// 監聽類
class Observer {
constructor(value) {
this.value = value;
if (Array.isArray(value)) {
} else {
this.walk(value)
}
}
walk(value) {
const keys = Object.keys(value);
keys.forEach(key => {
this.convert(key, value[key])
});
}
convert(key, val) {
defineReactive(this.value, key, val)
}
}
function defineReactive(obj, key, val) {
const dep = new Dep();
// 給子元素添加監聽
let chlidOb = observe(val);
Object.defineProperty(obj, key, {
get() {
// 如果Dep類存在target屬性,將其添加到dep實體的subs陣列中
// target指向一個Watcher實體,每個Watcher都是一個訂閱者
// Watcher實體在實體化程序中,會讀取data中的某個屬性,從而觸發當前get方法
if (Dep.darget) {
dep.depend();
}
console.log('獲取')
return val;
},
set(newValue) {
if (val === newValue) {
return;
}
console.log('修改')
val = newValue;
chlidOb = observe(newValue);
// 通過依賴管理器,通知所有訂閱者更新
dep.notify();
}
})
}
// 添加監聽
function observe(value) {
// 當值不存在,或者不是復雜資料型別時,不再需要繼續深入監聽
if (!value || typeof value !== 'object') {
return;
}
return new Observer(value);
}
可以加上下面的測驗代碼查看console效果
const obj = new Observer({
name: '余光',
age: '24'
})
obj.value.name // name屬性被讀獲取了
obj.value.name = 100; // name屬性被修改了
3.4 訂閱者——watcher
Watcher類的實體就是:
- 誰用到了資料,誰就是依賴,我們就為誰創建一個Watcher實體;
- 在之后資料變化時,我們不直接去通知依賴更新,而是通知依賴對應的Watch實體;
- 再由Watcher實體去通知真正的視圖,
// 實作一個訂閱者,即“依賴”者
class Watcher {
constructor(vm, expOrFn, cb) {
this.depIds = {}; // hash儲存訂閱者的id,避免重復的訂閱者
this.vm = vm; // 被訂閱的資料一定來自于當前Vue實體
this.cb = cb; // 當資料更新時想要做的事情
this.expOrFn = expOrFn; // 被訂閱的資料,資料key || 路徑
this.val = this.get(); // 維護更新之前的資料
}
// 對外暴露的介面,用于在訂閱的資料被更新時,由訂閱者管理員(Dep)呼叫
update() {
this.run();
}
addDep(dep) {
// 如果在depIds的hash中沒有當前的id,可以判斷是新Watcher,因此可以添加到dep的陣列中儲存
// 此判斷是避免同id的Watcher被多次儲存
if (!this.depIds.hasOwnProperty(dep.id)) {
dep.addSub(this);
this.depIds[dep.id] = dep;
}
}
run() {
// 執行一次get方法
const val = this.get();
console.log(val);
if (val !== this.val) {
this.val = val;
//
this.cb.call(this.vm, val);
}
}
get() {
// 當前訂閱者(Watcher)讀取被訂閱資料的最新更新后的值時,通知訂閱者管理員收集當前訂閱者
Dep.target = this; // 賦值給Dep.target
// 這段代碼需要結合背景關系,含義是主動觸發get,將該訂閱者添加到依賴管理器中
const val = this.vm._data[this.expOrFn]; // 主動觸發
// 置空,用于下一個Watcher使用
Dep.target = null;
return val;
}
}
分析
當實體化Watcher類時:
- 會先執行其建構式:
- 在建構式中呼叫了
this.get()實體方法;- 2.1 首先通過
Dep.target = this(把自身賦給了全域一個唯一物件Dep.target上); - 2.2 然后通過
this.vm._data[this.expOrFn]獲取一下被依賴的資料,獲取被依賴資料的目的是觸發該資料上面的getter,上文我們說過,在getter里會呼叫dep.depend()收集依賴,而在dep.depend()中取到掛載window.target上的值并將其存入依賴陣列中; - 2.3 在
get()方法最后將Dep.target釋放掉,
- 2.1 首先通過
- 而當資料變化時,會觸發資料的
setter,在setter中呼叫了dep.notify()方法,在dep.notify()方法中,遍歷所有依賴(即watcher實體),執行依賴的update()方法,也就是Watcher類中的update()實體方法,在update()方法中呼叫資料變化的更新回呼函式,從而更新視圖,
3.5 掛載到Vue
class Vue {
constructor(options = {}) {
// 簡化了$options的處理
this.$options = options;
// 簡化了對data的處理
let data = this._data = this.$options.data;
// 將所有data最外層屬性代理到Vue實體上
Object.keys(data).forEach(key => this._proxy(key));
// 監聽資料
observe(data);
}
// 對外暴露呼叫訂閱者的介面,內部主要在指令中使用訂閱者
$watch(expOrFn, cb) {
new Watcher(this, expOrFn, cb);
}
_proxy(key) {
Object.defineProperty(this, key, {
configurable: true,
enumerable: true,
get: () => this._data[key], // 取options.data
set: val => {
this._data[key] = val;
},
});
}
}
// 測驗代碼
let demo = new Vue({
data: {
text: '',
},
});
const p = document.getElementById('p');
const input = document.getElementById('input');
input.addEventListener('keyup', function(e) {
demo.text = e.target.value;
});
demo.$watch('text', str => p.innerHTML = str);
至此我們拆解了部分代碼,來看一下它的實際效果?
See the Pen RwKJrbq by 姜博健 (@webbj97) on CodePen.
再次回顧下圖:

3.5 任重道遠的defineProperty
Object.defineProperty的第一個缺陷,無法監聽陣列變化,
然而Vue的檔案提到了Vue是可以檢測到陣列變化的,但是只有以下八種方法,vm.items[indexOfItem] = newValue這種是無法檢測的,
push()
pop()
shift()
unshift()
splice()
sort()
reverse()
其實作者在這里用了一些奇技淫巧,把無法監聽陣列的情況hack掉了,以下是方法示例,
const aryMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
const arrayAugmentations = [];
aryMethods.forEach((method)=> {
// 這里是原生Array的原型方法
let original = Array.prototype[method];
// 將push, pop等封裝好的方法定義在物件arrayAugmentations的屬性上
// 注意:是屬性而非原型屬性
arrayAugmentations[method] = function () {
console.log('我被改變啦!');
// 呼叫對應的原生方法并回傳結果
return original.apply(this, arguments);
};
});
let list = ['a', 'b', 'c'];
// 將我們要監聽的陣列的原型指標指向上面定義的空陣列物件
// 別忘了這個空陣列的屬性上定義了我們封裝好的push等方法
list.__proto__ = arrayAugmentations;
list.push('d'); // 我被改變啦! 4
// 這里的list2沒有被重新定義原型指標,所以就正常輸出
let list2 = ['a', 'b', 'c'];
list2.push('d'); // 4
我們應該注意到在上文中的實作里,我們多次用遍歷方法遍歷物件的屬性,這就引出了Object.defineProperty的第二個缺陷,只能劫持物件的屬性,因此我們需要對每個物件的每個屬性進行遍歷,如果屬性值也是物件那么需要深度遍歷,顯然能劫持一個完整的物件是更好的選擇,
Object.keys(value).forEach(key => this.convert(key, value[key]));
四、資料檢測(Vue 3.x)
Proxy 可以對目標物件的讀取、函式呼叫等操作進行攔截,然后進行操作處理,它不直接操作物件,而是像代理模式,通過物件的代理物件進行操作,在進行這些操作時,可以添加一些需要的額外操作,
本小結簡單介紹一下proxy為什么能替代defineProperty:
前置知識:
- proxy同樣擁有set和get方法,對指定資料進行代碼
*Reflect物件的方法與Proxy物件的方法一一對應,只要是Proxy物件的方法,就能在Reflect物件上找到對應的方法,
4.1 重構極簡版雙向系結
我們還是以上文中用Object.defineProperty實作的極簡版雙向系結為例,用Proxy進行改寫,
const input = document.getElementById('input');
const p = document.getElementById('p');
const obj = {};
const newObj = new Proxy(obj, {
get: function(target, key, receiver) {
console.log(`getting ${key}!`);
return Reflect.get(target, key, receiver);
},
set: function(target, key, value, receiver) {
console.log(target, key, value, receiver);
if (key === 'text') {
input.value = value;
p.innerHTML = value;
}
return Reflect.set(target, key, value, receiver);
},
});
input.addEventListener('keyup', function(e) {
newObj.text = e.target.value;
});
我們可以看到,Proxy直接可以劫持整個物件,并回傳一個新物件,不管是操作便利程度還是底層功能上都遠強于Object.defineProperty,
4.2 proxy的優勢
Proxy有多達13種攔截方法,不限于apply、ownKeys、deleteProperty、has等等是Object.defineProperty不具備的,
Proxy回傳的是一個新物件,我們可以只操作新的物件達到目的,而Object.defineProperty只能遍歷物件屬性直接修改,
Proxy作為新標準將受到瀏覽器廠商重點持續的性能優化,也就是傳說中的新標準的性能紅利,
當然,Proxy的劣勢就是兼容性問題,而且無法用polyfill磨平,因此Vue的作者才宣告需要等到下個大版本(3.0)才能用Proxy重寫,
寫在最后
參考:
面試官: 實作雙向系結Proxy比defineproperty優劣如何?
本篇文章是Vue系列的第一篇文章,我的本意是多圖少字,但不知不徑訓是邊幅過長,寫這篇文章也是我一個自己總結的機會,希望能幫助到大家
JavaScript系列:
- 《JavaScript內功進階系列》(已完結)
- 《JavaScript專項系列》(持續更新)
- 《ES6基礎系列》(持續更新)
關于我
- 花名:余光(沉迷JS,虛心學習中)
- WX:j565017805
其他沉淀
- Js版LeetCode題解
- 前端進階筆記
- 我的CSDN博客
如果您看到了最后,對文章有任何建議,都可以在評論區留言
這是文章所在GitHub倉庫的傳送門,如果真的對您有所幫助,希望可以點個star,這是對我最大的鼓勵 ~
轉載請註明出處,本文鏈接:https://www.uj5u.com/qianduan/277350.html
標籤:其他
