Elm入門實踐(三)——進階篇

kpaxqin發表於2016-06-27

在之前我們介紹了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/coreelm-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引數將elmelm-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型別的訊息值:InsertRemove。而負責修改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分別實現該需求。

相關文章