vue 實現原理及簡單示例實現

Hewitt發表於2020-09-11


主要理解、實現如下方法:
  • Observe :監聽器 監聽屬性變化並通知訂閱者

  • Watch :訂閱者 收到屬性變化,更新檢視

  • Compile :解析器 解析指令,初始化模板,繫結訂閱者,繫結事件

  • Dep :存放對應的所有 watcher 例項

主要執行流程

右鍵點選圖片,在新標籤頁開啟,可檢視更清晰圖片

將watcher裝入對應的dep例項的訂閱列表中的過程

相關html程式碼,用於被解析繫結資料

這裡的程式碼即是compile中要解析的模板,解析其中的 v-model {{}} v-on 等命令

<div id="app">
    <p> 姓名:<input type="text" v-model="name"></p>
    <p>學號:<input type="text" v-model="number"></p>
    <p><span>學號:</span> <span>{{ number }}</span></p>
    <p><span>computed實現:</span> <span>{{ getStudent }}</span></p>

    <p>
        <button v-on:click="setData">事件繫結</button>
    </p>
</div>

observer程式碼

為data資料新增 get、set 以便新增 watcher,和建立 Dep 例項,通知更新檢視

const defineProp = function(obj, key, value) {
    observe(value)

    /*
    * 預先為每一個不同屬性建立一個獨有的dep
    */
    const dep = new Dep()
    Object.defineProperty(obj, key, {
        get: function() {
            /*
            * 根據不同的屬性建立且只在建立Watcher時呼叫
            */
            if(Dep.target) {
                dep.targetAddDep()
            }
            return value
        },
        set: function(newVal) {

            if(newVal !== value) {
                /*
                * 這裡的賦值操作,是以便於get  方法中返回value,因為都是賦值後馬上就會呼叫get方法
                */
                value = newVal
                /*
                * 通知監聽的屬性的所有訂閱者
                */
                dep.notify()
            }
        }
    })
}

const observe = function(obj) {
    if(!obj || typeof obj !== 'object') return

    Object.keys(obj).forEach(function(key) {
        defineProp(obj, key, obj[key])
    })
}

Dep程式碼

主要是將 watcher 放入 對應的 dep 訂閱列表

let UUID = 0
function Dep() {
    this.id = UUID++
    // 存放當前屬性的所有的監聽watcher
    this.subs = []
}
Dep.prototype.addSub = function(watcher) {
    this.subs.push(watcher)
}
// 目的是將當前 dep 例項 傳入 watcher
Dep.prototype.targetAddDep = function() {
    // 這裡 this 是例項化後的 dep
    Dep.target.addDep(this)
}
Dep.prototype.notify = function() {
    // 觸發當前屬性的所有 watcher
    this.subs.forEach(_watcher => {
        _watcher.update()
    })
}
Dep.target = null

Watcher 程式碼

資料更新後,更新檢視

function Watcher(vm, prop, callback) {
    this.vm = vm
    this.prop = prop
    this.callback = callback
    this.depId = {}
    this.value = this.pushWatcher()
}

Watcher.prototype = {
    update: function() {
        /* 更新值的變化 */
        const value = this.vm[this.prop]
        const oldValue = this.value
        if (value !== oldValue) {
            this.value = value
            this.callback(value)
        }
    },
    // 目的是接收 dep 例項,用於將當前watcher例項放入 subs
    addDep: function(dep) {
        if(!this.depId.hasOwnProperty(dep.id)) {
            dep.addSub(this)
            this.depId[dep.id] = dep.id
        } else {
            console.log('already exist');
        }
    },
    pushWatcher: function() {
        // 存貯訂閱器
        Dep.target = this
        // 觸發物件的get監聽,將上面賦值給 target 的this 加入到subs
        var value = this.vm[this.prop]
        // 加入完後就刪除
        Dep.target = null
        return value
    }
}

Compile 程式碼

解析html模板,建立程式碼片段,繫結資料事件

