騰訊 Omi 5.0 釋出 - Web 前端 MVVM 王者歸來,mappingjs 強力加持

當耐特發表於2018-11-27

寫在前面

騰訊 Omi 框架正式釋出 5.0,依然專注於 View,但是對 MVVM 架構更加友好的整合,徹底分離檢視與業務邏輯的架構。

mvvm

你可以通過 omi-cli 快速體驗 MVVM:

$ npm i omi-cli -g        
$ omi init-mvvm my-app    
$ cd my-app         
$ npm start                   
$ npm run build             

npx omi-cli init-mvvm my-app 也支援(要求 npm v5.2.0+)

MVVM 演化

MVVM 其實本質是由 MVC、MVP 演化而來。

mvvm

目的都是分離檢視和模型,但是在 MVC 中,檢視依賴模型,耦合度太高,導致檢視的可移植性大大降低,在 MVP 模式中,檢視不直接依賴模型,由 P(Presenter)負責完成 Model 和 View 的互動。MVVM 和 MVP 的模式比較接近。ViewModel 擔任這 Presenter 的角色,並且提供 UI 檢視所需要的資料來源,而不是直接讓 View 使用 Model 的資料來源,這樣大大提高了 View 和 Model 的可移植性,比如同樣的 Model 切換使用 Flash、HTML、WPF 渲染,比如同樣 View 使用不同的 Model,只要 Model 和 ViewModel 對映好,View 可以改動很小甚至不用改變。

Mappingjs

當然 MVVM 這裡會出現一個問題, Model 裡的資料對映到 ViewModel 提供該檢視繫結,怎麼對映?手動對映?自動對映?在 ASP.NET MVC 中,有強大的 AutoMapper 用來對映。針對 JS 環境,我特地封裝了 mappingjs 用來對映 Model 到 ViewModel。

const testObj = {
  same: 10,
  bleh: 4,
  firstName: 'dnt',
  lastName: 'zhang',
  a: {
    c: 10
  }
}

const vmData = mapping({
  from: testObj,
  to: { aa: 1 },
  rule: {
    dumb: 12,
    func: function () {
      return 8
    },
    b: function () {
      //可遞迴對映
      return mapping({ from: this.a })
    },
    bar: function () {
      return this.bleh
    },
    //可以重組屬性
    fullName: function () {
      return this.firstName + this.lastName
    },
    //可以對映到 path
    'd[2].b[0]': function () {
      return this.a.c
    }
  }
})

你可以通後 npm 安裝使用:

npm i mappingjs

再舉例說明:

var a = { a: 1 }
var b = { b: 2 }

assert.deepEqual(mapping({
  from: a,
  to: b
}), { a: 1, b: 2 })

Deep mapping:


QUnit.test("", function (assert) {
  var A = { a: [{ name: 'abc', age: 18 }, { name: 'efg', age: 20 }], e: 'aaa' }
  var B = mapping({
    from: A,
    to: { d: 'test' },
    rule: {
      a: null,
      c: 13,
      list: function () {
        return this.a.map(function (item) {
          return mapping({ from: item })
        })
      }
    }
  })

  assert.deepEqual(B.a, null)
  assert.deepEqual(B.list[0], A.a[0])
  assert.deepEqual(B.c, 13)
  assert.deepEqual(B.d, 'test')
  assert.deepEqual(B.e, 'aaa')
  assert.deepEqual(B.list[0] === A.a[0], false)
})

Deep deep mapping:


QUnit.test("", function (assert) {
  var A = { a: [{ name: 'abc', age: 18, obj: { f: 'a', l: 'b' } }, { name: 'efg', age: 20, obj: { f: 'a', l: 'b' } }], e: 'aaa' }
  var B = mapping({
    from: A,
    rule: {
      list: function () {
        return this.a.map(function (item) {
          return mapping({
            from: item, rule: {
              obj: function () {
                return mapping({ from: this.obj })
              }
            }
          })
        })
      }
    }
  })

  assert.deepEqual(A.a, B.list)
  assert.deepEqual(A.a[0].obj, B.list[0].obj)
  assert.deepEqual(A.a[0].obj === B.list[0].obj, false)
})

