MVVM原始碼 - 如何實現一個MVVM框架

JsRicardo發表於2019-12-18

在學習react 和 vue 這種 MVVM 框架的使用之後,是時候來嘗試著學習MVVM框架的原始碼了。 專案連結 簡易版mvvm框架原始碼

mvvm框架的核心就是虛擬節點,資料雙向繫結...

先來一段簡單的程式碼

new Rue({
    el: 'app',
    data: {
        content: 'ricardo',
        desc: '很帥',
        dec: {
            x: 1,
            y: 2
        },
        list: [{
            name: 'ee',
            age: 13
        }]
    }
})
複製程式碼

這是一段簡單的VUE型別程式碼,一個掛載節點,一個data資料物件。那麼MVVM框架又是怎麼把這些程式碼轉換成網頁(dom),實現資料雙向繫結的呢?

思路

  • 要想讓資料和dom之間雙向繫結,必然要建立某種聯絡(廢話嘛...),VUE中有個很經典的概念虛擬節點樹,將dom節點和資料新增對映,實現雙向繫結。

  • 但是怎麼監聽資料的改變,實現改變頁面資料呢? 眾所周知,物件的屬性改變,外部是感知不到的。這裡就用到了代理,(目前版本的VUE還是defineProperty,最新版本的VUE已經換成了proxy。)所以還需要一個代理。

  • 得到這些資料,對映之後,當然就是渲染頁面了。

所以路線大概是這樣的: 將data物件傳遞給VUE,VUE將物件代理了,將dom節點也給獲取到,生成一個虛擬的dom節點樹,將dom節點鐘帶有模板語法的地方和資料進行雙向對映,渲染頁面...

接下來就動手寫程式碼

編碼

webpack配置

工欲善其事必先利其器,配置好環境之後才能更好的編碼嘛,這裡只用到了webpack的熱更新,模板html檔案,babel-loader而已(babel我配置了stage0,因為需要用到靜態方法和靜態屬性)。程式碼就不貼了,很簡單,有興趣可以去我的github看原始碼

  • 目錄結構
├─public
    └─index.html // 模板頁
├─src
    ├─core // 專案主資料夾
        ├─instance // Rue父類,建立方法
        ├─proxy // 代理資料
        ├─render // 渲染方法
        ├─grammar // 自定義語法
        ├─util // 工具類
        ├─vdom // 生成虛擬節點樹
        └─index.js // 匯出Rue類
    └─index.js // 主檔案
├─.babelrc
├─.gitignor
├─.package.json
├─README.md
└─webpack.config.js
複製程式碼

接下來就是見證奇蹟的時刻,經過一系列編碼後,頁面實現了!!!

Rue實現效果

實現渲染

一、Rue類

要想實現new Rue 首先要有一個Rue類,他接收一個物件,裡面有el,data...等,並對這些資料進行處理。在instance中建立一個Rue.js檔案。

我希望這個Rue類在被new的時候,有一個方法可以進行一系列的初始化,所以有了一個_init方法,將options傳給他處理。

還要有一個_render方法,在初始化完了之後,呼叫_render就能渲染資料

export class Rue extends InitMixin {
    constructor(options) {
        super()
        this._init(options)
        this._render()
    }
}
複製程式碼

為了程式碼的好看,結構清晰,Rue只做呼叫方法,那麼初始化資料在哪裡做呢?新建一個InitMixin檔案,在這裡來做資料處理,方法實現。

InitMixin需要做的事情就是構造一些方法,讓Rue在new的時候可以直接使用。

  • _init需要做的事情就是初始化代理data中的資料, 初始化ele並且掛載, 初始化...
  • _render則是提供渲染方法
export class InitMixin {
    constructor() {
        this._uid = 0
        this._isRue = true
        this._data = null
        this._vnode = null
    }

    _init(options) {
        this._uid++ // rueId 防止重複
        this._isRue = true // 是否是Rue物件
        // 1.初始化data 代理
        if (options && options.data) {
            this._data = ConstructProxy.proxy(this, options.data, '')
        }
        // 2.初始化el並掛載
        if (options && options.el) {
            let rootDom = document.getElementById(options.el)
            Mount.mount(this, rootDom)
        }
        // 3.初始化created
        // 4.初始化methods
        // 5.初始化computed 
    }
    
