3天學寫mvvm框架[一]:資料監聽
首先我們將從資料監聽開始講起,對於這一部分的內容相信許多小夥伴都看過網上各種各樣的原始碼解讀了,不過當我自己嘗試去實現的時候,還是發現自己動手對於鞏固知識點非常重要。不過鑑於Vue3將使用 Proxy
來實現資料監聽,所以我這裡是通過 Proxy
來實現了。如果你還不瞭解js中的這部分內容,請先通過MDN來學習一下哦。
當然這一部分的程式碼很可能將與Vue2以及Vue3都不盡相同,不過核心原理都是相同的。
目標
今天我們的目標是讓以下程式碼如預期執行:
const data = proxy({ a: 1 }); const watcher = watch(() => { return data.a + data.b; }, (oldVal, value) => { console.log('watch callback', oldVal, value); }); data.b = 1; // console.log('watch callback', NaN, 2); data.a += 1; // console.log('watch callback', 2, 3); 複製程式碼
我們將實現 proxy
與 watch
兩個函式。 proxy
接受一個數據物件,並返回其通過 Proxy
生成的代理物件; watch
方法接受兩個引數,前者為求值函式,後者為回撥函式。
因為這裡的求值函式需要使用到 data.a
與 data.b
兩個資料,因此當兩者改變時,求值函式將重新求值,並觸發回撥函式。
原理介紹
為了實現以上目標,我們需要在求值函式執行時,記錄下其所依賴的資料,從而在資料發生改變時,我們就能觸發重新求值並觸發回調了。
從另一個角度來說,每當我們從 data
中取它的 a
與 b
資料時,我們希望能記錄下當前是誰在取這些資料。
這裡有兩個問題:
- 何時進行記錄:如果你已經學習了
Proxy
的用法,那這裡的答案應當很明顯了,我們將通過Proxy
來設定getter
,每當資料被取出時,我們設定的getter
將被呼叫,這時我們就可以 - 記錄的目標是誰:我們只需要在呼叫一個求值函式之前用一個變數將其記錄下來,再呼叫這個求值函式,那麼在呼叫結束之前,觸發這些
getter
的應當都是這一求值函式。在求值完成後,我們再置空這一變數就行了
這裡需要注意的是,我們將編寫的微型mvvm框架不會包含計算屬性。由於計算屬性也是求值函式,因此可能會出現求值函式巢狀的情況(例如一個求值函式依賴了另一個計算屬性),這樣的話我們不能僅使用單一變數來記錄當前的求值函式,而是需要使用棧的結構,在求值函式執行前後進行入棧與出棧操作。對於這部分內容,感興趣的小夥伴不妨可以自己試試實現以下計算屬性哦。
使用Proxy建立getter與setter
首先我們實現一組最簡單的 getter
與 setter
,僅僅進行一個簡單的代理:
const proxy = function (target) { const data = new Proxy(target, { get(target, key) { return target[key]; }, set(target, key, value) { target[key] = value; return true; } }); return data; }; 複製程式碼
對於最簡單的資料例如 { a: 1, b: 1 }
上面的做法是行得通的。但對於複雜一些的資料呢?例如 { a: { b: 1 } }
,外層的資料 a
是通過 getter
取出的,但我們並沒有為 a
即 { b: 1 }
設定 getter
,因此對於獲取 a.b
我們將不得而知。因此,我們需要遞迴的遍歷資料,對於型別為物件的值遞迴建立 getter
與 setter
。同時不僅在初始化時,每當資料被設定時,我們也需要檢查新的值是否是物件:
const proxy = function (target) { for (let key in target) { const child = target[key]; if (child && typeof child === 'object') { target[key] = proxy(child); } } return _proxyObj(target); }; const _proxyObj = function (target) { const data = new Proxy(target, { get(target, key) {console.log(1); return target[key]; }, set(target, key, value) { if (value && typeof value === 'object') { value = proxy(value); } target[key] = value; return true; } }); return data; }; 複製程式碼
這裡要注意一點, typeof null
也會返回 "object"
,但我們並不應該將其作為物件遞迴處理。
Dep和DepCollector
Dep類
對於如下的求值函式:
() => { return data.a + data.b.c; } 複製程式碼
將被記錄為:這個求值函式依賴於 data
的 a
屬性,依賴於 data
的 b
屬性,以及 data.b
的 c
屬性。對於這些依賴,我們將用Dep類來表示。
對於每個物件或者陣列形式的資料,我們將為其建立一個 Dep
例項。 Dep
例項將會有一個 map
鍵值對屬性,其鍵為屬性的 key
,而值是一個數組,用來將相應的監聽者不重複地 watcher
記錄下來。
Dep
例項有兩個方法: add
和 notify
。 add
在 getter
過程中通過鍵新增 watcher
; notify
在 setter
過程中觸發對應的 watcher
讓它們重新求值並觸發回撥:
class Dep { constructor() { this.map = {}; } add(key, watcher) { if (!watcher) return; if (!this.map[key]) this.map[key] = new DepCollector(); watcher.addDepId(this.map[key].id); if (this.map[key].includes(watcher)) return; this.map[key].push(watcher); } notify(key) { if (!this.map[key]) return; this.map[key].forEach(watcher => watcher.queue()); } } 複製程式碼
同時需要修改 proxy
方法,為資料建立 Dep
例項,並在 getter
( currentWatcher
指向當前在求值的 Watcher
例項)和 setter
過程中呼叫其 add
和 notify
方法:
const proxy = function (target) { const dep = target[KEY_DEP] || new Dep(); if (!target[KEY_DEP]) target[KEY_DEP] = dep; for (let key in target) { const child = target[key]; if (child && typeof child === 'object') { target[key] = proxy(child); } } return _proxyObj(target, dep, target instanceof Array); }; const _proxyObj = function (target, dep) { const data = new Proxy(target, { get(target, key) { if (key !== KEY_DEP) dep.add(key, currentWatcher); return target[key]; }, set(target, key, value) { if (key !== KEY_DEP) { if (value && typeof value === 'object') { value = proxy(value); } target[key] = value; dep.notify(key); return true; } } }); return data; }; 複製程式碼
這裡我們用 const KEY_DEP = Symbol('KEY_DEP');
作為鍵將已經建立的 Dep
例項儲存到資料物件上,使得一個數據被多次 proxy
時能重用先前的 Dep
例項。
DepCollector類
DepCollector
類僅僅是對陣列進行了一層包裝,這裡的主要目的是為每個 DepCollector
例項新增一個用以唯一表示的 id
,在介紹 Watcher
類的時候就會知道這個 id
有什麼用了:
let depCollectorId = 0; class DepCollector { constructor() { const id = ++depCollectorId; this.id = id; DepCollector.map[id] = this; this.list = []; } includes(watcher) { return this.list.includes(watcher); } push(watcher) { return this.list.push(watcher); } forEach(cb) { this.list.forEach(cb); } remove(watcher) { const index = this.list.indexOf(watcher); if (index !== -1) this.list.splice(index, 1); } } DepCollector.map = {}; 複製程式碼
陣列的依賴
對於陣列的變動,例如呼叫 push
、 pop
、 splice
等方法或直接通過下邊設定陣列中的元素時,將發生改變的陣列對應的下標以及 length
都將作為 key
觸發我們的 getter
,這是 Proxy
很強大的地方,但我們不需要這麼細緻的監聽陣列的變動,而是統一觸發一個 陣列發生了變化
的事件就可以了。
因此我們將建立一個特殊的 key
—— KEY_DEP_ARRAY
來表示這一事件:
const KEY_DEP_ARRAY = Symbol('KEY_DEP_ARRAY'); const proxy = function (target) { const dep = target[KEY_DEP] || new Dep(); if (!target[KEY_DEP]) target[KEY_DEP] = dep; for (let key in target) { const child = target[key]; if (child && typeof child === 'object') { target[key] = proxy(child); } } return _proxyObj(target, dep, target instanceof Array); }; const _proxyObj = function (target, dep, isArray) { const data = new Proxy(target, { get(target, key) { if (key !== KEY_DEP) dep.add(isArray ? KEY_DEP_ARRAY : key, currentWatcher); return target[key]; }, set(target, key, value) { if (key !== KEY_DEP) { if (value && typeof value === 'object') { value = proxy(value); } target[key] = value; dep.notify(isArray ? KEY_DEP_ARRAY : key); return true; } } }); return data; }; 複製程式碼
小結
這裡我們用一張圖進行一個小結:

