为什么需要引入子模块?
随着代码量的增加,相关逻辑日渐复杂,需要维护的状态和传递的消息也迅速的增加起来。
Elm 的架构文档中并没有详细说明如何组织比较复杂的项目,我调查中看到的文章中的方案也大多仍然需要模块间的耦合,实际使用中并不能得到满意的效果。
期望达到的效果
首先需要做到的是代码层面的分离,模块内部实现细节的修改对外部来说尽量不可见,减少代码的耦合程度,便于开发。
下一个阶段的目标是模块的可重用性,除了简单的函数层面的重用,在更高层次上也有很多相似性,例如如果由于应用场景的考虑,需要发布多个微信小程序的话,其中有不少逻辑是可以共用的,例如微信端用户登录、信息获取,服务后台的 Session 管理,等等。
模块之间的交互应尽量简单,可以用可维护的方式进行组织。
如何拆分子模块
个人的习惯是先从数据开始设计,在 Model 的部分先做分割,之后进行 Msg 的设计,宗旨是把聚合度高的部分放在一起,封装成独立的模块。
子模块间如何交互
多个模块需要彼此协调才能完成完整的应用逻辑,根据具体情况有以下的情境
数据依赖
某个模块需要外部提供所需的数据,有几种处理的方法,可以根据具体需要进行选择
- 作为输入事件的参数传递进来,只在相关事件的处理中使用
- 封装成内部的数据,加入 Model,在需要时访问
- 作为 update 方法的参数,每次更新时都可以访问到
事件触发
对于子模块来说,其实不用了解事件的具体来源,可以是模块自身,可以是其它模块,或是应用层面的用户输入。只要把自身的生命周期管理好即可,由于 Elm 架构的函数式和不可变特性,一般来说调试也很方便,只要观察 Msg 的序列以及相应的 Model 的变化往往就能找到问题所在。
WxApp 子模块
由于所有的微信小程序都需要进行用户身份的管理,在 elm-wx-app 中提供了一个基本的身份认证子模块,在 API 调用之上提供了更高一层的接口。
下面列出了部分的代码,结构相对比较简单,感兴趣的话可以 Clone 完整的版本。
(目前的版本还比较简单,接口也没有完全固定下来)
Model
type alias Type =
{ systemInfo : SystemInfo.Type
, userCode : String
, userInfo : UserInfo.Type
, userSecret : UserSecret.Type
, tabs : List UiTab.Type
, currentTabKey : UiTab.Key
, pages : List UiPage.Type
}
Msg
type Msg
= DoInit
| DoGetSystemInfo
| DoCheckSession
| DoLogin
| DoLoadWxModel
| DoGetUserInfo
| GetSystemInfoMsg (Result Error GetSystemInfo.Msg)
| CheckSessionMsg (Result Error CheckSession.Msg)
| LoginMsg (Result Error Login.Msg)
| LoadWxModelMsg (Result Error WxModel.Type)
...
update
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
DoInit ->
( model
, cmd DoGetSystemInfo
)
DoGetSystemInfo ->
( model
, GetSystemInfo.cmd GetSystemInfoMsg
)
DoCheckSession ->
( model
, CheckSession.cmd CheckSessionMsg
)
DoLogin ->
( model
, Login.cmd LoginMsg
)
...
WxApp Wrapper 实例分析
wrapper
wrapper 的细节请看 elm-component-updater 的实现代码,基本上是从主模型中访问子模型(get),调用子模块的 update,之后再把返回的子模型更新到主模型中(set)
wrapper : Wrapper Msg Wx.Msg
wrapper =
wrap WxMod
{ get = Just << .wx
, set = \modModel model -> { model | wx = modModel }
, update = Wx.update
, react = reaction
}
cmd msg =
toCmd msg
|> Cmd.map wrapper
reaction
reaction 的目的是对于特定的子模块事件产生相应的外部事件,来达到对其他模块的控制。
reaction modMsg modModel model =
model ! []
|> case modMsg of
Wx.PopPageMsg pageKey (Ok _) ->
case List.length modModel.pages of
0 ->
addCmd <| cmd <| Wx.SwitchTab "dialogue"
_ ->
noOperation
_ ->
noOperation
主应用中的相关代码
model
首先是在 Model 中包含子模块的部分
type alias Type =
{ rev : Int
, wx : Wx.Model
...
事件定义
import Updater
import WxApp.Mod as Wx
type alias Delegate = (Updater Model Msg)
type Msg
= WxMod Delegate
| WxMsg Wx.Msg
WxMod 代表由 Wrapper 处理的事件,WxMsg 则是普通事件,需要在 update 中转换为 Wrapper 事件。
这里做区分的原因是在 Elm 中无法循环 import,在被 WxApp Wrapper 引用的代码中如果也需要通知 WxApp 的模块,则只能产生一个 WxMsg 类型的事件。
update
import Wrapper.Wx as Wx
updateMod : Msg -> (Model, Cmd Msg) -> (Model, Cmd Msg)
updateMod msg (model, cmd) =
case msg of
WxMod delegate ->
delegate model
WxMsg msg ->
(model, Wx.cmd msg)
...
可以看到这里对于 WxMsg 类型的事件使用 wrapper 做了一次转换,略显繁琐,不确定是否有更好的方式。
总结
实际开发中应用以上方式写了不少代码,在模块的分隔上感觉还是一种不错的方法。
在需要调整模块结构的情况下,Elm 作为静态类型语言提供了很大的帮助,编译器可以发现不匹配的接口,重构起来有一气呵成的感觉。