Elm 架構教程

本文翻譯自 https://github.com/evancz/elm-architectu...html

本教程概述了「Elm 程序的架構」,你在全部 Elm 程序中都能看到它,從 TodoMVCdreamwriterNoRedInk 以及 CircuitHub 在生產環境中運行的代碼。這種基本模式不管用在編寫 Elm 或 JS 前端代碼時都頗有用。前端

Elm 架構是無限嵌套組件的簡單模式,對於模塊化、代碼重用和測試都頗有效。並且,這種模式能夠很容易地用模塊化的方式建立複雜的 Web 應用程序。咱們將經過 8 個例子,一步步學習它的核心原則和模式:react

  1. 計數器git

  2. 雙計數器github

  3. 計數器列表web

  4. 計數器列表 (變體)數據庫

  5. GIF 提取編程

  6. 雙 GIF 提取器json

  7. GIF 提取器隊列api

  8. 兩個動畫

本教程在某些方面上能夠稱得上前無古人,後無來者!它教會你必要的概念和想法,讓開發例子7和例子8變得超級簡單。你這筆基礎投資絕對是物超所值得的!

在這些示例的架構中有一個很是有趣的地方:它會從 Elm 中 天然浮現 出來。Elm 語言的設計自己致使你走向這個架構,不管你是否已閱讀本文件,知道它的好處與否。我只是在使用 Elm 時偶然發現了這種模式,並深深地被它的簡單和強悍所震驚。

注意: 要使用此教程,必須和代碼一塊兒學習。安裝 Elm 並 Fork 這個項目。在本教程的每一個例子中都給出瞭如何運行項目代碼的指令。

基礎模式

每一個 Elm 程序的邏輯將被分爲三個徹底分離的部分:

  • model

  • update

  • view

你能夠很是放心地使用下面的腳手架,而後爲你的具體需求不斷增長實現細節。

若是你是第一次閱讀 Elm 代碼,請查看 elm 語言官方文檔 它涵蓋了從語法到 「函數式思惟」。或者這份 完整指南 的前兩章能夠幫你快速入門。

-- MODEL

type alias Model = { ... }


-- UPDATE

type Action = Reset | ...

update : Action -> Model -> Model
update action model =
  case action of
    Reset -> ...
    ...


-- VIEW

view : Model -> Html
view =
  ...

本教程都是關於這種模式的變化和擴展。

Example 1: A Counter

演示Demo / 源碼

咱們的第一個例子是一個簡單的計數器,它能夠遞增或遞減。

這段代碼 以一個很是簡單的模型開始。咱們只須要跟蹤一個數字:

type alias Model = Int

當須要更新咱們的模型時,事情又一次變得簡單。咱們定義一組能夠執行的動做,以及一個 update 函數來實際執行這些動做:

type Action = Increment | Decrement

update : Action -> Model -> Model
update action model =
  case action of
    Increment -> model + 1
    Decrement -> model - 1

請注意,Action 這個 union type 沒有作任何事。它簡單地描述了可能的行動。若是有人認爲當按下某個按鈕時咱們的計數器應該增長一倍,咱們只須要增長一個新的 Action。這意味着這段代碼很是清楚 model 該如何變化。任何閱讀此代碼的人將馬上知道那些是容許的,哪些不是。此外,他們將知道如何以一致的方式添加新的功能。

最後,咱們建立了一個 view 來展現 Model。咱們使用 [elm-html][] 來建立一些在瀏覽器中顯示的 HTML。咱們先建立一個最外層的 div,它內含:一個減量按鈕,顯示當前計數的 div,和一個增量按鈕。

view : Signal.Address Action -> Model -> Html
view address model =
  div []
    [ button [ onClick address Decrement ] [ text "-" ]
    , div [ countStyle ] [ text (toString model) ]
    , button [ onClick address Increment ] [ text "+" ]
    ]

countStyle : Attribute
countStyle =
  ...