只要能理清觀察者、資料物件、以及 Dep
和 DepCollector
之間的關係,那這一部分就不會讓你感到困惑了。
Watcher
接下來我們需要實現 Watcher
類,我們需要完成以下幾個步驟:
-
Watcher
建構函式將接收一個求值函式以及一個回撥函式 -
Watcher
例項將實現eval
方法,此方法將呼叫求值函式,同時我們需要維護當前的watcher
例項currentWatcher
。 -
queue
方法將呼叫queueWatcher
,使得Watcher
例項的eval
在nextTick
中被呼叫。 - 實現
addDepId
與clearDeps
方法,前者使Watcher
例項記錄與DepCollector
的依賴關係,後者使得Watcher
可以在重新求值後或銷燬時清理與DepCollector
的依賴關係。 - 最後我們實現
watch
方法,它將呼叫Watcher
建構函式。
為什麼在重新求值後我們需要清理依賴關係呢?
想象這樣的函式:
() => { return data.a ? data.b : data.c; } 複製程式碼
因為 a
的值改變,將改變這個求值函式依賴於 b
還是 c
。
又或者:
const data = proxy({ a: { b: 1 } }); const oldA = data.a; watch(() => { return data.a.b; }, () => {}); data.a = { b: 2 }; 複製程式碼
由於 data.a
已被整體替換,因此我們將為其生成新的 Dep
,以及為 data.a.b
生成新的 DepCollector
。此時我們再修改 oldA.b
,不應該再觸發我們的 Watcher
例項,因此這裡是要進行依賴的清理的。
最終程式碼如下:
let watcherId = 0; class Watcher { constructor(func, cb) { this.id = ++watcherId; this.func = func; this.cb = cb; } eval() { this.depIds = this.newDepIds; this.newDepIds = {}; pushWatcher(this); this.value = this.func(); // 快取舊的值 popWatcher(); this.clearDeps(); } addDepId(depId) { this.newDepIds[depId] = true; } clearDeps() { // 移除已經無用的依賴 for (let depId in this.depIds) { if (!this.newDepIds[depId]) { DepCollector.map[depId].remove(this); } } } queue() { queueWatcher(this); } run() { const oldVal = this.value; this.eval(); // 重新計算並收集依賴 this.cb(oldVal, this.value); } } let currentWatcheres = []; // 棧,computed屬性 let currentWatcher = null; const pushWatcher = function (watcher) { currentWatcheres.push(watcher); currentWatcher = watcher; }; const popWatcher = function (watcher) { currentWatcheres.pop(); currentWatcher = currentWatcheres.length > 0 ? currentWatcheres[currentWatcheres.length - 1] : null; }; const watch = function (func, cb) { const watcher = new Watcher(func, cb); watcher.eval(); return watcher; }; 複製程式碼
queueWatcher與nextTick
nextTick
會將回調加入一個數組中,如果當前沒有還預定延時執行,則請求延時執行,在執行時依次執行陣列中所有的回撥。
延時執行的實現方式有很多,例如 requestAnimationFrame
、 setTimeout
或者是node.js的 process.nextTick
與 setImmediate
等等,這裡不做糾結,使用 requestIdleCallback
:
const nextTickCbs = []; const nextTick = function (cb) { nextTickCbs.push(cb); if (nextTickCbs.length === 1) { requestIdleCallback(() => { nextTickCbs.forEach(cb => cb()); nextTickCbs.length = 0; }); } }; 複製程式碼
queueWatcher
方法會將 watcher
加入待處理列表中(如果它尚不在這個列表中)。
整個待處理列表將按照 watcher
的 id
進行排序。這點暫時是用不著的,但如果存在計算屬性等使用者建立的 watcher
或是元件概念,我們希望從父元件其向下更新元件,或是使用者建立的 watcher
優先於元件渲染的 watcher
執行,那麼我們就需要維護這樣的順序。
最後,如果 flushSchedulerQueue
尚未通過 nextTick
加入延時執行,則將其加入:
const queue = []; let has = {}; let waiting = false; let flushing = false; let index = 0; const queueWatcher = function (watcher) { const id = watcher.id; if (has[id]) return; has[id] = true; if (!flushing) { queue.push(watcher); } else { const i = queue.length - 1; while (i > index && queue[i].id > watcher.id) { i--; } queue.splice(i + 1, 0, watcher); } if (waiting) return; waiting = true; nextTick(flushSchedulerQueue); }; const flushSchedulerQueue = function () { flushing = true; let watcher, id; queue.sort((a, b) => a.id - b.id); for (index = 0; index < queue.length; index++) { watcher = queue[index]; id = watcher.id; has[id] = null; watcher.run(); } index = queue.length = 0; has = {}; waiting = flushing = false; }; 複製程式碼
你還可以嘗試...
在我的簡陋的程式碼的基礎上,你可以嘗試進一步實現計算屬性,給 Watcher
類新增銷燬方法,用不同的方式實現 nextTick
,或是新增一些容錯性與提示。如果使用時不小心, queueWatch
可能會因為計算屬性的互相依賴而陷入死迴圈,你可以嘗試讓你的程式碼發現並處理這一問題。
如果仍感到迷惑,不妨閱讀Vue的原始碼,無論是整體的實現還是一些細節的處理都能讓我們受益匪淺。
總結
今天我們實現了 Dep
、 DepCollectpr
以及 Watcher
類,並最終實現了 proxy
和 watch
兩個方法,通過它們我們可以對資料新增監聽,從而為響應式模板打下基礎。
下一次,我們將自己動手完成模板的解析工作。
參考:
- ofollow,noindex">Vue
程式碼:TODO