最近看到一句話很有感觸 —— 有人問 35 歲之後你還會在寫程式碼嗎?各種中年程式設計師的言論充斥的耳朵,好像中年就不該寫程式碼了,但是我想說,若干年以後,有人問你閒來無事你會幹什麼,我想我會說,寫程式碼,我想這個答案就夠了,年齡不是你不愛的理由。
理論基礎
雙向繫結是 MVVM 框架最核心之處,那麼雙向繫結的核心是什麼呢?核心就是 Object.defineProperty
這個 API,關於這個 API 的具體內容,請移步 MDN - Object.defineProperty ,裡面有更詳細的說明。
接下來我們來看一下 Vue 是怎麼設計的:
圖中有幾個重要的模組:
- 監聽者(Observer): 這個模組的主要功能是給 data 中的資料增加
getter
和setter
,以及往觀察者列表中增加觀察者,當資料變動時去通知觀察者列表。 - 觀察者列表(Dep): 這個模組的主要作用是維護一個屬性的觀察者列表,當這個屬性觸發
getter
時將觀察者新增到列表中,當屬性觸發setter
造成資料變化時通知所有觀察者。 - 觀察者(Watcher): 這個模組的主要功能是對資料進行觀察,一旦收到資料變化的通知就去改變檢視。
我們簡化一下 Vue 裡的各種程式碼,只關注我們剛剛說的那些東西,實現一個簡單版的 Vue。
Coding Time
我們就拿 Vue 的一個例子來檢驗成果。
<body>
<div id="app">
<p>{{ message }}</p>
<button v-on:click="reverseMessage">逆轉訊息</button>
</div>
</body>
<script src="vue/index.js"></script>
<script src="vue/observer.js"></script>
<script src="vue/compile.js"></script>
<script src="vue/watcher.js"></script>
<script src="vue/dep.js"></script>
<script>
const vm = new Vue({
el: '#app',
data: {
message: 'Hello Vue.js!'
},
methods: {
reverseMessage: function () {
this.message = this.message.split('').reverse().join('')
}
},
mounted: function() {
setTimeout(() => {
this.message = 'I am changed after mounte';
}, 2000);
},
});
</script>
複製程式碼
new Vue()
首先,看 Vue 的原始碼我們就能知道,在 Vue 的建構函式中我們完成了一系列的初始化工作,以及生命週期鉤子函式的設定。那我們的簡易版 Vue 該怎麼寫呢?我們在使用 Vue 的時候是通過一個建構函式來開始使用,所以我們的簡易程式碼也從建構函式開始。
class Vue {
constructor(options) {
this.data = options.data;
this.methods = options.methods;
this.mounted = options.mounted;
this.el = options.el;
this.init();
}
init() {
// 代理 data
Object.keys(this.data).forEach(key => {
this.proxy(key);
});
// 監聽 data
observe(this.data, this);
// 編譯模板
const compile = new Compile(this.el, this);
// 生命週期其實就是在完成一些操作後呼叫的函式,
// 所以有些屬性或者例項在一些 hook 裡其實還沒有初始化,
// 也就拿不到相應的值
this.callHook('mounted');
}
proxy(key) {
Object.defineProperty(this, key, {
enumerable: false,
configurable: true,
get: function() {
return this.data[key]
},
set: function(newVal) {
this.data[key] = newVal;
}
});
}
callHook(lifecycle) {
this[lifecycle]();
}
}
複製程式碼
可以看到我們在建構函式中例項化了 Vue,並且對 data 進行代理,為什麼我們要進行代理呢?原因是通過代理我們就能夠直接通過 this.message
操作 message,而不需要 this.data.message
,代理的關鍵也是我們上面所說的 Object.defineProperty
。而生命週期其實在程式碼中也是在特定時間點呼叫的函式,所以我們做一些操作的時候也要去想想,它初始化完成沒有,新手經常犯的錯誤就是在沒有完成初始化的時候去進行操作,所以對生命週期的理解是非常重要的。
好了,完成了初始化,下面我們就要開始寫如何監聽這些資料的變化了。
Observer
通過上面的認識,我們知道,Observer 主要是給 data 的每個屬性都加上 getter
和 setter
,以及在觸發相應的 get
、set
的時候執行的功能。
class Observer {
constructor(data) {
this.data = data;
this.init();
}
init() {
this.walk();
}
walk() {
Object.keys(this.data).forEach(key => {
this.defineReactive(key, this.data[key]);
});
}
defineReactive(key, val) {
const dep = new Dep();
const observeChild = observe(val);
Object.defineProperty(this.data, key, {
enumerable: true,
configurable: true,
get() {
if(Dep.target) {
dep.addSub(Dep.target);
}
return val;
},
set(newVal) {
if(newVal === val) {
return;
}
val = newVal;
dep.notify();
observe(newVal);
}
});
}
}
function observe(value, vm) {
if(!value || typeof value !== 'object') {
return;
}
return new Observer(value);
}
複製程式碼
在上面,我們完成了對 data 的監聽,通過遞迴呼叫實現了對每個屬性值的監聽,給每個資料都新增了 setter 和 getter,在我們對資料進行取值或者是賦值操作的時候都會觸發這兩個方法,基於這兩個方法,我們就能夠做更多的事了。
現在我們知道了怎麼監聽資料,那麼我們如何去維護觀察者列表呢?我相信有些朋友和我一樣,看到 get 中的 Dep.target 有點懵逼,這到底是個啥,怎麼用的,帶著這個疑問,我們來看看觀察者列表是如何實現的。
Dep
class Dep {
constructor() {
this.subs = [];
}
addSub(sub) {
this.subs.push(sub);
}
notify() {
this.subs.forEach(sub => sub.update());
}
}
Dep.target = null;
複製程式碼
在 Dep 中我們維護一個觀察者列表(subs),有兩個基礎的方法,一個是往列表中新增觀察者,一個是通知列表中所有的觀察者。可以看到我們最後一行的 Dep.target = null;
,可能大家會好奇,這東西是幹什麼用的,其實很好理解,我們定義了一個全域性的變數 Dep.target
,又因為 JavaScript 是單執行緒的,同一時間只可能有一個地方對其進行操作,那麼我們就能夠在觀察者觸發 getter
的時候,將自己賦值給 Dep.target
,然後新增到對應的觀察者列表中,這也就是上面的 Observer
的 getter
中有個對 Dep.target
的判斷的原因,然後當 Watcher 被新增到列表中,這個全域性變數又會被設定成 null
。當然了這裡面有些東西還需要在 Watcher 中實現,我們接下來就來看看 Watcher 如何實現。
Watcher
在寫程式碼之前我們先分析一下,Watcher 需要一些什麼基礎功能,Watcher 需要訂閱 Dep,同時需要更新 View,那麼在程式碼中我們實現兩個函式,一個訂閱,一個更新。那麼我們如何做到訂閱呢?看了上面的程式碼我們應該有個初步的認識,我們需要在 getter
中去將 Watcher 新增到 Dep 中,也就是依靠我們上面說的 Dep.target
,而更新我們使用回撥就能做到,我們看程式碼。
class Watcher {
constructor(vm, exp, cb) {
this.vm = vm;
this.exp = exp;
this.cb = cb;
this.value = this.get();
}
get() {
Dep.target = this;
const value = this.vm.data[this.exp.trim()];
Dep.target = null;
return value;
}
update() {
const newVal = this.vm.data[this.exp.trim()];
if(this.value !== newVal) {
this.value = newVal;
this.cb.call(this.vm, newVal);
}
}
}
複製程式碼
那麼,我們有了 Watcher 之後要在什麼地方去呼叫它呢?想這個問題之前,我們要思考一下,我們如何拿到你在 template
中寫的各種 {{message}}
、v-text
等等指令以及變數。對,我們還有一個模版編譯的過程,那麼我們是不是可以在編譯的時候去觸發 getter
,然後我們就完成了對這個變數的觀察者的新增,好了說了那麼多,我們來看下下面的模組如何去做。
Compile
Compile 主要要完成的工作就是把 template 中的模板編譯成 HTML,在編譯的時候拿到變數的過程也就觸發了這個資料的 getter
,這時候就會把觀察者新增到觀察者列表中,同時也會在資料變動的時候,觸發回撥去更新檢視。我們下面就來看看關於 Compile 這個模組該怎麼去完成。
// 判斷節點型別
const nodeType = {
isElement(node) {
return node.nodeType === 1;
},
isText(node) {
return node.nodeType === 3;
},
};
// 更新檢視
const updater = {
text(node, val) {
node.textContent = val;
},
// 還有 model 啥的,但實際都差不多
};
class Compile {
constructor(el, vm) {
this.vm = vm;
this.el = document.querySelector(el);
this.fragment = null;
this.init();
}
init() {
if(this.el) {
this.fragment = this.nodeToFragment(this.el);
this.compileElement(this.fragment);
this.el.appendChild(this.fragment);
}
}
nodeToFragment(el) {
// 使用 document.createDocumentFragment 的目的就是減少 Dom 操作
const fragment = document.createDocumentFragment();
let child = el.firstChild;
// 將原生節點轉移到 fragment
while(child) {
fragment.appendChild(child);
child = el.firstChild;
}
return fragment;
}
// 根據節點型別不同進行不同的編譯
compileElement(el) {
const childNodes = el.childNodes;
[].slice.call(childNodes).forEach((node) => {
const reg = /\{\{(.*)\}\}/;
const text = node.textContent;
// 根據不同的 node 型別,進行編譯,分別編譯指令以及文字節點
if(nodeType.isElement(node)) {
this.compileEl(node);
} else if(nodeType.isText(node) && reg.test(text)) {
this.compileText(node, reg.exec(text)[1]);
}
// 遞迴的對元素節點進行深層編譯
if(node.childNodes && node.childNodes.length) {
this.compileElement(node);
}
});
}
// 在這裡我們就完成了對 Watcher 的新增
compileText(node, exp) {
const value = this.vm[exp.trim()];
updater.text(node, value);
new Watcher(this.vm, exp, (val) => {
updater.text(node, val);
});
}
compileEl(node) {
const attrs = node.attributes;
Object.values(attrs).forEach(attr => {
var name = attr.name;
if(name.indexOf('v-') >= 0) {
const exp = attr.value;
// 只做事件繫結
const eventDir = name.substring(2);
if(eventDir.indexOf('on') >= 0) {
this.compileEvent(node, eventDir, exp);
}
}
});
}
compileEvent(node, dir, exp) {
const eventType = dir.split(':')[1];
const cb = this.vm.methods[exp];
if(eventType && cb) {
node.addEventListener(eventType, cb.bind(this.vm));
}
}
}
複製程式碼
這就是 Compile 完成的部分工作,當然了這個模組不會這麼簡單,這裡只是簡單的實現了一點功能,如今 Vue 2.0 引入了 Virtual DOM,對元素的操作也不像這麼簡單了。
最後實現的功能由於我比較懶,大家可以自己寫一寫或者在我的 GitHub 倉庫裡可以看到。
總結
上面的程式碼也借鑑了前人的想法,但由於時間比較久了,所以我也沒找到,感謝大佬提供思路。
Vue 的設計很有意思,在學習之中也能有很多不一樣的感受,同時,在讀原始碼的過程中,不要過多的追求讀懂每一個變數,每一個句子。第一遍程式碼,先讀懂程式是怎麼跑起來的,大概是怎麼走的,通讀一遍,第二遍再去深究,扣一扣當時不清楚的東西,這是我看原始碼的一些心得,可能每個人的方法不一樣,希望你能有所收穫。
最後,因為 Vue 2.0 已經出來一段時間了,原始碼也有很多的變動,生命週期的變化、Virtual DOM 等等,還有比較感興趣的 diff 演算法,這些後續會繼續研究的,謝謝。