比較棘手的是 viewAddress 函數。咱們會在下一章深刻講解它!如今,我只是想讓你注意到 這段代碼徹底只是聲明。咱們使用 Model 生成 Html。就是這樣,在任什麼時候候,咱們不會手工改變 DOM,這給了一些庫 更大的自由度作出更聰明的優化 而且使渲染速度更快。這簡直瘋了!並且, view 是一個普通的函數,因此咱們建立 view 時能夠獲得 Elm 的模塊系統,測試框架和庫。

這種模式是架構 Elm 程序的精髓。咱們從如今開始看到每個例子都將只是對這個基本模式的略微變化: Modelupdateview

啓動程序

幾乎全部的 Elm 程序都會有一個簡短的代碼,用它來驅動整個應用程序。在本教程的每一個例子中,該代碼被命名爲 Main.elm。雖然是個反例,但它依然頗有趣:

import Counter exposing (update, view)
import StartApp.Simple exposing (start)

main =
  start { model = 0, update = update, view = view }

咱們使用 StartApp 這個庫把初始 modelupdateview 鏈接起來。它只是對 Elm 的 signals 作了一個小封裝,因此你還不須要深刻研究它的原理。

裝配應用的關鍵概念是 Address。每一個事件處理器在 view 函數中獲得一個特定的地址,而且和數據塊一塊兒傳遞過來。StartApp 監聽全部傳給這個地址的消息,而後把它們發送給 update 函數。model 得到更新, 而 [elm-html][] 負責渲染和高效的修改。

這意味着,Elm 程序中的數據只會在一個方向上流動,相似這樣:

clipboard.png

藍色部分是咱們 Elm 程序的核心,這正是 model/update/view,咱們一直在討論的模式。使用 Elm 編程,你能夠一直呆在這個舒服的盒子裏面,並取得很大的進步。

注意,咱們 不執行 送回應用程序的 action。咱們只是轉發一些數據。這種分離是一個關鍵的細節,使咱們的邏輯徹底從視圖代碼中分離出來。

Example 2: A Pair of Counters

demo / see code

在上一個例子裏咱們搞了一個計數器,若是增長到兩個計數器時這個模式會怎樣變化呢?咱們能繼續保持模塊化嗎?

若是咱們能徹底重用 例子1 的代碼就再好不過了。Elm 架構最瘋狂的就是:咱們能夠一句不變地重用代碼。當咱們實現 例子1 的 Counter 模塊時,它包括了全部細節,因此咱們能夠在任何地方使用它。

module Counter (Model, init, Action, update, view) where

type Model

init : Int -> Model

type Action

update : Action -> Model -> Model

view : Signal.Address Action -> Model -> Html

編寫模塊代碼其實徹底是在建立一種很強的抽象。咱們期待的是提供合適的函數接口,可是隱藏具體執行過程。從 Counter 模塊的外部咱們只能看到一些基礎的值: ModelinitActionupdateview。咱們徹底不用關心這些是如何實現的。事實上,也不可能知道這些是如何實現的。這意味着沒人須要依賴這些不公開的實現細節。

咱們本能夠徹底複製 Counter 模塊, 但咱們仍是使用它的一部分來實現 CounterPair。 像往常同樣, 咱們從一個 Model 開始:

type alias Model =
    { topCounter : Counter.Model
    , bottomCounter : Counter.Model
    }

init : Int -> Int -> Model
init top bottom =
    { topCounter = Counter.init top
    , bottomCounter = Counter.init bottom
    }

咱們的 Model 紀錄了兩個計數器, 其中一個是須要在屏幕上顯示的。這個 Model 徹底描述了應用全部的狀態。咱們還有一個 init 函數能夠在任何地方建立一個新的 Model

下一步來描述下咱們想要支持的 Actions。咱們須要的功能是:重置全部的計數器,更新頂部的計數器,或者更新下面的計數器。

type Action
    = Reset
    | Top Counter.Action
    | Bottom Counter.Action

請注意,咱們的 [union type][] 是參考 Counter.Action 類型,但咱們並不知道那些 action 的細節。當咱們建立 update 函數時,主要工做是路由這些 Counter.Actions 到正確的地方:

