雙向綁定
Vue 用著最舒服的地方,想必就是它的雙向綁定機(jī)制,不過(guò)在搞清楚這方點(diǎn)之前,舉個(gè)最簡(jiǎn)單的例子回顧下 Vue 雙向綁定的表象。

無(wú)論是從輸入框輸入,還是從 Vue 開(kāi)發(fā)工具中編輯,都會(huì)發(fā)現(xiàn)數(shù)據(jù)和頁(yè)面展示是同步的,而 Vue 實(shí)時(shí)監(jiān)聽(tīng)頁(yè)面和數(shù)據(jù)的變化,打通雙向通道的核心,其實(shí)只是 defineProperty 這一個(gè) js 原生方法。
那就先來(lái)說(shuō)說(shuō)這個(gè) defineProperty 的功能:
1.可以直接在一個(gè)對(duì)象上定義一個(gè)新屬性。
2.可以直接修改一個(gè)對(duì)象的現(xiàn)有屬性。
Object.defineProperty(object, prop, descriptor)共有三個(gè)參數(shù):
object:需要操作的對(duì)象。
prop:需要操作的屬性名。
descriptor:屬性描述符,描述操作屬性取值,以及是否可以修改、刪除、迭代。
比如定義一個(gè) author 對(duì)象,希望可以動(dòng)態(tài)新增一個(gè) name 屬性,值為書(shū)雁祉,就可以使用 defineProperty。
let author = {};
Object.defineProperty(author, "name", {
value: "書(shū)雁祉",
writable: false,
configurable: false,
enumerable: false,
});
value:指定屬性值。
writable:指定屬性值是否可修改,默認(rèn)值為 false 不可修改。
configurable:指定屬性是否可刪除,默認(rèn)值為 false 不可刪除。
enumerable:指定屬性是否可迭代,默認(rèn)值為 false 不可迭代。

除此之外,defineProperty 還可以傳入 get、set 方法用來(lái)監(jiān)聽(tīng)屬性,在獲取 / 修改對(duì)象的屬性值時(shí),會(huì)自動(dòng)調(diào)用 get / set 方法,但傳入 get、set 時(shí),不可傳入 value、writable。
let author = { name: "書(shū)雁祉", age: 14 };
for (let key in author) {
let syz = author[key];
Object.defineProperty(author, key, {
configurable: false,
enumerable: false,
get() {
console.log("get");
return syz;
},
set(value) {
if (value !== syz) {
console.log("set");
syz = value;
}
},
});
}

在 Vue 中,為方便管理代碼,Vue 使用了一個(gè) Observe 類(lèi)專(zhuān)門(mén)來(lái)處理對(duì)象的監(jiān)聽(tīng),在初始化類(lèi)時(shí)將需要監(jiān)聽(tīng)的對(duì)象傳入即可。defineRecative 中,this.observer(old)是考慮到對(duì)象的屬性值也可能是對(duì)象,this.observer(value)則是考慮到賦值時(shí)可能賦值一個(gè)對(duì)象,這兩種情況都需要監(jiān)聽(tīng)。
class Observer {
constructor(data) {
this.observer(data);
}
observer(object) {
if (object && typeof object === "object") {
for (let key in object) {
this.defineRecative(object, key, object[key]);
}
}
}
defineRecative(object, attr, old) {
this.observer(old);
Object.defineProperty(object, attr, {
get() {
console.log("get");
return old;
},
set: (value) => {
if (value !== old) {
console.log("set");
this.observer(value);
old = value;
}
},
});
}
}
let author = {
name: {
family: "書(shū)",
first: "雁祉",
},
pet: "neko",
};
new Observer(author);

