Model-View-Intent & Component
Intro
我們會希望寫出來的 code 能夠做成被複用的 Component,
不過首先要來拆解一下越來越肥大的 main function。
而 main 就可以被拆成 Model、View 、Intent。
Model View Intent
先看一下上次 BMI example 的 main function
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 34 35 36 37 38 39 40 41
| function main(sources) { const changeWeight$ = sources.DOM.select('.weight').events('input') .map(ev => ev.target.value).startWith(70); const changeHeight$ = sources.DOM.select('.height').events('input') .map(ev => ev.target.value).startWith(170); const state$ = Rx.Observable.combineLatest( changeWeight$, changeHeight$, (weight, height) => { const heightM = height/100; const bmi = Math.round(weight / (heightM * heightM)); return { bmi, weight, height } }) return { DOM: state$.map(state => div([ div([ label(`Weight: ${state.weight}kg`), input('.weight', { type: 'range', min: 40, max: 150, value: state.weight }) ]), div([ label(`Height: ${state.height}cm`), input('.height', { type: 'range', min: 140, max: 250, value: state.height }) ]), h1(`BMI is ${state.bmi}`) ]) ) } }
|
這麼大一包看起來絕對不是好事。
所以我們會把 main 分成三塊,
分別是 Model, Intent, View
Intent: to listen to the user
Model: to process information
View: to output back to the user
Intent
第一塊是「Intent」,
簡單說就是 User 想對 UI 做什麼事情的 Intent,
在這裡當然就是指雙方互動的部分:
1 2 3 4 5
| const changeWeight$ = sources.DOM.select('.weight').events('input') .map(ev => ev.target.value).startWith(70); const changeHeight$ = sources.DOM.select('.height').events('input') .map(ev => ev.target.value).startWith(170);
|
1 2 3 4 5 6 7
| function intent (DOMSource) { const changeWeight$ = DOMSource.select('.weight').events('input') .map(ev => ev.target.value).startWith(70); const changeHeight$ = DOMSource.select('.height').events('input') .map(ev => ev.target.value).startWith(170); return {changeWeight$,changeHeight$}; }
|
Model
model 則是處理資料流的部分:
1 2 3 4 5 6 7 8 9 10 11 12
| function model(changeWeight$, changeHeight$) { const state$ = Rx.Observable.combineLatest( changeWeight$, changeHeight$, (weight, height) => { const heightM = height/100; const bmi = Math.round(weight / (heightM * heightM)); return { bmi, weight, height } }) return state$; }
|
View
這裏則是依照 Model 中的資料去建 Virtual DOM tree
我們不會把最後要 return 給 Driver 的東西也放在這
僅放跟 UI 生成相關的而已
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
| function view(state$) { const vtree$ = state$.map(state => div([ div([ label(`Weight: ${state.weight}kg`), input('.weight', { type: 'range', min: 40, max: 150, value: state.weight }) ]), div([ label(`Height: ${state.height}cm`), input('.height', { type: 'range', min: 140, max: 250, value: state.height }) ]), h1(`BMI is ${state.bmi}`) ]) ) return vtree$; }
|
MVI
然後我們的 main 變得簡潔許多,
看起來只是 function 組合起來而已:
1 2 3 4 5 6 7 8
| function main(sources) { const {changeWeight$,changeHeight$} = intent(sources.DOM); const state$ = model(changeWeight$, changeHeight$); const vtree$ = view(state$); return { DOM: vtree$ } }
|
Component
那我們該如何減少重複的 Code 呢?
當 UI 的操作越變越複雜以後,
我們不會希望所有事情都能在一個 main 裡面解決,
這時候我們可以把重複的部分抽出來變成 component。
egghead 課程裡面有更精簡的怎麼把 main 提煉成 component 的過程,
不過核心精神蠻簡單的,就是 props 也是 stream。
因為 props 是會跟著傳下來的「資料」,
所以很自然的我們就會選擇處理資料的 model 下手。
而 model 收到的 sources 是從 Drivers 來的,
第一步就是先更動 drivers
1 2 3 4 5 6 7 8 9 10
| const drivers = { DOM: makeDOMDriver('#app'), props: () => Rx.Observable.of({ label: 'Height', unit: 'cm', min: 100, max: 220, init: 170 }) }
|
再來就是把 props 傳進去:
1
| const state$ = model(upcomingValue$, sources.props);
|
記住: props 也是 Observable
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| function model(upcomingValue$, props$) { const initValue$ = props$.map(props => props.init).first(); const value$ = initValue$.concat(upcomingValue$); const state$ = Rx.Observable.combineLatest(value$, props$, (value, props) => { return { label: props.label, unit: props.unit, min: props.min, max: props.max, value: value } }) return state$ }
|
initial value 的 stream concat 新進來 value 的 stream,
取代原本的 startWith
下一步就是把 label 的名字和單位給 return 出來,
變成一條 UI component 可以吃到的 state stream,
再把對應的值塞進 view 裡面,就能得到我們想要的 vtree$ 了。
Using component with Main function
我們現在每個 component 中都會有個 main function,
事實上我們能把 main 改成這個 component 的名字,
並且在更上層的 main 中去使用它,
因為事實上他就是一個 function,在 functional programming 中,
“composable” 可以說是最重要的概念之一。
1 2 3 4 5 6 7 8 9 10 11 12 13
| function LabelSlider(sources) { const upcomingValue$ = intent(sources.DOM); const state$ = model(upcomingValue$, sources.props); const vtree$ = view(state$); return { DOM: vtree$, } } function main (sources) { return LabelSlider(sources) }
|
而事實上,我們可以把 props 這件事移到 main 中去做
1 2 3 4 5 6 7 8 9 10 11 12 13
| function main (sources) { const props$ = Rx.Observable.of({ label: 'Height', unit: 'cm', min: 100, max: 220, init: 170 }) return LabelSlider({DOM: sources.DOM, props: props$}) } const drivers = { DOM: makeDOMDriver('#app'), }
|
Multiple Components
如果只有ㄧ個 component 的話,那 cycle.js 也太慘,
我們當然是可以組合多個 components,
只是該怎麼做呢?
很簡單,先把 sinks 個別抽出來:
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
| function main (sources) { const weightProps$ = Rx.Observable.of({ label: 'Weight', unit: 'kg', min: 30, max: 220, init: 70 }) const weightSinks$ = LabelSlider({DOM: sources.DOM, props: weightProps$}) const heightProps$ = Rx.Observable.of({ label: 'Height', unit: 'cm', min: 100, max: 220, init: 170 }); const heightSinks$ = LabelSlider({DOM: sources.DOM, props: heightProps$}) const vtree$ = Rx.Observable.combineLatest(weightSinks$.DOM, heightSinks$.DOM, (weightVtree, heightVtree) => div([ weightVtree, heightVtree ])) return { DOM: vtree$ } }
|
這裏會發現一個問題,就是當我們移動其中一個 slider 時,
另一個也會被影響 ,使用者的互動 => intent
因為兩個的 class 都是 slider,
而 intent 中監聽的又是 “.slider” 底下的 input。
其實我們在 LabelSlider 裡就可以讓兩條 stream 分流,
因為我們傳進去的 sources.DOM
,是可以只要選取 weight 或 height 就好:
1 2 3 4
| const weightSinks$ = LabelSlider({ DOM: sources.DOM.select('.weight'), props: weightProps$ })
|
這裏做的事情就等於在 intent 裡面這樣:
1 2 3 4 5
| function intent(DOMSource) { const change$ = DOMSource.select('.weight').select('.slider').events('input') .map(ev => ev.target.value); return change$; }
|
我們 pre-select 了在 DOM 上面 class name 為 ‘.weight’的 stream。
Isolate component
Isolate
要隔離開每個 Component 如果都像上面那樣做應該會瘋掉,
所以 Cyclejs 其實提供給我們一個 helper function: isolate
使用方法是傳入一個 Component function 當作 argument
再來會回傳一個 scoped 的 component function,
同樣吃 sources 進去,吐 sinks 出來
isolate(dataflowComponent, scope)
:第二個參數是 optional 的,如同看到的一樣
可能會有人覺得沒什麼差別,但如果單純使用 isolate(dataflowComponent)
,
那會是一個不純的 function ,因為每次呼叫都會 return 一個不一樣的 scoped component function
但如果我們指定了 scope,那每次回來的就是同一個 scope 下的 component function
真正的濃醇香!
1 2 3 4 5 6
| const WeightSlider = isolate(LabelSlider, 'weight'); const weightSinks$ = WeightSlider({ DOM: sources.DOM, props: weightProps$ }); const weightVtree$ = weightSinks$.DOM;
|
如此一來又減少了一些 boiler plate
Final BMI
目前缺的就是把 bmi 給算出來了,
首先我們知道這個運算會放在 main 裡面,
因為這就是這個簡單小 App 的主要邏輯。
1 2 3 4 5
| const bmi$ = Rx.Observable.combineLatest(weightValue$, heightValue$, (weight, height) => { const heightMeters = height * 0.01; const bmi = Math.round(weight/(heightMeters*heightMeters)) return bmi; });
|
現在問題來了:我們要怎樣得到 weightValue$ 以及 heightValue$ 呢?
從 sources 拿啊!
概念很簡單,我們從 main 中拿到的 source,
其實就是從前一層 component 中吐出來的 sinks,
所以我們自然從前一層 component 中回傳的 sinks 下手:
1 2 3 4 5 6 7 8 9
| function LabelSlider(sources) { const upcomingValue$ = intent(sources.DOM); const state$ = model(upcomingValue$, sources.props); const vtree$ = view(state$); return { DOM: vtree$, value: state$.map(state=> state.value) } }
|
實作起來也是這麼簡單。
最後我們回到 main 中,
把 bmi$ 也加進去就成啦!
1 2 3 4 5 6 7 8 9
| const vtree$ = Rx.Observable.combineLatest(bmi$, weightVtree$, heightVtree$, (bmi, weightVtree, heightVtree) => div([ weightVtree, heightVtree, h1(`BMI is: ${bmi}`) ])) return { DOM: vtree$ }
|
Conclusion
總計 21 回的課程算不上太長,很推薦有興趣的人去把它看完,
儘管實際上要弄懂 Cycle.js 的概念的確需要花點時間,
但學習 FRP 是值得的,畢竟我們就是在處理 dataflow + UI,
再加上 pure function 好測試、composable 的特性,
不由得感慨 Rx 寫起來真是爽。
相較於 React,Cycle.js 當然更接近 functinoal programming,
不論這個東西將來會不會用到產品上,
純函數式的東西總會莫名的吸引我。
如果要追求 fp,更應該要感受一下 elm
這一堂課的影片幾乎都在 jsfiddle 上完成,
(不曉得作者為啥要這樣XD)
我中間練習的程式碼有放在 github 上面,
筆記等年假再來好好整理一番。
參考資料