update : Action -> Model -> Model
update action model =
  case action of
    Reset -> init 0 0

    Top act ->
      { model |
          topCounter = Counter.update act model.topCounter
      }

    Bottom act ->
      { model |
          bottomCounter = Counter.update act model.bottomCounter
      }

因此最後要作的事情就是建立一個 view 函數顯示兩個計數器和兩個重置按鈕。

view : Signal.Address Action -> Model -> Html
view address model =
  div []
    [ Counter.view (Signal.forwardTo address Top) model.topCounter
    , Counter.view (Signal.forwardTo address Bottom) model.bottomCounter
    , button [ onClick address Reset ] [ text "RESET" ]
    ]

請注意,咱們能夠在兩個計數器之中複用 Counter.view 函數,給每一個計數器建立一個轉發地址。大致上,這裏作的事情實際上是:「讓這倆計數器給全部向外傳遞的消息打上 TopBottom 標誌,以便區分」

這就是全部的工做。最屌的是咱們能夠一層又一層地保持嵌套。咱們能夠建立 CounterPair 模塊,暴露關鍵值和方法,而後建立 CounterPairPair 或者任何其餘咱們須要的。

Example 3: A Dynamic List of Counters

demo / see code

兩個計數器已經很屌了,一個能夠隨意添加和刪除的計數器隊列會怎麼樣呢?這種模式還有效嗎?

甚至咱們能夠徹底像 例子1 和 例子2 裏那樣複用 Counter

module Counter (Model, init, Action, update, view)

這意味着咱們能夠開始建立 CounterList 模塊了。 像往常同樣, 咱們從 Model 開始:

type alias Model =
    { counters : List ( ID, Counter.Model )
    , nextID : ID
    }

type alias ID = Int

如今,咱們的 model 有了一個計數器隊列,每一個計數器有一個惟一的 ID。這些 ID 使咱們能夠區別它們,因此若是咱們要更新 4 號計數器,咱們能夠很輕鬆的找到它。(當咱們考慮優化渲染時,這個 ID 也給了咱們一些 key 的便利,然而它並非這個教程的重點!)咱們的 modal 還包含一個 nextID 幫助咱們指定 ID 給每個新增的計數器。

如今咱們能夠定義一組 Action 來操做 model。咱們但願能夠添加計數器,刪除計數器,以及更新特定的計數器。

type Action
    = Insert
    | Remove
    | Modify ID Counter.Action

咱們的 Action union type 使人震驚的接近高階描述。下面咱們能夠定義 update 函數了。

update : Action -> Model -> Model
update action model =
  case action of
    Insert ->
      let newCounter = ( model.nextID, Counter.init 0 )
          newCounters = model.counters ++ [ newCounter ]
      in
          { model |
              counters = newCounters,
              nextID = model.nextID + 1
          }

    Remove ->
      { model | counters = List.drop 1 model.counters }

    Modify id counterAction ->
      let updateCounter (counterID, counterModel) =
            if counterID == id
                then (counterID, Counter.update counterAction counterModel)
                else (counterID, counterModel)
      in
          { model | counters = List.map updateCounter model.counters }

這裏有對每種狀況的高階描述:

  • Insert — 首先咱們創造一個新的計數器,並把它當在計數器隊列的最後。而後咱們給 nextID 加一,以便下一次添加時有一個新的ID。

  • Remove — 刪除計數器列表的第一個成員。

  • Modify — 遍歷全部計數器,當找到匹配的 ID 時,用所給的 Action 操做這個計數器。

下面惟一要作的就是定義 view

view : Signal.Address Action -> Model -> Html
view address model =
  let counters = List.map (viewCounter address) model.counters
      remove = button [ onClick address Remove ] [ text "Remove" ]
      insert = button [ onClick address Insert ] [ text "Add" ]
  in
      div [] ([remove, insert] ++ counters)

viewCounter : Signal.Address Action -> (ID, Counter.Model) -> Html
viewCounter address (id, model) =
  Counter.view (Signal.forwardTo address (Modify id)) model

