手把手教你擼一個vue框架(原理篇)
前言:三月四月是招聘旺季,相信不少面試前端崗的同學都有被問到vue的原理是什麼吧?本文就以最簡單的方式教你如何實現vue框架的基本功能。為了減少大家的學習成本,我就以最簡單的方式教大家擼一個vue框架。
一、準備
希望準備閱讀本文的你最好具備以下技能:
Object.defineProperty()
首先,我們按照以下程式碼建立一個HTML檔案,本文主要就是教大家如何實現以下功能。
<script src="../src/vue.js"></script> </head> <body> <div id="app"> <!--解析插值表示式 --> <h2>title 是 {{title}}</h2> <!-- 解析常見指令 --> <p v-html='msg1' title='混淆屬性1'>混淆文字1</p> <p v-text='msg2' title='混淆屬性2'>混淆文字2</p> <input type="text" v-model="something"> <!-- 雙向資料繫結 --> <p>{{something}}</p> <!-- 複雜資料型別 --> <p>{{dad.son.name}}</p> <p v-html='dad.son.name'></p> <input type="text" v-model="dad.son.name"> <button v-on:click='sayHi'>sayHi</button> <button @click='printThis'>printThis</button> </div> </body> 複製程式碼
let vm = new Vue({ el: '#app', data: { title: '手把手教你擼一個vue框架', msg1: '<a href="#">應該被解析成a標籤</a>', msg2: '<a href="#">不應該被解析成a標籤</a>', something: 'placeholder', dad: { name: 'foo', son: { name: 'bar', son: {} } } }, methods: { sayHi() { console.log('hello world') }, printThis() { console.log(this) } }, }) 複製程式碼
準備工作做好了,那我們就一起來實現vue框架的基本功能吧!
MVVM 實現思路
我們都知道,vue是基於MVVM設計模式的漸進式框架。那麼在JavaScript中,我們該如何實現一個MVVM框架呢? 主流的實現MVVM框架的思路有三種:
- backbone.js
釋出者-訂閱者模式,一般通過pub和sub的方式實現資料和檢視的繫結。
- Angular.js
Angular.js是通過髒值監測的方式對比資料是否有變更,來決定是否更新檢視。類似於通過定時器輪尋監測資料是否發生了額改變。
- Vue.js
Vue.js是採用資料劫持結合釋出者-訂閱者模式的方式。在vue2.6之前,是通過 Object.defineProperty() 來劫持各個屬性的setter和getter方法,在資料變動時釋出訊息給訂閱者,觸發相應的回撥。這也是IE8以下的瀏覽器不支援vue的根本原因。
Vue實現思路
- 實現一個Compile模板解析器,能夠對模板中的指令和插值表示式進行解析,並賦予對應的操作
- 實現一個Observer資料監聽器,能夠對資料物件(data)的所有屬性進行監聽
- 實現一個Watcher 偵聽器。講Compile的解析結果,與Observer所觀察的物件連線起來,建立關係,在Observer觀察到資料物件變化時,接收通知,並更新DOM
- 建立一個公共的入口物件(Vue),接收初始化配置,並協調Compile、Observer、Watcher模組,也就是Vue。
上述流程如下圖所示:

二、Vue入口檔案
把邏輯捋順清楚後,我們會發現,其實我們要在這個入口檔案做的事情很簡單:
- 把data和methods掛載到根例項中;
- 用Observer模組監聽data所有屬性的變化
- 如果存在掛載點,則用Compile模組編譯該掛載點下的所有指令和插值表示式
/** * vue.js (入口檔案) * 1. 將data,methods裡面的屬性掛載根例項中 * 2. 監聽 data 屬性的變化 * 3. 編譯掛載點內的所有指令和插值表示式 */ class Vue { constructor(options={}){ this.$el = options.el; this.$data = options.data; this.$methods = options.methods; debugger // 將data,methods裡面的屬性掛載根例項中 this.proxy(this.$data); this.proxy(this.$methods); // 監聽資料 // new Observer(this.$data) if(this.$el) { //new Compile(this.$el,this); } } proxy(data={}){ Object.keys(data).forEach(key=>{ // 這裡的this 指向vue例項 Object.defineProperty(this,key,{ enumerable: true, configurable: true, set(value){ if(data[key] === value) return return value }, get(){ return data[key] }, }) }) } } 複製程式碼
三、Compile模組
compile主要做的事情是解析指令(屬性節點)與插值表示式(文字節點),將模板中的變數替換成資料,然後初始化渲染頁面檢視,並將每個指令對應的節點繫結更新函式,新增監聽資料的訂閱者,一旦資料有變動,收到通知,更新檢視。
因為遍歷解析的過程有多次操作dom節點,這會引發頁面的 迴流與重繪 的問題,為了提高效能和效率,我們最好是在記憶體中解析指令和插值表示式,因此我們需要遍歷掛載點下的所有內容,把它儲存到DocumentFragments中。
DocumentFragments 是DOM節點。它們不是主DOM樹的一部分。通常的用例是建立文件片段,將元素附加到文件片段,然後將文件片段附加到DOM樹。因為文件片段存在於記憶體中,並不在DOM樹中,所以將子元素插入到文件片段時不會引起頁面迴流(對元素位置和幾何上的計算)。因此,使用文件片段通常會帶來更好的效能。
所以我們需要一個 node2fragment()
方法來處理上述邏輯。
實現node2fragment,將掛載點內的所有節點儲存到DocumentFragment中
node2fragment(node) { let fragment = document.createDocumentFragment() // 把el中所有的子節點挨個新增到文件片段中 let childNodes = node.childNodes // 由於childNodes是一個類陣列,所以我們要把它轉化成為一個數組,以使用forEach方法 this.toArray(childNodes).forEach(node => { // 把所有的位元組點新增到fragment中 fragment.appendChild(node) }) return fragment } 複製程式碼
this.toArray()
是我封裝的一個類方法,用於將類陣列轉化為陣列。實現方法也很簡單,我使用了開發中最常用的技巧:
toArray(classArray) { return [].slice.call(classArray) } 複製程式碼
解析fragment裡面的節點
接下來我們要做的事情就是解析fragment裡面的節點: compile(fragment)
。
這個方法的邏輯也很簡單,我們要遞迴遍歷fragment裡面的所有子節點,根據節點型別進行判斷,如果是文字節點則按插值表示式進行解析,如果是屬性節點則按指令進行解析。在解析屬性節點的時候,我們還要進一步判斷:是不是由 v-
開頭的指令,或者是特殊字元,如 @
、 :
開頭的指令。
// Compile.js class Compile { constructor(el, vm) { this.el = typeof el === "string" ? document.querySelector(el) : el this.vm = vm // 解析模板內容 if (this.el) { // 為了避免直接在DOM中解析指令和差值表示式所引起的迴流與重繪,我們開闢一個Fragment在記憶體中進行解析 const fragment = this.node2fragment(this.el) this.compile(fragment) this.el.appendChild(fragment) } } // 解析fragment裡面的節點 compile(fragment) { let childNodes = fragment.childNodes this.toArray(childNodes).forEach(node => { // 如果是元素節點,則解析指令 if (this.isElementNode(node)) { this.compileElementNode(node) } // 如果是文字節點,則解析差值表示式 if (this.isTextNode(node)) { this.compileTextNode(node) } // 遞迴解析 if (node.childNodes && node.childNodes.length > 0) { this.compile(node) } }) } } 複製程式碼
處理解析指令的邏輯:CompileUtils
接下來我們要做的就只剩下解析指令,並把解析後的結果通知給檢視了。
當資料發生改變時,通過Watcher物件監聽expr資料的變化,一旦資料發生變化,則執行回撥函式。
new Watcher(vm,expr,callback)
// 利用Watcher將解析後的結果返回給檢視.
我們可以把所有處理編譯指令和插值表示式的邏輯封裝到 compileUtil
物件中進行管理。
這裡有兩個坑點大家需要注意一下:
- 如果是複雜資料的情形,例如插值表示式:
{{dad.son.name}}
或者<p v-text='dad.son.name'></p>
,我們拿到v-text
的屬性值是字串dad.son.name
,我們是無法通過vm.$data['dad.son.name']
拿到資料的,而是要通過vm.$data['dad']['son']['name']
的形式來獲取資料。因此,如果資料是複雜資料的情形,我們需要實現getVMData()
和setVMData()
方法進行資料的獲取與修改。 - 在vue中,methods裡面的方法裡面的this是指向vue例項,因此,在我們通過
v-on
指令給節點繫結方法的時候,我們需要把該方法的this指向繫結為vue例項。
// Compile.js let CompileUtils = { getVMData(vm, expr) { let data = vm.$data expr.split('.').forEach(key => { data = data[key] }) return data }, setVMData(vm, expr,value) { let data = vm.$data let arr = expr.split('.') arr.forEach((key,index) => { if(index < arr.length -1) { data = data[key] } else { data[key] = value } }) }, // 解析插值表示式 mustache(node, vm) { let txt = node.textContent let reg = /\{\{(.+)\}\}/ if (reg.test(txt)) { let expr = RegExp.$1 node.textContent = txt.replace(reg, this.getVMData(vm, expr)) new Watcher(vm, expr, newValue => { node.textContent = txt.replace(reg, newValue) }) } }, // 解析v-text text(node, vm, expr) { node.textContent = this.getVMData(vm, expr) new Watcher(vm, expr, newValue => { node.textContent = newValue }) }, // 解析v-html html(node, vm, expr) { node.innerHTML = this.getVMData(vm, expr) new Watcher(vm, expr, newValue => { node.innerHTML = newValue }) }, // 解析v-model model(node, vm, expr) { let that = this node.value = this.getVMData(vm, expr) node.addEventListener('input', function () { // 下面這個寫法不能深度改變資料 // vm.$data[expr] = this.value that.setVMData(vm,expr,this.value) }) new Watcher(vm, expr, newValue => { node.value = newValue }) }, // 解析v-on eventHandler(node, vm, eventType, expr) { // 處理methods裡面的函式fn不存在的邏輯 // 即使沒有寫fn,也不會影響專案繼續執行 let fn = vm.$methods && vm.$methods[expr] try { node.addEventListener(eventType, fn.bind(vm)) } catch (error) { console.error('丟擲這個異常表示你methods裡面沒有寫方法\n', error) } } } 複製程式碼
四、Observer模組
其實在Observer模組中,我們要做的事情也不多,就是提供一個 walk()
方法,遞迴劫持 vm.$data
中的所有資料,攔截setter和getter。如果資料變更,則釋出通知,讓所有訂閱者更新內容,改變檢視。
需要注意的是,如果設定的值是一個物件,則我們需要保證這個物件也要是響應式的。 用程式碼來描述即: walk(aObjectValue)
。關於如何實現響應式物件,我們採用的方法是 Object.defineProperty()
完整程式碼如下:
// Observer.js class Observer { constructor(data){ this.data = data this.walk(data) } // 遍歷walk中所有的資料,劫持 set 和 get方法 walk(data) { // 判斷data 不存在或者不是物件的情況 if(!data || typeof data !=='object') return // 拿到data中所有的屬性 Object.keys(data).forEach(key => { // console.log(key) // 給data中的屬性新增 getter和 setter方法 this.defineReactive(data,key,data[key]) // 如果data[key]是物件,深度劫持 this.walk(data[key]) }) } // 定義響應式資料 defineReactive(obj,key,value) { let that = this // Dep訊息容器在Watcher.js檔案中宣告,將Observer.js與Dep容器有關的程式碼註釋掉並不影響相關邏輯。 let dep = new Dep() Object.defineProperty(obj,key,{ enumerable:true, configurable: true, get(){ // 如果Dep.target 中有watcher 物件,則儲存到訂閱者陣列中 Dep.target && dep.addSub(Dep.target) return value }, set(aValue){ if(value === aValue) return value = aValue // 如果設定的值是一個物件,那麼這個物件也應該是響應式的 that.walk(aValue) // watcher.update // 釋出通知,讓所有訂閱者更新內容 dep.notify() } }) } } 複製程式碼
五、Watcher模組
Watcher的作用就是將Compile解析的結果和Observer觀察的物件關聯起來,建立關係,當Observer觀察的資料發生變化是,接收通知( dep.notify
)告訴Watcher,Watcher在通過Compile更新DOM。這裡面涉及一個釋出者-訂閱者模式的思想。

Watcher是連線Compile和Observer的橋樑。
我們在Watcher的建構函式中,需要傳遞三個引數:
vm expr callback
注意,為了獲取深層資料物件,這裡我們需要引用之前宣告的 getVMData()
方法。
定義Watcher
constructor(vm,expr,callback){ this.vm = vm this.expr = expr this.callback = callback // this.oldValue = this.getVMData(vm,expr) // } 複製程式碼
暴露update()方法,用於在資料更新時更新頁面
我們應該在什麼情況更新頁面呢?
我們應該在Watcher中實現一個update方法,對新值和舊值進行比較。當資料發生改變時,執行回撥函式。
update() { // 對比expr是否發生改變,如果改變則呼叫callback let oldValue = this.oldValue let newValue = this.getVMData(this.vm,this.expr) // 變化的時候呼叫callback if(oldValue !== newValue) { this.callback(newValue,oldValue) } } 複製程式碼
關聯Watcher與Compile

vm.msg
的值的時候,需要重新渲染DOM,所以我們還需要通過Watcher偵聽expr值的變化。
// compile.js mustache(node, vm) { let txt = node.textContent let reg = /\{\{(.+)\}\}/ if (reg.test(txt)) { let expr = RegExp.$1 node.textContent = txt.replace(reg, this.getVMData(vm, expr)) // 偵聽expr值的變化。當expr的值發生改變時,執行回撥函式 new Watcher(vm, expr, newValue => { node.textContent = txt.replace(reg, newValue) }) } }, 複製程式碼
那麼我們應該在什麼時候呼叫update方法,觸發回撥函式呢?
由於我們在上文中已經在Observer實現了響應式資料,所以在資料發生改變時,必然會觸發set方法。所以我們在觸發set方法的同時,還需要呼叫watcher.update方法,觸發回撥函式,修改頁面。
// observer.js defineReactive(obj,key,value) { ... set(aValue){ if(value === aValue) return value = aValue // 如果設定的值是一個物件,那麼這個物件也應該是響應式的 that.walk(aValue) watcher.update } } 複製程式碼
那麼問題來了,我們在解析不同的指令時,new 了很多個Watcher,那麼這裡要呼叫哪個Watcher的update方法呢?如何通知所有的Watcher,告訴他資料發生了改變了呢?

所以這裡又引出了一個新的概念:釋出者-訂閱者模式。
什麼是釋出者-訂閱者模式?
釋出者-訂閱者模式也叫觀察者模式。 他定義了一種一對多的依賴關係,即當一個物件的狀態發生改變時,所有依賴於他的物件都會得到通知並自動更新,解決了主體物件與觀察者之間功能的耦合。
這裡我們用微信公眾號為例來說明這種情況。
譬如我們一個班級都訂閱了公眾號,那麼這個班級的每個人都是訂閱者(subscriber),公眾號則是釋出者(publisher)。如果某一天公眾號發現文章內容出錯了,需要修改一個錯別字(修改vm.$data中的資料),是不是要通知每一個訂閱者?總不能學委那裡的文章發生了改變,而班長的文章沒有發生改變吧。在這個過程中,釋出者不用關心誰訂閱了它,只需要給所有訂閱者推送這條更新的訊息即可(notify)。
所以這裡涉及兩個過程:
addSub(watcher) notify(){ sub.update() }
在這個過程中,充當釋出者角色的是每一個訂閱者所共同依賴的物件。
我們在Watcher中定義一個類:Dep(依賴容器)。在我們每次new一個Watcher的時候,都往Dep裡面新增訂閱者。一旦Observer的資料發生改變了,則通知Dep發起通知(notify),執行update函式更改DOM即可。
// watcher.js // 訂閱者容器,依賴收集 class Dep { constructor(){ // 初始化一個空陣列,用來儲存訂閱者 this.subs = [] } // 新增訂閱者 addSub(watcher){ this.subs.push(watcher) } // 通知 notify() { // 通知所有的訂閱者更改頁面 this.subs.forEach(sub => { sub.update() }) } } 複製程式碼
接下來我們的思路就很明確了,就是在每次new一個Watcher的時候,將它儲存到Dep容器中。即將Dep與Watcher關聯到一起。我們可以為Dep新增一個類屬性target來儲存Watcher物件,即我們需要在Watcher的建構函式中,將this賦給Dep.target。

還是以上面這個圖為例,我們分析下解析插值表示式的流程:
this.oldValue = this.getVMData(vm, expr)
所以我們也就不難發現新增訂閱者的時機,程式碼如下:
- 將Watcher新增到訂閱者陣列中,如果資料發生改變,則為所有訂閱者發起通知
// Observer.js // 定義響應式資料 defineReactive(obj,key,value) { // defineProperty 會改變this指向 let that = this let dep = new Dep() Object.defineProperty(obj,key,{ enumerable:true, configurable: true, get(){ // 如果Dep.target存在,即存在watcher 物件,則儲存到訂閱者陣列中 // debugger Dep.target && dep.addSub(Dep.target) return value }, set(aValue){ if(value === aValue) return value = aValue // 如果設定的值是一個物件,那麼這個物件也應該是響應式的 that.walk(aValue) // watcher.update // 釋出通知,讓所有訂閱者更新內容 dep.notify() } }) } 複製程式碼
- 將Watcher儲存到Dep容器中後,將Dep.target置為空,以便下一次儲存Watcher
// Watcher.js constructor(vm,expr,callback){ this.vm = vm this.expr = expr this.callback = callback Dep.target = this // debugger this.oldValue = this.getVMData(vm,expr) Dep.target = null } 複製程式碼
Watcher.js完整程式碼如下:
// Watcher.js class Watcher { /** * * @param {*} vm 當前的vue例項 * @param {*} expr data中資料的名字 * @param {*} callback一旦資料改變,則需要呼叫callback */ constructor(vm,expr,callback){ this.vm = vm this.expr = expr this.callback = callback Dep.target = this this.oldValue = this.getVMData(vm,expr) Dep.target = null } // 對外暴露的方法,用於更新頁面 update() { // 對比expr是否發生改變,如果改變則呼叫callback let oldValue = this.oldValue let newValue = this.getVMData(this.vm,this.expr) // 變化的時候呼叫callback if(oldValue !== newValue) { this.callback(newValue,oldValue) } } // 只是為了說明原理,這裡偷個懶,就不抽離出公共js檔案了 getVMData(vm,expr) { let data = vm.$data expr.split('.').forEach(key => { data = data[key] }) return data } } class Dep { constructor(){ this.subs = [] } // 新增訂閱者 addSub(watcher){ this.subs.push(watcher) } // 通知 notify() { this.subs.forEach(sub => { sub.update() }) } } 複製程式碼
至此,我們就已經實現了Vue框架的基本功能了。
本文只是通過用最簡單的方式來模擬vue框架的基本功能,所以在細節上的處理和程式碼質量上肯定會犧牲很多,還請大家見諒。
文中難免會有一些不嚴謹的地方,歡迎大家指正,有興趣的話大家可以一起交流下