手動實現vue v-指令編譯,雙向綁定功能的詳細步驟

首先寫一下要用於測試的html代碼

<div id="app">
    <h1>{{person.name}} --- {{person.age}}</h1>
    <ul>
        <li>1</li>
        <li>2</li>
        <li>3</li>
    </ul>
    <p>{{}}</p>
    <p v-text="msg"></p>
    <input type="text" v-model="msg">
</div>

<script src="./vue.js"></script>
<script>
    let vm = new Vue({
        el: "#app",
        data: {
            person: {
                name: 'zem',
                age: 18
            },
            msg: 'text'
        }
    })
</script>

在引入vue源文件後,我們在瀏覽器中的顯示就會如圖
在這裏插入圖片描述
修改輸入框的內容,msg的值會相應發生變化,這就是視圖(view)的改變導致數據的改變

接下來我們要做的就是來實現這些功能,解析節點中的{{}},替換其中的內容,當數據發生變化的時候改變這些內容,對v-model綁定的數據,當視圖發生改變的時候對數據也進行改變

下面會是詳細的實現步驟,實現的最終代碼我放在github上,也可直接看實現代碼學習,有相應的註釋

初始化一個Vue類


首先,我們新建一個vue.js,替換掉原來的vue.js,然後初始化一個Vue類

class Vue{
    constructor(){

    }
}

在構造實例的時候,我們要傳入的內容,就是上面在script標籤中new Vue()括號內的內容,即是

{
    el:"#app",
    data:{
        person:{
            name:"qzm",
            age:18
        },
        msg:"text"
    }
}

所以我們要將這些內容放到實例裏面對應的屬性裏

class Vue{
    constructor(options){
        // 將傳入的值放到實例中對應的屬性
        this.$el = options.el;
        this.$data = options.data;
        this.$options = options;
    }
}

接下來,我們要對傳入的元素進行解析,解析前首先要判斷是否存在這個屬性值

class Vue{
    constructor(options){
        // 將傳入的值放到實例中對應的屬性
        this.$el = options.el;
        this.$data = options.data;
        this.$options = options;
        if(this.$el){ // 判斷是否有傳入el屬性
            // 解析入口節點元素
        }
    }
}

實現編譯類


完成了Vue類的初始化,拿到了對應的入口節點元素,接下來就是對這個入口節點元素進行編譯了
我們在Vue中判斷存在入口節點元素後,將其傳入編譯類中,因爲在編譯中,我們可能還需要用到傳入vue實例的data屬性的內容,所以將vue實例也傳入編譯類實例中

class Vue{
    constructor(options){
        // 將傳入的值放到實例中對應的屬性
        this.$el = options.el;
        this.$data = options.data;
        this.$options = options;
        if(this.$el){ // 判斷是否有傳入el屬性
            // 解析入口節點元素
            new Compile(this.$el,this);
        }
    }
}

接下來就是實現Compile類了

class Compile{
    constructor(el,vm){

    }
}

實現Compile類第一步,對傳入的節點進行判斷,因爲el屬性是可以傳入字符串,也可以直接傳入節點的,如果是傳入字符串的話,我們要使用document.querySelector()來獲取這個節點,如果傳入的本身就是一個節點的話,我們就直接使用這個節點。這裏我通過實現一個方法來,用nodeType對節點類型進行判斷

關於nodeType常見的值

  • 1:代表節點元素
  • 2:代表屬性
  • 3:代表元素或屬性中的文本內容
  • 8:代表註釋

完整的看W3C中關於nodeType的介紹

接下來繼續寫代碼,實現如下

class Compile{
    constructor(el,vm){
        this.el = this.isElement(el) ? el : document.querySelector(el);
    }
    isElement(node){ // 判斷是否爲一個節點對象
        return node.nodeType === 1;
    }
}

將this.el打印出來,可以看到完整的入口節點元素
在這裏插入圖片描述