這裏的 viewCounter 函數比較有趣。它必須使用同一個 Counter.view 函數,但在這裏咱們提供了一個轉發地址來標記全部的消息和正在渲染的計數器的 ID。

實際上,當咱們建立 view 函數時,咱們映射 viewCounter 到全部的計數器,而後建立添加和刪除按鈕直接返回 address

這個 ID 的玩法能夠用在任何你須要數目可變的子模塊時。計數器是簡單的,可是這種模式能夠徹底不變的在用戶信息,tweets,新聞列表或者產品列表上覆用。

Example 4: A Fancier List of Counters

demo / see code

OK,在一個動態的計數器列表上保持簡單和模塊化是很屌的,可是若是不要一個通用的刪除按鈕,而是每一個計數器有一個單獨的刪除按鈕呢?它會把事情搞糟嗎?

不, 它仍然有效.

在這裏,咱們的目標是找到一種新的方法給每一個計數器添加一個刪除按鈕。有趣的是,咱們能夠繼續使用原有的 view 函數並添加一個新的 viewWithRemoveButton 函數,這個函數爲咱們依賴的 Model 提供一個微小的變化。屌屌屌,咱們不用重複任何代碼更不用作任何瘋狂的繼承和重載。咱們只是給公開的 API 添加了一個函數暴露新的功能!

module Counter (Model, init, Action, update, view, viewWithRemoveButton, Context) where

...

type alias Context =
    { actions : Signal.Address Action
    , remove : Signal.Address ()
    }

viewWithRemoveButton : Context -> Model -> Html
viewWithRemoveButton context model =
  div []
    [ button [ onClick context.actions Decrement ] [ text "-" ]
    , div [ countStyle ] [ text (toString model) ]
    , button [ onClick context.actions Increment ] [ text "+" ]
    , div [ countStyle ] []
    , button [ onClick context.remove () ] [ text "X" ]
    ]

viewWithRemoveButton 函數添加了一個額外的按鈕。請注意 增長/減小 按鈕發送消息給 actions 地址,可是刪除按鈕發送消息給 remove 這個地址。咱們發給 remove 的消息實際上是在說:「嘿,不管誰擁有我,請刪掉我!」 這個計數器的擁有者負責刪除。

既然咱們有了新的 viewWithRemoveButton, 咱們能夠建立一個新的 CounterList 模塊把全部獨立的計數器放在一塊兒。這個 Model 和 栗子3 中的同樣: 帶各自 ID 的計數器列表。

type alias Model =
    { counters : List ( ID, Counter.Model )
    , nextID : ID
    }

type alias ID = Int

咱們的 action 稍有不一樣。不是刪除一箇舊的計數器,而是刪除特定的一個,因此 Remove 須要一個 ID。

type Action
    = Insert
    | Remove ID
    | Modify ID Counter.Action

update 函數和 栗子3 中的很是像。

update : Action -> Model -> Model
update action model =
  case action of
    Insert ->
      { model |
          counters = ( model.nextID, Counter.init 0 ) :: model.counters,
          nextID = model.nextID + 1
      }

    Remove id ->
      { model |
          counters = List.filter (\(counterID, _) -> counterID /= id) model.counters
      }

    Modify id counterAction ->
      let updateCounter (counterID, counterModel) =
            if counterID == id
                then (counterID, Counter.update counterAction counterModel)
                else (counterID, counterModel)
      in
          { model | counters = List.map updateCounter model.counters }

Remove 時,咱們取出擁有該 ID 的計數器。不然,退出直接退出並保持原來那樣。

最後,咱們咱們把萌寶寶們都放進 view 中:

view : Signal.Address Action -> Model -> Html
view address model =
  let insert = button [ onClick address Insert ] [ text "Add" ]
  in
      div [] (insert :: List.map (viewCounter address) model.counters)

