转载

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分别实现该需求。

原文  https://segmentfault.com/a/1190000005808614
正文到此结束
Loading...