拿到了入口節點元素後,接下來就是要對這個元素進行解析,將裏面的{{}}的內容替換爲相應的值,但是因爲每次的替換都會造成整個頁面的迴流和重繪,如果直接遍歷節點替換的話,會造成很多的迴流重繪,對整個性能會有很大的損耗,爲了在理論上減少損耗,這裏將傳入的節點元素轉換城文檔碎片

關於文檔碎片的相關知識及爲什麼說是理論上的–>javascript文檔碎片的使用

這裏寫個方法來將獲取的節點的子孫節點放入文檔碎片,

class Compile{
    constructor(el,vm){
        this.el = this.isElement(el) ? el : document.querySelector(el);
        this.vm = vm;
        // 將獲取入口節點元素的子孫節點放入到文檔碎片中
        const frag = this.transformToFrag(this.el)
    }
    isElement(node){ // 判斷是否爲一個節點對象
        return node.nodeType === 1;
    }
    transformToFrag(node){
        // 創建文檔碎片
        const frag = document.createDocumentFragment();
        // 將節點依次放入到文檔碎片中
        let firstChild = node.firstChild; // 取出開頭的節點
        while(firstChild){ // 判斷是否還有子孫節點
            frag.appendChild(firstChild);
            firstChild = node.firstChild;
        }
        return frag;
    }
}

此時打開頁面發現所有內容都被放入到文檔碎片中了,入口節點元素已經沒有子節點了
在這裏插入圖片描述
將frag文檔碎片打印出來,可以看到

const frag = this.transformToFrag(this.el)
console.log(frag)

在這裏插入圖片描述

放入文檔碎片後,要對文檔碎片中的節點進行遍歷,替換相應內容,這個過程就是編譯,在編譯之後將編譯好的子孫節點重新插入到入口節點元素中
遍歷時要判斷是爲文本還是爲節點,如果是節點的話,要繼續看該節點是否有子孫節點,對該節點使用同樣的方法操作

class Compile{
    constructor(el,vm){
        this.el = this.isElement(el) ? el : document.querySelector(el);
        this.vm = vm;
        // 將獲取入口節點元素的子孫節點放入到文檔碎片中
        const frag = this.transformToFrag(this.el);
        // 編譯文檔碎片中的子孫節點
        this.compile(frag)
        // 將編譯好的文檔碎片插入到入口元素節點
        this.el.appendChild(frag);
    }
    compile(frag){
        // 獲取文檔碎片的子元素
        const childNodes = frag.childNodes;
        // 這裏的childNodes就是一個NodeList數組NodeList(9) [text, h1, text, ul, text, p, text, input, text]
        // 使用...運算符來處理childNodes將其變爲一個數組
        [...childNodes].forEach(node=>{
            if(this.isElement(node)){ // 判斷爲元素節點
                // 對元素節點的編譯操作
                this.compileElement(node);
            }else{ // 文本節點
                // 對文本節點的編譯操作
                this.compileText(node);
            }
            if(node.childNodes){
                this.compile(node);
            }
        })
    }
    compileElement(node){

    }
    compileText(node){

    }
    // ...
}

這裏寫了compileElement方法和compileText方法分別用來解析元素節點和文本節點

對元素節點的解析

首先是對元素節點的解析,一個元素節點是否需要進行處理,就看元素中是否有v-開頭的屬性,如v-text,v-html,v-model,這三個值分別表示元素對應的文本內容,html內容,和表單的value值。

首先,我們獲取元素所有的屬性值,打印出所有的屬性和值

compileElement(node){
    const attrs = node.attributes;
    [...attrs].forEach(attr=>{ // attr是object類型
        const {name,value} = attr;
        console.log(`name:${name} --- value:${value}`)
    })
}

在這裏插入圖片描述
接下來要對v-開頭的元素進行處理,除了v-開頭之外,v-bind的簡寫:和v-on的簡寫@也要進行處理,這裏寫一個函數來判斷一個標籤是否爲要處理的標籤

isVueElement(name){ // 判斷一個屬性是否爲要處理的屬性
    return name.startsWith("v-")||name.startsWith(":")||name.startsWith("@")
}

