从零开始编写一个 MVVM 框架(三)

上一篇文章中,我们创建了一个应用来展示学生信息,然后将它重构成了 MVVM 风格的。
我并没有提及,但是你或许也已经察觉到了,我们最后已经做了一些看起来像 MVVM 框架的工作。
如果你忘记了上一篇中的内容或者你略过了它,不用担心,这里有源码(而且我还添加了一些注释):

/**
* @param {Node} root
* @param {Object} model
* @param {Function} view
* @param {Function} vm
*/
const run = function (root, {model, view, vm}) {
  let m = {...model}
  let m_old = {}

  setInterval( function (){
    if(!_.isEqual(m, m_old)){
      const rendered = view(vm(m))
      root.innerHTML = ''
      root.appendChild(rendered)

      m_old = {...m}
    }
  },1000)
}

喂,你在搞笑吗?一个 10 行代码的框架?
一个框架是一种抽象内容,它通常决定了代码被如何被组织以及整个程序该如何运行。

这并不意味着你应该写下大量的代码或者庞大的类,虽然企业内部使用的框架的 API 列表总是长得吓人。

但是如果你浏览框架仓库的核心目录,你或许会发现它是惊人地小巧(对比整个项目)。

核心代码控制了它的工作流程,以及其它部分的内容,或许我们可以叫它外设(peripherals),帮助开发者以一种更舒适的方式构建他们的应用。cycle.js 就是一个典型的例子,它只有 124 行代码(包括注释和空格等)。

我强烈推荐你观看 Andre André Staltz 介绍 Cycle.js 的 视频。这一个视频展示了一个框架形成的整个过程(并且让我在观看之后,喜欢上了重新造轮子)。

抽象你的框架

寻找通用性

我们认为,一个框架一般提供了整个程序运行的通用流程。

这段描述是非常有野心且没有道理的。如果我需要知道在我的程序中,哪些东西是通用,那么编程将变得至少简单 10 倍。

框架一般寻找开发者们需要的通用的东西以及可以复用的东西,它们为具体类型的问题提供不至于太差的模板。而这也是我为啥对构建广泛使用的框架的人怀有崇高的敬意。他们解决了困难的部分,让我们的工作变得简单。

那我们的学生信息应用怎么样呢?我们将其重构成了 MVVM 风格,那么哪些是通用的部分呢?幸运地是,我们已经知道了 MVVM 并且理解了它是如何运作的:


从零开始编写一个 MVVM 框架(三)_第1张图片
MVVM

我们的应用主要包含了四部分内容,并且框架应该把它们结合起来。它定义了界面,并且维护数据流。

这就有点像自己组装一台 PC。你有了 CPU,硬盘以及其他的组件,并且也有一块配有插槽的主板。你自定义的代码就像组件,而框架就如同主板。你唯一需要关心的是,组件是否需要一个界面。那它们是如何组合在一起的呢?没人会在意,主板会完成这一切。

根据上面的图表,我们框架会形成如下的数据流的循环:

  1. 数据从模型开始,通过适配器,最后被展示在视图上;
  2. 用户交互从视图开始,通过 actions,最后改变模型;
  3. 数据从修改后的模型出发,重复步骤 1;

事实上,框架可能根据他们工作的内容适当变化。它们共享了一些界面上的特性,而非实现方式。

细节:选择视图工具箱

在这一节,我们将看到一些框架构成的细节。这里可能有很多值得关注的点,但是我们只重点关注其中我认为重要的几个。

这里的权衡利弊主要是基于我个人的经验,所以它可能不太适合各位看官。我并不是在视图说服你什么,我只是在展示一些掺杂了我个人想法的技术而已。

第一点也是最值得关注的一点是,视图界面。这一点将极大地影响开发者的用户体验。如果一个用户界面框架不能提供好的用户界面创建体验,这会让人感到非常失望。

