手撸一个 MVVM 不是梦

实现 VUE 中 MVVM 的系列文章的最后一篇文章中说道:我以为可响应的数据结构做用很大,在整理了一段时间后,这是咱们的最终产出:RD - Reactive Datacss

ok 回到整理,这篇文章咱们不研究 Vue 了,而是根据咱们如今的研究成果来手撸一个 MVVMhtml

简单介绍 RD

先看看下咱们的研究成果:一个例子vue

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 的同样,先说说拥有那些属性吧:node

关于数据react

  • data
  • computed
  • method
  • watch
  • prop
  • inject/provied

关于生命周期git

  • beforeCreate
  • created
  • beforeDestroy
  • destroyed

关于实例间关系程序员

  • parent

实例下的方法:github

关于事件web

  • $on
  • $once
  • $emit
  • $off

其余方法npm

  • $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 的编写过程,能够到个人博客查看

相关文章
相关标签/搜索