原文信息
原文: Code your JS app like it's 86
原文作者:Victor Perron
原文創(chuàng)作時(shí)間:September 28, 2016
翻譯作者:無法畢業(yè)的fulvaz
Contact:fulvaz@foxmail.com
譯者注:
這篇文章非常有趣,作者介紹了利用復(fù)古的的圖形界面設(shè)計(jì)模式實(shí)現(xiàn)組件間解耦。但說起來古老,實(shí)際上說明了flux做了什么事情,以及為什么要有flux。
正文
利用過去的編程范式可以避免重寫你的JavaScript應(yīng)用。
太多時(shí)間花在了重寫UI上面
我們會出于不同的原因去重寫UI。
通常來說,我們會把原因歸結(jié)到工具上 ---- 這很合理。
框架,庫,包管理,guidelines,甚至是語言和元語言本身都變化地非常快,所以現(xiàn)在很難找到一個做應(yīng)用的普遍適用標(biāo)準(zhǔn)。
更別提測試和測試的方法,那可以另外寫一篇文章進(jìn)行討論。硬要進(jìn)行測試,也只是靠眼睛去看他是否可以正常工作。
大多數(shù)情況,UI都會連同產(chǎn)品一起發(fā)布,用戶會花錢買,然后在一個重寫了數(shù)據(jù)庫的關(guān)鍵更新后,用戶會花錢再買一次。
整個行業(yè)的公司都會招新人畢業(yè)生,然后叫他們用最新的工具,在最短的時(shí)間組裝出Web UI,最后,把UI作為產(chǎn)品的一部分賣掉。
最大的問題在于,沒人會去考慮UI的長期支持(LTS)和維護(hù) --- 這里的工作量差不多是要建一個生態(tài)系統(tǒng)。
我們可以做出改變。我們可以建立一個標(biāo)準(zhǔn)。我們可以專注于寫Web UI時(shí)出現(xiàn)頻率高的問題,然后解決這些問題。而且我們可以用古老的設(shè)計(jì)模式和編碼策略去解決這些問題。
三個錯誤
下面看一個簡單的React component例子。
// FooComponent.js
import react from 'react'
import {ApiClient} from '../api_client'
var FooComponent = React.createClass({
componentDidMount: function () {
ApiClient.getTitle().then((data) => this.setState({title: data}))
},
handleClick: function (e) {
e.preventDefault();
bar_component.resetCoconuts()
},
render: function() {
return (
<div className="title">{this.state.title}/>
<div className="button" onClick={this.handleClick}>
)
}
})
特別簡單的一個例子,你可以看出上面代碼有什么問題嗎?
不恰當(dāng)?shù)膶?dǎo)入和數(shù)據(jù)流
當(dāng)應(yīng)用的邏輯分散在你的組件和控制器中時(shí),你也只能毫無辦法地使用隱式全局變量(hidden globals)
去組織你的代碼。我用還是使用上面的例子,然后用其他方式重寫這段代碼。
首先,代碼中先導(dǎo)入了ApiClient
import {ApiClient} from '../api_client'
這段代碼將你的組件和數(shù)據(jù)源耦合了起來。這不是一種好的實(shí)踐。這種設(shè)計(jì)至少有3個問題。
- 修改
ApiClient
會導(dǎo)致FooComponent
跟著也要修改 - 測試
FooComponent
需要一個mock的ApiClient
:比如一個HTTP后端 - 如果(在頁面內(nèi))同時(shí)有兩個
FooComponent
,那這個頁面會想服務(wù)器提交兩次請求。
但這些都不是主要問題。
主要問題是:這個API client還沒有初始化。如果它需要一個base URL或者是一個token呢?
你的組件就需要去給這個API client提供參數(shù)。意思是你需要給這個組件提供一些選項(xiàng)的參數(shù),然而這樣就違反了關(guān)注點(diǎn)分離原則。
const myComponent = FooComponent.bootstrap('#anchor', {
baseUrl: "https://xxxx",
token: "MY_TOKEN",
actualOption: xxxx,
})
這段代碼中至少有兩個非必要的參數(shù)(baseUrl
和token
)。這兩個參數(shù)需要在測試的時(shí)候mock。
你有見過組件需要傳URL才能工作嗎?組件只需要數(shù)據(jù)。
這個組件依賴不可見的全局變量
其次,這段代碼還依賴了全局的bar_component
去處理點(diǎn)擊事件和重置cocounts
。 這種寫法非常不好。
handleClick: function (e) {
e.preventDefault();
bar_component.resetCoconuts()
},
imports里面沒任何東西可以提醒我們,這個組件依賴著在window
對象的隱式全局變量bar_component
.
另外,問題并不僅是因?yàn)樗侨值模@種寫法還會引起其他的bug。比如說,bar_component
在handleClick()
函數(shù)的作用域中被定義了。(譯注:那么bar_component重置的Cocount就是另外一個Cocount了)
下面列出了不同等級的麻煩:
Level 1:你不能在沒有
BarComponent
的情況下測試FooComponent
.Level 10:
BarComponent
并不僅是一個依賴,它需要進(jìn)行實(shí)例化
- Level 9001:這個實(shí)例需要保存在一個全局范圍內(nèi)。而不能通過顯式聲明,top-level, automatically-retrievable,或者是導(dǎo)入等方式來使用這個實(shí)例。
譯注1:
此處top-level意思是通過查詢依賴鏈的根部然后使用實(shí)例。
譯注2:automatically-retrievable:自動查詢依賴,RPM的自動查詢依賴
對AngularJS用戶:遇到這種情況的最常見原因是在FooComponent
內(nèi)注入BarComponent
或者ApiService
當(dāng)然,這些依賴都是可以mock的。(用angular特定的方式) 即便如此,他們依然是需要在定義在某處的全局變量。
他們間產(chǎn)生了耦合。
數(shù)據(jù)流不明確
一個非常常見的問題:代碼各處都是數(shù)據(jù)查詢(XHR,JSONP)
var FooComponent = React.createClass({
componentDidMount: function () {
ApiClient.getTitle().then((data) => this.setState({title: data.comment}))
},
// […]
})
這段代碼中,除了我們剛才的耦合問題,還有數(shù)據(jù)流不清晰的問題,即你沒法清晰地看出HTTP請求在哪,什么時(shí)候發(fā)出。
更糟糕的是,你部分組件可能依賴于未更新的請求(API可能會改變),而你的應(yīng)用的其他部分依賴更新過的API請求。
如果通過XHR獲得的(JSON內(nèi)的)comment
屬性發(fā)生了改變,你就要在組件內(nèi)部修改才能修正你的組件,這看起來并不太對。
這堆錯誤的實(shí)踐累加起來,最終就會導(dǎo)致不久后的重寫。你需要的是嚴(yán)格的關(guān)注點(diǎn)分離。實(shí)現(xiàn)的方法是提前計(jì)劃好,盡可能準(zhǔn)確地預(yù)估你的應(yīng)用是做什么的,數(shù)據(jù)流是怎么樣的。
然而,這里還有另一個范式。
"main loop"
下面從介紹一個古老的設(shè)計(jì)模式開始。
回憶你寫代碼最開始要做什么:打開一個編輯器,創(chuàng)建一個main()
循環(huán),然后在某處輸出『Hello World』
這看起來很簡單,但在游戲和桌面軟件領(lǐng)域,main循環(huán)是相關(guān)邏輯的骨架。
在Windows API中
下面的簡單代碼來自Windows API例子:
LRESULT APIENTRY WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
PAINTSTRUCT ps;
HDC hdc;
switch (message)
{
case WM_PAINT:
hdc = BeginPaint(hwnd, &ps);
TextOut(hdc, 0, 0, "Hello, Windows!", 15);
EndPaint(hwnd, &ps);
return 0L;
// Process other messages.
}
}
int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
LPSTR lpCmdLine, int nCmdShow)
{
HWND hwnd;
hwnd = CreateWindowEx(
// parameters
);
ShowWindow(hwnd, SW_SHOW);
UpdateWindow(hwnd);
return msg.wParam;
}
非常復(fù)古的代碼,對吧?
分析:WinMain函數(shù)使用幾個參數(shù)創(chuàng)建了應(yīng)用的window。其中WndProc回調(diào)函數(shù)負(fù)責(zé)處理各種事件:用戶事件,重繪事件等等
一個main循環(huán),一個事件循環(huán)。
就是這樣。生存了20年,擁有數(shù)百萬的應(yīng)用
的Windows生態(tài)系統(tǒng),只是基于簡單的main函數(shù)和事件循環(huán)。
SDL API里
SDL API主要用來設(shè)計(jì)游戲。經(jīng)常被當(dāng)做輕量級的OpenGL。
下面是一個簡單的app
#include <SDL2/SDL.h>
#include <iostream>
int main()
{
SDL_Window* handle(0);
SDL_Event events;
bool end(false);
if(SDL_Init(SDL_INIT_VIDEO) < 0)
{
SDL_Quit();
return -1;
}
handle = SDL_CreateWindow("Test SDL 2.0", SDL_WINDOWPOS_CENTERED,
SDL_WINDOWPOS_CENTERED, 800, 600,
SDL_WINDOW_SHOWN);
if(handle == 0)
{
SDL_Quit();
return -1;
}
while(!end)
{
SDL_WaitEvent(&events);
if(events.window.event == SDL_WINDOWEVENT_CLOSE)
end = true;
}
SDL_DestroyWindow(handle);
SDL_Quit();
return 0;
}
不同的生態(tài)系統(tǒng),但是東西還是那套東西。一個main入口把事情初始化了,一個事件循環(huán)負(fù)責(zé)處理用戶輸入和其他東西。
OpenGL應(yīng)用極度簡單,你可在這這里找到例子。
不敢相信對吧,就是main循環(huán),初始化。這就是我們今天要說的:幾乎所有類型的UI都是基于一個事件循環(huán),然后應(yīng)用的不同部分觸發(fā)不同的事件。
那么,為什么大部分前端應(yīng)用不使用同樣的方法呢?
因?yàn)闆]人告訴過我們可以這么用。我們習(xí)慣用了jQuery,然后慢慢開始組建Angular組件,訪問不知道定義在哪的全局變量。
Javascript應(yīng)用:Main loop model
我們已經(jīng)知道了非常簡單的Windows API例子、對游戲開發(fā)友好的OpenGL和SDL庫。
在某種程度上說,一個web界面就是一個簡單的圖形應(yīng)用。不同的是它用的是更現(xiàn)代的工具。
如果我們將我們的應(yīng)用寫成這樣子
// main.js
import {ApiClient} from './api_client'
import {FooComponent} from './components/foo'
import {BarComponent} from './components/bar'
// Init the components
FooComponent.bootstrap($('#foo_component'), options)
const bar = new BarComponent(document.getElementByID('#bar_component')))
// Get the data
const api = ApiClient.authenticate(getTokenFromStorage())
api.fetchCoconuts(function (coconuts) {
// Handle-based data passing
bar.setCoconuts(coconuts)
// Event-based data passing
document.dispatchEvent(new Event('data_is_fetched', coconuts))
})
api.fetchTitle(function (data) {
foo.setTitle(data.title)
})
// Event loop
document.addEventListener('foo:click', function () {
alert('Foo component was clicked')
bar.resetCoconuts()
})
// FooComponent.js
import react from 'react'
var FooComponent = React.createClass({
handleClick: function (e) {
e.preventDefault();
// Notify other layers (simplistic, but works)
document.dispatchEvent(new Event('foo:click'))
},
setTitle: function (str) {
this.setState({title: str}))
},
render: function() {
return (
<div className="title">{this.state.title}/>
<div className="button" onClick={this.handleClick}>
)
}
})
我們來認(rèn)真看看上述代碼,特別是main.js
我們獲得了解耦和的組件,F(xiàn)ooComponent和BarComponent都不需要知道數(shù)據(jù)請求的細(xì)節(jié)。
他們不需要知道,也不想知道。
他們所需要的是,請求自一個hardcoded fixture的cocount
, 關(guān)鍵的是,bar
對外暴露一個非常簡單的setCocount()
API函數(shù)。任何應(yīng)用邏輯都可以在外部使用它。
事件在代碼中廣播(嫌low使用event bus也可以),不同的組件可以捕獲事件然后進(jìn)行處理。foo顯式發(fā)送點(diǎn)擊事件,在應(yīng)用中監(jiān)聽這個事件,就可以實(shí)現(xiàn)通過點(diǎn)擊foo
然后重置bar
的count。
這樣,組件就真正地解除了耦合。他們可以隨意重用,而不需要知道組件內(nèi)部是如何實(shí)現(xiàn)的。
只有main循環(huán)里面需要寫與app相關(guān)的業(yè)務(wù)邏輯。
注:這里沒有使用全局變量,我們在main循環(huán)中利用了閉包。
總結(jié)
文中說的幾個原則其實(shí)非常簡單。在你的應(yīng)用中使用這種簡單的代碼結(jié)構(gòu),然后繼續(xù)使用這個代碼結(jié)構(gòu)。
盡管如此,我們還是看到了許多錯誤的設(shè)計(jì)模式。(或者根本沒有設(shè)計(jì)模式)他們甚至連改善設(shè)計(jì)模式的想法都沒有。
在Polyconseil公司,我們使用了就像上文中說的,經(jīng)歷時(shí)間洗禮的方法。我們使用了make
,而不是Grunt
,Gulp
,broccoli
,詳見
我們下次會討論其他的編程范式,路由,還有一堆我們在Polyconseil和其他地方遇到的常見陷阱。
PS:你可能發(fā)發(fā)現(xiàn)這些解除耦合的方法很像:Flux
- EOF -