如何從0到1開發自己的mvvm框架

項目地址

Sth框架源碼地址
https://github.com/shangth/MyVue
基於Sth的toDoList地址
https://shangth.github.io/MyVue/todoList.html

寫在前面

此框架是一個自己開發的mvvm框架,大量參考了vue源碼,實現了虛擬dom,虛擬節點,數據代理,計算屬性,雙向綁定,數據改動局部重新渲染視圖,v-for,v-bind(: ),v-on(@),生命週期鉤子等功能,實現了一部分語法分析,但是相比真正的Vue還有一定差距。
但是可以幫助我們理解框架的內部原理,虛擬dom的概念。
下面,來講一講我是如何開發這個框架的。

框架目錄

|-core
    |-grammer
        |=vbind.js          分析處理v-bind指令
        |=vfor.js           分析處理v-for指令
        |=vmodel.js         分析處理v-model指令
        |=von.js            分析處理v-on指令
    |-instance
        |=index.js          STH框架主函數
        |=init.js           給Sth構造函數加入初始化方法
        |=mount.js          DFS算法構建虛擬dom樹
        |=proxy.js          代理data對象
        |=render.js         渲染頁面
    |-util
        |=code.js           編譯工具
        |=objectUtil.js     其他工具函數
    |-vdom
        |=vnode.js          虛擬dom構造函數
    |=index.js              入口函數

聲明構造函數Sth

path:instance/index.js
這個模塊完成的主要任務是

  • 聲明Sth函數;
  • 引入initMixIn,renderMixIn函數,給Sth原型上添加初始化以及渲染方法
import {initMixIn} from './init.js';
import {renderMixIn} from './render.js';


function Sth(options) {
    // 初始化Sth
    this._init(options);
    // 渲染
    this._render()
}
// 添加初始化方法
initMixIn(Sth)
renderMixIn(Sth)

export default Sth

初始化sth對象

在主模塊中,我們引入並執行了initMixIn方法,那麼這個方法主要做了什麼呢?
首先給Sth原型添加_init方法,_init方法主要做了以下幾件事

  • 給這個sth對象設置uid屬性(uid唯一)
  • 給sth設置isSth = true,代表這是一個sth對象
  • 初始化生命週期函數beforeCreate方法,如果有該方法,就去執行
  • 初始化data,通過遞歸代理
  • 初始化methods
  • 初始化computed
  • 初始化生命週期函數created方法,如果有該方法,就去執行
  • 初始化生命週期函數update方法
  • 初始化生命週期函數beforeMount方法
  • 檢查是否有el,掛載節點,完成後執行生命週期函數beforeMount方法

uid與isSth比較簡單,代碼如下,不過多贅述

let uid = 0;
export function initMixIn(Sth) {
	Sth.prototype._init = function(options) {
		const vm = this;
		// Sth唯一編號
		this.uid = uid++;
		// 記錄一個對象是不是Sth對象
        this.isSth = true;
	};
}

這是init所有要做的事,但是當前我們只需要去關注如何代理data

代理data

代理data主要使用的是get與set
proxy.js中有三個核心函數
分別爲

  • construtionProxy 代理不知道是對象還是數組的對象
  • construtionObjectProxy 代理對象 代理數組主要做的是遞歸代理自己和自己下面的屬性
  • construtionArrayProxy 代理數組 代理數組主要做的是代理數組本身和自己的方法

這三個函數的邏輯如下

function construtionProxy() {
    if (當前數據是數組) {
        construtionArrayProxy()
    } else if (當前數據是對象) {
        construtionObjectProxy()
    } else {
        拋出錯誤
    }
}

代理的目的是,當我們修改了數據時,我們可以檢測到數據的變化,從而做一些處理

代理對象

代理對象與代理數組不同,代理對象是代理對象下的每一個屬性,如果該對象下的屬性不是一個簡單數據類型,那就再通過construtionProxy代理它,在vue中,data對象代理到了兩個地方,一個是vue實例的_data中,一個是vue實例本身上,所以我們也將data代理到這兩個地方

