在实现 VUE 中 MVVM 的系列文章的最后一篇文章中说道:我觉得可响应的数据结构作用很大,在整理了一段时间后,这是我们的最终产出:RD – Reactive Data
ok 回到整理,这篇文章我们不研究 Vue
了,而是根据我们现在的研究成果来手撸一个 MVVM
。
简单介绍 RD
先看看下我们的研究成果:一个例子
let demo = new RD({
data(){
return {
text: 'Hello',
firstName: 'aco',
lastName: 'yang'
}
},
watch:{
'text'(newValue, oldValue){
console.log(newValue)
console.log(oldValue)
}
},
computed:{
fullName(){
return this.firstName + ' ' + this.lastName
}
},
method:{
testMethod(){
console.log('test')
}
}
})
demo.text = 'Hello World'
// console: Hello World
// console: Hello
demo.fullName
// console: aco yang
demo.testMethod()
// console: test
写法上与 Vue
的一样,先说说拥有那些属性吧:
关于数据
- data
- computed
- method
- watch
- prop
- inject/provied
关于生命周期
- beforeCreate
- created
- beforeDestroy
- destroyed
关于实例间关系
- parent
实例下的方法:
关于事件
- $on
- $once
- $emit
- $off
其他方法
- $watch
- $initProp
类下方法:
- use
- mixin
- extend
以上便是所有的内容,因为 RD
仅仅关注于数据的变化,所以生命周期就就只有创建和销毁。
对比与 Vue
多了一个 $initProp
,同样的由于仅仅关注于数据变化,所以当父实例相关的 prop
发生变化时,需要手动通知子组件修改相关数据。
其他的属性以及方法的使用与 Vue
一致。
ok 大概说了下,具体的内容可以点击查看
手撸 MVVM
有了 RD
我们来手撸一个 MVVM
框架。
我们先确定我们大致需要什么?
- 一个模板引擎(不然怎么把数据变成
dom
结构) - 现在主流都用虚拟节点来实现,我们也加上
ok 模板引擎,JSX
语法不错,来一份。
接着虚拟节点,github
上搜一搜,ok 找到了,点击查看
所有条件都具备了,我们的实现思路如下:
RD + JSX + VNode = MVVM
具体的实现我们一边写 TodoList
一边实现
首先我们得要有一个 render
函数,ok 配上,先来个标题组件 Title
和一个使用标题的 App
的组件吧。
可以对照完整的 demo
查看一下内容,demo。
var App = RD.extend({
render(h) {
return (
<div className='todo-wrap'>
<Title/>
</div>
)
}
})
var Title = RD.extend({
render(h) {
return (
<p className='title'>{this.title}</p>
)
},
data(){
return {
title:'这是个标题'
}
}
})
这里就不说明 JSX
语法了,可以在 babel
上看下转码的结果,点击查看。
至于 render
的参数为什么是 h
?这是大部分人都认可这么做,所以我们这么做就好。
根据 JSX
的语法,我们需要实现一个创建虚拟节点的方法,也就是 render
需要传入的参数 h
。
ok 实现一下,我们编写一个插件使用 RD.use
来实现对于实例的扩展
// demo/jsxPlugin/index.js
export default {
install(RD) {
RD.prototype.$createElement = function (tag, properties, ...children) {
return createElement(this, tag, properties, ...children)
}
RD.prototype.render = function () {
return this.$option.render.call(this, this.$createElement.bind(this))
}
}
}
我们把具体的处理逻辑放在 createElement
这个方法中,而实例下的 $createElement
仅仅是为了把当前对象 this
传入这个函数中。
接着我们把传入的 render
方法包装一下,挂载到实例的 render
方法下,我们先假设这个 createElement
能生成一个树结构,这样调用 实例下的 render()
,就能获得一个节点树。
注:这里获得的并不是虚拟节点树,节点树需要涉及子组件,我们要确保这个节点树仅仅和当前实例相关,不然会比较麻烦,暂且叫它是节点模板。
ok 我们可以想象一下这节点模板会长什么样?
参考虚拟节点的库后,得到这样一个结构:
{
tagName: 'div',
properties: {className: 'todo-wrap'},
children:[
tagName:'component-1',// 后面的 1 是扩展出来的类的 cid ,每个类都有一个单独的 cid
parent: App,
isComponent: true,
componentClass: Title
properties: {},
children: []
]
}
原有标签的处理虚拟节点的库已经帮我们做了,我们来实现一下组件的节点:
// demo/jsxPulgin/createElemet.js
import {h, VNode} from 'virtual-dom'
export default function createElement(ctx, tag, properties, ...children) {
if (typeof tag === 'function' || typeof tag === 'object') {
let node = new VNode() // 构建一个空的虚拟节点,带上组件的相关信息
node.tagName = `component-${tag.cid}`
node.properties = properties // prop
node.children = children // 组件的子节点,也就是 slot 这里并没有实现
node.parent = ctx // 父节点信息
node.isComponent = true // 用于判断是否是组件
node.componentClass = tag // 组件的类
return node
}
return h(tag, properties, children) // 一般标签直接调用库提供的方法生成
}
现在我们可以通过实例的 render
方法获取到了一个节点模板,但需要注意的是:这个仅仅只能算是通过 JSX
语法获取的一个模板,并没有转换为真正的虚拟节点,这是一个节点模板,当把其中的组件节点给替换掉就能得到真正的虚拟节点树。
捋一捋我们现在有的:
- 实例的
render
函数 - 可以通过
render
函数生成的一个节点模板
接着来实现一个方法,用于将节点模板转化为虚拟节点树,具体过程看代码中的注释
// demo/jsxPlugin/getTree.js
function extend(source, extend) {
for (let key in extend) {
source[key] = extend[key]
}
return source
}
function createTree(template) {
// 由于虚拟节点只接受通过 VNode 创建的对象
// 并且为了保持模板不被污染,所以新创建一个节点
let tree = extend(new VNode(), template)
if (template && template.children) {
// 遍历所有子节点
tree.children = template.children.map(node => {
let treeNode = node
// 如果是组件,则用保存的类实例化一个 RD 对象
if (node.isComponent) {
// 确定 parent 实例以及 初始化 prop
node.component = new node.componentClass({parent: node.parent, propData: node.properties})
// 将模板对应的节点模板指向实例的节点模板,实例下的 $vnode 用于存放节点模板
// 这样就将父组件中的组件节点替换为组件的节点模板,然后递归子组件,直到所有的组件节点都转换为了虚拟节点
// 这里使用了 $createComponentVNode 来获取节点模板,下一步我们就会实现它
treeNode = node.component.$vnode = node.component.$createComponentVNode(node.properties)
// 如果是组件节点,则保存一个字段在虚拟节点下,用于区分普通节点
treeNode.component = node.component
}
if (treeNode.children) {
// 递归生成虚拟节点树
treeNode = createTree(treeNode)
}
if (node.isComponent) {
// 将生成的虚拟节点树保存在实例的 _vnode 字段下
node.component._vnode = treeNode
}
return treeNode
})
}
return tree
}
现在的流程是 render => createElement => createTree
生成了虚拟节点,$createComponentVNode
其实就是调用组件的 render
函数,现在我们写一个 $patch
方法,包装这个行为,并且通过 $mount
实现挂载到 DOM
节点的过程。
// demo/jsxPlugin/index.js
import {create, diff, patch} from 'virtual-dom'
import createElement from './createElement'
export default {
install(RD) {
RD.$mount = function (el, rd) {
// 获取节点模板
let template = rd.render.call(rd)
// 初始化 prop
rd.$initProp(rd.propData)
// 生成虚拟节点树
rd.$patch(template)
// 挂载到传入的 DOM 上
el.appendChild(rd.$el)
}
RD.prototype.$createElement = function (tag, properties, ...children) {
return createElement(this, tag, properties, ...children)
}
RD.prototype.render = function () {
return this.$option.render.call(this, this.$createElement.bind(this))
}
// 对 render 的封装,用于获取节点模板
RD.prototype.$createComponentVNode = function (prop) {
this.$initProp(prop)
return this.render.call(this)
}
RD.prototype.$patch = function (newTemplate) {
// 获取到虚拟节点树
let newTree = createTree(newTemplate)
// 将生成 DOM 元素保存在 $el 下,create 为虚拟节点库提供,用于生成 DOM 元素
this.$el = create(newTree)
// 保存节点模板
this.$vnode = newTemplate
// 保存虚拟节点树
this._vnode = newTree
}
}
}
ok 接着我们来调用一下
// demo/index.js
import RD from '../src/index'
import jsxPlugin from './jsxPlugin/index'
import App from './component/App'
import './index.scss'
RD.use(jsxPlugin, RD)
RD.$mount(document.getElementById('app'), App)
到目前为止,我们仅仅是通过了页面的组成显示出了一个页面,并没有实现数据的绑定,但是有了 RD
的支持,我们可以很简单的实现这种由数据的变化导致视图变化的效果,加几段代码即可
// demo/jsxPlugin/index.js
import {create, diff, patch} from 'virtual-dom'
import createElement from './createElement'
import getTree from './getTree'
export default {
install(RD) {
RD.$mount = function (el, rd) {
let template = null
rd.$initProp(rd.propData)
// 监听 render 所需要用的数据,当用到的数据发生变化的时候触发回调,也就是第二个参数
// 回调的的参数新的节点模板(也就是 $watch 第一个函数参数的返回值)
// 回调触发 $patch
rd.$renderWatch = rd.$watch(() => {
template = rd.render.call(rd)
return template
}, (newTemplate) => {
rd.$patch(newTemplate)
})
rd.$patch(template)
el.appendChild(rd.$el)
}
RD.prototype.$createElement = function (tag, properties, ...children) {
return createElement(this, tag, properties, ...children)
}
RD.prototype.render = function () {
return this.$option.render.call(this, this.$createElement.bind(this))
}
RD.prototype.$createComponentVNode = function (prop) {
let template = null
this.$initProp(prop)
// 监听 render 所需要用的数据,当用到的数据发生变化的时候触发 $patch
this.$renderWatch = this.$watch(() => {
template = this.render.call(this)
return template
}, (newTemplate) => {
this.$patch(newTemplate)
})
return template
}
RD.prototype.$patch = function (newTemplate) {
// 由于是新创建和更新都在同一个函数中处理了
// 这里的 createTree 是需要条件判断调用的
// 所以这里的 getTree 就先认为是获取虚拟节点,之后再说
// $vnode 保存着节点模板,对于更新来说,这个就是旧模板
let newTree = getTree(newTemplate, this.$vnode)
// _vnode 是原来的虚拟节点,如果没有的话就说明是第一次创建,就不需要走 diff & patch
if (!this._vnode) {
this.$el = create(newTree)
} else {
this.$el = patch(this.$el, diff(this._vnode, newTree))
}
// 更新保存的变量
this.$vnode = newTemplate
this._vnode = newTree
this.$initDOMBind(this.$el, newTemplate)
}
// 由于组件的更新需要一个 $el ,所以 $initDOMBind 在每次 $patch 之后都需要调用,确定子组件绑定的元素
// 这里需要明确的是,由于模板必须使用一个元素包裹,所以父组件的状态改变时,父组件的 $el 是不会变的
// 需要变的仅仅是子组件的 $el 绑定,所以这个方法是向下进行的,不回去关注父组件以上的组件
RD.prototype.$initDOMBind = function (rootDom, vNodeTemplate) {
if (!vNodeTemplate.children || vNodeTemplate.children.length === 0) return
for (let i = 0, len = vNodeTemplate.children.length; i < len; i++) {
if (vNodeTemplate.children[i].isComponent) {
vNodeTemplate.children[i].component.$el = rootDom.childNodes[i]
this.$initDOMBind(rootDom.childNodes[i], vNodeTemplate.children[i].component.$vnode)
} else {
this.$initDOMBind(rootDom.childNodes[i], vNodeTemplate.children[i])
}
}
}
}
}
ok 现在我们大概实现了一个 MVVM
框架,缺的仅仅是 getTree
这个获取虚拟节点树的方法,我们来实现一下。
首先,getTree
需要传入两个参数,分别是新老节点模板,所以当老模板不存在时,走原来的逻辑即可
// demo/jsxPlugin/getTree.js
function deepClone(node) {
if (node.type === 'VirtualNode') {
let children = []
if (node.children && node.children.length !== 0) {
children = node.children.map(node => deepClone(node))
}
let cloneNode = new VNode(node.tagName, node.properties, children)
if (node.component) cloneNode.component = node.component
return cloneNode
} else if (node.type === 'VirtualText') {
return new VText(node.text)
}
}
export default function getTree(newTemplate, oldTemplate) {
let tree = null
if (!oldTemplate) {
// 走原来的逻辑
tree = createTree(newTemplate)
} else {
// 走更新逻辑
tree = changeTree(newTemplate, oldTemplate)
}
// 确保给出一份完全新的虚拟节点树,我们克隆一份返回
return deepClone(tree)
}
// 具体的更新逻辑
function changeTree(newTemplate, oldTemplate) {
let tree = extend(new VNode(), newTemplate)
if (newTemplate && newTemplate.children) {
// 遍历新模板的子节点
tree.children = newTemplate.children.map((node, index) => {
let treeNode = node
let isNewComponent = false
if (treeNode.isComponent) {
// 出于性能考虑,老节点模板中相同的 RD 类,就使用它
node.component = getOldComponent(oldTemplate.children, treeNode.componentClass.cid)
if (!node.component) {
// 在老模板中没有找到,就生成一个,与 createTree 中一致
node.component = new node.componentClass({parent: node.parent, propData: node.properties})
node.component.$vnode = node.component.$createComponentVNode(node.properties)
treeNode = node.component.$vnode
treeNode.component = node.component
isNewComponent = true
} else {
// 更新复用组件的 prop
node.component.$initProp(node.properties)
// 直接引用组件的虚拟节点树
treeNode = node.component._vnode
// 保存组件的实例
treeNode.component = node.component
}
}
if (treeNode.children && treeNode.children.length !== 0) {
if (isNewComponent) {
// 如果是新的节点,直接调用 createTree
treeNode = createTree(treeNode)
} else {
// 当递归的时候,有时可能出现老模板没有的情况,比如递归新节点的时候
// 所以需要判断 oldTemplate 的情况
if (oldTemplate && oldTemplate.children) {
treeNode = changeTree(treeNode, oldTemplate.children[index])
} else {
treeNode = createTree(treeNode)
}
}
}
if (isNewComponent) {
node.component._vnode = treeNode
}
return treeNode
})
// 注销在老模板中没有被复用的组件,释放内存
if (oldTemplate && oldTemplate.children.length !== 0)
for (let i = 0, len = oldTemplate.children.length; i < len; i++) {
if (oldTemplate.children[i].isComponent && !oldTemplate.children[i].used) {
oldTemplate.children[i].component.$destroy()
}
}
}
return tree
}
// 获取在老模板中可服用的实例
function getOldComponent(list = [], cid) {
for (let i = 0, len = list.length; i < len; i++) {
if (!list[i].used && list[i].isComponent && list[i].componentClass.cid === cid) {
list[i].used = true
return list[i].component
}
}
}
ok 整个 MVVM
框架实现,具体的效果可以把整个项目啦下来,执行 npm run start:demo
即可。上诉所有的代码都在 demo
中。
我们来统计下我们一共写了几行代码来实现这个 MVVM
的框架:
- createElement.js 22行
- getTree.js 111行
- jsxPubgin/index.js 65行
所以我们仅仅使用了 22 + 111 + 65 = 198
行代码实现了一个 MVVM
的框架,可以说是很少了。
可能有的同学会说这还不算使用 RD
和虚拟节点库呢?是的我们并没有算上,因为这两个库的功能足够的独立,即使库变动了,实现相应的 api
用上面的代码我们同样能够实现,所以黑盒里的代码我们不算。
同样的我们也可以这么说,我们使用 198
行的代码连接了 JSX/VNode/RD
实现了一个 MVVM
框架。
谈谈感想
在研究 Vue
源码的过程中,在代码里看到了不少 SSR
和 WEEX
的判断,个人觉得这个没必要。这会导致 Vue
不论在哪段使用都会有较多的代码冗余。我认为一个理想的框架应该是足够的可配置的,至少对于开发人员来说应该如此。
所以我觉得应该想 react
那样,在开发哪端的项目就引入相应的库即可,而不是将代码全部都聚合到同一个库中。
以下我认为是可以做的,比如在开发 web
应用时,这样写
import vue from 'vue'
import vue-dom from 'vue-dom'
vue.use(vue-dom)
在开发 WEEX
应用时:
import vue from 'vue'
import vue-dom from 'vue-weex'
vue.use(vue-weex)
在开发 SSR
时:
import vue from 'vue'
import vue-dom from 'vue-ssr'
vue.use(vue-ssr)
当然如果说非要一套代码统一 3
端
import vue from 'vue'
import vue-dom from 'vue-dynamic-import'
vue.use(vue-dynamic-import)
vue-dynamic-import
这个组件用于环境判断,动态导入相应环境的插件。
这种想法也是我想把 RD
给独立出来的原因,一个模块足够的独立,让环境的判断交给程序员来决定,因为大部分项目是仅仅需要其中的一个功能,而不需要全部的功能的。
以上,更多关于 Vue
的内容,已经关于 RD
的编写过程,可以到我的博客查看