手擼一個 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 的編寫過程,能夠到個人博客查看

相關文章
相關標籤/搜索