Web 开发中创建视图使用最广泛的技术是使用特定的模板语言(template DSL)。许多著名的解决方案都采用了它,比如 Angular 和 React。并且在单页 web 应用(SPA)流行起来之前,模板就已经被广泛使用了。我们所知道的最好的编程语言——PHP,一开始就是设计来使用服务器端的模板用于创建 HTML。

模板之所以变得流行,主要是因为它的高可读性以及尚可的可复用性。

为了方便展示,我们回顾一下前面文章当中的一些代码。

请花 10 秒钟时间理解下面的代码片段:

const createList = function(kvPairs){
  const createListItem = function (label, content) {
    const labelSpan = document.createElement('span')
    labelSpan.textContent = label

    const contentSpan = document.createElement('span')
    contentSpan.textContent = content

    const li = document.createElement('li')
    li.appendChild(labelSpan)
    li.appendChild(contentSpan)

    return li
  }

  const root = document.createElement('ul')
  kvPairs.forEach(function (x) {
    root.appendChild(createListItem(x.key, x.value))
  })
  return root
}

现在时间到了,我认为你们大多数人会觉得迷茫,尽管这部分代码风格已经不错了。但这也不是你或者我的错。通过 JavaScript DOM api 来描述 HTML 碎片确实不够直观。

我们先阅读以下代码,然后在我们的脑海中,手动的编译和运行来得到 HTML 代码。之后,我们需要手动地将 HTML 代码编写到网页里面来确认它是否正常工作。
但是有了模板,我们只需要一步手动编写:HTML -> 网页

    @foreach(var x in kvPairs) {
  • @x.key @x.value
  • }

显然,你们大多人数可以在几秒钟内理解它,甚至你们都不需要知道我使用的是什么语言。(ASP.NET 的 Razor)

模板对用户来说相当的炫酷,但对框架的开发者来说并非如此。使用特定模板语言意味着你需要通过一个模板引擎迁移你的框架。大多数情况下,一个模板引擎的大小往往可以容忍但又令人蛋疼。比如著名的模板语言——jade.js,压缩版本都占据了 46kb 空间。

一个框架专用的模板引擎或者编译器将小得多,但是它需要额外的精力来维护编译器。

尽管我个人认为,模板时最好的解决方案,但是我们不会在我们的示例框架中使用它。我想要的是更容易的实现且尚可的可阅读性。

如果你听说过 Elm.js,你可能就注意到了这种创建 DOM 视图的特殊方式。

main =
  span [class "welcome-message"] [text "Hello, World!"]

上面是如何使用 Elm 来创建一个 hello world 页面。Elm 大部分语言特性都借鉴了 Haskell。如果你不知道 Haskell,没关系。我会将其翻译成 JavaScript。

const main = function () {
  const attrs = [class('welcome-message')]
  //class is a function return a Node attribute of class name
  const children = [text('Hello World!')]
  //text is a function returns a text Node

  return span(attrs, children)
  //span is a function returns a span Node
}

它看起来还是有点乱糟糟的,虽然已经比 document.createElement 版本要好得多。同时它非常容易使用,spanclasstext 等都是 JS 的函数。你没必要知道编译器、解释器之类的东西。

我认为这是一种可以接受的一种折中方案(或许可能对你而言不是)。

所以我将介绍 HyperScript 和一个 Helper 库。有了这些库,我们可以像这样轻易地创建列表视图:

const createList = function (kvPairs) {
  const listItem = function ({key, value) {
    return li({}, 
              [span({},[text(x.key)]), 
               span({},[text(x.value)])
              ])
  }

  return ul({}, kvPairs.map(listItem))
}

尽管 HyperScript-Helpers 可以支持各种各样的 api,但是我们只简单使用其中两条:

/**
* @param {String} selector - The query String like '.class', '#id'
* @param {Object} attributes - The Node Attributes dict
* @param {Array} children - The list of children nodes.
*/
TagName(selector, attributs, children)
TagName(attributes, children)

细节:如何重绘?

另一个选择 HyperScript 的原因就是其更好的重绘支持。

最古老的更新网页的解决方案是刷新页面。那是,重绘意味着再次从服务器获取了数据。

后来出现了 ajax。通过 ajax,我们可以控制页面上特定部分内容被重绘。

但是在一个复杂的 DOM 树中管理 DOM 实属不易。开发者们需要在运行效率和开发效率中间谋求平衡。

以我们的学生信息应用为例,我们在模型改变的时候,重绘了整个 DOM 树,事实上,那并不是必须的:不管模型如何变化,列表视图的结构、标签等等都保持不变。

对于一个 hello-world 级别的示例,你怎么做并不重要,但是对于行业的项目,性能非常重要。

现代(作者写的 Model,笔者感觉应该是 Modern)web 框架近年来一直试图解决这个问题。通过解析模板和收集依赖,许多框架都能准确控制每一个节点。

在所有开发者先去们所作出的努力中,Fackbook 所推崇的虚拟 DOM 是最广为接受的解决方案。

虚拟 DOM 的想法并不新奇,Java 开发者们通过缓存来构建字符串已经好几十年了。通过虚拟 DOM:

  1. 我们修改虚拟 DOM 树(vtree),并不会立即修改真实的 DOM 树;
  2. 我们完成修改,然后比较虚拟 DOM 树和它老版本的差异;
  3. 我们补全差异比较的结果;
  4. 虚拟 DOM 在我们补全的基础之上做一些优化,然后更新到真实的 DOM 树上;

所以不管我们修改多少次虚拟 DOM,我们只更新一次真实的 DOM 树。它节省了很多诸如 $(selector).attr(name, value) 的代码,因为这段代码会导致页面多次重绘。

const render = function (root, left, right) {
  patch(root, diff(left, right))
}

上面是一个 Hello-world 级别的虚拟 DOM api 示例,但它对我们的示例框架也足够了。

细节:什么时候开始重绘?

换句话说,我怎么知道我应该什么时候重绘?
在之前的代码中,我们使用了轮询。
轮询非常容易实现,其性能对于我们的学生信息应用确实是够了。每秒一次的循环对于现代的 CPU 是绰绰有余的。但是如果你需要更加频繁地更新视图,或者你需要支持一些非常古老的机器,在浏览器中轮询可能不是一个好的想法。

幸运地是,当面对这些问题的时候,面向对象编程的先驱们已经发明出一些相互作用的技术:

我该如何通知系统的一部分其另一部分已经被修改?
我们经常使用观察者模式。观察者模式被非常广泛地使用着,以至于每一个前端开发者都用过它,甚至他们都没有听说过它。

是的,当你写下 node.addEventListener(myFunc) 的时候,你就在享受观察者模式带来的便利。而你每天要面对的“回调地狱”,可以视为是一种特殊的观察者模式。

观察者模式的核心思路是将 “When” 和 “What” 独立开。被观察对象,知晓事件将在什么时候发生;而观察者(或者说用户),知晓该发生什么。

光谈概念都是扯淡,我们看一下实际的代码:

let observable = {
  _observers: [],
  notify: function() {
    this._observers.forEach(function(wather){
      watcher.onNotify()
    })
  }
}

let observer = {
  onNotify: function() {/*custom code*/}
} 

const observe = function(observer, observable) {
  if(!observable._observers.contains(observer)){
    observable._observers.push(observer)
  }
}

一旦你为用户的被观察对象添加了观察者, onNotify() 方法将在察觉到被观察对象变化时立即被调用。

正如你所见,这和一个直接的函数调用并没有任何区别,除了函数调用是硬编码的(固定写死的)。

如果模型是可被观察的,我们可以观察它并在变化时更新(视图),大多数框架也正是这样处理的。

Knockout.js 是第一代的 MVVM 工具箱。要使用 Knockout,你需要让你的对象变得可以被 Knockout 观察。

const model = function (data) {
  this.firstName = ko.obersevable(data.firstName)
}

许多人认为手动装箱是非常枯燥的重复性工作,所以他们通过 es5 的 Object.defineProperty api 来修改了模型。
下面是简单示例:

const notifyPropertyChange = function (prop) {
  /*your notifying logic here*/
}

const hack = function(obj) {
  const keys = Object.keys(obj).filter(obj.hasOwnProperty)
  keys.forEach(fucntion(key) {
    let value = obj[key]
    Object.defineProperty(obj, key, {
      set: function(newVal) {
        value = newVal
        notifyPropertyChange(key)
      },
      get: () => value,
      writable: true,
      configurable: true
    })
  })
}

Vue.js 就通过这种技术简化了观察的流程。当创建一个 Vue 的组件的时候,框架会自动修改 datacomputed 字段。

而 Cycle.js 则更为激进,它的整个观察者系统都基于 Rx.js。
来看个例子:

import Cycle from '@cycle/core';
import {div, label, input, hr, h1, makeDOMDriver} from '@cycle/dom';

function main(sources) {
  const sinks = {
    DOM: sources.DOM.select('.field').events('input')
      .map(ev => ev.target.value)
      .startWith('')
      .map(name =>
        div([
          label('Name:'),
          input('.field', {attributes: {type: 'text'}}),
          hr(),
          h1('Hello ' + name),
        ])
      )
  };
  return sinks;
}

Cycle.run(main, { DOM: makeDOMDriver('#app-container') });

我从 Cycle 的主页复制来了这段代码。你可以看见它和我们熟悉的框架大不相同。Cycle 背后的哲学相当地又去,访问它的官网你将知道得更多。

于我而言,为了保持我们框架的小巧,我更愿意使用手动实现的方式。(这也是早期 .Net WPF 的解决方案)。它很容易实现,并且将更多的控制器移交给了用户,而代价则是(需要使用)更多的样板代码。

最后工作

我们的框架现在会像是这样,它比我一开始想象的简单多了:

import {h, patch, diff, create} from 'virtual-dom'

const render = function (root, left, right) {
  patch(root, diff(left, right))
}

/**
* @param {Object} model
* @param {Function} view - takes one param, viewmodel
* @param {Function} viewModel - takes two params, model and notify
*/
export default function run (rootSelector, {model, view, viewModel}) {
  let left = h('div')
  let right
  let root = create(left)

  const notify = function notify () {
    left = right
    right = view(viewModel(model, notify))
    render(root, left, right)
  }

  document.querySelector(rootSelector).appendChild(root)

  right = view(viewModel(model, notify))
  render(root, left, right)
}

总共 27 行,其中还注释,很惊讶吧?并且我还写了一个小的 hello world 示例,它长得就像 Angular.js 的首页一样。

import helper from 'hyperscript-helpers'
import {h} from 'virtual-dom'

import run from '../src/index'

const {div, label, input, hr, h1} = helper(h)

let model = {
  tpml: (x) => `hello ${x} !`,
  name: ''
}

const viewModel = function (model, notify) {
  return {
    msg: model.tpml(model.name),
    name: model.name,
    oninput: function (ev) {
      model.name = ev.target.value
      notify()
    }
  }
}

const view = function (vm) {
  return div({},
             [label({textContent: 'Name: '}, []),
              input({type: 'text', value: vm.name, oninput: vm.oninput}, []),
              hr({}, []),
              h1({textContent: vm.msg}, [])
             ])
}

run('#app', {model, view, viewModel})

你可以从 这里 下载到源码和示例,你可以随便玩儿。

后记

你可能对我的实现感觉到不满意。尤其是,许多人都不喜欢 notify() 函数。

const notify = function notify () {
    left = right
    right = view(viewModel(model, notify))
    render(root, left, right)
}

事实上,这里的递归根本是不必要的。你可以通过一个代理来替代它:

//Pseudo code
const proxy = {
  notify: function () {
    this.renderers.forEach(func => func())
  },
  renderers: []
}

proxy.renderers.push(function(){
  left = right
  right = view(viewModel(model,proxy.notify)
  render(root, left, right)
})

proxy.notify()

这都取决于个人喜好。

上一篇


原文地址

你可能感兴趣的:(从零开始编写一个 MVVM 框架(三))