// 對象代理方法
function construtionObjectProxy(vm, obj, namespace) {
    let proxyObj = {};
    for (let prop in obj) {
        Object.defineProperty(proxyObj, prop, {
            configurable: true,
            get() {
                return obj[prop]
            },
            set(value) {
                console.log(`${getNameSpace(namespace, prop)}屬性修改,新的值爲${value}`)
                obj[prop] = value;
                renderData(vm, getNameSpace(namespace, prop));
                // 生命週期update
                if (vm._update != null) {
                    vm._update.call(vm);
                }
            }
        })
        Object.defineProperty(vm, prop, {
            configurable: true,
            get() {
                return obj[prop]
            },
            set(value) {
                console.log(`${getNameSpace(namespace, prop)}屬性修改,新的值爲${value}`)
                obj[prop] = value;
                renderData(vm, getNameSpace(namespace, prop));
                // 生命週期update
                if (vm._update != null) {
                    vm._update.call(vm);
                }
            }
        })
        // 遞歸 由於不知道obj[prop]是數組還是對象,所以使用construtionProxy
        if (obj[prop] instanceof Object) {
            proxyObj[prop] = construtionProxy(vm, obj[prop], getNameSpace(namespace, prop))
        }
    }
    return proxyObj
}

代理數組

代理數組除了要代理數組每一項之外,還要代理數組的方法,因爲當數組通過push,pop等方法被改變時,我們也要檢測到

// 代理數組
function construtionArrayProxy(vm, arr, namespace) {
    let obj = {
        eletype: "Array",
        toString: () => {
            let result = '';
            for (let i = 0; i < arr.length; i++) {
                result += arr[i] + ', '
            }
            return result.slice(0, -2)
        },
        push() {},
        pop() {},
        shift() {},
        unshift() {},
        splice() {},
    }
    defArrayFunc.call(vm, obj, 'push', namespace, vm);
    defArrayFunc.call(vm, obj, 'pop', namespace, vm);
    defArrayFunc.call(vm, obj, 'shift', namespace, vm);
    defArrayFunc.call(vm, obj, 'unshift', namespace, vm);
    defArrayFunc.call(vm, obj, 'splice', namespace, vm);

    arr.__proto__ = obj;
    return arr
}

// 代理數組方法
const arrayProto = Array.prototype;
function defArrayFunc(obj, funcName, namespace, vm) {
    Object.defineProperty(obj, funcName, {
        enumerable: true,
        configurable: true,
        value: function(...args) {
            let originFun = arrayProto[funcName];
            const result = originFun.apply(this, args);
            console.log(`${funcName}方法被調用`);
            rebuild(vm, getNameSpace(namespace, ''));
            renderData(vm, getNameSpace(namespace, ''));
            // 生命週期update
            if (vm._update != null) {
                vm._update.call(vm);
            }
            return result
        }
    })
}

代理時還有一個很關鍵的概念是命名空間,通過命名空間可以準確定位被修改的值
之後預渲染時會創建節點與數據的映射關係,用到的也是命名空間
這樣,通過遞歸,就代理了data對象

生成虛擬dom數

這是框架的核心,虛擬dom,通過虛擬dom,可以檢測數據改變時,需要重新渲染的節點。
首先我們需要虛擬dom構造函數

let num = 0
export default class VNode{
    constructor(
        tag,  // 標籤名,例如DIV,SPAN,#TEXT
        elm,  // 真實節點
        children,  // 子節點 
        text,  // 文本(僅文本節點存在)
        data,  // 暫時保留(v-for構建虛擬節點時,用來儲存需要用到的數組命名空間)
        parent,  // 父級節點
        nodeType,  // 節點類型
    ) {
        this.tag = tag;
        this.elm = elm;
        this.children = children;
        this.text = text;
        this.data = data;
        this.parent = parent;
        this.nodeType = nodeType;
        this.env = {},  // 環境變量
        this.instructions = null;  // 存放指令
        this.template = [];  // 涉及模板
        this.num = num++
    }
}

除此之外還需要一些dom節點的基本知識
節點的nodeType 標籤節點爲1,文本節點爲3,模板在文本節點中,所以渲染只需要渲染文本節點即可
dfs算法構建虛擬dom樹