viewCounter : Signal.Address Action -> (ID, Counter.Model) -> Html
viewCounter address (id, model) =
  let context =
        Counter.Context
          (Signal.forwardTo address (Modify id))
          (Signal.forwardTo address (always (Remove id)))
  in
      Counter.viewWithRemoveButton context model

viewCounter 函數中, 咱們構造了 Counter.Context 來傳遞全部必需的轉發地址。在兩種狀況下分別聲明 Counter.Action 以便咱們知道哪一個計數器須要修改或刪除。

收穫一些人生的經驗

基礎模式 — 任何事都是圍繞 Model 建立出來的,包括更新 model 的函數, 以及 modelview。任何事均可以看做基礎模式的變體。

嵌套 Modules — 轉發地址使基礎模式的嵌套變的簡單,徹底隱藏實現細節。咱們能夠無限深地嵌套這種模式,而且每一層只須要知道下一層在發生什麼。

添加上下文 — 有時對 modal 進行 update 或者 view 操做時須要額外的信息。咱們隨時能夠添加 Context 給這些函數並傳遞全部的附加信息而不須要改變 Model

update : Context -> Action -> Model -> Model
view : Context' -> Model -> Html

在嵌套的每一層,咱們均可覺得每一個子模塊衍生出所需的 Context

測試變的簡單 — 咱們建立的全部函數都是 純潔函數。這樣測試 update 函數變的極其簡單。不須要特別的初始化、模擬、配置步驟,你只要帶着你想要測試的參數直接調用函數便可。

Example 5: Random GIF Viewer

demo / see code

咱們已經講了如何建立可無限嵌套的組件,但當咱們在某個組件裏發出一個 HTTP 請求時會發生什麼呢?與數據庫通訊呢?這個栗子使用 elm-effects 來建立一個簡單的組件,這個組件能夠從 giphy.com 獲取隨機的可愛喵星人的 gif。

若是看了 這個栗子的實現, 你會注意到它和 栗子1 中的代碼很是接近。它的 Model 很是典型:

type alias Model =
    { topic : String
    , gifUrl : String
    }

咱們須要知道要查找的 topic 值和當前展現的 gifUrl。這裏惟一新穎的東西是 initupdate 的類型:

init : String -> (Model, Effects Action)

update : Action -> Model -> (Model, Effects Action)

並不是只是返回一個新的 Model 咱們還返回一些咱們須要執行的效果。因此咱們將會使用 Effects API,看起來像這樣:

module Effects where

type Effects a

none : Effects a
  -- don't do anything

task : Task Never a -> Effects a
  -- request a task, do HTTP and database stuff

Effects 類型本質上是一個包含了一些會在以後執行的獨立任務的數據類型。讓咱們經過分析這裏的 update 來更深刻了解下這是怎麼工做的:

type Action
    = RequestMore
    | NewGif (Maybe String)


update : Action -> Model -> (Model, Effects Action)
update msg model =
  case msg of
    RequestMore ->
      ( model
      , getRandomGif model.topic
      )

    NewGif maybeUrl ->
      ( Model model.topic (Maybe.withDefault model.gifUrl maybeUrl)
      , Effects.none
      )

-- getRandomGif : String -> Effects Action

因此用戶能夠經過點擊 「More Please!」 按鈕來觸發 RequestMore,當服務器響應請求後它會給咱們一個 NewGifaction。咱們在 update 函數中處理這兩種狀況。

在這裏 RequestMore 第一次返回已經存在的 model。用戶只是點擊了一個按鈕,這時並無任何改變。咱們還使用 getRandomGif 函數建立了一個 Effects Action。咱們立刻將會知道 getRandomGif 是如何定義的。到此爲止,咱們只需知道當一個 Effects Action 運行時,會有一系列 Action 值產生並被傳遞給整個應用。因此 getRandomGif model.topic 最終會產生像這樣的一個 action like

NewGif (Just "http://s3.amazonaws.com/giphygifs/media/ka1aeBvFCSLD2/giphy.gif")

