在学习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.aobj.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
}
]
接下来就行见证奇迹的时刻!
成功!
尝试添加记录
成功!