回到創(chuàng)建 Vue 實(shí)例的代碼,由此可以分析出:
1.Vue 是一個(gè)類(lèi),構(gòu)造函數(shù)接收一個(gè)對(duì)象。
2.類(lèi)會(huì)將 el 指定的 dom 元素作為根節(jié)點(diǎn),將 data 中的屬性值編譯渲染到該節(jié)點(diǎn)上。
3.el 可以使 id 名,也可以是 dom 元素。
4.vue 會(huì)將 dom 與 data 分別綁定到$el 和$data 上
<div id="app">
<input type="text" v-model="name" />
<p>{{ name }}</p>
</div>
<script>
const vue = new Vue({
el: "#app",
data: {
name: "書(shū)雁祉",
age: 14,
},
});
console.log(vue.$el);
console.log(vue.$data);
</script>

由此,定義一個(gè) Sue 類(lèi)來(lái)實(shí)現(xiàn)與 Vue 相似的效果。
1.首先將 dom 元素和 data 都綁定在實(shí)例的$el 和$data 上,nodeType === 1 代表 el 為 dom 元素。
2.如果 dom 元素存在,根據(jù)指定的 dom 節(jié)點(diǎn),定義 Compier 編譯類(lèi),利用 data 渲染節(jié)點(diǎn)。
class Sue {
constructor(options) {
if (options.el.nodeType === 1) {
this.$el = options.el;
} else {
this.$el = document.getElementById(options.el);
}
this.$data = options.data;
if (this.$el) {
new Compiler(this);
}
}
}
class Compiler {
constructor(vm) {
this.vm = vm;
}
}
在寫(xiě)編譯類(lèi)前,需要了解一點(diǎn),Vue 在解析節(jié)點(diǎn)時(shí),不會(huì)每解析到一個(gè)需要渲染 data 的位置就更新一遍 dom 樹(shù),dom樹(shù)更新會(huì)十分耗時(shí)且耗費(fèi)瀏覽器性能,Vue 會(huì)將 dom 樹(shù)放在內(nèi)存中,將 data 渲染到內(nèi)存中的虛擬 dom 樹(shù)上,再將虛擬 dom 一次性渲染到界面中。
那么如何構(gòu)建虛擬dom樹(shù),方法其實(shí)有很多,Vue 才用了 js 內(nèi)置的 DocumentFragment 來(lái)構(gòu)建。
class Compiler {
constructor(vm) {
this.vm = vm;
const fragment = this.createFragment(vm.$el);
console.log(fragment);
}
createFragment(app) {
const fragment = document.createDocumentFragment();
const node = app.firstChild;
while (node) {
fragment.appendChild(node);
node = app.firstChild;
}
return fragment;
}
}
注意:DocumentFragment 調(diào)用 appendChild 后,該 dom 元素會(huì)從 dom 節(jié)點(diǎn)中消失,所以取 firstChild 即可取到第一個(gè)。
<div id="app">
<input type="text" v-model="name" />
<p>{{ name }}</p>
</div>
<script>
const sue = new Sue({
el: "#app",
data: {
name: "書(shū)雁祉",
age: 14,
},
});
console.log(sue.$el);
console.log(sue.$data);
</script>

可見(jiàn),虛擬 dom 已經(jīng)寫(xiě)入內(nèi)存,sue 實(shí)例上的 data 也分別掛載了 dom 元素和 data。下一步則需要解析解析虛擬 dom 樹(shù),渲染 $data 中的數(shù)據(jù)。
1.遍歷虛擬 dom,判斷當(dāng)前遍歷到的是元素節(jié)點(diǎn)還是文本節(jié)點(diǎn)。
2.元素節(jié)點(diǎn)則需獲取屬性名和屬性值,判斷有沒(méi)有 v-* 屬性(如 v-model),同時(shí)將節(jié)點(diǎn)繼續(xù)傳入 buildFragment 處理子節(jié)點(diǎn)。
3.文本節(jié)點(diǎn)則需判斷內(nèi)容有沒(méi)有 {{}}。
class Compiler {
constructor(vm) {
this.vm = vm;
const fragment = this.createFragment(vm.$el);
this.buildFragment(fragment);
}
createFragment(app) {
const fragment = document.createDocumentFragment();
let node = app.firstChild;
while (node) {
fragment.appendChild(node);
node = app.firstChild;
}
return fragment;
}
buildFragment(fragment) {
for (let node of fragment.childNodes) {
if (node.nodeType === 1) {
this.buildElement(node);
this.buildFragment(node);
} else {
this.buildText(node);
}
}
}
buildElement(node) {
for (let attr of node.attributes) {
let { name, value } = attr;
if (name.startsWith("v-")) {
console.log("元素", node, attr, name, value);
}
}
}
buildText(node) {
let content = node.textContent;
const regexp = /\{\{.+?\}\}/gi;
if (regexp.test(content)) {
console.log("文本", content);
}
}
}

