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/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
然后执行node_modules/.bin/elm-package install,和npm类似,这个命令会把相关的依赖安装到名为elm-stuff的文件夹下。

注意之前我们并没有使用-g参数将elm和elm-live安装到全局,这意味着你不能直接在命令行里使用它们,而只能使用node_modules/.bin/ [args]。

这样做的好处是隔离项目间依赖,如果你的电脑上有多个项目依赖了不同的elm版本,切换项目会是非常麻烦的事。其它团队成员设置环境时也会更麻烦。

但老是写node_modules/.bin/就像重复代码一样多余,更常见的是结合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.jsSuccess! Compiled 1 module.                                         Successfully generated build/index.js

有意外也没关系,编译器会给出详细的错误信息。

在浏览器中运行

有了js文件,就进入熟悉的套路了,在项目根目录下新建一个index.html文件:

Elm in practice  var node = document.getElementById('container');  var app = Elm.Main.embed(node);

这里的核心是Elm.Main.embed(node),elm会为入口模块在全局生成Elm.对象,包含三个方法:

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的标准。

这和平常我们接触到的组件方案有所不同,多数的架构把组件看作一个闭合的整体:

然后在闭合的基础上,再定义开放的接口,比如添加回调。这个方案的风险之处在于:闭合和开放的边界非常难以界定,最初定义的开放接口不能满足需要,在维护期中改得千疮百孔是常有的事。

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 (

      +    {value}    -)

}
}
这使得在渲染Counter列表时,代码只需要短短一句:

this.state.list.map(i=> )
而Elm绕了一大圈,把组件拆得七零八落,收益在哪呢?

下面请看思考题:

设CounterList中有固定的三个子Counter:A, B, C。它们正常工作,就像我们在本章实现的一样。为了简化问题,我们暂时移除且不考虑添加和删除Counter的功能。

突然,你家产品经理想出了提升KPI的绝妙办法:在操作A的加减时,应该改变B的值,操作B时改变C,操作C时改变A。

请思考:在不对产品经理造成人身伤害的前提下,如何用React闭合组件、Redux、Elm分别实现该需求。

关键字:elm, react.js, Redux

版权声明

本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处。如若内容有涉嫌抄袭侵权/违法违规/事实不符,请点击 举报 进行投诉反馈!

立即
投稿

微信公众账号

微信扫一扫加关注

返回
顶部