接下來就是要調用這個方法來判斷是否爲要處理的標籤,對要處理的標籤進行分割字符串,判斷是哪種類型,然後執行相應的處理

這裏先在文件根目錄下創建一個對象來存儲對不同指令的操作

const compileHandle = {
    text:function(node,expr,vm){

    },
    html:function(node,expr,vm){

    },
    model:function(node,expr,vm){

    },
    if:function(node,expr,vm){

    },
    show:function(node,expr,vm){

    },
    for:function(node,expr,vm){

    },
    key:function(node,expr,vm){

    },
    bind:function(node,expr,vm,bindType){

    },
    on:function(node,expr,vm,bindType){

    }
}

接下來對指令進行分割,將分割後對應的內容傳到上面創造的對象裏面相應的方法,在調用方法後將指令刪掉

compileElement(node){
    const attrs = node.attributes;
    [...attrs].forEach(attr=>{ // attr是object類型
        let {name,value} = attr;
        if(this.isVueElement(name)){
            if(name.startsWith(":"))
                name = "v-bind"+name;
            else if(name.startsWith("@"))
                name = "v-on:"+name.slice(1);
            const [,instructions] = name.split("-"); // 將指令如text,bind:type,on:click賦值給instructions
            const [type,bindType] = instructions.split(":"); // 將text,on,bind之類的值給type,bind,on綁定的值給bindType
            compileHandle[type](node,value,this.vm,bindType);
            node.removeAttribute(name)
        }
    })
}

接下來實現上面對象中的對應方法
首先是v-text,其實也就是在vue實例的data屬性中找到當前處理指令的值相應名字的屬性,因爲傳入的字符串可能是"msg"這種字符串,也可能是"person.name"這種字符串,所以不能直接使用vm.data[expr]來處理,這裏使用reduce函數來處理,用textContent來給元素的文本賦值

const compileHandle = {
    text:function(node,expr,vm){
        node.textContent = expr.split(".").reduce((data,attr)=>{
            return data[attr];
        },vm.$data)
    },
    // ...
}

同理實現v-html和v-model,這裏先不實現v-model的雙向綁定

const compileHandle = {
    text:function(node,expr,vm){
        node.textContent = expr.split(".").reduce((data,attr)=>{
            return data[attr];
        },vm.$data)
    },
    html:function(node,expr,vm){
        node.innerHTML = expr.split(".").reduce((data,attr)=>{
            return data[attr];
        },vm.$data)
    },
    model:function(node,expr,vm){
        node.value = expr.split(".").reduce((data,attr)=>{
            return data[attr];
        },vm.$data)
    },
    // ...
}

這裏三種獲取值的操作都是一樣的,寫一個方法將其分離出來,因爲後面的視圖改變也可能影響數據,所以將數據的修改放到該對象的另一個屬性裏

const compileHandle = {
    text(node,expr,vm){
        const val = this.getVal(expr,vm)
        this.updater.textUpdate(node,val)
    },
    html(node,expr,vm){
        const val = this.getVal(expr,vm)
        this.updater.htmlUpdate(node,val)
    },
    model(node,expr,vm){
        const val = this.getVal(expr,vm)
        this.updater.modelUpdate(node,val)
    },
    // ...
    updater:{
        textUpdate(node,value){
            node.textContent = value
        },
        htmlUpdate(node,value){
            node.innerHTML = value
        },
        modelUpdate(node,value){
            node.value = value
        }
    },
    getVal(expr,vm){
        return expr.split(".").reduce((data,attr)=>{
            return data[attr];
        },vm.$data)
    }
}

打開頁面,發現text已經被渲染出來了
在這裏插入圖片描述
在index.html中加上下面的標籤,測試是否有效

<p v-text="person.name"></p>

在這裏插入圖片描述
實踐有效,zem渲染出來了,接下來我們來處理一下方法,其他的指令我會在之後寫到代碼中,這裏就不一一敘述了

先寫兩個按鈕,測試兩種綁定方法,在vue實例中添加一個方法

