/ Vue

手撸一个 MVVM 不是梦

实现 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 框架。

我们先确定我们大致需要什么?

  1. 一个模板引擎(不然怎么把数据变成 dom 结构)
  2. 现在主流都用虚拟节点来实现,我们也加上

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 语法获取的一个模板,并没有转换为真正的虚拟节点,这是一个节点模板,当把其中的组件节点给替换掉就能得到真正的虚拟节点树。

捋一捋我们现在有的:

  1. 实例的 render 函数
  2. 可以通过 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 源码的过程中,在代码里看到了不少 SSRWEEX 的判断,个人觉得这个没必要。这会导致 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 的编写过程,可以到我的博客查看