Omi MVVM Todo 實戰

定義 Model:

let id = 0

export default class TodoItem {
  constructor(text, completed) {
    this.id = id++
    this.text = text
    this.completed = completed || false

    this.author = {
      firstName: 'dnt',
      lastName: 'zhang'
    }
  }

  clone() {
    return new TodoItem(this.text, this.completed)
  }
}

Todo 就省略不貼出來了,太長了,可以直接 看這裡。反正統一按照物件導向程式設計進行抽象和封裝。

定義 ViewModel:

import mapping from 'mappingjs'
import shared from './shared'
import todoModel from '../model/todo'
import ovm from './other'

class TodoViewModel {
  constructor() {
    this.data = {
      items: []
    }
  }

  update(todo) {
    //這裡進行對映
    todo &&
      todo.items.forEach((item, index) => {
        this.data.items[index] = mapping({
          from: item,
          to: this.data.items[index],
          rule: {
            fullName: function() {
              return this.author.firstName + this.author.lastName
            }
          }
        })
      })

    this.data.projName = shared.projName
  }

  add(text) {
    todoModel.add(text)
    this.update(todoModel)
    ovm.update()
  }
  
  getAll() {
    todoModel.getAll(() => {
      this.update(todoModel)
      ovm.update())
    })
  }

  changeSharedData() {
    shared.projName = 'I love omi-mvvm.'
    ovm.update()
    this.update()
  }
}

const vd = new TodoViewModel()

export default vd
  • vm 只專注於 update 資料,檢視會自動更新
  • 公共的資料或 vm 可通過 import 依賴

定義 View, 注意下面是繼承自 ModelView 而非 WeElement。

import { ModelView, define } from 'omi'
import vm from '../view-model/todo'
import './todo-list'
import './other-view'

define('todo-app', class extends ModelView {
  vm = vm

  onClick = () => {
    //view model 傳送指令
    vm.changeSharedData()
  }

  install() {
    //view model 傳送指令
    vm.getAll()
  }

  render(props, data) {
    return (
      <div>
        <h3>TODO</h3>
        <todo-list items={data.items} />
        <form onSubmit={this.handleSubmit}>
          <input onChange={this.handleChange} value={this.text} />
          <button>Add #{data.items.length + 1}</button>
        </form>
        <div>{data.projName}</div>
        <button onClick={this.onClick}>Change Shared Data</button>
        <other-view />
      </div>
    )
  }

  handleChange = e => {
    this.text = e.target.value
  }

  handleSubmit = e => {
    e.preventDefault()
    if(this.text !== ''){
      //view model 傳送指令
      vm.add(this.text)
      this.text = ''
    }
  }
})
  • 所有資料通過 vm 注入
  • 所以指令通過 vm 發出
define('todo-list', function(props) {
  return (
    <ul>
      {props.items.map(item => (
        <li key={item.id}>
          {item.text} <span>by {item.fullName}</span>
        </li>
      ))}
    </ul>
  )
})

可以看到 todo-list 可以直接使用 fullName

→ 完整程式碼戳這裡

mapping.auto

是不是感覺對映寫起來略微麻煩?? 簡單的還好,複雜物件巢狀很深就會很費勁。沒關係 mapping.auto 拯救你!

  • mapping.auto(from, [to]) 其中 to 是可選引數

舉個例子:

class TodoItem {
  constructor(text, completed) {
    this.text = text
    this.completed = completed || false

    this.author = {
      firstName: 'dnt',
      lastName: 'zhang'
    }
  }
}

const res = mapping.auto(new TodoItem('task'))

deepEqual(res, {
  author: {
    firstName: "dnt",
    lastName: "zhang"
  },
  completed: false,
  text: "task"
})