export function initMount(Sth) {
    Sth.prototype.$mount = function (el) {
        let vm = this;
        let rootDom = document.getElementById(el);
        mount(vm, rootDom);
    }
}
export function mount(vm, el) {
    // 進行掛載(生成虛擬dom樹)
    vm._vnode = constructVNode(vm, el, null);
    // 進行預備渲染(將模板轉換爲對應的值)
    prepareRender(vm, vm._vnode)
}

function constructVNode(vm, elm, parent) { // 深搜
    let vnode = analysisAttr(vm, elm, parent);
    if (!vnode) {
        let children = [];
        let text = getNodeText(elm);
        let data = null;
        let nodeType = elm.nodeType;
        let tag = elm.nodeName;
        vnode = new VNode(tag, elm, children, text, data, parent, nodeType);
        if (elm.nodeType == 1 && elm.getAttribute('env')) {
            vnode.env = mergeAttr(vnode.env, JSON.parse(elm.getAttribute('env')))
        } else {
            vnode.env = mergeAttr(vnode.env, parent ? parent.env : {})
        }
    }
    let childs = vnode.elm.childNodes;
    for (let i = 0; i < childs.length; i++) {
        let childNodes = constructVNode(vm, childs[i], vnode);
        if (childNodes instanceof VNode) {
            vnode.children.push(childNodes)
        } else { // v-for 返回節點數組
            vnode.children = vnode.children.concat(childNodes)
        }
    }
    return vnode
}

虛擬dom樹就是與真實dom樹一一對應的樹形結構,用對象表示相對應的dom節點

預備渲染

// 預備渲染(創建了兩個映射)
export function prepareRender(vm, vnode) {
	if (vnode == null) {
		return;
	}
	if (vnode.nodeType == 3) {
		// 文本節點
		analysisTemplateString(vnode);
	}
    if (vnode.nodeType == 0) {
        setTemplate2vnode(vnode, vnode.data);
        setVnode2template(vnode, vnode.data);
    }
	analysisAttr(vm, vnode);
	for (let i = 0; i < vnode.children.length; i++) {
		prepareRender(vm, vnode.children[i]);
	}
}

預備渲染主要構建了兩個映射

  • 模板到虛擬dom的映射(主要用於當模板值修改後,獲取哪些節點需要修改)
  • 虛擬dom到模板的映射(主要用於渲染時,將模板替換爲對應真實值)

渲染
渲染主要渲染的是文本節點
將文本節點中的模板換成對應真實值即可
但是模板的真實值來自哪?模板真實值不一定來自sth._data,也有可能來自v-for聲明的局部變量,還有可能來自計算屬性,所以在取值時要注意
getTemplateValue方法就是可以從多個變量取到真實值的方法

function renderNode(vm, vnode) {
	if (vnode.nodeType == 3) {
		// 拿到這個文本節點用到的模板
		let templates = vnode2template.get(vnode);
		if (templates != null) {
			let result = vnode.text;
			for (let i = 0; i < templates.length; i++) {
				let templateValue = getTemplateValue(
					[vm._data, vnode.env, vm._computed],
					templates[i]
                );
                if (typeof templateValue == 'function') {
                    templateValue = templateValue.call(vm)
                }
				if (templateValue != null) {
					result = result.replace(
						"{{" + templates[i] + "}}",
						templateValue.toString()
					);
				}
			}
			vnode.elm.nodeValue = result;
		}
	} else if (vnode.nodeType == 1 && vnode.tag == "INPUT") {
		// 拿到這個文本節點用到的模板
		let templates = vnode2template.get(vnode);
		if (templates != null) {
			for (let i = 0; i < templates.length; i++) {
				let templateValue = getTemplateValue(
					[vm._data, vnode.env],
					templates[i]
				);
				if (templateValue != null) {
					vnode.elm.value = templateValue;
				}
			}
		}
	} else {
		for (let i = 0; i < vnode.children.length; i++) {
			renderNode(vm, vnode.children[i]);
		}
	}
}

這樣就可以完成渲染
同時當數據修改後,可以找到使用到這個數據的節點,並且for循環這些節點,

這裏有一個遺留的bug,就是當計算屬性所需要的數據改變時,計算屬性模板不會重新渲染,這是由於當數據改變時,只重新渲染了使用這個數據的模板,沒有重新渲染相關計算屬性模板,這裏有個優化思路,在初始化計算屬性的時候,進行語法分析,分析後建立一個屬性到計算屬性的映射,當數據修改後,同時查看有沒有計算屬性用到了這個數據,如果有,重新渲染計算屬性對應的節點

