【Vue原理剖析】Object的變化偵測

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

1、準備

但願準備閱讀本文的你最好具有如下技能:html

  • 熟悉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框架的基本功能吧!vue

MVVM 實現思路

咱們都知道,vue是基於MVVM設計模式的漸進式框架。那麼在JavaScript中,咱們該如何實現一個MVVM框架呢? 主流的實現MVVM框架的思路有三種:java

  • backbone.js

發佈者-訂閱者模式,通常經過pub和sub的方式實現數據和視圖的綁定。node

  • 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。

上述流程以下圖所示:設計模式

2、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]
                },
            })
        })
    }
}
複製代碼

3、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)
        }
    }
}
複製代碼

4、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()
            }
        })
    }
} 
複製代碼

5、Watcher模塊

Watcher的做用就是將Compile解析的結果和Observer觀察的對象關聯起來,創建關係,當Observer觀察的數據發生變化是,接收通知(dep.notify)告訴Watcher,Watcher在經過Compile更新DOM。這裏面涉及一個發佈者-訂閱者模式的思想。

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

以插值表達式爲例:(下文也會以這個例子進行說明) 當咱們在控制檯修改 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。

仍是以上面這個圖爲例,咱們分析下解析插值表達式的流程:

  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框架的基本功能,因此在細節上的處理和代碼質量上確定會犧牲不少,還請你們見諒。

文中不免會有一些不嚴謹的地方,歡迎你們指正,有興趣的話你們能夠一塊兒交流下

相關文章
相關標籤/搜索