在學習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類
要想實現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肯定要跟著改變
所以要做的事情就是,重新構建改變的這一部分虛擬節點,重新走一次渲染流程
- 找到這些需要重新構建的節點
- 找到需要重新構建的節點的父節點,刪除之前生成的子節點
- 再把虛擬模板節點li放回去,變成最開始的模板形態
- 重新構建需要改變的這一部分節點,不用全量重新構建
- 清空索引(因為新生成的節點有變化了,之前的索引不管用了)
- 重新構建索引
所以就需要一個重構方法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
}
]
複製程式碼
接下來就行見證奇蹟的時刻!
成功!
嘗試新增記錄
成功!