v-model

v-model是一個較爲簡單的指令
只需要在遞歸掛載的時候,遇到標籤節點就分析attribute,如果有v-model屬性並且節點爲input就去執行此方法
只需要給節點綁定onkeyup即可

export function vmodel(vm, elm, data) {
    elm.onkeyup = function (event) {
        setValue(vm._data, data, elm.value)
    }
}

v-for

v-for是整個框架最難的部分,邏輯十分複雜
大致思路是當遇到含有v-for屬性的標籤時,先在真實dom中刪除這個標籤,然後拿到v-for循環的那個數組,通過數組長度來建立一批一模一樣的節點,並且將源節點的屬性也複製過來,接下來給每個節點設置局部變量,保存在vnode的env中,這樣在取值的時候,可以在env中拿到對應數據
代碼見文章頂部的github地址
這存在一個bug,含有v-for的dom節點的兄弟節點會被解析兩遍,對應的v-on事件也會被綁定兩遍,暫無很好的解決方法

數組修改後,重新渲染

當數組修改後找到用到了這個數組的節點,重新構建這個節點的父節點下的所有內容,然後清空之前的虛擬節點與vnode的映射,重新建立映射,重新建立映射是因爲由於數組改變之前的映射關係可能就不準確了,這時候需要重新建立整個映射關係,由於建立映射關係不需要操作dom所以這個過程並不會慢,最後局部重新渲染即可
代碼見文章頂部的github地址

v-bind

v-bind的解析需要等虛擬dom構建完成以後再解析,因爲綁定的變量有可能來自v-for產生的局部變量
獲取v-bind:xxx的屬性值後有兩種情況,分別來說一下

  • 屬性值爲變量
    這種比較好處理,直接獲取到變量對應的值,直接賦值即可
  • 屬性爲表達式
    這種情況就比較麻煩,值有可能爲{red:obj.x > 0};
    這時候需要進行語法分析,這裏用了一個很奇妙的處理方法
    首先構建一個執行環境(字符串),執行環境中聲明這個節點能訪問到的左右變量,並且聲明一個bool,初始值爲false,然後將判斷條件作爲字符串拼接進去,eval()執行這個字符串,獲取bool,如果爲真,將該屬性加入返回結果中,如果是爲false,就不加
    代碼過長,見文章頂部的github地址

v-on

v-on實現較爲簡單,與v-bind大致相同,只是把設置attribute改成了綁定事件。
這裏只需要注意使用bind改變事件的this指向即可
如果時間有參數的話,需要做一個判斷,檢查@xxx的屬性值是否存在’(’
如果存在,就老辦法獲取到每一個屬性值對應的真實值,並且bind的時候拼接在後面

export function checkVOn(vm, vnode) {
    if (vnode.nodeType == 1) {
        let attrNames = vnode.elm.getAttributeNames();
        let filterAttrNames = attrNames.filter((item) => item.startsWith('v-on:') || item.startsWith('@'));
        for (let i = 0; i < filterAttrNames.length; i++) {
            let eventName = filterAttrNames[i].includes(':') ? filterAttrNames[i].split(':')[1] : filterAttrNames[i].split('@')[1];
            von(vm, vnode, eventName, vnode.elm.getAttribute(filterAttrNames[i]))
        }
    }
}

function von(vm, vnode, eventName, name) {
    let argList = []
    let index = name.indexOf('(');
    let funName = name
    if (index >= 0) {
        funName = name.slice(0, index);
        argList = name.slice(index + 1, -1).split(',');
        console.log(argList);
    }
    for (let i = 0; i < argList.length; i++) {
        argList[i] = getTemplateValue([vm._data, vnode.env],argList[i])
    }
    let method = getValue(vm._methods, funName);
    if (method) {
        vnode.elm.addEventListener(eventName, method.bind(vm, ...argList))
    }
}

生命週期函數

生命週期函數很簡單,只需要在init的時候初始化,並且在特定時間調用即可,注意修改this指向
例如init.js中,代理data前執行beforeCreate方法
代理後執行created方法
掛載前執行beforeMount方法等

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章