Elm入门实践(三)——进阶篇

在之前我们介绍了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分别实现该需求。

阅读 3.8k更新于 2016-07-04
推荐阅读
kpaxqin
用户专栏

前端工程师

84 人关注
12 篇文章
专栏主页
目录