天天看点

手撸一个 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

 源码的过程中,在代码里看到了不少 

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

 的编写过程,可以到

我的博客查看

原文发布时间为:2018年06月28日

原文作者:掘金

本文来源: 

掘金

 如需转载请联系原作者

继续阅读