Vue源碼分析之實現一個簡易版的Vue
目標
參考 //cn.vuejs.org/v2/guide/reactivity.html
使用 Typescript
編寫簡易版的 vue 實現數據的響應式和基本的視圖渲染,以及雙向綁定功能。
測試程式碼中,編寫vue.js是本篇的重點,基本使用方法與常規的Vue一樣:
<div id='app'>
<div>{{ person.name }}</div>
<div>{{ count }}</div>
<div v-text='person.name'></div>
<input type='text' v-model='msg' />
<input type='text' v-model='person.name'/>
</div>
<script src='vue.js'></script>
<script>
let vm = new Vue({
el: '#app',
data: {
msg: 'Hello vue',
count: 100,
person: { name: 'Tim' },
}
});
vm.msg = 'Hello world';
console.log(vm);
//模擬數據更新
setTimeout(() => { vm.person.name = 'Goooooood'; }, 1000);
<script>
頁面渲染結果如下
實現的簡易Vue需要完成以下功能
- 可以解析插值表達式,如 {{person.name}}
- 可以解析內置指令,如
v-text
- 可以雙向綁定數據,如
v-model
- 數據更新視圖隨之更新
Vue當中有以下重要的組件
- 初始化時通過
Object.defineProperty
代理Vue.data的數據方便操作, 訪問Vue.prop
等於訪問Vue.data.prop
- 通過
Observer
對Vue.data
里所有的數據及其子節點(遞歸)都進行捕捉,通過getter
setter
實現數據雙向綁定 - 初始
Observer
在getter
中收集依賴(watcher觀察者)在setter
中發送通知notify
Watcher
中註冊依賴Dep
基層Vue
Vue 數據結構,這裡只關注下面三個屬性
欄位 | 說明 |
---|---|
$options | 存放構造時傳入的配置參數 |
$data | 存放數據 |
$el | 存放需要渲染的元素 |
實現Vue時,需要完成以下功能:
- 負責接收初始化參數
options
- 負責把data屬性注入到vue,並轉換成
getter/setter
- 負責調用
observer
監聽所有屬性變化 - 負責調用
compiler
解析指令和差值表達式
類型介面定以
為保持靈活性,這裡直接用any類型
interface VueData {
[key: string]: any,
}
interface VueOptions {
data: VueData;
el: string | Element;
}
interface Vue {
[key: string]: any,
}
Vue實現程式碼
class Vue {
public $options: VueOptions;
public $data: VueData;
public $el: Element | null;
public constructor(options: VueOptions) {
this.$options = options;
this.$data = options.data || {};
if (typeof options.el == 'string') {
this.$el = document.querySelector(options.el);
} else {
this.$el = options.el;
}
if (!this.$el) {
throw Error(`cannot find element by selector ${options.el}`);
return;
}
this._proxyData(this.$data);
}
//生成代理,通過直接讀寫vue屬性來代理vue.$data的數據,提高便利性
//vue[key] => vue.data[key]
private _proxyData(data: VueData) {
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get() {
return data[key];
},
set(newVal) {
if (newVal == data[key]) {
return;
}
data[key] = newVal;
}
})
})
}
}
- 對於Vue的元數據均以
$
開頭表示,因為訪問Vue.data
會被代理成Vue.$data.data
,即注入屬性與元屬性進行區分 - $el 可以為選擇器或Dom,但最終需要轉成Dom,若不存在Dom拋出錯誤
- _porxyData,下劃線開頭為私有屬性或方法,此方法可以將 $data 屬性注入到vue中
- enumerable 為可枚舉, configurable 為可配置,如重定以和刪除屬性
- setter 中,如果數據沒有發生變化則return,發生變化更新 $data
簡單測試一下
let vm = new Vue({
el: '#app',
data: {
msg: 'Hello vue',
count: 100,
person: { name: 'Tim' },
}
});
上圖中顏色比較幽暗的,表示注入到Vue的屬性已成功設置了getter和setter
Observer
- 負責把data選項中的屬性轉換成響應式數據
- data中某個屬性的值也是對象,需要遞歸轉換成響應式
- 數據發生變化時發送通知
Observer 實現程式碼
class Observer {
constructor(data) {
this.walk(data);
}
walk(data) {
Object.keys(data).forEach(key => {
this.defineReactive(data, key, data[key]);
});
}
defineReactive(obj, key, val) {
//遞歸處理成響應式
if (typeof val === 'object') {
this.walk(val);
}
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
//注意:這裡val不可改成obj[key],會無限遞歸直至堆棧溢出
return val;
},
set: (newVal) => {
if (newVal == val) {
return;
}
//注意:這裡newVal不可改成obj[key],會觸發 getter
val = newVal;
if (typeof newVal == 'object') {
this.walk(newVal);
}
}
});
}
}
walk方法
用於遍歷$data屬性,傳遞給defineReactive
做響應式處理defineReactive
如果值為對象則遞歸調用walk
,如果值為原生數據則設置getter和setter
說明
有的人可能會覺得 defineReactive(data, key, val)
中的形參val多此一舉,因為 data[key] == val
也可以獲取
其實不然,假設我們只傳遞了 defineReactive(data, key)
那麼在 defineProperty
中 getter 和 setter 要是使用
data[key]
的方式訪問值的話,在getter會無限觸發get()
, 而在setter會觸發一次get()
,因為 data[key]
就是觸發getter的方式
另外defineProperty
內部引用了 defineReactive
的參數val,這裡會產生閉包空間存儲 val的值
defineReactive
Observer 引用
在上面編寫的 Vue.constructor
中添加Observer的引用,並傳入$data
//Vue.constructor
public constructor(options: VueOptions) {
this.$options = options;
this.$data = options.data || {};
if (typeof options.el == 'string') {
this.$el = document.querySelector(options.el);
} else {
this.$el = options.el;
}
if (!this.$el) {
throw Error(`cannot find element by selector ${options.el}`);
return;
}
this._proxyData(this.$data);
new Observer(this.$data); //新增此行
}
測試
重新列印vm可以看到 $data 里的成員也有getter和setter方法了
Compiler
- 負責編譯模板,解析指令
v-xxx
和插值表達式{{var}}
- 負責頁面首次渲染
- 當數據發生變化時,重新渲染視圖
注意,為簡化程式碼,這裡的插值表達式,不處理複雜情況,只處理單一的變數讀取
如 {{count + 2}}
=> 不進行處理
如 {{person.name}}
=> 可以處理
Util 輔助工具
為方便操作,我們需要提前編寫幾個簡單的函數功能,並封裝到 Util 類中靜態方法里
class Util {
static isPrimitive(s: any): s is (string | number) {
return typeof s === 'string' || typeof s === 'number';
}
static isHTMLInputElement(element: Element): element is HTMLInputElement {
return (<HTMLInputElement>element).tagName === 'INPUT';
}
//處理無法引用 vm.$data['person.name'] 情況
static getLeafData(obj: Object, key: string): any {
let textData: Array<any> | Object | String | Number = obj;
if (key.indexOf('.') >= 0) {
let keys = key.split('.');
for (let k of keys) {
textData = textData[k];
}
} else {
textData = obj[key];
}
return textData;
}
static setLeafData(obj: Object, key: string, value: any): void {
if (key.indexOf('.') >= 0) {
let keys = key.split('.');
for (let i = 0; i < keys.length; i++) {
let k = keys[i];
if (i == keys.length - 1) {
obj[k] = value;
} else {
obj = obj[k];
}
}
} else {
if (obj[key]){
obj[key] = value;
}
}
}
}
- isPrimitive
該函數用於判斷變數是否為原生類型(string or number)
- isHTMLInputElement
該函數用於判斷元素是否為Input元素,用於後面處理 v-model
指令的雙向綁定數據,默認:value
@input
- getLeafData
因為key可能為 person.name
, 如果直接中括弧訪問對象屬性如 obj['person.name']
無法等同於 obj.person.name
該函數如果傳遞的鍵key中,若不包含點.
,則直接返回 obj[key]。 若包含,則解析處理返回 obj.key1.key2.key3
- setLeafData
同上, key為person.name
時,設置 obj.person.name = value
,否則設置 obj.key = value
Complier 實現程式碼
class Compiler {
public el: Element | null;
public vm: Vue;
constructor(vm: Vue) {
this.el = vm.$el,
this.vm = vm;
if (this.el) {
this.compile(this.el);
}
}
compile(el: Element) {
let childNodes = el.childNodes;
Array.from(childNodes).forEach((node: Element) => {
if (this.isTextNode(node)) {
this.compileText(node);
} else if (this.isElementNode(node)) {
this.compileElement(node);
}
//遞歸處理孩子nodes
if (node.childNodes && node.childNodes.length !== 0) {
this.compile(node);
}
})
}
//解析插值表達式 {{text}}
compileText(node: Node) {
let pattern: RegExpExecArray | null;
if (node.textContent && (pattern = /\{\{(.*?)\}\}/.exec(node.textContent))) {
let key = pattern[1].trim();
if (key in this.vm.$data && Util.isPrimitive(this.vm.$data[key])) {
node.textContent = this.vm.$data[key];
}
}
}
//解析 v-attr 指令
compileElement(node: Element) {
Array.from(node.attributes).forEach((attr) => {
if (this.isDirective(attr.name)) {
let directive: string = attr.name.substr(2);
let value = attr.value;
let processer: Function = this[directive + 'Updater'];
if (processer) {
processer.call(this, node, value);
}
}
})
}
//處理 v-model 指令
modelUpdater(node: Element, key: string) {
if (Util.isHTMLInputElement(node)) {
let value = Util.getLeafData(this.vm.$data, key);
if (Util.isPrimitive(value)) {
node.value = value.toString();
}
node.addEventListener('input', () => {
Util.setLeafData(this.vm.$data, key, node.value);
console.log(this.vm.$data);
})
}
}
//處理 v-text 指令
textUpdater(node: Element, key: string) {
let value = Util.getLeafData(this.vm.$data, key);
if (Util.isPrimitive(value)) {
node.textContent = value.toString();
}
}
//屬性名包含 v-前綴代表指令
isDirective(attrName: string) {
return attrName.startsWith('v-');
}
//nodeType為3屬於文本節點
isTextNode(node: Node) {
return node.nodeType == 3;
}
//nodeType為1屬於元素節點
isElementNode(node: Node) {
return node.nodeType == 1;
}
}
- compile
用於首次渲染傳入的 div#app
元素, 遍歷所有第一層子節點,判斷子節點nodeType
屬於文本
還是元素
若屬於 文本
則調用 compileText
進行處理, 若屬於 元素
則調用 compileElement
進行處理。
另外如果子節點的孩子節點 childNodes.length != 0
則遞歸調用 compile(node)
- compileText
用於渲染插值表達式,使用正則 \{\{(.*?)\}\}
檢查是否包含插值表達式,提取括弧內變數名
通過工具函數 Utils.getLeafData(vm.$data, key)
嘗試讀取 vm.$data[key]
和 vm.$data.key1.key2
的值
如果能讀取成功,則渲染到視圖當中 node.textContent = this.vm.$data[key];
- compileElement
用於處理內置v-指令,通過 node.attributes
獲取所有元素指令,Array.from()
可以使NamedNodeMap
轉成可遍歷的數組
獲取屬性名,判斷是否有 v-
前綴,若存在則進行解析成函數,解析規則如下
- v-text 解析的函數名為
textUpdater()
- v-model 解析函數名為
modelUpdater()
可以通過嘗試方法獲取,如 this[directive + "Updater"]
若不為 undefined 說明指令處理函數是存在的
最後通過 call 調用,使得 this 指向 Compiler類實例
- textUpdater
與 compileText 類似,嘗試讀取變數並渲染到Dom中
- modelUpdate
除了嘗試讀取變數並渲染到Dom中,還需要設置 @input
函數監聽視圖的變化來更新數據
node.addEventListener('input', () => {
Util.setLeafData(this.vm.$data, key, node.value);
})
Complier 實例化引用
在 Vue.constructor 中引用 Compiler 進行首次頁面渲染
//Vue.constructor
public constructor(options: VueOptions) {
this.$options = options;
this.$data = options.data || {};
if (typeof options.el == 'string') {
this.$el = document.querySelector(options.el);
} else {
this.$el = options.el;
}
if (!this.$el) {
throw Error(`cannot find element by selector ${options.el}`);
return;
}
this._proxyData(this.$data);
new Observer(this.$data);
new Compiler(this); //新增此行
}
測試程式碼
<div id='app'>
<div>{{ person.name }}</div>
<div>{{ count }}</div>
<div v-text='person.name'></div>
<input type='text' v-model='msg' />
<input type='text' v-model='person.name'/>
</div>
<script src='vue.js'></script>
<script>
let vm = new Vue({
el: '#app',
data: {
msg: 'Hello vue',
count: 100,
person: { name: 'tim' },
}
})
</scirpt>
渲染結果
至此完成了初始化數據驅動和渲染功能,我們修改 input 表單里的元素內容是會通過 @input
動態更新$data對應綁定v-model
的數據
但是此時我們在控制台中修改 vm.msg = ‘Gooooood’ ,視圖是不會有響應式變化的,因此下面將通過Watcher
和 Dep
觀察者模式來實現響應式處理
Watcher 與 Dep
Dep(Dependency)
實現功能:
- 收集依賴,添加觀察者(Watcher)
- 通知所有的觀察者 (notify)
Dep 實現程式碼
class Dep {
static target: Watcher | null;
watcherList: Watcher[] = [];
addWatcher(watcher: Watcher) {
this.watcherList.push(watcher);
}
notify() {
this.watcherList.forEach((watcher) => {
watcher.update();
})
}
}
Watcher
實現功能:
- 當變化觸發依賴時,Dep通知Watcher進行更新視圖
- 當自身實例化時,向Dep中添加自己
Watcher 實現程式碼
每個觀察者Watcher都必須包含 update方法,用於描述數據變動時如何響應式渲染到頁面中
class Watcher {
public vm: Vue;
public cb: Function;
public key: string;
public oldValue: any;
constructor(vm: Vue, key: string, cb: Function) {
this.vm = vm;
this.key = key;
this.cb = cb;
//註冊依賴
Dep.target = this;
//訪問屬性觸發getter,收集target
this.oldValue = Util.getLeafData(vm.$data, key);
//防止重複添加
Dep.target = null;
}
update() {
let newVal = Util.getLeafData(this.vm.$data, this.key);
if (this.oldValue == newVal) {
return;
}
this.cb(newVal);
}
}
修改 Observer.defineReactive
對於$data
中每一個屬性,都對應著一個 Dep,因此我們需要在$data初始化響應式時創建Dep實例,在getter 中收集觀察者Dep.addWatcher()
, 在 setter 中通知觀察者 Dep.notify()
defineReactive(obj: VueData, key: string, val: any) {
let dep = new Dep(); //新增此行,每個$data中的屬性都對應一個Dep實例化
//如果data值的為對象,遞歸walk
if (typeof val === 'object') {
this.walk(val);
}
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
Dep.target && dep.addWatcher(Dep.target); //檢查是否有Watcher,收集依賴的觀察者
//此處不能返回 obj[key] 會無限遞歸觸發get
console.log('getter')
return val;
},
set: (newVal) => {
if (newVal == val) {
return;
}
val = newVal;
if (typeof newVal == 'object') {
this.walk(newVal)
}
//發送通知
dep.notify(); //新增此行,$data中屬性發送變動時發送通知
}
});
}
修改 Compiler類,下面幾個方法均添加實例化Watcher
每個視圖對應一個Watcher,以key為關鍵字觸發響應的Dep,並通過getter將Watcher添加至Dep中
class Compiler {
//插值表達式
compileText(node: Node) {
let pattern: RegExpExecArray | null;
if (node.textContent && (pattern = /\{\{(.*?)\}\}/.exec(node.textContent))) {
let key = pattern[1].trim();
let value = Util.getLeafData(this.vm.$data, key);
if (Util.isPrimitive(value)) {
node.textContent = value.toString();
}
new Watcher(this.vm, key, (newVal: string) => { node.textContent = newVal; }); //新增此行
}
}
//v-model
modelUpdater(node: Element, key: string) {
if (Util.isHTMLInputElement(node)) {
let value = Util.getLeafData(this.vm.$data, key);
if (Util.isPrimitive(value)) {
node.value = value.toString();
}
node.addEventListener('input', () => {
Util.setLeafData(this.vm.$data, key, node.value);
console.log(this.vm.$data);
})
new Watcher(this.vm, key, (newVal: string) => { node.value = newVal; }); //新增此行
}
}
//v-text
textUpdater(node: Element, key: string) {
let value = Util.getLeafData(this.vm.$data, key);
if (Util.isPrimitive(value)) {
node.textContent = value.toString();
}
new Watcher(this.vm, key, (newVal: string) => { node.textContent = newVal; }); //新增此行
}
}
至此本篇目的已經完成,實現簡易版Vue的響應式數據渲染視圖和雙向綁定,下面是完整 ts程式碼和測試程式碼
實現簡易版Vue完整程式碼
//vue.js
interface VueData {
[key: string]: any,
}
interface VueOptions {
data: VueData;
el: string | Element;
}
interface Vue {
[key: string]: any,
}
class Util {
static isPrimitive(s: any): s is (string | number) {
return typeof s === 'string' || typeof s === 'number';
}
static isHTMLInputElement(element: Element): element is HTMLInputElement {
return (<HTMLInputElement>element).tagName === 'INPUT';
}
//處理無法引用 vm.$data['person.name'] 情況
static getLeafData(obj: Object, key: string): any {
let textData: Array<any> | Object | String | Number = obj;
if (key.indexOf('.') >= 0) {
let keys = key.split('.');
for (let k of keys) {
textData = textData[k];
}
} else {
textData = obj[key];
}
return textData;
}
static setLeafData(obj: Object, key: string, value: any): void {
if (key.indexOf('.') >= 0) {
let keys = key.split('.');
for (let i = 0; i < keys.length; i++) {
let k = keys[i];
if (i == keys.length - 1) {
obj[k] = value;
} else {
obj = obj[k];
}
}
} else {
if (obj[key]){
obj[key] = value;
}
}
}
}
class Vue {
public $options: VueOptions;
public $data: VueData;
public $el: Element | null;
public constructor(options: VueOptions) {
this.$options = options;
this.$data = options.data || {};
if (typeof options.el == 'string') {
this.$el = document.querySelector(options.el);
} else {
this.$el = options.el;
}
if (!this.$el) {
throw Error(`cannot find element by selector ${options.el}`);
return;
}
this._proxyData(this.$data);
new Observer(this.$data);
new Compiler(this);
}
//生成代理,通過直接讀寫vue屬性來代理vue.$data的數據,提高便利性
//vue[key] => vue.data[key]
private _proxyData(data: VueData) {
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get() {
return data[key];
},
set(newVal) {
if (newVal == data[key]) {
return;
}
data[key] = newVal;
}
})
})
}
}
class Observer {
constructor(data: VueData) {
this.walk(data);
}
walk(data: VueData) {
Object.keys(data).forEach(key => {
this.defineReactive(data, key, data[key]);
});
}
//觀察vue.data的變化,並同步渲染至視圖中
defineReactive(obj: VueData, key: string, val: any) {
let dep = new Dep();
//如果data值的為對象,遞歸walk
if (typeof val === 'object') {
this.walk(val);
}
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
//收集依賴
Dep.target && dep.addWatcher(Dep.target);
//此處不能返回 obj[key] 會無限遞歸觸發get
console.log('getter')
return val;
},
set: (newVal) => {
if (newVal == val) {
return;
}
val = newVal;
if (typeof newVal == 'object') {
this.walk(newVal)
}
//發送通知
dep.notify();
}
});
}
}
class Compiler {
public el: Element | null;
public vm: Vue;
constructor(vm: Vue) {
this.el = vm.$el,
this.vm = vm;
if (this.el) {
this.compile(this.el);
}
}
compile(el: Element) {
let childNodes = el.childNodes;
Array.from(childNodes).forEach((node: Element) => {
if (this.isTextNode(node)) {
this.compileText(node);
} else if (this.isElementNode(node)) {
this.compileElement(node);
}
//遞歸處理孩子nodes
if (node.childNodes && node.childNodes.length !== 0) {
this.compile(node);
}
})
}
// {{text}}
compileText(node: Node) {
let pattern: RegExpExecArray | null;
if (node.textContent && (pattern = /\{\{(.*?)\}\}/.exec(node.textContent))) {
let key = pattern[1].trim();
let value = Util.getLeafData(this.vm.$data, key);
if (Util.isPrimitive(value)) {
node.textContent = value.toString();
}
new Watcher(this.vm, key, (newVal: string) => { node.textContent = newVal; })
}
}
//v-attr
compileElement(node: Element) {
Array.from(node.attributes).forEach((attr) => {
if (this.isDirective(attr.name)) {
let directive: string = attr.name.substr(2);
let value = attr.value;
let processer: Function = this[directive + 'Updater'];
if (processer) {
processer.call(this, node, value);
}
}
})
}
//v-model
modelUpdater(node: Element, key: string) {
if (Util.isHTMLInputElement(node)) {
let value = Util.getLeafData(this.vm.$data, key);
if (Util.isPrimitive(value)) {
node.value = value.toString();
}
node.addEventListener('input', () => {
Util.setLeafData(this.vm.$data, key, node.value);
console.log(this.vm.$data);
})
new Watcher(this.vm, key, (newVal: string) => { node.value = newVal; })
}
}
//v-text
textUpdater(node: Element, key: string) {
let value = Util.getLeafData(this.vm.$data, key);
if (Util.isPrimitive(value)) {
node.textContent = value.toString();
}
new Watcher(this.vm, key, (newVal: string) => {
node.textContent = newVal;
});
}
isDirective(attrName: string) {
return attrName.startsWith('v-');
}
isTextNode(node: Node) {
return node.nodeType == 3;
}
isElementNode(node: Node) {
return node.nodeType == 1;
}
}
class Dep {
static target: Watcher | null;
watcherList: Watcher[] = [];
addWatcher(watcher: Watcher) {
this.watcherList.push(watcher);
}
notify() {
this.watcherList.forEach((watcher) => {
watcher.update();
})
}
}
class Watcher {
public vm: Vue;
public cb: Function;
public key: string;
public oldValue: any;
constructor(vm: Vue, key: string, cb: Function) {
this.vm = vm;
this.key = key;
this.cb = cb;
//註冊依賴
Dep.target = this;
//訪問屬性觸發getter,收集target
this.oldValue = Util.getLeafData(vm.$data, key);
//防止重複添加
Dep.target = null;
}
update() {
let newVal = Util.getLeafData(this.vm.$data, this.key);
if (this.oldValue == newVal) {
return;
}
this.cb(newVal);
}
}
測試程式碼
<div id='app'>
<div>{{ person.name }}</div>
<div>{{ count }}</div>
<div v-text='person.name'></div>
<input type='text' v-model='msg' />
<input type='text' v-model='person.name'/>
</div>
<script src='dist/main.js'></script>
<script>
let vm = new Vue({
el: '#app',
data: {
msg: 'Hello vue',
count: 100,
person: { name: 'tim' },
}
})
// vm.msg = 'Hello world';
console.log(vm);
setTimeout(() => { vm.person.name = 'Goooooood' }, 1000);
</scirpt>