    _render() {
        Render.renderNode(this, this._vnode)
    }
}
複製程式碼

二、代理資料(proxy)

有了Rue之後,能夠接收到傳入的值了,接下來進行代理資料

  • 代理資料又分為代理物件,和代理陣列
  • 這裡的代理只需要用到defineProperty,並且進行遞迴就行了(因為會有物件套物件的情況)
  • 代理陣列時這裡只代理了陣列的幾個方法。
  • 代理陣列的方法時需要代理的其實就是方法的Value => push:function(){}
  • 注意: 因為物件可能有多層,所以需要一個名稱空間namespace來儲存這個值,以便後面取值的時候,可以知道需要取得是哪個物件下的哪一個值。例如obj.a obj.b
export class ConstructProxy {
    static arrayProto = Array.prototype
    /**
     * 代理方法
     * @param vm Rue物件
     * @param obj 需要代理的物件
     * @param namespace 名稱空間 表示當前修改的是哪個屬性
     */
    static proxy(vm, obj, namespace) {}
    /**
     * 代理陣列
     * @param arr 需要代理的陣列
     * @param vm Rue物件
     * @param namespace 名稱空間
     */
    static proxyArray(arr, vm, namespace) {
        // 將陣列當做一個 k-v結構來進行代理
        let proxyObject = {
            eleType: 'Array',
            toString: () => {
                let res = ''
                arr.forEach(it => {
                    res += it + ', '
                })
                return res.substring(0, res.length - 2)
            },
            push() {},
        }
        this.proxyArrayFunc.call(vm, proxyObject, 'push', namespace, vm)
        arr.__proto__ = proxyObject
        return arr
    }
    /**
     * 代理陣列的方法
     * @param obj 陣列物件
     * @param func 方法
     * @param namespace 名稱空間 
     * @param vm Rue物件
     */
    static proxyArrayFunc(obj, func) {
        Object.defineProperty(obj, func, {
            enumerable: true,
            configurable: true,
            // 方法其實也是k-v結構  push: function()
            value: (...args) => {
                let original = this.arrayProto[func]
                const result = original.apply(this, args)
                return result
            }
        })
    }

    /**
     * 代理物件
     * @param obj 需要代理的物件
     * @param vm Rue物件
     * @param namespace 名稱空間 表示當前修改的是哪個屬性
     */
    static proxyObject(obj, vm, namespace) {}
    /**
     * 獲取當前的名稱空間
     * @param nowNamespce 當前的名稱空間
     * @param nowProp 當前要修改的屬性
     */
    static getNameSpace(nowNamespce, nowProp) {}
}
複製程式碼

三、構建虛擬節點樹(VDOM)

虛擬節點樹VDOM 是MVVM框架中非常重要的一個概念,正是因為這個東西,讓我們不再直接的操作DOM元素,大大的提升了效能。

  • 虛擬節點樹,其實就是將dom結構,用一顆樹的結構存起來。
  • 注意: 換行也是一個節點,文字是文位元組點
  • 需要操作的就是文位元組點的模板字串

先構建一個節點物件

/**
 * 虛擬節點類
 * 和真實節點相互對應
 */
export class VNode {
    constructor(
        tag, // 標籤名稱: DIV SPAN #TEXT
        ele, // 對應的真實節點
        children, // 子節點
        text, // 當前節點的文字
        data, // VNodeData 保留欄位
        parent, // 父級節點
        nodeType, // 節點型別
    ) {
        this.tag = tag
        this.ele = ele
        this.children = children
        this.text = text
        this.data = data
        this.parent = parent
        this.nodeType = nodeType
        this.env = {} // 當前節點的環境變數 v-for等時  這個節點在什麼環境下
        this.instructions = null // 存放指令 v-for v-if...
        this.template = [] // 當前節點涉及的模板
    }
}
複製程式碼

現在有了節點物件了,接下來根據dom樹,把這些一個一個的VNODE組成一顆VDOM就行了

