上一篇文章中,我們創(chuàng)建了一個應(yīng)用來展示學(xué)生信息盏袄,然后將它重構(gòu)成了 MVVM 風(fēng)格的辕羽。
我并沒有提及刁愿,但是你或許也已經(jīng)察覺到了酌毡,我們最后已經(jīng)做了一些看起來像 MVVM 框架的工作。
如果你忘記了上一篇中的內(nèi)容或者你略過了它掰曾,不用擔(dān)心旷坦,這里有源碼(而且我還添加了一些注釋):
/**
* @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 行代碼的框架辆它?
一個框架是一種抽象內(nèi)容锰茉,它通常決定了代碼被如何被組織以及整個程序該如何運(yùn)行飒筑。
這并不意味著你應(yīng)該寫下大量的代碼或者龐大的類俏脊,雖然企業(yè)內(nèi)部使用的框架的 API 列表總是長得嚇人著瓶。
但是如果你瀏覽框架倉庫的核心目錄沸久,你或許會發(fā)現(xiàn)它是驚人地小巧(對比整個項目)卷胯。
核心代碼控制了它的工作流程窑睁,以及其它部分的內(nèi)容担钮,或許我們可以叫它外設(shè)(peripherals)箫津,幫助開發(fā)者以一種更舒適的方式構(gòu)建他們的應(yīng)用。cycle.js 就是一個典型的例子赡模,它只有 124 行代碼(包括注釋和空格等)教硫。
我強(qiáng)烈推薦你觀看 Andre André Staltz 介紹 Cycle.js 的 視頻辆布。這一個視頻展示了一個框架形成的整個過程(并且讓我在觀看之后,喜歡上了重新造輪子)丧鸯。
抽象你的框架
尋找通用性
我們認(rèn)為丛肢,一個框架一般提供了整個程序運(yùn)行的通用流程穆刻。
這段描述是非常有野心且沒有道理的氢伟。如果我需要知道在我的程序中,哪些東西是通用诚些,那么編程將變得至少簡單 10 倍。
框架一般尋找開發(fā)者們需要的通用的東西以及可以復(fù)用的東西绞吁,它們?yōu)榫唧w類型的問題提供不至于太差的模板。而這也是我為啥對構(gòu)建廣泛使用的框架的人懷有崇高的敬意唬格。他們解決了困難的部分掀泳,讓我們的工作變得簡單。
那我們的學(xué)生信息應(yīng)用怎么樣呢西轩?我們將其重構(gòu)成了 MVVM 風(fēng)格,那么哪些是通用的部分呢脑沿?幸運(yùn)地是藕畔,我們已經(jīng)知道了 MVVM 并且理解了它是如何運(yùn)作的:
![MVVM](https://twiknight.gitbooks.io/blog/content/assets/framework_workflow.png)
我們的應(yīng)用主要包含了四部分內(nèi)容,并且框架應(yīng)該把它們結(jié)合起來庄拇。它定義了界面注服,并且維護(hù)數(shù)據(jù)流措近。
這就有點(diǎn)像自己組裝一臺 PC屈张。你有了 CPU,硬盤以及其他的組件,并且也有一塊配有插槽的主板。你自定義的代碼就像組件瞎颗,而框架就如同主板倦逐。你唯一需要關(guān)心的是抒巢,組件是否需要一個界面型诚。那它們是如何組合在一起的呢还绘?沒人會在意昔案,主板會完成這一切捞稿。
根據(jù)上面的圖表衰齐,我們框架會形成如下的數(shù)據(jù)流的循環(huán):
- 數(shù)據(jù)從模型開始,通過適配器,最后被展示在視圖上;
- 用戶交互從視圖開始嗽元,通過
actions
旁壮,最后改變模型; - 數(shù)據(jù)從修改后的模型出發(fā)免胃,重復(fù)步驟 1呢蛤;
事實(shí)上,框架可能根據(jù)他們工作的內(nèi)容適當(dāng)變化涂佃。它們共享了一些界面上的特性辜荠,而非實(shí)現(xiàn)方式。
細(xì)節(jié):選擇視圖工具箱
在這一節(jié),我們將看到一些框架構(gòu)成的細(xì)節(jié)。這里可能有很多值得關(guān)注的點(diǎn)催烘,但是我們只重點(diǎn)關(guān)注其中我認(rèn)為重要的幾個。
這里的權(quán)衡利弊主要是基于我個人的經(jīng)驗(yàn)丸卷,所以它可能不太適合各位看官枕稀。我并不是在視圖說服你什么,我只是在展示一些摻雜了我個人想法的技術(shù)而已谜嫉。
第一點(diǎn)也是最值得關(guān)注的一點(diǎn)是萎坷,視圖界面。這一點(diǎn)將極大地影響開發(fā)者的用戶體驗(yàn)沐兰。如果一個用戶界面框架不能提供好的用戶界面創(chuàng)建體驗(yàn)哆档,這會讓人感到非常失望。
Web 開發(fā)中創(chuàng)建視圖使用最廣泛的技術(shù)是使用特定的模板語言(template DSL)僧鲁。許多著名的解決方案都采用了它虐呻,比如 Angular 和 React。并且在單頁 web 應(yīng)用(SPA)流行起來之前寞秃,模板就已經(jīng)被廣泛使用了斟叼。我們所知道的最好的編程語言——PHP,一開始就是設(shè)計來使用服務(wù)器端的模板用于創(chuàng)建 HTML春寿。
模板之所以變得流行朗涩,主要是因?yàn)樗母呖勺x性以及尚可的可復(fù)用性。
為了方便展示绑改,我們回顧一下前面文章當(dāng)中的一些代碼谢床。
請花 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
}
現(xiàn)在時間到了,我認(rèn)為你們大多數(shù)人會覺得迷茫厘线,盡管這部分代碼風(fēng)格已經(jīng)不錯了识腿。但這也不是你或者我的錯。通過 JavaScript DOM api 來描述 HTML 碎片確實(shí)不夠直觀造壮。
我們先閱讀以下代碼渡讼,然后在我們的腦海中,手動的編譯和運(yùn)行來得到 HTML 代碼耳璧。之后成箫,我們需要手動地將 HTML 代碼編寫到網(wǎng)頁里面來確認(rèn)它是否正常工作。
但是有了模板旨枯,我們只需要一步手動編寫:HTML -> 網(wǎng)頁
<ul>
@foreach(var x in kvPairs)
{
<li>
<span>@x.key</span>
<span>@x.value</span>
</li>
}
</ul>
顯然蹬昌,你們大多人數(shù)可以在幾秒鐘內(nèi)理解它,甚至你們都不需要知道我使用的是什么語言攀隔。(ASP.NET 的 Razor)
模板對用戶來說相當(dāng)?shù)撵趴嵩矸罚珜蚣艿拈_發(fā)者來說并非如此。使用特定模板語言意味著你需要通過一個模板引擎遷移你的框架竞慢。大多數(shù)情況下先紫,一個模板引擎的大小往往可以容忍但又令人蛋疼。比如著名的模板語言——jade.js筹煮,壓縮版本都占據(jù)了 46kb 空間遮精。
一個框架專用的模板引擎或者編譯器將小得多,但是它需要額外的精力來維護(hù)編譯器败潦。
盡管我個人認(rèn)為本冲,模板時最好的解決方案,但是我們不會在我們的示例框架中使用它劫扒。我想要的是更容易的實(shí)現(xiàn)且尚可的可閱讀性檬洞。
如果你聽說過 Elm.js,你可能就注意到了這種創(chuàng)建 DOM 視圖的特殊方式沟饥。
main =
span [class "welcome-message"] [text "Hello, World!"]
上面是如何使用 Elm 來創(chuàng)建一個 hello world 頁面添怔。Elm 大部分語言特性都借鑒了 Haskell湾戳。如果你不知道 Haskell,沒關(guān)系广料。我會將其翻譯成 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
}
它看起來還是有點(diǎn)亂糟糟的,雖然已經(jīng)比 document.createElement
版本要好得多艾杏。同時它非常容易使用韧衣,span
、class
购桑、text
等都是 JS 的函數(shù)畅铭。你沒必要知道編譯器、解釋器之類的東西勃蜘。
我認(rèn)為這是一種可以接受的一種折中方案(或許可能對你而言不是)硕噩。
所以我將介紹 HyperScript 和一個 Helper 庫。有了這些庫缭贡,我們可以像這樣輕易地創(chuàng)建列表視圖:
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)
細(xì)節(jié):如何重繪衙熔?
另一個選擇 HyperScript 的原因就是其更好的重繪支持隔显。
最古老的更新網(wǎng)頁的解決方案是刷新頁面。那是校仑,重繪意味著再次從服務(wù)器獲取了數(shù)據(jù)穆端。
后來出現(xiàn)了 ajax袱贮。通過 ajax,我們可以控制頁面上特定部分內(nèi)容被重繪体啰。
但是在一個復(fù)雜的 DOM 樹中管理 DOM 實(shí)屬不易攒巍。開發(fā)者們需要在運(yùn)行效率和開發(fā)效率中間謀求平衡。
以我們的學(xué)生信息應(yīng)用為例荒勇,我們在模型改變的時候柒莉,重繪了整個 DOM 樹,事實(shí)上沽翔,那并不是必須的:不管模型如何變化兢孝,列表視圖的結(jié)構(gòu)、標(biāo)簽等等都保持不變仅偎。
對于一個 hello-world 級別的示例跨蟹,你怎么做并不重要,但是對于行業(yè)的項目橘沥,性能非常重要窗轩。
現(xiàn)代(作者寫的 Model,筆者感覺應(yīng)該是 Modern)web 框架近年來一直試圖解決這個問題座咆。通過解析模板和收集依賴痢艺,許多框架都能準(zhǔn)確控制每一個節(jié)點(diǎn)仓洼。
在所有開發(fā)者先去們所作出的努力中,F(xiàn)ackbook 所推崇的虛擬 DOM 是最廣為接受的解決方案堤舒。
虛擬 DOM 的想法并不新奇衬潦,Java 開發(fā)者們通過緩存來構(gòu)建字符串已經(jīng)好幾十年了。通過虛擬 DOM:
- 我們修改虛擬 DOM 樹(vtree)植酥,并不會立即修改真實(shí)的 DOM 樹;
- 我們完成修改弦牡,然后比較虛擬 DOM 樹和它老版本的差異友驮;
- 我們補(bǔ)全差異比較的結(jié)果;
- 虛擬 DOM 在我們補(bǔ)全的基礎(chǔ)之上做一些優(yōu)化驾锰,然后更新到真實(shí)的 DOM 樹上卸留;
所以不管我們修改多少次虛擬 DOM,我們只更新一次真實(shí)的 DOM 樹椭豫。它節(jié)省了很多諸如 $(selector).attr(name, value)
的代碼耻瑟,因?yàn)檫@段代碼會導(dǎo)致頁面多次重繪。
const render = function (root, left, right) {
patch(root, diff(left, right))
}
上面是一個 Hello-world 級別的虛擬 DOM api 示例赏酥,但它對我們的示例框架也足夠了喳整。
細(xì)節(jié):什么時候開始重繪?
換句話說裸扶,我怎么知道我應(yīng)該什么時候重繪框都?
在之前的代碼中,我們使用了輪詢呵晨。
輪詢非常容易實(shí)現(xiàn)魏保,其性能對于我們的學(xué)生信息應(yīng)用確實(shí)是夠了。每秒一次的循環(huán)對于現(xiàn)代的 CPU 是綽綽有余的摸屠。但是如果你需要更加頻繁地更新視圖谓罗,或者你需要支持一些非常古老的機(jī)器,在瀏覽器中輪詢可能不是一個好的想法季二。
幸運(yùn)地是檩咱,當(dāng)面對這些問題的時候,面向?qū)ο缶幊痰南闰?qū)們已經(jīng)發(fā)明出一些相互作用的技術(shù):
我該如何通知系統(tǒng)的一部分其另一部分已經(jīng)被修改胯舷?
我們經(jīng)常使用觀察者模式税手。觀察者模式被非常廣泛地使用著,以至于每一個前端開發(fā)者都用過它需纳,甚至他們都沒有聽說過它芦倒。
是的,當(dāng)你寫下 node.addEventListener(myFunc)
的時候不翩,你就在享受觀察者模式帶來的便利兵扬。而你每天要面對的“回調(diào)地獄”麻裳,可以視為是一種特殊的觀察者模式。
觀察者模式的核心思路是將 “When” 和 “What” 獨(dú)立開器钟。被觀察對象津坑,知曉事件將在什么時候發(fā)生;而觀察者(或者說用戶)傲霸,知曉該發(fā)生什么疆瑰。
光談概念都是扯淡,我們看一下實(shí)際的代碼:
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()
方法將在察覺到被觀察對象變化時立即被調(diào)用穆役。
正如你所見,這和一個直接的函數(shù)調(diào)用并沒有任何區(qū)別梳凛,除了函數(shù)調(diào)用是硬編碼的(固定寫死的)耿币。
如果模型是可被觀察的,我們可以觀察它并在變化時更新(視圖)韧拒,大多數(shù)框架也正是這樣處理的淹接。
Knockout.js 是第一代的 MVVM 工具箱。要使用 Knockout叛溢,你需要讓你的對象變得可以被 Knockout 觀察塑悼。
const model = function (data) {
this.firstName = ko.obersevable(data.firstName)
}
許多人認(rèn)為手動裝箱是非常枯燥的重復(fù)性工作楷掉,所以他們通過 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 就通過這種技術(shù)簡化了觀察的流程。當(dāng)創(chuàng)建一個 Vue 的組件的時候靖诗,框架會自動修改 data
和 computed
字段郭怪。
而 Cycle.js 則更為激進(jìn),它的整個觀察者系統(tǒng)都基于 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 的主頁復(fù)制來了這段代碼鄙才。你可以看見它和我們熟悉的框架大不相同。Cycle 背后的哲學(xué)相當(dāng)?shù)赜秩ゴ倜啵L問它的官網(wǎng)你將知道得更多攒庵。
于我而言,為了保持我們框架的小巧败晴,我更愿意使用手動實(shí)現(xiàn)的方式浓冒。(這也是早期 .Net WPF 的解決方案)。它很容易實(shí)現(xiàn)尖坤,并且將更多的控制器移交給了用戶稳懒,而代價則是(需要使用)更多的樣板代碼。
最后工作
我們的框架現(xiàn)在會像是這樣慢味,它比我一開始想象的簡單多了:
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})
你可以從 這里 下載到源碼和示例,你可以隨便玩兒顶岸。
后記
你可能對我的實(shí)現(xiàn)感覺到不滿意腔彰。尤其是,許多人都不喜歡 notify()
函數(shù)辖佣。
const notify = function notify () {
left = right
right = view(viewModel(model, notify))
render(root, left, right)
}
事實(shí)上霹抛,這里的遞歸根本是不必要的。你可以通過一個代理來替代它:
//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()
這都取決于個人喜好凌简。