你可以把任意 class 對映到簡單的 json obj!那麼開始改造 ViewModel:

class TodoViewModel {
  constructor() {
    this.data = {
      items: []
    }
  }

  update(todo) {
    todo && mapping.auto(todo, this.data)

    this.data.projName = shared.projName
  }
  ...
  ...
  ...

以前的一堆對映邏輯變成了一行程式碼: mapping.auto(todo, this.data)。當然由於沒有 fullName 屬性了,這裡需要在檢視裡直接使用對映過來的 author:

define('todo-list', function(props) {
  return (
    <ul>
      {props.items.map(item => (
        <li key={item.id}>
          {item.text} <span>by {item.author.firstName + item.author.lastName}</span>
        </li>
      ))}
    </ul>
  )
})

小結

從巨集觀的角度來看,Omi 的 MVVM 架構也屬性網狀架構,網狀架構目前來看有:

  • Mobx + React
  • Hooks + React
  • MVVM (Omi)

大勢所趨!簡直是前端工程化最佳實踐!也可以理解成網狀結構是描述和抽象世界的最佳途徑。那麼網在哪?

  • ViewModel 與 ViewModel 之間相互依賴甚至迴圈依賴的網狀結構
  • ViewModel 一對一、多對一、一對多、多對多依賴 Models 形成網狀結構
  • Model 與 Model 之間形成相互依賴甚至迴圈依賴的網狀結構
  • View 一對一依賴 ViewModel 形成網狀結構

總結如下:

Model ViewModel View
Model 多對多 多對多 無關聯
ViewModel 多對多 多對多 一對一
View 無關聯 一多一 多對多

其餘新增特性

單位 rpx 的支援

import { render, WeElement, define, rpx } from 'omi'

define('my-ele', class extends WeElement {
  css() {
    return rpx(`div { font-size: 375rpx }`)
  }
  
  render() {
    return (
      <div>abc</div>
    )
  }
})

render(<my-ele />, 'body')

比如上面定義了半螢幕寬度的 div。

htm 支援

htm 是谷歌工程師,preact作者最近的作品,不管它是不是未來,先支援了再說:

import { define, render, WeElement } from 'omi'
import 'omi-html'

define('my-counter', class extends WeElement {
  static observe = true

  data = {
    count: 1
  }

  sub = () => {
    this.data.count--
  }

  add = () => {
    this.data.count++
  }

  render() {
    return html`
      <div>
        <button onClick=${this.sub}>-</button>
        <span>${this.data.count}</span>
        <button onClick=${this.add}>+</button>
      </div>`
  }
})

render(html`<my-counter />`, 'body')

你甚至可以直接使用下面程式碼在現代瀏覽器中執行,不需要任何構建工具:

騰訊 Omi 5.0 釋出 - Web 前端 MVVM 王者歸來,mappingjs 強力加持

Hooks 類似的 API

你也可以定義成純函式的形式:

import { define, render } from 'omi'

define('my-counter', function() {
  const [count, setCount] = this.use({
    data: 0,
    effect: function() {
      document.title = `The num is ${this.data}.`
    }
  })

  this.useCss(`button{ color: red; }`)

  return (
    <div>
      <button onClick={() => setCount(count - 1)}>-</button>
      <span>{count}</span>
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  )
})

render(<my-counter />, 'body')

如果你不需要 effect 方法, 可以直接使用 useData:

const [count, setCount] = this.useData(0)

更多的模板選擇

Template Type Command Describe
Base Template omi init my-app 基礎模板
TypeScript Template(omi-cli v3.0.5+) omi init-ts my-app 使用 TypeScript 的模板
SPA Template(omi-cli v3.0.10+) omi init-spa my-app 使用 omi-router 單頁應用的模板
omi-mp Template(omi-cli v3.0.13+) omi init-mp my-app 小程式開發 Web 的模板
MVVM Template(omi-cli v3.0.22+) omi init-mvvm my-app MVVM 模板

Star & Fork

相關文章