export class Mount {
    /**
     * 允許不傳el,建立完Rue之後,進行手動掛載
     * @param {*} Rue Rue物件
     */
    static inintMount(Rue) {
        Rue.prototype.$mount = (el) => {
            let rootDom = document.getElementById(el)
            this.mount(Rue, rootDom)
        }
    }
    /**
     * 掛載節點
     * @param {*} vm 
     * @param {*} ele 
     */
    static mount(vm, ele) {
        // 掛載節點
        vm._vnode = this.constructVNode(vm, ele, null)
        // 進行預備渲染 建立渲染索引 模板和vnode的雙向索引
        RenderTool.prepareRender(vm, vm._vnode)
    }
    /**
     * 構建虛擬節點樹
     * @param {*} vm 
     * @param {*} ele dom節點
     * @param {*} parent 父節點
     */
    static constructVNode(vm, ele, parent) {
        // 建立節點
        vnode = new VNode(tag, ele, children, text, data, parent, nodeType)
        let childs = vnode.ele.childNodes
        // 深度優先遍歷 建立節點
        // ...
        return vnode
    }
    /**
     * 獲取文字節點的文字
     * @param {*} ele dom節點 
     */
    static getNodeText(ele) {
        if (ele.nodeType === 3) {
            return ele.nodeValue
        }
        return ''
    }
}
複製程式碼

四、渲染(render)

有了虛擬節點樹,有了資料,接下來就是渲染了?NO,我們需要先建立一個虛擬節點樹和資料的雙向對映,便於以後做雙向資料繫結。

先來一個工具類, 在render資料夾建立一個RenderTool類

export class RenderTool {
    static vnode2Template = new Map()
    static template2VNode = new Map()
    /**
     * 預備渲染
     * @param {*} vm Rue物件
     * @param {*} vnode 虛擬節點
     */
    static prepareRender(vm, vnode) {
        if (vnode === null) return
        if (vnode.nodeType === 3) {
            // 文字節點 分析文字節點的內容,是否有模板字串 {{}}
            this.analysisTemplateString(vnode)
        }
        if (vnode.nodeType === 1) {
            // 標籤節點,檢查子節點
            for (let i = 0, len = vnode.children.length; i < len; i++) {
                // 遍歷根節點
                this.prepareRender(vm, vnode.children[i])
            }
        }
    }
    /**
     * 檢查文字節點中是否存在模版字串,建立對映
     * @param {*} str 文字節點內容
     */
    static analysisTemplateString(vnode) {}
    /**
     * 建立模板到節點的對映
     * 通過模板 找到那些節點用到了這個模板
     * @param {*} template 
     * @param {*} vnode 
     */
    static setTemplate2VNode(template, vnode) {}
    /**
     * 建立節點到模板的對映
     * 通過節點 找到這個節點下有哪些模版
     * @param {*} template 
     * @param {*} vnode 
     */
    static setVNode2Template(template, vnode) {}

    static getTemplateText(template) {
        // 截掉模板字串的花括號
        return template.substring(2, template.length - 2)
    }
    /**
     * 獲取模板字串在data或者env中的值
     * @param {*} objs [data, vnode.env]
     * @param {*} target 目標值
     */
    static getTemplateValue(objs, target) {}
    
    static getObjValue(obj, target) { // data.content }
}
複製程式碼

萬事俱備,接下來就來進行第一次渲染吧!

在render資料夾下建立Render類,負責渲染的工作

