介紹
本文通過仿照 Vue 浴鸿,簡單實現(xiàn)一個的 MVVM宇植,希望對大家學(xué)習(xí)和理解 Vue 的原理有所幫助蔬顾。
前置知識
nodeType
nodeType 為 HTML 原生節(jié)點的一個屬性郊供,用于表示節(jié)點的類型。
Vue 中通過每個節(jié)點的 nodeType 屬性是1還是3判斷是元素節(jié)點還是文本節(jié)點瞎惫,針對不同類型節(jié)點做不同的處理。
DocumentFragment
DocumentFragment是一個可以被 js 操作但不會直接出發(fā)渲染的文檔對象译株,Vue 中編譯模板時是現(xiàn)將所有節(jié)點存到 DocumentFragment 中瓜喇,操作完后再統(tǒng)一插入到 html 中,這樣就避免了多次修改 Dom 出發(fā)渲染導(dǎo)致的性能問題歉糜。
Object.defineProperty
Object.defineProperty接收三個參數(shù) Object.defineProperty(obj, prop, descriptor)
, 可以為一個對象的屬性 obj.prop t通過 descriptor 定義 get 和 set 方法進(jìn)行攔截乘寒,定義之后該屬性的取值和修改時會自動觸發(fā)其 get 和 set 方法。
從零實現(xiàn)一個類 Vue
以下代碼的 git 地址:以下代碼的 git 地址
目錄結(jié)構(gòu)
├── vue
│ ├── index.js
│ ├── obsever.js
│ ├── compile.js
│ └── watcher.js
└── index.html
實現(xiàn)的這個 類 Vue 包含了4個主要模塊:
- index.js 為入口文件现恼,提供了一個 Vue 類肃续,并在類的初始化時調(diào)用 obsever 與 compile 分別進(jìn)行數(shù)據(jù)攔截與模板編譯;
- obsever.js 中提供了一個 Obsever 類及一個 Dep 類叉袍,Obsever 對 vue 的 data 屬性遍歷始锚,給所有數(shù)據(jù)都添加 getter 與 setter 進(jìn)行攔截,Dep 用于記錄每個數(shù)據(jù)的依賴喳逛;
- compile.js 中提供了一個 Compile 類瞧捌,對傳入的 html 節(jié)點的所有子節(jié)點遍歷編譯,分析 vue 不同的指令并解析
{{}}
的語法; - watcher.js 中提供了一個 Watcher 類姐呐,用于監(jiān)聽每個數(shù)據(jù)的變化殿怜,當(dāng)數(shù)據(jù)變化時調(diào)用傳入的回調(diào)函數(shù);
入口文件
在 index.html 中是通過 new Vue() 來使用的:
<div id="app">
<input type="text" v-model="msg">
{{ msg }}
{{ user.name }}
</div>
<script>
const vm = new Vue({
el: '#app',
data: {
msg: 'hello',
user: {
name: 'pan'
}
}
})
</script>
因此入口文件需提供這個 Vue 的類并進(jìn)行一些初始化操作:
class Vue {
constructor(options) {
// 參數(shù)掛載到實例
this.$el = document.querySelector(options.el);
this.$data = options.data;
if (this.$el) {
// 數(shù)據(jù)劫持
new Observer(this.$data);
// 編譯模板
new Compile(this.$el, this);
}
}
}
Compile
index.js 中調(diào)用了 new Compile()
進(jìn)行模板編譯曙砂,因此這里需要提供一個 Compile 類:
class Compile {
constructor(el, vm) {
this.el = el;
this.vm = vm;
if (this.el) {
// 將 dom 轉(zhuǎn)入 fragment 內(nèi)存中
const fragment = this.node2fragment(this.el);
// 編譯 提取需要的節(jié)點并替換為對應(yīng)數(shù)據(jù)
this.compile(fragment);
// 插回頁面中去
this.el.appendChild(fragment);
}
}
// 編譯元素節(jié)點 獲取 Vue 指令并執(zhí)行對應(yīng)的編譯函數(shù)(取值并更新 dom)
compileElement(node) {
const attrs = node.attributes;
Array.from(attrs).forEach(attr => {
const attrName = attr.name;
if (this.isDirective(attrName)) {
const expr = attr.value;
let [, ...type] = attrName.split('-');
type = type.join('');
// 調(diào)用指令對應(yīng)的方法更新 dom
CompileUtil[type](node, this.vm, expr);
}
})
}
// 編譯文本節(jié)點 判斷文本內(nèi)容包含 {{}} 則執(zhí)行文本節(jié)點編譯函數(shù)(取值并更新 dom)
compileText(node) {
const expr = node.textContent;
const reg = /\{\{\s*([^}\s]+)\s*\}\}/;
if (reg.test(expr)) {
// 調(diào)用文本節(jié)點對應(yīng)的方法更新 dom
CompileUtil['text'](node, this.vm, expr);
}
}
// 遞歸遍歷 fragment 中所有節(jié)點判斷節(jié)點類型并編譯
compile(fragment) {
const childNodes = fragment.childNodes;
Array.from(childNodes).forEach(node => {
if (this.isElementNode(node)) {
// 元素節(jié)點 編譯并遞歸
this.compileElement(node);
this.compile(node);
} else {
// 文本節(jié)點
this.compileText(node);
}
})
}
// 循環(huán)將 el 中每個節(jié)點插入 fragment 中
node2fragment(el) {
const fragment = document.createDocumentFragment();
let firstChild;
while (firstChild = el.firstChild) {
fragment.appendChild(firstChild);
}
return fragment;
}
isElementNode(node) {
return node.nodeType === 1;
}
isDirective(name) {
return name.startsWith('v-');
}
}
這里利用了 nodeType 區(qū)分 元素節(jié)點 還是 文本節(jié)點头谜,分別調(diào)用了 compileElement 和 compileText。
compileElement 及 compileText 中最終調(diào)用了 CompileUtil 的方法更新 dom鸠澈。
CompileUtil = {
// 獲取實例上對應(yīng)數(shù)據(jù)
getVal(vm, expr) {
expr = expr.split('.');
return expr.reduce((prev, next) => {
return prev[next];
}, vm.$data);
},
// 文本節(jié)點需先去除 {{}} 并利用正則匹配多組
getTextVal(vm, expr) {
return expr.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (...arguments) => {
return this.getVal(vm, arguments[1]);
})
},
// 從 vm.$data 上取值并更新節(jié)點的文本內(nèi)容
text(node, vm, expr) {
expr.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (...arguments) => {
// 添加數(shù)據(jù)監(jiān)聽柱告,數(shù)據(jù)變化時調(diào)用回調(diào)函數(shù)
new Watcher(vm, arguments[1], () => {
this.updater.textUpdater(node, this.getTextVal(vm, expr));
})
})
this.updater.textUpdater(node, this.getTextVal(vm, expr));
},
// 從 vm.$data 上取值并更新輸入框內(nèi)容
model(node, vm, expr) {
// 添加數(shù)據(jù)監(jiān)聽,數(shù)據(jù)變化時調(diào)用回調(diào)函數(shù)
new Watcher(vm, expr, () => {
this.updater.modelUpdater(node, this.getVal(vm, expr));
})
// 輸入框輸入時修改 data 中對應(yīng)數(shù)據(jù)
node.addEventListener('input', e => {
const newValue = e.target.value;
this.setVal(vm, expr, newValue);
})
this.updater.modelUpdater(node, this.getVal(vm, expr));
},
updater: {
textUpdater(node, value) {
node.textContent = value;
},
modelUpdater(node, value) {
node.value = value;
}
}
}
getVal 方法用于處理嵌套對象的屬性笑陈,如傳入表達(dá)式 expr 為 user.name
的情況际度,利用 reduce 從 vm.$data 上拿到。
Observer
index.js 中調(diào)用了 new Observer()
進(jìn)行數(shù)據(jù)劫持涵妥,Vue 實例 data 屬性的每項數(shù)據(jù)都通過 defineProperty 方法添加 getter setter 攔截數(shù)據(jù)操作將其定義為響應(yīng)式數(shù)據(jù)乖菱,因此這里首先需要提供一個 Observer 類:
class Observer {
constructor(data) {
// 遍歷 data 將每個屬性定義為響應(yīng)式
this.observer(data);
}
observer(data) {
if (!data || typeof data !== 'object') {
return;
}
for (const [key, value] of Object.entries(data)) {
this.defineReactive(data, key, value);
// 當(dāng)屬性為對象則需遞歸遍歷
this.observer(value);
}
}
// 定義響應(yīng)式屬性
defineReactive(obj, key, value) {
const that = this;
const dep = new Dep();
Object.defineProperty(obj, key, {
enumerable: true,
configurable: false,
// 獲取數(shù)據(jù)時調(diào)用
get() {
// 將 Watcher 實例存入依賴
Dep.target && dep.addSub(Dep.target);
return value;
},
// 設(shè)置數(shù)據(jù)時調(diào)用
set(newVal) {
if (newVal !== value) {
// 當(dāng)新值為對象時,需遍歷并定義對象內(nèi)屬性為響應(yīng)式
that.observer(newVal);
value = newVal;
// 通知依賴更新
dep.notify();
}
}
})
}
}
定義為響應(yīng)式數(shù)據(jù)后再對其取值和修改是會觸發(fā)對應(yīng)的 get 和 set 方法蓬网。
取值時將改值本身返回窒所,并先判斷是否有依賴目標(biāo) Dep.target,如果有則保存起來拳缠。
修改值時先手動將原值修改并通知保存的所有依賴目標(biāo)進(jìn)行更新操作墩新。
這里對每項數(shù)據(jù)都通過創(chuàng)建一個 Dep 類實例進(jìn)行保存依賴和通知更新的操作,因此需要寫一個 Dep 類:
class Dep {
constructor() {
this.subs = [];
}
addSub(watcher) {
this.subs.push(watcher);
}
notify() {
this.subs.forEach(watcher => watcher.update());
}
}
Dep 中有一個數(shù)組窟坐,用于保存數(shù)據(jù)的依賴目標(biāo)(watcher)海渊,notify 遍歷所有依賴并調(diào)用其 update 方法進(jìn)行更新。
Watcher
通過上面的 Observer 可以知道哲鸳,每項數(shù)據(jù)在被調(diào)用時可能會有依賴目標(biāo)臣疑,依賴目標(biāo)需要被保存并在取值時調(diào)用 notify 通知更新,且通過 Dep 可以知道依賴目標(biāo)是一個有 update 方法的對象實例徙菠。
因此需要創(chuàng)建一個 Watcher 類:
class Watcher {
constructor(vm, expr, cb) {
this.vm = vm;
this.expr = expr;
this.cb = cb;
// 記錄舊值
this.value = this.get();
}
getVal(vm, expr) {
expr = expr.split('.');
return expr.reduce((prev, next) => {
return prev[next];
}, vm.$data);
}
get() {
Dep.target = this;
// 獲取 data 會觸發(fā)對應(yīng)數(shù)據(jù)的 get 方法讯沈,get 方法中從 Dep.target 拿到 Watcher 實例
let value = this.getVal(this.vm, this.expr);
Dep.target = null;
return value;
}
// 對外暴露的方法,獲取新值與舊值對比后若不同則觸發(fā)回調(diào)函數(shù)
update() {
let newValue = this.getVal(this.vm, this.expr);
let oldValue = this.value;
if (newValue !== oldValue) {
this.cb(newValue);
}
}
}
依賴目標(biāo)就是 Watcher 的實例婿奔,對外提供了 update 方法缺狠,調(diào)用 update 時會重新根據(jù)表達(dá)式 expr 取值與老值對比并調(diào)用回調(diào)函數(shù)。
這里的回調(diào)函數(shù)就是對應(yīng)的更新 dom 的方法萍摊,在 compile.js 中的 model 及 text 方法中有執(zhí)行 new Watcher()
挤茄,在模板解析時就為每項數(shù)據(jù)添加了監(jiān)聽:
model(node, vm, expr) {
// 添加數(shù)據(jù)監(jiān)聽,數(shù)據(jù)變化時調(diào)用回調(diào)函數(shù)
new Watcher(vm, expr, () => {
this.updater.modelUpdater(node, this.getVal(vm, expr));
})
this.updater.modelUpdater(node, this.getVal(vm, expr));
},
Watcher 中很巧妙的一點就是冰木,模板編譯之前已經(jīng)將所有添加了數(shù)據(jù)攔截穷劈,在 Watcher 的 get 方法中調(diào)用 getVal 取值時會觸發(fā)該數(shù)據(jù)的 getter 方法笼恰,因此這里在取值前通過 Dep.target = this;
將該 Watcher 實例暫存,對應(yīng)數(shù)據(jù)的 getter 方法中又將該實例作為依賴目標(biāo)保存到了自身對應(yīng)的 Dep 實例中歇终。
總結(jié)
這樣就實現(xiàn)了一個簡易的 MVVM 原理社证,里面的一些思路還是非常值得反復(fù)體會學(xué)習(xí)的。