vue 無疑是一個非常棒的前端MVVM庫,懷著好奇的心情開始看VUE原始碼,當然遇到了很多的疑問,也查了很多的資料看了一些文章。但是這些資料很多都忽略了很重要的部分或者是一些重要的細節,亦或是一些很重要的部分沒有指出,特別是在computed的實現上。所以才打算寫這篇文章,記錄一下自己的學習過程,當然也希望能給其他想了解VUE原始碼的童鞋一點參考。如果筆者在某些地方理解有誤,也歡迎批評指正出來,一起學習。
為了加深理解,我按著原始碼的思路造了一個簡易的輪子,基本核心的實現是與VUE原始碼一致。測試 demo。倉庫的地址:eltonchan/rollup-ts
VUE的原始碼採用rollup和 flow至於為什麼不採用typescript,主要考慮工程上成本和收益的考量, 這一點尤大在知乎也有說過。(3.0+版本確定改用typesript)
Vue 2.0 為什麼選用 Flow 進行靜態程式碼檢查而不是直接使用TypeScript
不懂rollup 與typescript 也沒關係,本專案已經配置好了, 只需要先執行npm i (或者cnpm i)安裝相應依賴,然後 npm start 啟動就可以。 npm run build 構建,預設是輸出umd格式,如果需要cmd或者amd 可以在rollup.config.js配置檔案修改。
output: {
file: 'dist/bundle.js',
format: 'umd',
name: 'myBundle',
sourcemap: true
}
複製程式碼
questions ? 帶著問題去了解一個事物往往能帶來更好的收益,那我們就從下面幾個問題開始
- 如何對http://this.xxx的訪問代理到http://this._data.xxx 上 ?
- 如何實現資料劫持,監聽資料的讀寫操作 ?
- 如何實現依賴快取 ?
- template 改變的時候 如何清理依賴項集合? eg: v-if 、元件銷燬
- 如何實現資料修改 dom更新 ?
vue實現雙向繫結原理,主要是利用Object.defineProperty getter/setter(事實上,大多數響應式程式設計的庫都是利用這個實現的,比如非常棒的mobx.js)和釋出訂閱模式(定義了物件間的一對多的依賴關係,當一個物件的狀態發生改變時,所有依賴於它的物件都將獲得通知),而在vue中,watcher 就是訂閱者,而一對多的依賴關係 就是指data上的屬性與watcher,而data上的屬性如何與watcher 關聯起來, dep 就是橋樑, 所以搞懂 dep, watcher, observe三者的關係,自然就搞懂了vue實現雙向繫結的原理了。
一、 Proxy 回到第一個問題, 答案其實是:對於每一個 data 上的key,都在 vm 上做一個代理,實際操作的是 this._data、 實現的程式碼如下:
export function proxy (target: IVue, sourceKey: string, key: string) {
Object.defineProperty(target, key, {
enumerable: true,
configurable: true,
get() {
return this[sourceKey][key];
},
set(val: any) {
this[sourceKey][key] = val;
}
});
}
複製程式碼
可以看出獲取和修改this.xx 都是在獲取或者修改this.data.xx;
二、Observer 用於把data上的屬性封裝成可觀察的屬性 用Object.defineProperty來攔截物件的讀寫gettet/setter操作, 在獲取的時候收集依賴, 在修改的時候通知相關的依賴。
walk(data): void {
if (!data || typeof data !== 'object') return;
Object.keys(data).forEach(key => {
this.defineReactive({
data,
key,
value: data[key]
});
});
}
defineReactive({ data, key, value }: IReactive): void {
const dep = new Dep();
this.walk(value);
const self = this;
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
if (Dep.target) {
Dep.target.addDep(dep);
}
return value;
},
set(newVal: any): void {
if (value === newVal) return;
self.walk(value);
value = newVal;
dep.notify();
}
});
}
複製程式碼
可以看出, 在get的時候收集依賴,而Dep.target 其實就是watcher, 等下講到watcher的時候再回過來, 這裡要關注dep 其實dep在這裡是一個閉包環境,在執行get 或者set的時候 還可以訪問到建立的dep. 比如 this.name當在獲取this.name的值的時候 會建立一個Dep的例項, 把watcher 新增到這個dep中。
為什麼物件上新增屬性不會監聽,而修改整個物件為什麼能檢測到子屬性的變化 ?
由於 JavaScript 的限制,Vue 不能檢測物件屬性的新增或刪除(當然mobx也不例外的)。所以可觀察的物件屬性的新增或者刪除無法觸發set 方法,而直接修改物件則可以,而在set 方法中則會判斷新值是否是物件陣列型別,如果是 則子屬性封裝成可觀察的屬性,這也是set中self.walk(value);的作用。
三、Watcher 剛才提到了watcher,從上圖中也可以看到了watcher的作用 事實上,每一個computed屬性和watch 都會new 一個 Watcher。接下來會講到。先來看watcher的實現。
constructor(
vm: IVue,
expression: Function | string,
cb: Function,
) {
this.vm = vm;
vm._watchers.push(this);
this.cb = cb || noop;
this.id = ++uid;
// 處理watch 的情況
if (typeof expression === 'function') {
this.getter = expression;
} else {
this.getter = () => vm[expression];
}
this.expression = expression.toString();
this.depIds = new Set();
this.newDepIds = new Set();
this.deps = [];
this.newDeps = [];
this.value = this.get();
}
複製程式碼
這裡的expression,對於初始化用來渲染檢視的watcher來說,就是render方法,對於computed來說就是表示式,對於watch才是key,所以這邊需要判斷是字串還是函式,而getter方法是用來取value的。這邊有個depIds,deps,但是又有個newDepIds,newDeps,為什麼這樣設計,接下去再講,先看this.value = this.get();可以看出在這裡給watcher的value賦值,再來看get方法。
get() :void {
Dep.target = this;
const value = this.getter.call(this.vm); // 執行一次get 收集依賴
Dep.target = null;
this.cleanupDeps(); // 清除依賴
return value;
}
複製程式碼
可以看到getter是用來取值的,當執行這一行程式碼的時候,以render的那個watcher為例,會執行VNode render 當遇到{{ msg }}的表示式的時候會取值,這個時候會觸發msg的get方法,而此時的Dep.target 就是這個watcher, 所以我們會把這個render的watcher和msg這個屬性關聯起,也就是msg的dep已經有render的這個watcher了。這個就是Watcher,Dep,Observer的關係。我們再來看Dep:
export default class Dep implements IDep {
static target: any = null;
subs:any = [];
id;
constructor () {
this.id = uid++;
this.subs = [];
}
addSub(sub: IWatcher): void {
if (this.subs.find(o => o.id === sub.id)) return;
this.subs.push(sub);
}
removeSub (sub: IWatcher) {
const idx = this.subs.findIndex(o => o.id === sub.id);
if (idx >= 0) this.subs.splice(idx, 1);
}
notify():void {
this.subs.forEach((sub: Isub) => {
sub.update();
})
}
depend () {
if (Dep.target) {
Dep.target.addDep(this);
}
}
}
複製程式碼
Dep的實現很簡單,這邊看notify的方法,我們知道在修改data上的屬性的時候回觸發set,然後觸發notify方法,然後我們知道sub就是watcher,所以watcher.update方法就是修改屬性所執行的方法,回到watcher看這個update的實現。
update() {
// 推送到觀察者佇列中,下一個tick時呼叫。*/
queueWatcher(this);
}
run(cb) {
const value = this.get();
if (value !== this.value) {
const oldValue = this.value;
this.value = value;
cb.call(this.vm, value, oldValue);
}
}
複製程式碼
update方法並沒有直接render vNode。而是把watcher推到一個佇列中,事實上vue是的更新dom是非同步的,為什麼要非同步更新佇列,這邊摘抄了一下官網的描述:Vue 非同步執行 DOM 更新。只要觀察到資料變化,Vue 將開啟一個佇列,並緩衝在同一事件迴圈中發生的所有資料改變。如果同一個 watcher 被多次觸發,只會被推入到佇列中一次。這種在緩衝時去除重複資料對於避免不必要的計算和 DOM 操作上非常重要。然後,在下一個的事件迴圈“tick”中,Vue 重新整理佇列並執行實際 (已去重的) 工作。Vue 在內部嘗試對非同步佇列使用原生的 Promise.then 和 MessageChannel,如果執行環境不支援,會採用 setTimeout(fn, 0) 代替。其實這是一種非常好的效能優化方案,我們設想一下如果在mounted中迴圈賦值,如果不採用非同步更新策略,每一個賦值都更新,完全是一種浪費。
四、nextTick 關於nextTick其實很多文章寫的都不錯,這邊就不詳細介紹了。涉及到的概念可以點選下面連結檢視:
JavaScript 執行機制詳解:再談Event Loop
五、Computed 計算屬性是基於它們的依賴進行快取的。只在相關依賴發生改變時它們才會重新求值 回到開始的那個問題,如何實現依賴快取? name的更新如何讓info也更新,如果name不變,info如何取值?
剛才在講watcher的時候,提到過每個computed會例項化一個Watcher,從下面程式碼中也可以看出來,每一個computed屬性都有一個訂閱者watcher。
initComputed(computed) {
if (!computed || typeof computed !== 'object') return;
const keys = Object.keys(computed);
const watchers = this._computedWatchers;
let i = keys.length;
while(i--) {
const key = keys[i];
const func = computed[key];
watchers[key] = new Watcher(
this,
func || noop,
noop,
);
defineComputed(this, key);
}
}
複製程式碼
看這個例子:
computed: {
info() {
console.info('computed update');
return this.name + 'hello';
}
},
複製程式碼
watcher 的getter方法就是computed屬性的表示式,而在執行this.value = this.get();這個value就會是表示式的執行結果,所以其實Vue是把info的值儲存在它的watcher的value裡面的,然後又知道在取name的值的時候,會觸發name的get方法,此時的Dep.target 就是這個info的watcher,而dep是一個閉包,還是之前收集name的那個dep, 所以name的dep就會有兩個watcher,[renderWatcher, computedWatcher], 當name更新的時候,這兩個訂閱者watcher都會收到通知,這也就是name的更新讓info也更新。
那info的值是watcher的value, 所以這邊要做一個代理,把computed屬性的取值代理到對應watcher的value,實現起來也很簡單。
export default function defineComputed(vm: IVue, key: string) {
Object.defineProperty(vm, key, {
enumerable: true,
configurable: true,
get() {
const watcher = vm._computedWatchers && vm._computedWatchers[key];
return watcher.value;
},
});
}
複製程式碼
六、依賴更新
<p v-if="switch">{{ name }}</p>
複製程式碼
假設switch由true切換成false時候,是需要把name上面的renderWatcher刪除掉的,所以需要用depIds和deps的屬性來記錄dep。
addDep(dep: Dep) {
const id = dep.id;
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id);
this.newDeps.push(dep);
if (!this.depIds.has(id)) {
dep.addSub(this);
}
}
}
cleanupDeps() {
let i = this.deps.length;
while (i--) {
const dep = this.deps[i];
if (!this.depIds.has(dep.id)) {
dep.removeSub(this);
}
}
const tmp = this.depIds;
this.depIds = this.newDepIds;
this.newDepIds = tmp;
this.newDepIds.clear();
const deps = this.deps;
this.deps = this.newDeps;
this.newDeps = deps;
this.newDeps.length = 0;
}
複製程式碼
這裡把newDepIds賦值給了depIds, 然後newDepIds再清空,deps也是這樣的操作,這是一種效率很高的操作,避免使用了深拷貝。新增依賴的時候都是用newDepIds,newDeps來記錄,刪除的時候會去deps裡面遍歷查詢,等刪除了再把newDepIds賦值給depIds,這樣能保證在更新依賴的時候,沒有使用的依賴會從這個watcher中移除。
七、watch 為什麼watch 一個物件的時候 oldValue == value ?
watch的屬性也是一個例項化的Watcher,只是這個時候的expression是key,value 是vm[key],而cb就是回撥函式,所以這個時候對應屬性的dep中自然就有這個watcher。
initWatch(watch) {
if (!watch || typeof watch !== 'object') return;
const keys = Object.keys(watch);
let i = keys.length;
while(i--) {
const key = keys[i];
const cb = watch[key];
new Watcher(this, key, cb);
}
}
複製程式碼
當屬性更新的時候,會執行到這個run方法, 當watch一個物件的時候,watcher的value其實是一個引用,修改這個屬性的時候,this.value也同步修改了,所以也就是為什麼oldValue == value了, 至於作者為什麼這麼設計,我想肯定是有他原因的。
run(cb) {
const value = this.get();
if (value !== this.value) {
const oldValue = this.value;
this.value = value;
cb.call(this.vm, value, oldValue);
}
}
複製程式碼
八、Compile vue 2+ 已經使用VNode了,這部分還沒有細緻研究過,所以我這邊自己寫了個簡易的Compile,這部分已經和原始碼沒有關係了。主要用到了DocumentFragment和閉包而已,有興趣的童鞋可以到這個倉庫檢視。
components vnode 待補充...