手把手教你擼一個vue框架(原理篇)

上沅兮之發表於2019-04-03

前言: 三月四月是招聘旺季,相信不少面試前端崗的同學都有被問到vue的原理是什麼吧?本文就以最簡單的方式教你如何實現vue框架的基本功能。為了減少大家的學習成本,我就以最簡單的方式教大家擼一個vue框架。

一、準備

希望準備閱讀本文的你最好具備以下技能:

  • 熟悉ES6語法
  • 瞭解HTML DOM 節點型別
  • 熟悉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框架(原理篇)

二、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物件中進行管理。

這裡有兩個坑點大家需要注意一下:

  1. 如果是複雜資料的情形,例如插值表示式:{{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()方法進行資料的獲取與修改。
  2. 在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。這裡面涉及一個釋出者-訂閱者模式的思想。

手把手教你擼一個vue框架(原理篇)

Watcher是連線Compile和Observer的橋樑。

我們在Watcher的建構函式中,需要傳遞三個引數:

  • vm :vue例項
  • expr:vm.$data中資料的名字(key)
  • 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

手把手教你擼一個vue框架(原理篇)
以插值表示式為例:(下文也會以這個例子進行說明) 當我們在控制檯修改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,告訴他資料發生了改變了呢?

手把手教你擼一個vue框架(原理篇)

所以這裡又引出了一個新的概念:釋出者-訂閱者模式。

什麼是釋出者-訂閱者模式?

釋出者-訂閱者模式也叫觀察者模式。 他定義了一種一對多的依賴關係,即當一個物件的狀態發生改變時,所有依賴於他的物件都會得到通知並自動更新,解決了主體物件與觀察者之間功能的耦合。

這裡我們用微信公眾號為例來說明這種情況。

譬如我們一個班級都訂閱了公眾號,那麼這個班級的每個人都是訂閱者(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。

手把手教你擼一個vue框架(原理篇)
還是以上面這個圖為例,我們分析下解析插值表示式的流程:

  1. 首先我們會進入Observer劫持data中的資料msg,這裡我們會進入Observer中的get方法;
  2. 劫持後我們會判斷el是否存在,存在的話則編譯插值表示式進入Compile;
  3. 如果此時劫持的資料msg發生改變,則會通過mustache中的Watcher來偵聽資料的改變;
  4. 在Watcher的建構函式中,通過this.oldValue = this.getVMData(vm, expr)方法會在一次進入Observer中的get方法,然後程式執行完畢。

所以我們也就不難發現新增訂閱者的時機,程式碼如下:

  • 將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框架的基本功能,所以在細節上的處理和程式碼質量上肯定會犧牲很多,還請大家見諒。

文中難免會有一些不嚴謹的地方,歡迎大家指正,有興趣的話大家可以一起交流下

相關文章