<button v-on:click="test">v-on test</button>
<button @click="test">@ test</button>
let vm = new Vue({
    el: "#app",
    data: {
        person: {
            name: 'zem',
            age: 18
        },
        msg: 'text'
    },
    methods: {
        test() {
            console.log(this);
        }
    }
})

回到vue.js裏面的compileHandle對象來編寫相應的方法

on(node,expr,vm,bindType){
    let fn = vm.$options.methods&&vm.$options.methods[expr]; // 將函數賦值給fn
    node.addEventListener(bindType,fn.bind(vm),false); // 使用bind將this綁定到vue實例中
},

對文本節點的解析

接下來對文本節點進行解析
對文本節點,我們只需要解析放在{{}}中的內容就可以了,所以首先寫一個正則表達式來匹配這些內容

compileText(node){
    const text = node.textContent;
    if(/\{\{.+?\}\}/g.test(text)){
        console.log(text);
    }
}

這裏的中間的.+?就是我們後面要用來替換的依據,即要從data裏面獲取的相應屬性名,這裏的.+?和.+的區別是
.+:貪婪匹配,貪婪模式是先看整個是否匹配,如果不匹配則去掉最後一個再匹配,不符合繼續去掉最後一個,知道匹配或者字符串數爲0
.+?:惰性匹配,惰性模式是先匹配第一個字符,不符合加入下一個字符,直到匹配或者到最後一位
打印出來的內容
在這裏插入圖片描述
因爲{{}}和v-text一樣都是對textContent進行操作,所以我們可以使用compileHandle對象中的text方法來完成替換操作,但是,我們需要對方法進行一定的修改

const compileHandle = {
    text(node,expr,vm){
        let val
        if(expr.includes("{")){ // 判斷是否爲文本節點的修改
            val = expr.replace(/\{\{(.+?)\}\}/g,(...args)=>{ // 找到所有的{{}},將{{}}內的內容做爲一個分組來替換
                return this.getVal(args[1],vm); // 這裏的args[1]就是我們要的每個{{}}內的值
            })
        }else{ 
            val = this.getVal(expr,vm)
        }
        this.updater.textUpdate(node,val)
    },
    // ...
}

然後調用該方法

compileText(node){
    const text = node.textContent;
    if(/\{\{.+?\}\}/g.test(text)){
        compileHandle.text(node,text,this.vm)
    }
}

打開頁面發現已經渲染成功了
在這裏插入圖片描述

到這一步之後,文本節點的解析基本完成,數據已經可以正常渲染了,接下來我們需要對數據進行監聽

實現監聽


數據渲染雖然完成了,但是每當數據發生改變的時候,我們要監聽數據的變化,將相應的變化的值渲染到頁面上,在vue文件裏面實現一個監聽類,監聽vue實例中data的數據變化,在vue實例化的時候就使用這個監聽,將vue實例的data傳入觀察實例中

class Observer{
    constructor(data){

    }
}
class Vue{
    constructor(options){
        this.$el = options.el;
        this.$data = options.data;
        this.$options = options;
        if(this.$el){ // 判斷el是否存在
            // 實現一個觀察者
            new Observer(this.$data);
            // 實現一個指令解析器
            new Compile(this.$el,this);
        }
    }
}

在觀察者中,我們要對data中的每個數據進行監聽,如果數據是對象的話,還要進行遞歸遍歷,這裏使用object.defineProperty來實現數據監聽

class Observer{
    constructor(data,vm){
        vm.$data = this.observe(data);
    }
    observe(data){
        if(data && typeof data === "object"){
            Object.keys(data).forEach(key=>{ // 使用Object.keys獲取當前一層的屬性名
                this.defineReactive(data,key,data[key]); // 對data的key屬性進行監聽
            })
        }
        return data;
    }
    defineReactive(data,key,val){
        this.observe(val); // 遞歸遍歷
        Object.defineProperty(data,key,{
            get:()=>{
                return val
            },
            set:(newVal)=>{
                if(newVal!==val){
                    this.observe(newVal); // 對傳入的新值進行監聽
                    val = newVal
                }
            }
        })
    }
}