  • 渲染就是根據之前建立的資料到節點的對映,去替換虛擬節點樹中的nodeVlue
export class Render {
    /**
     * 渲染節點
     * @param {*} vm Rue物件
     * @param {*} vnode 虛擬節點樹
     */
    static renderNode(vm, vnode) {
        if (vnode.nodeType === 3) {
            // 如果是一個文字節點 就渲染文字節點
            // ...
        } else {
            // 如果不是文字節點就遍歷子節點
            for (let i = 0, len = vnode.children.length; i < len; i++) {
                this.renderNode(vm, vnode.children[i])
            }
        }
    }
}
複製程式碼

五、修改資料之後,自動渲染

想要修改資料之後,頁面能夠跟著自動渲染,資料和頁面必然要聯絡起來,而且要能監聽資料的改變,這也是為什麼前面要將模板和資料做雙向對映,並且要代理資料的原因。

OK,現在有了代理之後的資料,也有了template2VNode這個對映,修改資料之後,自動渲染就只需要在呼叫物件的set方法時,找到修改的屬性對應的虛擬節點,更新虛擬節點的值就可以了。

先在Render類中新增一個template尋找vnode的方法

export class Render {
    /**
     * 渲染節點
     * @param {*} vm Rue物件
     * @param {*} vnode 虛擬節點樹
     */
    static renderNode(vm, vnode) {}
    /**
     * 根據資料渲染節點
     * @param {*} vm rue物件
     * @param {*} data 要渲染的資料
     */
    static dataRender(vm, data) {
        // 根據對映  找到使用這個模板的所有虛擬節點
        const vnodes = RenderTool.template2VNode.get(data)
        if (vnodes !== undefined) {
            for (let i = 0, len = vnodes.length; i < len; i++) {
                // 渲染
                this.renderNode(vm, vnodes[i])
            }
        }
    }
}
複製程式碼

有了這個方法之後只需要在代理物件的set方法裡面輕輕的呼叫Render.dataRender即可實現改變資料,重新整理頁面資料。

實時重新整理資料

標籤上屬性解析

標籤上的屬性就是諸如 v-model v-for v-bind... 之類的東西,那麼他們應該在哪裡處理呢?

之前有RenderTool類裡面有一個方法prepareRender,在這裡,迴圈了虛擬節點樹,並且將標籤和data中的資料進行了雙向對映。在這裡可以拿到資料和節點,也符合為了渲染的邏輯,所以可以在這裡進行屬性的處理。

  • 在RenderTool類中新增一個靜態方法 analysisAttr,用以分析節點上面的屬性,在prepareRender中分析節點元素的時候呼叫它,因為只有元素節點才需要處理屬性。
export class RenderTool {
    static vnode2Template = new Map()
    static template2VNode = new Map()
    /**
     * 預備渲染
     * @param {*} vm Rue物件
     * @param {*} vnode 虛擬節點
     */
    static prepareRender(vm, vnode) {
        if (vnode === null) return
        if (vnode.nodeType === 3) {
            // 文字節點 分析文字節點的內容,是否有模板字串 {{}}
        }
        if (vnode.nodeType === 1) {
            this.analysisAttr(vm, vnode)
            // 標籤節點,檢查子節點
            // 遍歷根節
        }
    }
    // ...
    /**
     * 分析標籤屬性,建立對映,方便資料雙向繫結
     * @param {*} vm 
     * @param {*} vnode 
     */
    static analysisAttr(vm, vnode) {
        let attrNames = vnode.ele.getAttributeNames()
        if (attrNames.indexOf('v-model') > -1) {
            const vModel = vnode.ele.getAttribute('v-model')
            this.setTemplate2VNode(vModel, vnode)
            this.setVNode2Template(vModel, vnode)
            Grammar.vmodel(vm, vnode.ele, vModel)
        }
    }
}
複製程式碼

OK, analysisAttr就是用來處理節點上面的自定義指令的方法(自定義方法稱為grammar,放在grammar資料夾下)

接下來,就需要一個Grammar類用來處理自定義指令方法,那就新建一個Gramma類。

v-model

v-model在vue中就是用來實現雙向資料繫結的一個指令。他要做的事情就是將可改變值的元素和data中的某一個值進行繫結,改變其中一個,另一個也跟著改變。

有了之前資料渲染了方法,已經處理了首次資料渲染,資料改變渲染節點,這裡就只需要處理文字輸入時,改變資料了。

那麼方法就是:在觸發元素的onchange事件時,動態改變元素和data中的值。

這裡為了方便新建一個util資料夾,匯出一個Tool工具類,用於設定物件的值,還有獲取物件的值。簡單的遞迴就行。

export class Tool{
    /**
     * 獲取物件的某個值
     * @param {*} obj 物件
     * @param {*} target 想要獲取的目標屬性 data.content
     */
    static getObjValue(obj, target) {}
    /**
     * 設定物件的某個值
     * @param {*} obj 物件
     * @param {*} target 想要設定的目標屬性 data.content
     * @param {*} value 設定的值
     */
    static setObjValue(obj, target, value) {}
}
複製程式碼

實現Grammar類

/**
 * 規定語法類
 */
export class Grammar{
    /**
     * v-model雙向資料繫結
     * @param {*} vm 
     * @param {*} ele 
     * @param {*} data 
     */
    static vmodel (vm, ele, data) {
        ele.onchange = e => {
            // 元素值改變之後需要進行雙向改變
            Tool.setObjValue(vm._data, data, ele.value)
        }
    }
}
複製程式碼

這樣就實現了在輸入框中輸入值,可以改變資料的值,但是這個時候,input框還是空的,首次渲染的時候並沒有給input框填值。

怎麼辦?

其實也很簡單,之前在Render.renderNode這個方法中,只處理了文字節點,接下來還需要處理元素節點,也就是nodeType為1的節點,需要在這裡根據節點到資料的對映找到INPUT元素對應的data中的值,將這個值賦給INPUT的value。

// Render
static renderNode(vm, vnode) {
        if (vnode.nodeType === 3) {
            // 如果是一個文字節點 就渲染文字節點
            // 獲取到模板字串陣列
        } else if (vnode.nodeType === 1 && vnode.tag === 'INPUT') {
            // 雙向資料繫結
            const templates = RenderTool.vnode2Template.get(vnode)
            if (templates) {
                for (let i = 0, len = templates.length; i < len; i++){
                    const templateValue = RenderTool.getTemplateValue([vm._data, vm.env], templates[i])
                    if (templateValue){
                        vnode.ele.value = templateValue
                    }
                }
            }
        } else {
            // 如果不是文字節點就遍歷子節點
        }
    }
複製程式碼

OK,改造一下模板網頁

<div id='app'>
    <div>{{content}}, {{desc.x}}</div>
    <div>{{desc.y}}</div>
    <span>粉絲:{{fans}}</span>
    <hr>
    <input type="text" v-model='fans' />
</div>
複製程式碼

接下來就是見證奇蹟的時刻!

首次渲染成功!

首次渲染

input框中輸入資料,雙向繫結成功!

雙向繫結

v-for

先來看一段模板程式碼

<ul>
    <li v-for='(item, index) in list'>姓名:{{item.name}} - 粉齡:{{item.time}}</li>
</ul>
複製程式碼

這是一段簡單的vue模式迴圈生成dom節點的模板語法,那麼需要考慮的是,這個模板節點肯定不是一個真實的節點!而是我們需要根據這個模板和list陣列去生成真實的節點。

引入紅黑樹的概念:

