在之前我们介绍了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/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文件
这一步非常简单,在根目录创建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类型的消息值: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分别实现该需求。