此前為了學習Vue的原始碼,我決定自己動手寫一遍簡化版的Vue。現在我將我所瞭解到的分享出來。如果你正在使用Vue但還不瞭解它的原理,或者正打算閱讀Vue的原始碼,希望這些分享能對你瞭解Vue的執行原理有所幫助。
Proxy
首先我們將從資料監聽開始講起,對於這一部分的內容相信許多小夥伴都看過網上各種各樣的原始碼解讀了,不過當我自己嘗試去實現的時候,還是發現自己動手對於鞏固知識點非常重要。不過鑑於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;
};
複製程式碼
Proxy和defineProperty的比較
Vue2使用了defineProperty
,而Vue3將使用Proxy
。
Proxy
作為新的特性,有其強大之處,例如對於陣列也可以直接代理,而此前需要攔截陣列方法例如push
等,而對於arr[2] = 3
或者obj.newProp = 3
這樣陣列元素和物件新屬性的直接設定都無法處理,需要提供Vue.set
這樣的幫助函式。
不過需要注意,defineProperty
是原地替換的,而Proxy
並不是,例如:
const target = { a: 1 };
const data = new Proxy(target, { ... });
target.a = 2; // 不會觸發setter
data.a = 3; // 修改data才會觸發setter
複製程式碼
你還可以嘗試...
在我的簡陋的程式碼的基礎上,你可以嘗試進一步實現計算屬性,給Watcher
類新增銷燬方法,用不同的方式實現nextTick
,或是新增一些容錯性與提示。如果使用時不小心,queueWatch
可能會因為計算屬性的互相依賴而陷入死迴圈,你可以嘗試讓你的程式碼發現並處理這一問題。
如果仍感到迷惑,不妨閱讀Vue的原始碼,無論是整體的實現還是一些細節的處理都能讓我們受益匪淺。
總結
今天我們實現了Dep
、DepCollectpr
以及Watcher
類,並最終實現了proxy
和watch
兩個方法,通過它們我們可以對資料新增監聽,從而為響應式模板打下基礎。
下一次,我們將自己動手完成模板的解析工作。
參考:
程式碼:TODO