它返回一個 Maybe 由於向服務器發出的請求可能失敗。那個 Action 將會原路返回給 update 函數。因此當咱們執行 NewGif 時,咱們只是更新當前的 gifUrl,若是他能夠被更新。當請求失敗後,咱們只是停留在當前的 model.gifUrl

咱們看到一樣的事情發生在 init 函數中,它定義了初始時的 modal 而且經過 giphy.com 的 API 請求一個特定話題的 GIF。

init : String -> (Model, Effects Action)
init topic =
  ( Model topic "assets/waiting.gif"
  , getRandomGif topic
  )

-- getRandomGif : String -> Effects Action

再一次,當隨機的 GIF 下載完成,它會產生一個 Action 發送給 update 函數。

注意: 以前咱們使用的是來自 the start-app packageStartApp.Simple 模塊,可是如今請升級到 StartApp 模塊。它能夠處理更實際的 web 應用中的複雜狀況。它有 更優雅的 API。更相當重要的改變是它能夠處理咱們新的 initupdate 類型。

這個例子中一個相當重要的方面是 getRandomGif 函數,它描述瞭如何獲得一張隨機的 GIF。它使用了 任務Http 庫, 我會盡力概述它是如何運作的。讓咱們看定義:

getRandomGif : String -> Effects Action
getRandomGif topic =
  Http.get decodeImageUrl (randomUrl topic)
    |> Task.toMaybe
    |> Task.map NewGif
    |> Effects.task

-- The first line there created an HTTP GET request. It tries to
-- get some JSON at `randomUrl topic` and decodes the result
-- with `decodeImageUrl`. Both are defined below!
--
-- Next we use `Task.toMaybe` to capture any potential failures and
-- apply the `NewGif` tag to turn the result into a `Action`.
-- Finally we turn it into an `Effects` value that can be used in our
-- `init` or `update` functions.


-- Given a topic, construct a URL for the giphy API.
randomUrl : String -> String
randomUrl topic =
  Http.url "http://api.giphy.com/v1/gifs/random"
    [ "api_key" => "dc6zaTOxFJmzC"
    , "tag" => topic
    ]


-- A JSON decoder that takes a big chunk of JSON spit out by
-- giphy and extracts the string at `json.data.image_url` 
decodeImageUrl : Json.Decoder String
decodeImageUrl =
  Json.at ["data", "image_url"] Json.string

一旦咱們寫了上面這些,咱們就能夠在 initupdate 函數中複用 getRandomGif

有趣的是,getRandomGif 返回的任務是永遠不會失敗的。緣由是任何可能的失敗必須被明確的處理,咱們不但願任何任務靜靜地失敗。

我試圖確切地解釋下它是如何實現的,雖然這對於整個項目的正常運行並不特別重要。Okay,這樣每一個 Task 有一個失敗的類型和一個成功的類型。例如,一個 HTTP 任務可能有類型如:Task Http.Error String,咱們能夠在失敗時返回一個 Http.Error 或者成功時返回一個 String。這樣能夠優雅地把一組任務串在一塊兒而不用過多的擔憂出錯。如今,假設咱們的組件請求了一個任務,可是任務失敗了。會發生什麼呢?誰會被通知?如何恢復?經過設置失敗類型爲 Never,咱們強制任何可能的錯誤變成成功類型,這樣它們就能夠被組件明確的處理了。在這個例子裏,咱們用 Task.toMaybe : Task x a -> Task y (Maybe a) 因此 update 函數精確的處理了 HTTP 失敗。這意味着任務不能靜默的失敗,你永遠精確的處理着未知的錯誤。

Example 6: Pair of random GIF viewers

demo / see code

好了,結果搞定了,可是 嵌套 的結果呢?你是否思考過這個問題?!這個例子徹底重用栗子5中的 GIF 查看器的代碼建立了兩個獨立的 GIF 查看器。

你閱讀 這個實現代碼 時,會注意到它和栗子2中的兩個計數器的代碼幾乎同樣。Model 被定義爲兩個 RandomGif.Model 的值:

type alias Model =
    { left : RandomGif.Model
    , right : RandomGif.Model
    }