function Compile(vm) {
    this._vm = vm
    this._el = vm._el
    this.methods = vm._methods
    this.fragment = null
    this.init()
}
Compile.prototype = {
    init: function() {
        this.fragment = this.createFragment()
        this.compileNode(this.fragment)

        // 當程式碼片段中的內容編譯完了之後,插入DOM中
        this._el.appendChild(this.fragment)
    },

    // 根據真實的DOM節點,建立文件片段
    createFragment: function() {
        const fragment = document.createDocumentFragment()
        let child = this._el.firstChild
        while(child) {
            // 將節點加入到文件片段中後,該節點會被從原來的位置刪除,相當於移動節點位置
            fragment.appendChild(child)
            child = this._el.firstChild
        }

        return fragment
    },

    compileNode: function(fragment) {
        let childNodes = fragment.childNodes;
        [...childNodes].forEach(node =>{
            if(this.isElementNode(node)) {
                this.compileElementNode(node)
            }

            let reg = /\{\{(.*)\}\}/
            // 獲取節點下的所有文字內容
            let text = node.textContent

            // 判斷是否已是純文字節點,且文字內容是否有{{}}
            if(this.isTextNode(node) && reg.test(text)) {
                let prop = reg.exec(text)[1].trim()
                this.compileText(node, prop)
            }

            if(node.childNodes && node.childNodes.length) {
                // 遞迴編譯子節點
                this.compileNode(node)
            }
        })
    },

    compileElementNode: function(element) {
        // 獲取屬性,只有元素節點有如下方法
        let nodeAttrs = element.attributes;
        [...nodeAttrs].forEach(attr => {
            let name = attr.name

            if(this.isDirective(name)) {
                /*
                * v-model 放在可接受input事件的標籤上
                */
                let prop = attr.value
                if (name === 'v-model') {
                    /*
                    * 獲取到的value 即為需要繫結的data
                    */
                    this.compileModel(element, prop)
                } else if(this.isEvent(name)) {
                    /*
                    * 繫結事件
                    */
                    this.bindEvent(element, name, prop)
                }
            }
        })
    },

    compileModel: function(element, prop) {
        let val = this._vm[prop]
        this.updateElementValue(element, val)
        new Watcher(this._vm, prop, value => {
            this.updateElementValue(element, value)
        })

        element.addEventListener('input', event => {
            let newValue = event.target.value
            if (val === newValue) return
            this._vm[prop] = newValue
        })
    },

    compileText: function(textNode, prop) {
        let text = ''
        if(/\./.test(prop)) {
            var props = prop.split('.')
            text = this._vm[props[0]][props[1]]
        } else {
            text = this._vm[prop]
        }

        this.updateText(textNode, text)

        console.log(text);

        new Watcher(this._vm, prop, (value) => {
            this.updateText(textNode, value)
        })
    },

    bindEvent: function(element, name, prop) {
        var eventType = name.split(':')[1]
        var fn = this._vm._methods[prop]
        element.addEventListener(eventType, fn.bind(this._vm))
    },

    /*
    * 判斷屬性是否為指令
    */
    isDirective: function (text) {
        return /v-/.test(text)
    },

    isEvent: function(text) {
        return /v-on/.test(text)
    },

    isElementNode: function(node) {
        // 元素節點返回1 文字節點(元素或屬性中的文字)3 屬性節點2(被廢棄)
        return node.nodeType === 1
    },

    isTextNode: function(node) {
        return node.nodeType === 3
    },

    updateElementValue: function(element, value) {
        element.value = value || ''
    },

    updateText: function(textNode, value) {
        textNode.textContent = value || ''
    }
}

vue 簡要建構函式

主要實現了資料的雙向繫結,自定義事件,computed

function FakeVue(options) {
    this._data = options.data
    this._methods = options.methods
    this._computed= options.computed
    this._el = document.querySelector(options.el)

    // 將 _data 中的屬性代理到外層的vm上,這裡只代理了_data的第一層屬性
    Object.keys(this._data).forEach(key => {
        this._proxyData(key)
    })
    this._initComputed()
    this._init()
}
FakeVue.prototype._init = function() {
    // 開始遞迴監聽物件的所有屬性,直到屬性值為值型別
    observe(this._data)
    new Compile(this)
}
FakeVue.prototype._proxyData = function(key) {
    Object.defineProperty(this, key, {
        get: function() {
            return this._data[key]
        },
        set: function (value) {
            this._data[key] = value
        }
    })
}
FakeVue.prototype._initComputed = function() {
    // 簡單的實現: 將值掛載到跟上即可
    const computed = this._computed
    Object.keys(computed).forEach((v) => {
        Object.defineProperty(this, v, {
            get: computed[v],
            set: function (value) {}
        })
    })
}

建立vue例項

try{
    let vm = new FakeVue({
        el: '#app',
        data: {
            name: 'warren',
            number: '10011',
            score: {
                math: 90
            }
        },

        computed: {
            getStudent: function() {
                return `${this.name}:學號是 ${this.number}`
            }
        },

        methods:{
            // 通過在compile中給元素繫結事件實現
            setData: function() {
                alert('name:'+this.name);
            }
        }
    });
} catch(error) {
    console.error(error)
}

結語

這是從作者的理解角度,闡述的一個簡單的vue實現原理示例,希望對正在探索的你有所幫助

在這個示例中,主要的複雜點在於對 html 模板的解析,資料的雙向繫結。

建議跟著程式碼的執行順序瞭解整個過程,關鍵點的程式碼都有必要的註釋,若發現問題請指正。

最後附上 vue 原始碼地址,主要關注其中的 corecompiler 檔案;

歡迎交流 Github

相關文章