  • 假設list的長度為3
  • 需要根據模板li生成三個真實的li元素

如圖:

節點紅黑樹

但是在生成虛擬節點樹的時候,又需要將虛擬模板li掛載在ul下,真實節點li則掛載在模板li下,這樣的話,當list有修改時,就可以根據模板li重新實生成真節點li

vue的作者則是把虛擬節點li合併到了ul中。

第一次渲染v-for

在哪裡做這個事情? 思考一下,我們之前構建了VDom,在這裡需要分析模板,那麼是不是可以在這裡分析一下標籤上有v-for這個屬性的元素,先分析,構建虛擬模板節點LI,根據這玩意兒生成真實節點LI,再去生成VDom,再渲染

這裡需要注意幾點:

  • 分析原生節點之後需要生成虛擬模板節點,和掛載在虛擬模板節點之下的虛擬真實節點,所以需要判斷有沒有生成虛擬模板節點來進行建立Vnode的操作
  • 因為v-for的特殊情況,會存在環境變數這個情況,生成的新的真實節點需要掛載一個env屬性用來儲存環境變數
  • 又存在v-for巢狀的情況,所以需要將環境變數合併成新的環境變數,用作v-for生成的節點的變數
  • 建立了虛擬模板節點和掛載在虛擬模板節點之下的虛擬真實節點。所以需要判斷自定義的nodeType: 0來建立虛擬節點樹(比如VNode(ul) -> VNode(li temp) -> VNode(li) * 3, 對應的真實節點則是 UL -> #TEXT + LI + LI + #TEXT)

OK,改造一下constructVNode的邏輯

static constructVNode(vm, ele, parent) {
        // 掛載前先分析可能生成新節點的屬性
        let vnode = this.analysisAttr(vm, ele, parent)
        if (!vnode) {
            // 如果沒有需要生成新節點的標籤
            // 建立節點
            // ...
            if (nodeType === 1 && ele.getAttribute('env')) {
                // env 是當前標籤的環境變數
                // 如果標籤是一個元素標籤,並且標籤上還有env這個屬性,則需要解析這個屬性
                // 合併環境變數  比如v-for 巢狀 v-for
                vnode.env = Tool.mergeObject(vnode.env, JSON.parse(ele.getAttribute('env')))
            } else {
                vnode.env = Tool.mergeObject(vnode.env, parent ? parent.env : {});
            }
        }
        let childs = vnode.nodeType === 0 ? vnode.parent.ele.childNodes : vnode.ele.childNodes
        // 深度優先遍歷 建立子節點
}

// 分析節點上的v-for屬性,針對v-for指令,生成節點
static analysisAttr(vm, ele, parent) {
    // ...
    // 處理vfor指令 返回vfor指令生成的節點
    return Grammar.vFor(vm, ele, parent, vForText);
}

// 生成虛擬模板節點 將生成的虛擬節點掛載到虛擬模板節點之下
// 將生成的真實節點掛載到模板節點的父節點之下
static vFor(vm, ele, parent, vForText) {
        // 構建虛擬模板節點li 此時形參data就應該存的是迴圈的list,方便後面的資料處理
        const strArr = this.getInstruction(vForText)
        const data = strArr[strArr.length - 1]
        const vNode = new VNode(ele.nodeName, ele, [], '', data, parent, 0)
        vNode.instructions = vForText;
        // 生成了虛擬模板節點之後,需要刪除原本的模板節點
        parent.ele.removeChild(ele)
        // 當把這個節點刪除之後,dom也會順帶的刪除一個文字節點,最後就剩一個文字節點
        // 此時應新增一個無意義的文字節點
        parent.ele.appendChild(document.createTextNode(''))

        // 分析vfor指令需要做什麼
        this.analysisInstructions(vm, ele, parent, strArr)

        return vNode
    }
// ...

複製程式碼

在修改了list的值的時候,應該做什麼

因為做了資料劫持,所以在改變陣列list的時候,我們是可以監聽到這個事件的,監聽到了這個事件,接下來又應該做什麼呢:

  • 首先,陣列變了,我們根據陣列生成的真實節點LI肯定要跟著改變

    所以要做的事情就是,重新構建改變的這一部分虛擬節點,重新走一次渲染流程

    1. 找到這些需要重新構建的節點
    2. 找到需要重新構建的節點的父節點,刪除之前生成的子節點
    3. 再把虛擬模板節點li放回去,變成最開始的模板形態
    4. 重新構建需要改變的這一部分節點,不用全量重新構建
    5. 清空索引(因為新生成的節點有變化了,之前的索引不管用了)
    6. 重新構建索引

所以就需要一個重構方法reBuild來做這些事情,然後在代理陣列設定值的時候,呼叫reBuild就行。

    /**
     * 節點變化後重新構建,如改變vfor陣列重新生成新的節點
     * @param {*} vm 
     * @param {*} template 
     */
    static reBuild(vm, template) {
        // 找到需要重新構建的虛擬節點(虛擬模板節點li)
        let vNodes = RenderTool.template2VNode.get(template);
        vNodes && vNodes.forEach(item => {
            // 找到li的父級節點,清空子節點
            item.parent.ele.innerHTML = ''
            // 再把虛擬模板節點li放回去,變成最開始的模板形態
            item.parent.ele.appendChild(item.ele)
            // 重新構建需要改變的這一部分節點,不用全量重新構建
            const result = this.constructVNode(vm, item.ele, item.parent)
            item.parent.children = [result]
            // 清空索引
            RenderTool.template2VNode.clear()
            RenderTool.vnode2Template.clear()
            // 重新構建索引,不會影響dom節點
            RenderTool.prepareRender(vm, vm._vnode)
        })
    }
複製程式碼

這樣操作的話,因為索引的關係,不用去構建所有的節點,可以節約非常多的時間。

初始options中新增list陣列

list: [
    {
        name: '迪麗熱巴',
        time: 10
    },
    {
        name: '楊冪',
        time: 13
    }
]
複製程式碼

接下來就行見證奇蹟的時刻!

初次渲染v-for

成功!

嘗試新增記錄

修改資料

成功!

相關文章