這讓咱們能夠獨立地分別跟蹤它們。咱們的 action 只是路由消息到正確的自模塊。

type Action
    = Left RandomGif.Action
    | Right RandomGif.Action

有趣的是,咱們實際上使用了 Left and Right 標籤在 updateinit 函數中。

-- Effects.map : (a -> b) -> Effects a -> Effects b

update : Action -> Model -> (Model, Effects Action)
update action model =
  case action of
    Left msg ->
      let
        (left, fx) = RandomGif.update msg model.left
      in
        ( Model left model.right
        , Effects.map Left fx
        )

    Right msg ->
      let
        (right, fx) = RandomGif.update msg model.right
      in
        ( Model model.left right
        , Effects.map Right fx
        )

因此不論在哪一個分支中調用 RandomGif.update 函數時都會返回一個新 model 和一些被咱們稱做 fx 的操做。咱們像往常同樣返回一個更新過的 model,可是須要在操做上作一些額外的工做。並不是直接返回它們,咱們使用 Effects.map 函數把他們轉化爲一種 Action。這工做很像 Signal.forwardTo,讓咱們標記這些值以便肯定如何路由。

init 函數也是同樣。咱們提供一個 topic 給每一個隨機 GIF 查看器,而後獲得一個初始的 model 和一些 effects

init : String -> String -> (Model, Effects Action)
init leftTopic rightTopic =
  let
    (left, leftFx) = RandomGif.init leftTopic
    (right, rightFx) = RandomGif.init rightTopic
  in
    ( Model left right
    , Effects.batch
        [ Effects.map Left leftFx
        , Effects.map Right rightFx
        ]
    )

-- Effects.batch : List (Effects a) -> Effects a

在這裏咱們並不是只用 Effects.map 來標記合適的結果,還要用 Effects.batch 函數來把他們歸併到一塊兒。全部請求的任務將會被生成而且獨立運行,因此左邊和右邊兩個 effects 會同時被處理。

Example 7: List of random GIF viewers

demo / see code

這個例子實現了一個隨機 GIF 查看器的隊列,你能夠本身爲他設置話題。並且,咱們徹底重用了 RandomGif 模塊的核心。

仔細看看 它的代碼 你會發現它和 例子3 幾乎一致。咱們把全部子模塊放進一個關聯了 ID 的列表,並依據這些 ID 來進行操做。惟一新鮮的是咱們使用 Effectsinitupdate 函數中,把他們和 Effects.map 以及 Effects.batch 放在一塊兒。

若是你對它的實現細節還不夠清楚,請建立一個 issue。

Example 8: Animation

demo / see code

如今,咱們已經看到了帶任務的組件能夠很輕鬆地嵌套在一塊兒,可是用它如何實現動畫呢?

頗有趣,它們徹底同樣!(或許你已經再也不感到驚奇了,相同的模式在這裏也適用,真是一個可愛的模式!)

這個例子是兩個可點擊的方塊。當你點擊一個方塊時,它旋轉 90 度。整體上,這裏的代碼是對 例子2 和 例子6 的調整,咱們保留了全部的動畫邏輯在 SpinSquare.elm 裏面,而且在 SpinSquarePair.elm 裏屢次複用它。

全部新的和有趣的東西都發生在 SpinSquare 裏,因此咱們來關注下這裏的代碼。首先咱們須要一個 model:

type alias Model =
    { angle : Float
    , animationState : AnimationState
    }


type alias AnimationState =
    Maybe { prevClockTime : Time,  elapsedTime: Time }


rotateStep = 90
duration = second

因此 model 的核心是方塊當前的 angle 和一些用來記錄每一個動畫要作什麼的 animationState。若是沒有動畫就是 Nothing,可是若是有動做發生,它就變爲:

  • prevClockTime — 用於計算時間差的最近時間。它幫咱們精確地肯定上一幀後過了多少毫秒。

  • elapsedTime — 0 到 duration 之間的一個數字,告訴咱們當前動畫已經進行了多久。

