在之前我們介紹了Elm的基礎和型別,並且在Elm的線上編輯器中實現了一個Counter,程式碼如下:
import Html exposing (..)
import Html.Events exposing (onClick)
import Html.App as App
type alias Model = Int
type Msg = Increment | Decrement
update : Msg -> Model -> Model
update msg model =
case msg of
Increment ->
model + 1
Decrement ->
model - 1
view : Model -> Html Msg
view model =
div []
[ button [onClick Decrement] [text "-"]
, text (toString model)
, button [onClick Increment] [text "+"]
]
initModel : Model
initModel = 3
main = App.beginnerProgram {model = initModel, view = view, update = update}
相信你對這門語言已經不再感到陌生,甚至想開始用它做一些小專案。
然而,目前這個Counter還只能執行在elm官網提供的線上編輯器上,如何搭建一個Elm本地工程?如何封裝和複用Elm模組?這些就是我們今天將要介紹的內容
搭建本地工程
以上一篇文章中寫好的Counter為例,讓我們建立一個執行Counter的本地Elm工程,新建一個名為elm-in-practice的資料夾(當然名字隨便了)作為專案目錄。
package.json 與 elm-package.json
在建立好專案目錄後,第一件事就是建立package.json檔案(可以使用npm init
),雖然是elm專案,但是依託npm的依賴管理和構建工具也非常有用,並且更符合前端開發者的習慣,這裡我們用到的是elm和elm-live兩個包:
npm i --save-dev elm elm-live
然後是建立elm-package.json
,正如它的名字一樣,elm也提供了類似npm的包管理機制,你可以自由地釋出或者使用elm模組。在Counter中我們需要用到的有elm-lang/core
和elm-lang/html
兩個模組,之前我們使用的線上編輯器內建了這些常用依賴,在本地專案中則需要自行配置。完整的elm-package.json
檔案如下:
{
"version": "1.0.0",
"summary": "learn you a elm for great good",
"repository": "https://github.com/kpaxqin/elm-in-practice.git",
"license": "BSD3",
"source-directories": [
"."
],
"exposed-modules": [],
"dependencies": {
"elm-lang/core": "4.0.0 <= v < 5.0.0",
"elm-lang/html": "1.0.0 <= v < 2.0.0"
},
"elm-version": "0.17.0 <= v < 0.18.0"
}
然後執行node_modules/.bin/elm-package install
,和npm類似,這個命令會把相關的依賴安裝到名為elm-stuff
的資料夾下。
注意之前我們並沒有使用-g
引數將elm
和elm-live
安裝到全域性,這意味著你不能直接在命令列裡使用它們,而只能使用node_modules/.bin/<command> [args]
。
這樣做的好處是隔離專案間依賴,如果你的電腦上有多個專案依賴了不同的elm版本,切換專案會是非常麻煩的事。其它團隊成員設定環境時也會更麻煩。
但老是寫node_modules/.bin/<command>
就像重複程式碼一樣多餘,更常見的是結合npm run-script,將需要執行的命令新增到package.json的scripts欄位。在使用npm run
執行scripts的時候,node_modules/.bin/
會被臨時新增到PATH中,因此是可以省去的。
向package.json
中新增elm-install
命令
{
"name": "elm-in-practice",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"elm-install": "elm-package install"
},
"author": "",
"license": "ISC",
"devDependencies": {
"elm": "^0.17.0",
"elm-live": "^2.3.0"
}
}
然後執行npm run elm-install
即可。
建立Main.elm檔案
這一步非常簡單,在根目錄建立Main.elm檔案,並將之前的Counter程式碼複製進去。
目前為止不需要任何額外工作
和其它擁有模組機制的語言一樣,Elm也有模組匯出語法,但是應用的入口模組並不是必須的,只要模組中有main變數即可。
打包生成Javascript檔案
目前為止我們安裝好了依賴,也有了Elm原始碼,作為一門編譯到javascript的語言,要做的當然是打包生成.js檔案了。
elm提供了elm-make
命令,在package.json中新增scripts:
{
//...
scripts: {
"build": "elm-make Main.elm --output=build/index.js"
//...
}
//...
}
執行npm run build
,不出意外的話可以成功編譯出index.js檔案。
➜ elm-in-practice git:(master) ✗ npm run build
> elm-in-practice@1.0.0 build /Users/jwqin/workspace/elm/elm-in-practice
> elm-make Main.elm --output=build/index.js
Success! Compiled 1 module.
Successfully generated build/index.js
有意外也沒關係,編譯器會給出詳細的錯誤資訊。
在瀏覽器中執行
有了js檔案,就進入熟悉的套路了,在專案根目錄下新建一個index.html檔案:
<!DOCTYPE HTML>
<html>
<head>
<meta charset="UTF-8">
<title>Elm in practice</title>
</head>
<body>
<div id="container">
</div>
<script type="text/javascript" src="./build/index.js"></script>
<script type="text/javascript">
var node = document.getElementById(`container`);
var app = Elm.Main.embed(node);
</script>
</body>
</html>
這裡的核心是Elm.Main.embed(node)
,elm會為入口模組在全域性生成Elm.<Module Name>
物件,包含三個方法:
Elm.Main = {
fullscreen: function() { /* 在document.body上渲染 */ },
embed: function(node) { /* 在指定的node上渲染 */ },
worker: function() { /* 無UI執行 */ }
};
此處我們使用embed
將應用渲染到id為container的節點中。
在瀏覽器中開啟index.html,可以看到我們的Counter成功在本地執行起來了!
使用elm-live實現watch與live-reload
Counter並不是終點,接下來我們還要實現Counter list。但每次改完程式碼再手動執行編譯命令實在是太土鱉了,怎麼著也得有個watch吧?elm-live就是這方面的工具,它封裝了elm-make,並且提供了watch,dev server,live reload等實用的功能,不需要任何複雜的配置,相比原生elm-make,只用新增–open來自動開啟瀏覽器即可:
{
//...
scripts: {
"start": "elm-live Main.elm --output=build/index.js --open",
"build": "elm-make Main.elm --output=build/index.js"
//...
}
//...
}
執行npm start
感受一下吧
大名鼎鼎的webpack也可以用來編譯並打包elm檔案,甚至可以實現程式碼熱替換(Hot Module Replace),有興趣的可以參考elm-webpack-starter
CounterList
counter list是由任意個counter組成的counter列表,純react線上版:
https://jsfiddle.net/Kpaxqin/wh8hb8wr/
接下來就讓我們在Elm中實現同樣的功能
Counter模組
首先是需要抽象出可複用的Counter模組,新建目錄src,並在此目錄下建立Counter.elm。
將Main.elm的程式碼複製到Counter.elm中,然後刪除最後這句:
main = App.beginnerProgram {model = initModel, view = view, update = update}
作為模組,main已經不再需要了,取而代之的是我們需要匯出這個模組,在Counter.elm的第一行新增:
module Counter exposing (Model, initModel, Msg, update, view)
也可以使用exposing (..)
把當前檔案裡的所有變數都匯出,但具名匯出的方式要更健壯一些。
到此為止一個可複用的Counter模組就完成了。
在繼續之前還要做一件事,就是將src資料夾新增到elm-package.json的source-directories
中:
//elm-package.json
"source-directories": [
".",
"src"
],
這樣其它檔案就可以直接引用src下的模組了
再修改Main檔案:
import Html.App exposing (beginnerProgram)
import Counter
main = beginnerProgram {
model = Counter.initModel,
view = Counter.view,
update = Counter.update}
執行npm start
,效果和之前完全一樣,說明抽離模組的重構是成功的。
CounterList模組
再在src下新建一個CounterList.elm,可能你已經忘記了寫elm模組的套路,不用急,只要記得Elm的架構叫做M-V-U
就行了,任何元件都是由這幾部分組成:
--CounterList.elm
//Model
//Update
//View
這背後是非常自然的邏輯:描述資料,描述資料如何改變,將一切對映到檢視上。
Model
作為Counter列表,需要儲存的資料當然是Counter型別的陣列了
//Model
type alias Model = {counters: List Counter}
但是這樣的資料結構是有問題的:Counter型別本身並不包含id,當我們想要修改列表中某個counter時,如何查詢它呢?
為此我們需要新增額外的資料型別IndexedCounter
,負責將Counter和id組合起來:
type alias IndexedCounter = {id: Int, counter: Counter}
type alias Model = {counters: List IndexedCounter}
這樣就沒問題了,不過還得解決如何生成id,為了簡便,我們在Model上再新增一個uid欄位,儲存最近的id,每次新增一個counter就將它+1,相當於模擬一個自增id生成器:
type alias IndexedCounter = {id: Int, counter: Counter}
type alias Model = {uid: Int, counters: List IndexedCounter}
同時,我們可以定義一個Model型別的初始值:
initModel: Model
initModel = {uid= 0, counters = [{id= 0, counter= Counter.initModel}]}
Update
Msg
在處理變更前我們需要先定義變更,在Counter list中主要有三類:增加Counter、刪除Counter、修改Counter:
type Msg = Insert | Remove | Modify
新增和刪除Counter都不需要額外的資訊,但修改卻不一樣,它需要指明改哪個以及怎麼改,藉助前面講到的值構造器,我們可以通過讓Modify攜帶兩個已知型別來達到目的:Int表示目標counter的id,Counter.Msg表示要對該counter做的操作。
type Msg = Insert | Remove | Modify Int Counter.Msg
從架構上
type Msg
對應了Redux中的action,都用來表達對系統的變更。此例可以看出在Elm中,基於型別的action擁有強大的組合能力,而Redux基於字串的action在這方面的表達力則要弱一些。關於兩者的對比,在下一章會繼續探討
有了Msg,update函式就很好寫了,在開始寫邏輯之前可以先返回原model作為佔位:
update : Msg-> Model -> Model
update msg model =
case msg of
Insert ->
model
Remove ->
model
Modify id counterMsg ->
model
新增
先處理新增,邏輯是給model.uid加1,並且往model.counters裡新增一個IndexedCounter類的值:
update : Msg -> Model -> Model
update msg model =
case msg of
Insert ->
let
id = model.uid + 1
in
{
uid = id,
counters = model.counters ++ [{id = id, counter = Counter.initModel}]
}
Remove ->
model
Modify id counterMsg ->
model
這裡我們直接生成了一個新的model,++
是Elm中的拼接操作符,可以用來拼接List a
, String
等型別
其實
++
也是函式,和一般函式的func a b
不同,它的呼叫方式a func b
,這種被稱作中綴函式
,常用的操作符如+
、-
都是如此
刪除
刪除的邏輯就簡單很多了,直接去掉counters陣列中的最後一個即可
Remove ->
{counters | counters = List.drop 1 model.counters}
修改
修改的邏輯是最複雜的,基本的思路是map整個counters,如果counter的id和目標一致,則呼叫Counter
模組暴露出的update
函式更新,否則原樣返回:
Modify id counterMsg ->
let
counterMapper = updateCounter id counterMsg
in
{model | counters = List.map counterMapper model.counters}
updateCounter : Int -> Counter.Msg -> IndexedCounter -> IndexedCounter
updateCounter id counterMsg indexedCounter =
if id == indexedCounter.id
then {indexedCounter | counter = Counter.update counterMsg indexedCounter.counter}
else indexedCounter
List.map的第一個引數counterMapper是updateCounter函式被部分應用後返回的函式,它接收並返回IndexedCounter,這正是mapper函式需要做的。
在updateCounter中我們使用了Counter.update來獲取新的counter,寫到這裡你可能已經發現,在Model / Msg / update中,我們都使用了Counter模組的對應部分,這就是Elm最大的特點:無處不在的組合,接下來在View中你也會看到這一點
在繼續之前,我們可以先回顧一下目前為止的完整程式碼:
import Counter
type alias IndexedCounter = {id: Int, counter: Counter.Model}
type alias Model = {uid: Int, counters: List IndexedCounter}
type Msg = Insert | Remove | Modify id Counter.Msg
update : Msg -> Model -> Model
update msg model =
case msg of
Insert ->
let
id = model.uid + 1
in
{
uid = id,
counters = {id = id, counter = Counter.initModel} :: model.counters
}
Remove ->
{model | counters = List.drop 1 model.counters}
Modify id counterMsg ->
let
counterMapper = updateCounter id counterMsg
in
{model | counters = List.map counterMapper model.counters}
updateCounter : Int -> Counter.Msg -> IndexedCounter -> IndexedCounter
updateCounter id counterMsg indexedCounter =
if id == indexedCounter.id
then {indexedCounter | counter = Counter.update counterMsg indexedCounter.counter}
else indexedCounter
View
最後要做的事情很簡單,就是把資料和行為對映到檢視上:
view : Model -> Html Msg
view model =
div []
[ button [onClick Insert] [text "Insert"]
, button [onClick Remove] [text "Remove"]
, div [] (List.map showCounter model.counters)
]
showCounter : IndexedCounter -> Html Msg
showCounter indexedCounter =
Counter.view indexedCounter.counter
然而以上程式碼是不工作的!如果一個view函式的返回型別定義為Html Msg
,那它所有的節點都必須滿足該型別。Counter.view
函式的返回型別是Html Counter.Msg
,而我們需要的卻是Html Msg
(此處的Msg為當前CounterList模組的Msg)。
換個角度看,在兩個button的onClick事件中,我們會產生Msg型別的訊息值:Insert
和Remove
。而負責修改Counter的Modify
卻沒有地方能產生,這顯然是有問題的。
既然Counter.view
返回的型別Html Counter.Msg
和我們要的Html Msg
不匹配,就得想辦法做轉換,此處我們將要用到Html.App
模組的App.map
函式:
showCounter : IndexedCounter -> Html Msg
showCounter ({id, counter} as indexedCounter) =
App.map (counterMsg -> Modify id counterMsg) (Counter.view counter)
counterMsg -> Modify id counterMsg
是Elm中的匿名函式,在Elm中,匿名函式使用開頭緊接著引數,並在
->
後書寫返回值表示式,形如a -> b
。
App.map的型別簽名為(a -> msg) -> Html a -> Html msg
,第一個引數是針對msg的轉換函式,藉助它我們將Html Counter.Msg
型別的檢視轉換成了Html Msg
型別。還記得Modify的定義嗎?
type Msg = Insert | Remove | Modify id Counter.Msg
使用Modify
構造值所需要的:id和Counter.Msg,在showCounter裡全都滿足。這並不是巧合,而是Elm架構上的精妙之處,還請讀者自行思考體會。
上述程式碼還使用了Elm中的解構,即
{id, counter} as indexedCounter
,和ES 6中的const {a, b} = {a: 1, b: 2}
類似,不再贅述。
執行
至此,CounterList模組就基本宣告完成,為了使用它,我們還需要定義模組的匯出,和Counter.elm一樣,在最頂部新增:
module CounterList exposing (Msg, Model, initModel, update, view)
然後修改Main.elm:
import Html.App exposing (beginnerProgram)
import CounterList
main = beginnerProgram {
model = CounterList.initModel,
view = CounterList.view,
update = CounterList.update}
執行看看吧!
編譯失敗也不要緊,試著藉助Elm編譯器的錯誤提示去修改問題
以上的完整程式碼,請參考Github傳送門
小結
也許你已經注意到了,無論是Counter.elm還是CounterList.elm,元件的匯出都是碎片化的:
--Counter.elm
module Counter exposing (Model, Msg, initModel, update, view)
--CounterList.elm
module CounterList exposing (Model, Msg, initModel, update, view)
而這些碎片都符合Elm Architecture的標準。
這和平常我們接觸到的元件方案有所不同,多數的架構把元件看作一個閉合的整體:
<CounterList>
<Counter id={1} />
<Counter id={2} />
</CounterList>
然後在閉合的基礎上,再定義開放的介面,比如新增回撥。這個方案的風險之處在於:閉合和開放的邊界非常難以界定,最初定義的開放介面不能滿足需要,在維護期中改得千瘡百孔是常有的事。
Redux要求元件為儘量不具備行為的純檢視,可以看作是對閉合邊界的一種限定
一個具備完整功能性的元件至少由檢視
、資料
、行為
三部分組成,如果我們將它們全部封裝到閉合模組中,簡單場合下的複用會非常直觀,React版的CounterList就是例子,它的Counter是完全閉合的:
class Counter extends React.Component {
constructor(props) {
super(props);
this.state = {
value: 10
}
}
onDecrement() {
this.setState({
value: this.state.value - 1
})
}
onIncrement() {
this.setState({
value: this.state.value + 1
})
}
render() {
const {value} = this.state;
return (
<div>
<button onClick={this.onIncrement.bind(this)}>+</button>
{value}
<button onClick={this.onDecrement.bind(this)}>-</button>
</div>
)
}
}
這使得在渲染Counter列表時,程式碼只需要短短一句:
this.state.list.map(i=> <Counter key={i}/>)
而Elm繞了一大圈,把元件拆得七零八落,收益在哪呢?
下面請看思考題:
設CounterList中有固定的三個子Counter:A, B, C。它們正常工作,就像我們在本章實現的一樣。為了簡化問題,我們暫時移除且不考慮新增和刪除Counter的功能。
突然,你家產品經理想出了提升KPI的絕妙辦法:在操作A的加減時,應該改變B的值,操作B時改變C,操作C時改變A。
請思考:在不對產品經理造成人身傷害的前提下,如何用React閉合元件、Redux、Elm分別實現該需求。