項目地址
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方法等