實現觀察者和依賴收集


通過上面實現observer後,我們已經可以監聽數據的變化,那麼接下來就是要在數據變化的時候,調用相應的函數修改視圖
初始化觀察者類Watcher和依賴收集類Dep,觀察者要做到能獲取舊值,當傳入的新值與舊值不同的時候,要觸發更新方法調用相應的更新函數,使得所有是要該數據的視圖都重新渲染,而獲取舊值需要有指令相應的字符串表述(就如{{}}裏面的內容),所以初始化Watch要傳入三個值

class Watcher{
    constructor(vm,expr,cb){
        this.vm = vm;
        this.expr = expr;
        this.cb = cb;
        this.oldVal = this.getOldVal()
    }
    update(){
        const newVal = compileHandle.getVal(this.expr,this.vm);
        if(newVal!==this.oldVal){ // 如果新值與舊值不同
            this.cb(newVal) // 調用回調函數
        }
    }
    getOldVal(){
        return compileHandle.getVal(this.expr,this.vm)
    }
}

對於依賴收集類,我們要在初始化的時候即在對數據進行劫持時就創建一個列表來放置watcher,此外要有添加watcher到這個列表的方法,以及通知這個列表中的watcher觸發更新,這也就是發佈訂閱模式中的發佈者

class Dep{
    constructor(){
        this.subs = []; // 初始化依賴收集列表
    }
    addSub(watcher){ // 添加觀察者
        this.subs.push(watcher);
    }
    notify(){ // 通知列表中的所有觀察者觸發更新
        this.subs.forEach(w=>w.update());
    }
}

完成觀察者類和依賴收集的聲明後,接下來要做的就是將Dep和Observer關聯起來,一個數據要在什麼時候被監聽,或者說什麼數據應該被監聽,如果在data中有的數據一直沒被用到,那我們有什麼必要去更新這個數據呢?
所以應該在監聽的時候,在get方法中將watcher放到Dep的sub中,而Watcher是在我們初次對數據進行解析的時候就new的,所以也不會在Observer中new新的watcher,所以我們在Watcher初始化獲取舊值的時候,先將實例本身掛載到Dep的target屬性上,然後在獲取舊值完後將Dep的target屬性置爲null
在將watcher放到sub之後,當數據發生變化,也就是觸發set操作的時候,就要觸發dep的notify方法,通知各個watcher去更新視圖

class Watcher{
    // ...
    getOldVal(){
        Dep.target = this; // 掛載到Dep.target上
        const oldVal = compileHandle.getVal(this.expr,this.vm);
        Dep.target = null; // 將target.target置爲null
        return oldVal
    }
}

class Observer{
    // ...
    defineReactive(data,key,val){
        this.observe(val); // 遞歸遍歷
        const dep = new Dep();
        Object.defineProperty(data,key,{
            get:()=>{
                // 判斷Dep.target是否有值
                // 若有,將掛載在Dep上的watcher添加到dep的依賴列表中
                Dep.target && dep.addSub(Dep.target); 
                return val
            },
            set:(newVal)=>{
                if(newVal!==val){
                    this.observe(newVal); // 對傳入的新值進行監聽
                    val = newVal
                }
                dep.notify(); // 通知dep的依賴列表中的watcher觸發更新
            }
        })
    }
}

將Observer和Watcher關聯起來後,接下來就是要在解析的時候創建watcher了,watcher對應的回調函數就是updater對應的方法
以html爲例,我們只要加上一個new Watcher就可以了

new Watcher(vm,expr,(newVal=>{
    this.updater.htmlUpdate(node,newVal)
}))

但是文本內容有所不同,我們可能在處理的時候遇到{{person.name}}—{{person.age}}這樣的情況,而其中的person.name或者person.age的改變都會使得整個發生改變,變爲其中一個的值
比如我們這樣寫

