前端静径

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

前言

最后一篇文章,我们编写了一个app来显示学生信息并将其重构为MVVM风格。

我没有说,但也许你已经注意到,我们在上一篇中已经做了一些看起来像MVVM框架的结尾。如果你忘记了上一篇文章或者你跳过了,别担心,这里是代码(并且我加了一些注释):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* @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)
}

Hey,你在开玩笑?一个十行的框架?

框架是对如何组织代码和整个项目如何通用运作的抽象。

这并不意味着你应该有一堆代码或混乱的类,尽管企业可用的API列表经常都很可怕的长。但是如果你看一个框架仓库的核心文件夹,你将可能会发现它会出乎意料的小(相比于整个项目来说)。其核心代码包含主要工作进程,而其他部分,也许我们可以称他们为外设,帮助开发人员以更加舒适的方式构建应用程序。一个框架究竟会有多小的一个例子是cycle.js,只有124行(包含注释和空格)。我强烈的建议你去看看Andre André Staltz的描述Cycle.js的视频。视频展示了一个框架如何设计的整体过程。

抽象你的框架

通用性捕获

我们说一个框架提供了整个程序如何工作的通用过程。这种描述是不明确的,没有任何意义。如果我知道我的程序中什么是通用的,编程就会编程至少十倍的简单。框架是暗示开发人员可能是通用的,易于重用的东西。他们为某些特定类别的问题提供了不太糟糕的模板,这就是为什么我非常尊重那些建立广泛使用框架的人。他们解决了难得部分,而留给我们简单的方法。

那么我们的学生信息app怎么样呢?我们将其重构为mvvm风格,那么什么是通用的部分呢?幸运的是,我们已经知道了mvvm和它怎么工作的:

alt mvvm work way

我们的app主要由四部分组成,框架应该把他们结合在一起。这定义了接口和维护数据流。它就DIY一个个人电脑。你有CPU、内存、硬盘和其他组成部分,并且你有主板和一些插槽。你的自定义代码就像这些组件,而框架就是主板。你只需要关心组件是否需要接口。至于他们如何组合在一起,谁在乎?主板会做的。从图中可以看出,我们的框架将形成一个数据流圈:

  1. 数据通过适配器从Model层开始,最后在View层展示
  2. 用户交互从View开始,通过行为(actions)最终改变Model
  3. 然后数据从修改过的Model开始,重复第一步。

实际上,框架因工作原理而异。它们在接口上共享一些功能,而不是如何实现他们。

细节:为View选择工具

这一节,我们将看到制作一个框架的一些细节。这里可能会有很多问题,但我们将重点关注我认为重要的几点。

我们将做出的折中主要是基于我自己的经验,可能不适合读者。我不是在说服你。这只是我的只是加上我的个人评价的一个技术展示。

首当其冲的难题就是View的接口。这个问题影响开发人员的用户体验。如果一个UI框架无法提供UI创建的良好体验,那真是令人沮丧的。

Web开发中的创建View使用最广泛的技术是使用模板DSL。很多著名的解决方案都采用了它,如AngularReact。在SPA受欢迎之前,模板已经被广泛使用了。我们知道的最好的语言:PHP,它刚开始就是为了从服务器端的模板生成html而设计的。

模板主要由高可读性和可重用性而闻名。为了理解它,我们来回顾下之前文章中的代码。花十秒的时间来理解下面的代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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 -> 网页

1
2
3
4
5
6
7
8
9
<ul>
@foreach(var x in kvPairs)
{
<li>
<span>@x.key</span>
<span>@x.value</span>
</li>
}
</ul>

很明显,大部分人都可以在几秒钟内了解它。你甚至不需要知道我在使用什么语言(Razor for ASP.NET)。模板对于用户来说是非常酷的,但是对框架开发人员来说不是那么酷。使用模板DSL通常意味着你需要使用模板引擎运送你的框架。在大多数情况下,模板引擎将带来可承受但可怕的空格。例如,著名的模板DSLjade.js,其最小格式多用了46kb大小。

框架专用模板引擎或编译器要小得多,但是它需要额外的精力去维护编译器。毕竟,我认为模板是最好的解决方案。但是我们不会在我们这个小型框架中使用它。我想要一些更加容易实现的,而可读性仍然可以接受的东西。

如果你听说过Elm.js,你可能已经注意到了它创建DOM视图的特殊方式:

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

