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 的如出一辙。

好了,这就是用这些工具实现动画的基础!若是把全部东西都摆在这儿,可能不够清晰,因此当你有了更多的经验,请让咱们知道你的收获。但愿咱们能够把她变得更简单!

注意: 我期待咱们能够在这些核心思想之上构建一些抽象概念。这个例子作了一些基础的事情,可是我打赌随着咱们继续为它作出的工做,咱们能够找到一些优雅的模式使它更简单。若是你以为它如今仍是很复杂,请试着让它变得更好,并把你的想法告诉咱们吧!

相关文章
相关标签/搜索