常量 rotateStep 只是聲明每次點擊轉變多少度。你能夠隨意修改它,而不會影響正常運行。

如今,update 裏發生了一些有趣的事:

type Action
    = Spin
    | Tick Time


update : Action -> Model -> (Model, Effects Action)
update msg model =
  case msg of
    Spin ->
      case model.animationState of
        Nothing ->
          ( model, Effects.tick Tick )

        Just _ ->
          ( model, Effects.none )

    Tick clockTime ->
      let
        newElapsedTime =
          case model.animationState of
            Nothing ->
              0

            Just {elapsedTime, prevClockTime} ->
              elapsedTime + (clockTime - prevClockTime)
      in
        if newElapsedTime > duration then
          ( { angle = model.angle + rotateStep
            , animationState = Nothing
            }
          , Effects.none
          )
        else
          ( { angle = model.angle
            , animationState = Just { elapsedTime = newElapsedTime, prevClockTime = clockTime }
            }
          , Effects.tick Tick
          )

有兩種 Action 咱們須要處理:

  • Spin 標示一個用戶點擊了方塊,請求一次旋轉。因此在 update 函數中,若是沒有正在進行的動畫,咱們就請求一個時間戳,並把狀態設置爲一個動畫正在進行。

  • Tick 標示咱們已經獲得了一個時間戳,因此咱們須要進行一次動畫。在 update 函數中,這意味着咱們須要更新 animationState。因此,首先,咱們檢查當前是否有正在進行的動畫。若是有,咱們只是計算出 newElapsedTime 的值,經過把當前的 elapsedTime 加上一個時間差。若是當前通過的時間大於 duration,咱們就中止動畫並請求一個新的時間戳。不然,咱們更新動畫狀態,也請求一個新的時間戳。

再一次,隨着寫了這麼多相似的代碼,審視一遍它們,咱們會發現一個通用的模式。發現它時你必定很激動!

終於,不管如何咱們有了一個有趣的 view 函數!這個例子有了一個優雅又充滿活力的動畫,而咱們只是在時間線上增長了 elapsedTime 而已。這是怎麼作到的呢?

view 的代碼自己就是一個標準的 elm-svg,能夠製做一些漂亮的可點擊圖形。 代碼中 最牛X 的是 toOffset,它計算了當前 AnimationState 的旋轉的度數。

-- import Easing exposing (ease, easeOutBounce, float)

toOffset : AnimationState -> Float
toOffset animationState =
  case animationState of
    Nothing ->
      0

    Just {elapsedTime} ->
      ease easeOutBounce float 0 rotateStep duration elapsedTime

咱們使用 @Dandandaneasing 庫,它使得對數字、顏色、點以及其餘任何瘋狂的東西的 補間排序 變得很簡單。

因此 ease 函數從 0 到 duration 之間取出一個數。而後它把它轉變成一個 0 到 rotateStep(咱們以前的代碼裏已經把它設置爲 90 度了)之間的一個數。在這裏你還提供了一個 補間easeOutBounce 這意味着隨着它從 0 到 duration 變化,咱們會獲得一個從 0 到 90 變化的數字。太瘋狂了!嘗試替換 easeOutBounce另外一個補間 看看是什麼效果!

從這兒開始,咱們把全部東西都拼裝到了一塊兒成爲 SpinSquarePair, 而它的代碼幾乎與 例子2 和 例子6 的如出一轍。

好了,這就是用這些工具實現動畫的基礎!若是把全部東西都擺在這兒,可能不夠清晰,因此當你有了更多的經驗,請讓咱們知道你的收穫。但願咱們能夠把她變得更簡單!

注意: 我期待咱們能夠在這些核心思想之上構建一些抽象概念。這個例子作了一些基礎的事情,可是我打賭隨着咱們繼續爲它作出的工做,咱們能夠找到一些優雅的模式使它更簡單。若是你以爲它如今仍是很複雜,請試着讓它變得更好,並把你的想法告訴咱們吧!

相關文章
相關標籤/搜索