在之前我们介绍了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文件(可以使用 npm init
),虽然是elm项目,但是依托npm的依赖管理和构建工具也非常有用,并且更符合前端开发者的习惯,这里我们用到的是elm和elm-live两个包:
npm i --save-dev elm elm-live
然后是创建 elm-package.json
,正如它的名字一样,elm也提供了类似npm的包管理机制,你可以自由地发布或者 使用 elm模块。在Counter中我们需要用到的有 elm-lang/core
和 elm-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
参数将 elm
和 elm-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文件,并将之前的Counter代码复制进去。
目前为止不需要任何额外工作
和其它拥有模块机制的语言一样,Elm也有模块导出语法,但是应用的入口模块并不是必须的,只要模块中有main变量即可。
目前为止我们安装好了依赖,也有了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成功在本地运行起来了!
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
counter list是由任意个counter组成的counter列表,纯react在线版:
https://jsfiddle.net/Kpaxqin/wh8hb8wr/接下来就让我们在Elm中实现同样的功能
首先是需要抽象出可复用的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
,效果和之前完全一样,说明抽离模块的重构是成功的。
再在src下新建一个CounterList.elm,可能你已经忘记了写elm模块的套路,不用急,只要记得Elm的架构叫做 M-V-U
就行了,任何组件都是由这几部分组成:
--CounterList.elm //Model //Update //View
这背后是非常自然的逻辑:描述数据,描述数据如何改变,将一切映射到视图上。
作为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}]}
在处理变更前我们需要先定义变更,在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 : 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类型的消息值: Insert
和 Remove
。而负责修改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分别实现该需求。