const compileHandle = {
    text(node,expr,vm){
        let val
        if(expr.includes("{")){ // 判斷是否爲文本節點的修改
            val = expr.replace(/\{\{(.+?)\}\}/g,(...args)=>{ // 找到所有的{{}},將{{}}內的內容做爲一個分組來替換
                new Watcher(vm,args[1],(newVal=>{
                    this.updater.textUpdate(node,newVal) // 錯誤寫法
                }))
                return this.getVal(args[1],vm); // 這裏的args[1]就是我們要的每個{{}}內的值
            })
        }else{ 
            val = this.getVal(expr,vm)
            new Watcher(vm,expr,(newVal=>{
                this.updater.textUpdate(node,newVal)
            }))
        }
        this.updater.textUpdate(node,val)
    },
    // ...
}

在控制檯寫上

vm.$data.person.name = "1"

發現
在這裏插入圖片描述
變成了
在這裏插入圖片描述
這顯然不是我們要的結果,所以要再寫一個方法來找到文本內容中的每個{{}}內的內容,對每個內容的值返回相應的值
修改後的compileHandle對象如下

const compileHandle = {
    text(node,expr,vm){
        let val
        if(expr.includes("{")){ // 判斷是否爲文本節點的修改
            val = expr.replace(/\{\{(.+?)\}\}/g,(...args)=>{ // 找到所有的{{}},將{{}}內的內容做爲一個分組來替換
                new Watcher(vm,args[1],(newVal=>{
                    this.updater.textUpdate(node,this.getContentVal(expr,vm))
                }))
                return this.getVal(args[1],vm); // 這裏的args[1]就是我們要的每個{{}}內的值
            })
        }else{ 
            val = this.getVal(expr,vm)
            new Watcher(vm,expr,(newVal=>{
                this.updater.textUpdate(node,newVal)
            }))
        }
        this.updater.textUpdate(node,val)
    },
    html(node,expr,vm){
        const val = this.getVal(expr,vm)
        new Watcher(vm,expr,(newVal=>{
            this.updater.htmlUpdate(node,newVal)
        }))
        this.updater.htmlUpdate(node,val)
    },
    model(node,expr,vm){
        const val = this.getVal(expr,vm)
        new Watcher(vm,expr,(newVal=>{
            this.updater.modelUpdate(node,newVal)
        }))
        this.updater.modelUpdate(node,val)
    }
    getContentVal(expr,vm){
        return expr.replace(/\{\{(.+?)\}\}/g,(...args)=>{ // 找到所有的{{}},將{{}}內的內容做爲一個分組來替換
            return this.getVal(args[1],vm); // 這裏的args[1]就是我們要的每個{{}}內的值
        })
    },
    // ...
}

到這裏,我們完成了監聽數據變化修改視圖,在這裏,依賴收集Dep的實例對象就是data中數據的數量,而watcher就是使用到數據的視圖的數量,上面的data對象如下,就會有5個Dep的實例,person,person.name,person.age,msg,judge

data: {
    person: {
        name: 'zem',
        age: 18
    },
    msg: 'text',
    judge: true
}

接下里就是要做視圖的變化來影響數據了

實現視圖驅動數據


實際上會通過視圖驅動數據的,也就是v-model,所以我們直接修改compileHandle對象中的model方法,在創建watcher後,給有v-model屬性的元素綁定input方法,將表單元素的新值賦給vm.$data中相應的屬性,寫一個setVal方法來設置值

const compileHandle = {
    model(node,expr,vm){
        const val = this.getVal(expr,vm)
        new Watcher(vm,expr,(newVal=>{
            this.updater.modelUpdate(node,newVal)
        }))
        node.addEventListener("input",e=>{ // 監聽表單元素
            this.setVal(expr,vm,e.target.value) // 設置新值
        })
        this.updater.modelUpdate(node,val)
    },
    setVal(expr,vm,newVal){
        expr.split(".").reduce((data,attr)=>{
            data[attr] = newVal;
        },vm.$data)
    },
    // ...
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章