上一篇文章中,我们创建了一个应用来展示学生信息,然后将它重构成了 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 并且理解了它是如何运作的:
我们的应用主要包含了四部分内容,并且框架应该把它们结合起来。它定义了界面,并且维护数据流。
这就有点像自己组装一台 PC。你有了 CPU,硬盘以及其他的组件,并且也有一块配有插槽的主板。你自定义的代码就像组件,而框架就如同主板。你唯一需要关心的是,组件是否需要一个界面。那它们是如何组合在一起的呢?没人会在意,主板会完成这一切。
根据上面的图表,我们框架会形成如下的数据流的循环:
- 数据从模型开始,通过适配器,最后被展示在视图上;
- 用户交互从视图开始,通过
actions
,最后改变模型; - 数据从修改后的模型出发,重复步骤 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
版本要好得多。同时它非常容易使用,span
、class
、text
等都是 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:
- 我们修改虚拟 DOM 树(vtree),并不会立即修改真实的 DOM 树;
- 我们完成修改,然后比较虚拟 DOM 树和它老版本的差异;
- 我们补全差异比较的结果;
- 虚拟 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 的组件的时候,框架会自动修改 data
和 computed
字段。
而 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()
这都取决于个人喜好。
上一篇
原文地址