以上展示了用Elm如何创建一个Hello World页面。Elm借鉴了Haskell的大部分语言规范。如果你不知道Haskell,没关系,我将把它转换成JavaScript:

1
2
3
4
5
6
7
8
9
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等起来好得多,而且实现起来容易的多。span, class, text都是JS函数,你不需要知道编译器或解析器或者类似的东西。

这是个可以妥协接受的方案(可能不适合你)。

所以我将要介绍HyperScripthelper库,使用这些库,我们可以用下面的方法很轻松的创建列表视图:

1
2
3
4
5
6
7
8
9
10
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提供了很API,但我们可以简单地使用两个规则:

1
2
3
4
5
6
7
/**
* @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到来了,使用它,我们可以控制页面的哪个部分应该被重绘。但是,管理DOM整个复杂的DOM树并不容易。开发商必须在运行效率和开发效率之间取得平衡。

拿我们的学生信息app作为例子。我们每当数据改变的时候就重绘整个树,很显然,这是没有必要的:无论模型如何变化,列表视图和label spans的结构都是保持不变的。对于一个hello-world级别的演示,你怎么做影响很小。但对于工业级别项目而言,性能是很重要的。数据web框架已经试图解决这个问题很多年了,通过解析模板并收集依赖关系,许多框架都能够精确的控制每个节点。在网络开发人员的先驱们所做的一切努力中。 Facebook引入的虚拟DOM是最广泛接受的解决方案。虚拟DOM背后的想法并不是新的。Java开发人员已经使用缓冲区来建立字符串数十年了。使用虚拟DOM:

  1. 我们在虚拟DOM中编辑,在“真实”树中不会有任何事情发生。
  2. 我们结束编辑后,与旧版本的虚拟DOM树做比较
  3. 修补差异
  4. 虚拟DOM在我们的补丁上做一些优化,并更新“真实”树;

所以无论我们编辑虚拟树多少次,我们只更新真实树一次。这相比$(selector).attr(name, value)节约了很多性能。

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

上面的例子是hello-world级别的虚拟DOM API的例子,但是它对我们的小型框架够用了。

细节:什么时候重绘

换句话说,我怎么知道我需要重绘了呢?

在早起研究中,我们使用轮询。轮询容易实施,其性能对于我们的学生信息App来说也可以接受。每秒一个轮询可以适用于现在CPU,但是如果你想要视图更新的更频繁,或者你需要支持一些非常老的机器,在浏览器里面轮询可能不是一个好主意。幸运的是,OOP的先驱们面临同样的问题:

如何通知系统的一部分,另一部分的状态已经改变了?

时,已经发明了一些同等的技术。我们经常使用观察者模式,每个前端开发人员即使从未听过它,也都用过它。是的,当你编写node.addEventListener(myFunc)时,你正在享受观察者模式的便利。而你每天都遭受的回调噩梦,也可以被看作是一种特殊的观察者模式。观察者的核心思想是将“WHEN”和“WHAT”分开。被观察者知道一些东西“什么时候”发生,而观察者(或者订阅者)知道“什么”会发生。谈论概念很容易,让我们来看看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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)
}
}

一旦将观察者添加到observable的订阅列表中,当observable通知更改时,将立即调用onNotify()方法。正如你所看到的,除了调用函数不是硬编码,这与直接的函数调用没有区别。如果Model是可观察的,我们可以观察它并更新改变,这正是大多数框架如何处理的。Knockout.js是第一代MVVM工具包之一,要使用Knockout,你需要使你的数据成为一个Knockout可观察的:

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

很多人可能会认为手动进行封装是可怕重复的工作,所以他们使用es5的Object.definePropertyAPI来hack Model。让我们来看怎么去做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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组件时,框架将自动hack datacomputed字段。而Cycle.js更激进,Cycle的观察系统完全基于Rx.js。让我们来看一些例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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中使用的最早的解决方案)。它很容易实现,并为用户留下了更多的控制权。其代价是更多的样板代码。

最后的工作

让我们来看我们的框架会是什么样,它比我刚开始想的要更加简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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的demo,它看上去很像你在Angular.js官网主页上看到的那个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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()函数。

1
2
3
4
5
const notify = function notify () {
left = right
right = view(viewModel(model, notify))
render(root, left, right)
}

实际上,在这里递归是不必要的,你可以使用代理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//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()

这一切都是关于个人喜好的。