考慮到 v-* 屬性的多樣性,Vue 才用了一個(gè)專(zhuān)門(mén)用來(lái)處理指令的 CompilerUtil 對(duì)象,包含所有需要處理的屬性對(duì)贏得方法,這里取其中幾種舉例。
1.對(duì)于 v-model,通常用在 input 中,所以只需設(shè)置節(jié)點(diǎn)的 value 為對(duì)應(yīng) $data 的屬性值,最后在 constructor 中將 fragment 渲染到頁(yè)面即可。
2.但只是這樣會(huì)出現(xiàn)問(wèn)題,比如對(duì)應(yīng) $data 的屬性值是一個(gè)對(duì)象,而渲染的是該對(duì)象的屬性值,則無(wú)法正確獲取到,所以在 CompilerUtil 中定義 GetValue 函數(shù),專(zhuān)門(mén)用來(lái)獲取對(duì)應(yīng)的屬性值。
const CompilerUtil = {
GetValue(value, vm) {
const realValue = value.split(".").reduce((data, key) => {
console.log("GetValue", data, key);
return data[key];
}, vm.$data);
return realValue;
},
model(node, value, vm) {
node.value = this.GetValue(value, vm);
},
};
class Compiler {
constructor(vm) {
this.vm = vm;
const fragment = this.createFragment(vm.$el);
this.buildFragment(fragment);
vm.$el.appendChild(fragment);
}
createFragment(app) {
const fragment = document.createDocumentFragment();
let node = app.firstChild;
while (node) {
fragment.appendChild(node);
node = app.firstChild;
}
return fragment;
}
buildFragment(fragment) {
for (let node of fragment.childNodes) {
if (node.nodeType === 1) {
this.buildElement(node);
this.buildFragment(node);
} else {
this.buildText(node);
}
}
}
buildElement(node) {
for (let attr of node.attributes) {
let { name, value } = attr;
if (name.startsWith("v-")) {
const directive = name.split("-")[1];
CompilerUtil[directive](node, value, this.vm);
}
}
}
buildText(node) {
let content = node.textContent;
const regexp = /\{\{.+?\}\}/gi;
if (regexp.test(content)) {
console.log("文本", content);
}
}
}
<div id="app">
<input type="text" v-model="name" />
<input type="text" v-model="time.h" />
<p>{{ name }}</p>
</div>
<script>
const vue = new Sue({
el: "#app",
data: {
name: "書(shū)雁祉",
age: 14,
time: {
h: 10,
m: 20,
},
},
});
</script>

再用 v-html 和 v-text 示范,原理相似,分別修改元素的 innerHTML 和 innerText 為對(duì)應(yīng)屬性值。
const CompilerUtil = {
GetValue(value, vm) {
const realValue = value
.split(".")
.reduce((data, key) => data[key], vm.$data);
return realValue;
},
model(node, value, vm) {
node.value = this.GetValue(value, vm);
},
html(node, value, vm) {
node.innerHTML = this.GetValue(value, vm);
},
text(node, value, vm) {
node.innerText = this.GetValue(value, vm);
},
};
<div id="app">
<input type="text" v-model="name" />
<input type="text" v-model="time.h" />
<p>{{ name }}</p>
<p v-html="html"></p>
<p v-text="html"></p>
</div>
<script>
const vue = new Sue({
el: "#app",
data: {
name: "書(shū)雁祉",
age: 14,
time: {
h: 10,
m: 20,
},
html: "<div>a div</div>",
},
});
</script>

Part 1就先示范這么多,等有閑情逸致了再寫(xiě)Part 2。