Elm 中使用子模块的一种方法

为什么需要引入子模块?

随着代码量的增加,相关逻辑日渐复杂,需要维护的状态和传递的消息也迅速的增加起来。

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 作为静态类型语言提供了很大的帮助,编译器可以发现不匹配的接口,重构起来有一气呵成的感觉。

附录

链接

comments powered by Disqus