一、Vue工作機制
1.1 什麼是mvvm?
Vue就是基於MVVM模式實現的一套構建用戶界面的漸進式框架。MVVM是Model-View-ViewModel的簡寫。其中,Model指的是js中的數據,如對象,數組等等,View指的是頁面視圖,ViewModel指的是vue實例化對象,是連接view和model的橋樑。ViewModel的主要作用是實現Model和View之間的轉換。即將後端傳遞的數據轉化成所看到的頁面,或者將所看到的頁面轉化成後端的數據。我們也稱之爲“數據的雙向綁定”。
1.2 Vue的組成
Observer:監控Vue實例屬性的變化;
Compile:解析指令,負責把數據模型解析成視圖;
Dep:依賴管理器,負責管理所有訂閱者,以及通知所有訂閱者執行更新操作;
Watcher:訂閱者,Dep和Updater的橋樑,用於接收Dep的通知,然後通知Updater執行更新操作;
Updater:更新器。不同類型的指令對應不同的更新器,更新器負責執行更新視圖的操作;
1.3 Vue工作流程
從圖中可以看出,當執行 new Vue() 時,Vue 就進入了初始化階段。初始化階段主要完成兩個工作:
1)Vue 會遍歷 data 屬性,並通過Object.defineProperty 方法爲每一個屬性添加setter和getter方法。這兩個方法實現了數據變化監聽功能;
2)另一方面,Compile(編譯器)對元素節點的指令(如v-for、@click等)進行解析。解析完成後訂閱Watcher 來更新視圖, 此時Wather 會將自己添加到Dep(依賴管理器)中,完成初始化。
當數據發生變化時,Observer 中的 setter方法被觸發,setter 會立即調用Dep.notify()方法,該方法會遍歷所有訂閱的Watcher,並調用 update 方法執行更新操作。另外,查看vue原代碼,發現在vue初始化實例時, 有一個proxy代理方法,它的作用就是遍歷data中的屬性,把它代理到vue的實例上。
下面模擬Vue的核心功能,實現MVVM的數據雙向綁定。
二、自定義MVVM框架
2.1 Object.defineProperty用法
Object.defineProperty函數的作用就是直接在一個對象上定義一個新屬性,或者修改一個已經存在的屬性。通過Object.defineProperty()定義屬性,通過描述符的設置可以進行更精準的控制對象屬性。
命令格式:
Object.defineProperty(obj, prop, desc)
obj:需要定義屬性的當前對象
prop:當前需要定義的屬性名
desc:屬性描述符
其中,屬性描述符有以下這些:
1)value:屬性的值。
2)writable:代表該屬性是否可以被改變。writable默認爲false,即屬性的值默認不可以被改變。
3)configrable:是否可配置,以及可否刪除。當configrable爲false,不能刪除當前屬性,且不能重新配置當前屬性的描述符,但是在writable爲true的情況下,可以改變value的值;當configrable爲true時,可以刪除當前屬性,也可以配置當前屬性所有描述符;
4)enumerable:是否會出現在for in 或者 Object.keys()的遍歷中;
5)get:一個給屬性提供getter的方法,如果沒有getter則爲undefined。該方法返回值被用作屬性值。默認爲undefined。
6)set:一個給屬性提供setter的方法,如果沒有setter則爲undefined。該方法將接受唯一參數,並將該參數的新值分配給該屬性。默認值爲undefined。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title></title>
</head>
<body>
<div id="app">
<p>您好,<span id="name"></span></p>
</div>
<script>
var obj = {};
Object.defineProperty(obj, 'name', {
get() {
return document.getElementById("name").innerHTML;
},
set(inner) {
document.getElementById("name").innerHTML = inner;
},
});
console.log(obj.name); // 空
obj.name = 'zhong';
console.log(obj.name); // zhong
</script>
</body>
</html>
2.2 依賴管理器
依賴管理器主要負責管理所有訂閱者,以及通知所有訂閱者執行更新操作。
第一步:新建一個js文件,並命名爲kvue.js文件;
第二步:新建一個類,並命名爲Dep;
class Dep {
}
第三步:定義constructor方法,該方法初始化watchers數組,該數組用於存儲所有訂閱的watcher;
constructor() {
this.watchers = [];
}
第四步:定義addWatcher方法,用於往數組添加watcher;
addWatch(w) {
this.watchers.push(w);
}
第五步:定義notify方法,該方法用於通知watcher執行更新操作;
notify() {
// 通知所有watch執行更新操作
this.watchers.forEach(w => {
w.update();
});
}
2.3 定義Watcher
第一步:定義一個類,並命名爲Watcher;
class Watcher {
}
第二步:定義constructor方法;
constructor(vue, key, fn) {
Dep.target = this; // 將當前wathcer實例添加到Dep.target屬性中
this.vue = vue;
this.key = key;
this.fn = fn;
}
vue:代表當前KVue實例;
key:代表data對象中的某個屬性;
fn:更新器函數,負責更新視圖;
第三步:定義一個update方法,模擬視圖更新操作;
update() {
console.log('視圖更新啦...');
}
2.4 定義KVue實例
第一步:新建一個class,並命名爲KVue;
第二步:定義constructor方法,該方法包含一個options參數,該參數封裝了KVue對象的所有選項;
class KVue {
constructor(options) {
this.$methods= options.methods;
this.$data = options.data;
this.observe(this.$data);
}
}
從上面代碼看到,options中應該包含data和methods屬性。
第三步:定義observe方法,該方法用於監聽KVue實例中所有屬性;
observe(obj) {
if (!obj || typeof obj !== 'object') {
return;
}
// 遍歷對象中所有屬性,並且爲每一個屬性添加setter和getter方法
Object.keys(obj).forEach(key => {
this.defineReactive(obj, key, obj[key]);
});
}
defineReactive(obj, key, val) {
// 創建依賴管理器
const dep = new Dep();
// 爲對象屬性添加setter和getter方法
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
// 如果Dep.target屬性存在,則將Dep.target屬性中的watcher添加到依賴管理器中存儲起來
Dep.target && dep.addWatch(Dep.target);
return val;
},
set(newVal) {
// 如果修改數據沒有發生變化,則直接返回;否則才進行修改操作
if (newVal === val) {
return;
}
val = newVal;
// 修改完成後,通知訂閱watcher執行更新操作
dep.notify();
}
});
}
2.5 定義編譯器
第一步:新建一個js文件,並命名爲compile.js;
第二步:定義一個類,並命名爲Compile;
class Compile {
}
第三步:定義constructor方法;
constructor(el, vue) {
// 將KVue對象以及el選擇器對應的DOM元素添加到$vue和$el屬性中
this.$vue = vue;
this.$el = document.querySelector(el);
// 如果dom元素存在,則執行編譯操作
if (this.$el) {
// 將dom元素轉換爲Fragment,以提高執行效率
this.$fragment = this.node2Fragment(this.$el);
// 執行編譯
this.compile(this.$fragment);
// 將編譯後的結果重新添加到宿主元素中
this.$el.appendChild(this.$fragment);
}
}
第四步:定義node2Fragment方法,該方法將DOM元素轉換爲fragment對象;
node2Fragment(el) {
// 創建一個新的Fragment
const fragment = document.createDocumentFragment();
let child;
// 將原生節點移動到Fragment
while (child = el.firstChild) {
fragment.appendChild(child);
}
return fragment;
}
第五步:定義compile方法,執行編譯操作,即把模版中的關鍵字替換成實際值;
compile(fragment) {
// 獲取fragment中所有孩子節點
let childNodes = fragment.childNodes;
// 遍歷所有孩子節點
Array.from(childNodes).forEach(child => {
// 判斷孩子節點的類型,根據不同類型做相應的處理
if (this.isElementNode(child)) {
// 如果是元素節點
this.compileElement(child);
} else if (this.isTextNode(child) && /\{\{(.*)\}\}/.test(child.textContent)) {
// 如果是文本節點,只關心{{xx}}格式
this.compileText(child, RegExp.$1); // RegExp.$1用於獲取正則分組的數據
}
// 遞歸遍歷可能存在的子節點
if (child.childNodes && child.childNodes.length) {
this.compile(child);
}
});
}
第六步:定義isElementNode、isTextNode、compileElement、compileText方法;
// 判斷是否是元素節點,如果是返回true,否則返回false
isElementNode(node) {
return node.nodeType == 1;
}
// 判斷是否是文本節點,如果是返回true,否則返回false
isTextNode(node) {
return node.nodeType == 3;
}
// 編譯元素節點
compileElement(el) {
// 例如:<div k-text="test" @click="clickHandler">
console.log('編譯元素節點...');
// 獲取所有屬性節點
const attrs = el.attributes;
// 遍歷元素屬性
Array.from(attrs).forEach(attr => {
const name = attr.name; // 屬性名,如:k-text 或 @click
const expr = attr.value; // 屬性值:如:test 或 clickHandler
if (this.isDirective(name)) {
// 如果是指令,獲取指令名稱
const dir = name.substr(2);
// 調用指令對應的處理函數
this[dir] && this[dir](el, this.$vue, expr);
} else if (this.isEventDirective(name)) {
// 如果是事件,獲取事件名
const dir = name.substr(1);
// 調用事件處理函數
this.eventHandler(el, this.$vue, expr, dir);
}
});
}
// 編譯文本節點
compileText(node, textContent) {
console.log('編譯文本節點...');
this.text(node, this.$vue, textContent);
}
第七步:定義isDirective、isEventDirective、eventHandler方法;
// 判斷是否是指令,如果是則返回true,否則返回false
isDirective(attr) {
return attr.indexOf('k-') == 0;
}
// 判斷是否是事件,如果是則返回true,否則返回false
isEventDirective(attr) {
return attr.indexOf('@') == 0;
}
// 執行事件
eventHandler(node, vue, exp, dir) { // dir = click exp = onClick
let fn = vue.$methods && vue.$methods[exp];
if (dir && fn) {
// 第一個參數是監聽的事件名稱
// 第二個參數fn.bind(vue)就是把vue實例綁定到fn函數中,那麼fn函數就可以通過this訪問vue實例啦。
// 第三個參數表示是否啓用捕獲階段。
node.addEventListener(dir, fn.bind(vue), false);
}
}
第八步:定義text、html、model方法,它們負責執行視圖的更新操作;
text(node, vue, exp) {
this.update(node, vue, exp, 'text');
}
html(node, vue, exp) {
this.update(node, vue, exp, 'html');
}
model(node, vue, exp) {
this.update(node, vue, exp, 'model');
// 監聽模node元素的input事件
node.addEventListener('input', e => {
vue[exp] = e.target.value; // 把input元素的輸入內容設置到模型的value屬性中
});
}
// 更新dom
update(node, vue, exp, type) {
let updaterFn = this[type + 'Updateor'];
updaterFn && updaterFn(node, vue[exp]); // 執行更新
new Watcher(vue, exp, function(value) {
updaterFn && updaterFn(node, value);
});
}
第九步:定義更新處理函數;
textUpdateor(node, value) {
node.textContent = value;
}
htmlUpdateor(node, value) {
node.innerHTML = value;
}
modelUpdateor(node, value) {
node.value = value;
}
2.6 改造KVue實例
第一步:在constructor方法中構建Compile實例;
constructor(options) {
...
new Compile(options.el, this);
}
第二步:修改observe方法,給data屬性設置代理。
observe(obj) {
// 如果val不存在,或者val不是對象,則不需要執行響應式,直接返回
if (!obj || typeof obj !== 'object') {
return;
}
// 遍歷val對象所有屬性
Object.keys(obj).forEach(key => {
// 爲每一個key定義響應式
this.defineReactive(obj, key, obj[key]);
// 爲vue的data屬性做代理,相當於把所有key添加到vue實例中
this.proxyData(key);
});
}
proxyData(key) {
Object.defineProperty(this, key, {
get() {
return this.$data[key];
},
set(newVal) {
this.$data[key] = newVal;
}
});
}
2.7 改造Watcher
第一步:修改constructor方法,在構建Watcher實例時將當前watcher實例添加到Dep中;
constructor(vue, key, fn) {
...
// 執行該行代碼的主要作用是觸發一下get方法,在get方法把當前watcher添加到dep依賴管理器中
this.vue[this.key];
// 把Dep.target屬性清空是爲了避免不必要的重複添加
Dep.target = null;
}
第二步:修改update方法,調用fn函數執行視圖的更新操作;
update() {
// 執行回調函數,更新視圖
this.fn.call(this.vue, this.vue[this.key]);
}
2.8 測試
構建測試頁:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title></title>
<script type="text/javascript" src="js/kvue.js"></script>
<script type="text/javascript" src="js/compile.js"></script>
</head>
<body>
<div id="app">
{{test}}
<p k-text="test"></p>
<p k-html="html"></p>
<p>
<input k-model="test" />
</p>
<p>
<button @click="onClick">按鈕</button>
</p>
</div>
<script>
const o = new KVue({
el: '#app',
data: {
test: 'hehe',
foo: {
bar: 'bar123',
},
html: '<h1>奇蹟來了。。。</h1>'
},
methods: {
onClick() {
alert('balabala');
}
}
});
</script>
</body>
</html>
運行效果: