由于工作需要,最近进行了一些目前很热门的微信小程序开发,技术选型的过程和结果都有些值得分享的体会,尝试做个简要的介绍。

先说结果,核心的逻辑采用了 Elm 语言开发,编译到 JavaScript ,界面显示还是标准的 JavaScript 和 WXML。

Elm 是什么?

官网的简介:

A delightful language for reliable webapps. Generate JavaScript with great performance and no runtime exceptions.

翻译成中文大约是:

一门开发网页应用的令人愉悦的语言,生成高性能、没有运行时例外的 JavaScript 代码

过去两周,写了大约 5000 行的 Elm 代码,感觉上面的描述还是挺靠谱的,和手写 JavaScript 相比,确实令人愉悦。下面简单分析下技术上 Elm 是如何做到的。

(这篇文章以概念和经验介绍为主,就基本不上代码了,以后尽量有后续的详细介绍)

强类型,静态类型的编译语言

个人认为这是 Elm 和 JavaScript 最大的区别,JavaScript 不会对代码访问的数据做任何的类型检查,只有实际运行后才知道结果会怎样,可能会出现空指针,未定义变量,数据类型、格式不匹配等等各种问题,而且测试运行通过也不代表今后的运行还是正确的,因为将来的输入数据可能会有变化。

常见的解决方法有数据检查、代码检查工具(例如 Facebook FlowType)、代码扩展(例如微软的 TypeScript)等等。基本上都是补丁的方式,而且并非强制的,不能彻底解决问题。

作为编译语言, Elm 需要定义数据、函数的类型(也支持自动类型推定),会在编译时进行检查,只有所有的函数调用完全符合所声明的类型时才能编译通过。而且 Elm 中没有空指针的概念,对于可能为空的情况必须明确声明,并做相应的处理。

函数式语言,不可变数据

函数式其实是个历史悠久的概念,不过由于各种历史原因,目前的主流语言大多以面向对象为核心,导致很多人(包括我自己)都对函数式语言不了解,并且常常会有很难学,很难用的印象。数年之前用 XMonad 作为主力窗口管理器(现在偶尔也还会用),配置文件需要用 Haskell 写,在没学语法的情况下参考其他人的例子配了个相当满意的配置,一直想认真学一下,不过一直也没抽出时间来。

其实如果把对象看成数据结构和操作数据结构的方法的结合,在直观的层面上和函数式的方式并没有本质的区别,像是 C# 里面的 Extension 就是应用了语法糖的方式,伪装成成员方法的外在函数。

而不可变数据才是让函数式编程截然不同的原因,如果还是以对象的眼光来看待的话,可以理解成每次对于对象的修改都产生了一个独立的新对象,它们之间完全隔离,彼此没有任何影响。随之而来的各种好处是巨大的,例如对于并发的处理,缓存的处理等等。

另一个重要的特性就是高阶函数、闭包,虽然现在的主流语言基本上也都提供了支持,也很大程度上改善了语言的描述性,但在离开了不可变的情况下,并不能提供同样的强大支持。

Elm 架构

Elm 架构是构架在语言层面之上的系统组织形式,也有点像是 Elm 中的入口(main 函数),独到之处在于它是完整的运行周期管理,并且在 Elm 中,似乎没有其它的方式,只能以这一模式运行,貌似很死板,实际用起来还很适用。

如果你对于 Flux,Redux,有过了解的话,基本上也已经了解 Elm 架构了,它们的设计都受到了 Elm 的很大影响,基于同样的理念。

为了避免这篇文章太长而无法完成,就不详细介绍了,具体的细节可以参考官方的入门文档。

成果与心得

虽然 Evan Czaplicki (Elm 作者)非常强调 Elm 对初学者的友好,也花了不少精力提供了不错的文档和工具,但是真正把一门新语言应用到实际项目中始终都是个挑战,另外微信小程序与标准的 Web 开发也有不少区别,需要额外的时间和精力。

语法和类库的层面就不提了,语法写习惯了就好,核心的类库还是挺小的,文档也很清晰,就是往往没有明确的示例,需要一些试验才能真正理解,在开发周期比较紧的时候,压力还是很大的。

不过在这次的经验上,Elm 从入门到达到相对高效的状态比想象的要快,感觉设计思路上非常清晰,对于设计场景很适合。我自身的方面是各方面开发经验和接触过的语言还算多,函数式语言之前有过不到两个月的 Erlang 经验,多年的习惯是 Vim 开发,打日志调试为主,对 IDE 没太大需求。

柯里化(Currying)以及管道操作符

insertInt : String -> Int -> DataDict -> DataDict
insertInt key val =
    Dict.insert key (Json.Encode.int val)

这是一个极其简单的函数的声明,第一行是类型的定义,一堆箭头,让人有点晕,如果描述一下的话,版本 A 是这样的:

  • insertInt 是一个函数,有一个输入,类型是 String,输出类型是函数 insertInt_A1
  • insertInt_A1 也是一个函数,有一个输入,类型是 Int,输出类型是函数 insertInt_A2
  • insertInt_A2 还是一个函数,有一个输入,类型是 DataDict,输出类型也是 DataDict

    type alias Data = Json.Encode.Value type alias DataDict = (Dict.Dict String Data)

DataDict 就是一个字典,键的类型是 String,值的类型是一个 Json 数据

对于一个所有实现仅有一行的函数来说,还真是显得有点过于复杂了,其实这还不算完,还有版本 B:

  • insertInt 是一个函数,有两个输入,类型是 String 和 Int,输出类型是函数 insertInt_B
  • insertInt_B 也是一个函数,有一个输入,类型是 DataDict,输出类型也是 DataDict

或者是比较容易理解的版本 C:

  • insertInt 是一个函数,有三个输入,类型是 String,Int,DataDict,输出类型是DataDict

那么哪个是正确的版本呢,全都是,取决于使用的方式。定义的时候其实也是一样,从代码上看比较像是版本 B,有两个输入参数,而你完全可以用版本 A 或是版本 C 的方式来使用。

一旦开始用这样的眼光来看待多参数的函数,你会有一种发现了新世界的感觉,函数之间的重用非常方便,而实现起来又极为简单。概念上这是属于所谓柯里化(Currying)的范畴,使用上需要一些经验的积累,才能达到得心应手的状态。

encode : Type -> Data
encode info =
    empty
        |> insertString "nickName" info.nickName
        |> insertInt "gender" (Gender.encode info.gender)
        |> insertString "city" info.city
        |> insertString "province" info.province
        |> insertString "country" info.country
        |> insertString "avatarUrl" info.avatarUrl
        |> dictToData

这段代码是用了上面定义的函数来生成一个 Json 数据的过程,其中的 |> 表示的是把之前的数据作为后面函数调用的后一个参数,在合适的情境下,会让代码很清晰。

友情提示:用过 Elixir 的码农注意了,Elixir 的管道符是变成第一个参数的,别弄混了。

另外,Elm 中还有其它几个特殊符号:<|>>,都很有用,这里就不细说了,当然有时难免还是得加括号的。

单一行为的串联

update : Msg -> Model -> (Model, Cmd Msg)

在对于一个事件做处理时,往往需要做多种操作,更新数据,发送新消息,执行外部访问,等等,代码渐渐的就难以清晰的看出其中的意图来了,开始时也困惑了一阵子,后来找到了 elm-update-extra 这个包,一下子就清楚了,其实核心的思想就是引入中间的环节,多个环节就可以连接起来了

op : (Model, Cmd Msg) -> (Model, Cmd Msg)

例如

(updateModel <| SocketModel.setOnline True)
>> (updateModel <| SocketModel.updateChannel Channel.onJoin)
>> (addCmd <| toCmd DoJoinChannel topic res)

就更新了数据模型中的两个部分,并且发送了一个新的消息,有了这几个简单的函数(updateModel, addCmd, toCmd)的帮助,代码又变得很好读了,强烈推荐。

  • https://github.com/ccapndave/elm-update-extra

友情提示:如果实现上既带进来了旧的 model,又利用了其它的环节,一定注意不要把 model 弄混,如果错误的把旧的值传下去,会导致数据的丢失。

子模块的拆分和交互

文档中的示例是标准的 Todo 应用,逻辑很简单,并不能完全解决实际应用的需求,个人体会最大的需求是更好的模块化,把不同部分的逻辑互相隔离,经过一些调研,选择了 elm-component-updater 来支持模块化的组织,以及模块之间的交互,效果很满意,强烈推荐。

  • https://github.com/mpdairy/elm-component-updater

友情提示:一定化些时间把里面的示例完全看懂,明白了以后概念是很清晰的,实际使用中也很灵活。

这个话题有点大,要说清楚得不小的篇幅,只能留到以后了。

对微信小程序 API 的封装

微信提供的是 JavaScript 的接口,虽然文档还不错,但并不能很好的与 Elm 相结合,在熟悉了 Elm 之后,就尝试着做了一个封装,效果很好,可以进行类型检查,也完全是以 Elm 的方式在访问相关的接口。

这部分目前只实现了用到的几个接口,添加更多的接口实现上都比较简单直接,在成熟的时候会开源出来。

和界面部分的结合

由于微信小程序并不提供 Dom 的访问,Elm 中很强大的 Virtual Dom 并不能被用到,目前是在数据模型发生变化时发送更新给 JavaScript 端,再调用 setData(),完成页面渲染。

理想情况当然是能够实现兼容 Virtual Dom 的方式,不过技术上有一定的难度,目前还没有很好的方案。另一方面目前的模式也还是很清晰的,JavaScript 只负责简单的数据传递,修改请求也是用生成事件的方式回传给 Elm 的,所以虽然不是最优,立刻修改的需求也并不强烈。

elm-css 的应用

虽然也做过些网页相关的工作,不过基本上不具备 CSS 的技能,现在是个小团队,也得自己写写,学了语法,写起来还是有 JavaScript 的感觉,没有编译期的检查,往往只能频繁的尝试,偶尔也会因为格式的问题(写错键值、单位等等)产生问题,当时如果没发现,就成了隐患。

还好有其他人也有同样的感觉,发现了 elm-css 这个用 Elm 写 CSS 的工具,感觉其它的那些 CSS 工具都太弱了,所有的定义都有相应的类型,以及可接受的输入,编译期的检查保证了只能生成有效的 CSS,对于程序员来说是最自然、高效的方式,强烈推荐。

  • https://github.com/rtfeldman/elm-css

JavaScript 互操作

port modelOut : JsModel.Type -> Cmd msg
port msgIn : ((String, String, Params) -> msg) -> Sub msg

只有两个接口,一个是把最新的数据模型传给 JavaScript,一个是给 Elm 发送消息,其实也就够用了。

比较麻烦的是这里的 model 只能使用与 JavaScript 兼容的 Elm 数据结构,像是 Union 就不能用,实际应用中是加了一层处理,把完整的 model 做了一次包装,或者裁剪掉可以不用的部分,或是编码成支持的格式,不是太完美,也增加了代码量,好在比较简单直接,不会显著降低代码质量。

最完美的方案是如果能解决界面部分的 Elm 化,就不需要这两个接口了, 那么相关联的代码也都可以删掉了。

令人愉悦的重构

过程中最让人愉悦的部分大概是代码的演化与重构了,不论是逻辑关系从一对一改为一对多,改变模块的覆盖功能,调整外部请求的流程,往往能比预期更快的完成,确实常常是编译通过,一次运行就通过了,感觉上像是有了很多自动实现的单元测试,重构代码还不用重构测试,每次都感觉选择 Elm 实在是太正确了,否则在 JavaScript 的世界里,不知要花多少时间。

过程和思考

编码时往往容易被问题带着走,也常常会发现在用正确的方式解决着错误的问题,尤其是相对反常规的做法,更是会有隐藏的风险。

如何作出引入 Elm 的选择

之前几年都是以 C# 为主,对于 JavaScript 这样的解释型,动态弱类型语言不是很有兴趣,以前公司的 Python 项目也遇到过不少测试没能覆盖,上线遇到"惊喜"的先例。

之前看过 Elm 的材料,没实际做过项目,印象还是挺好的。

接到任务时的第一反应是照着教程用最简单的方式尽快出个 Demo 就好了,一切都按照官方文档来,尽量不引入外部依赖。实际上手才发现没那么简单,官方没有提到任何对数据的管理方式,纯手写逻辑又太不可控,考虑是否引入 Redux 这样的框架,以前 nodejs 用过的 async 库比较大,引入了一个支持 waterfall 的 weachy,再加一个消息转发的 postal,附带着又带进来 lodash, 这样下来依赖也越来越多,而且还是很重的拼凑的感觉,有入坑的预感。

于是用了一个周末的时间尝试了 Elm 方案,效果出乎意料的好,依赖全部删掉,重写了部分核心功能,直觉上是个正确的方向,后来搞定了微信接口的封装,又解决了行为串联,和子模块组织的设计之后,开发效率开始上来了,质量上比之前的 JavaScript 版本则是质的提高。

前期磨合

全新的语言、架构,产生大量的细节问题,要解决、调研,如果是个纯粹的练手项目,那么有足够的时间,而当前的项目又需要尽早上线,说实话压力是很大的,每天都得加班加点,还好进展一直都有,大概在写到第三、四天以后,体会到了视角的变化,感觉能从函数式的角度来理解系统了,之后就进入了比较顺利的阶段。

开发效率与体验

这个其实确实不好衡量,例如一个非常熟练的 JavaScript 程序员,仍然很可能可以比我做的更快,去掉前期学习的部分,差距会更大。

自己的感觉是很不错的,抛掉学习的成本,代码的增长还是很快的,尤其是质量很好,不会带来复杂性失控的问题。

至于开发体验的话,对我来说是近乎完美的体验,在可预见的将来,我想都不会回到原生 JavaScript 开发的方式上去,而且也必定会更加深入的采用函数式的技术或方式进行开发。

潜在风险?

对于这一类编译到 JavaScript 的语言来说,首要的问题是编译器是否稳定可靠,如果不行的化调试难度就太大了,Elm 的编译器本身是用 Haskell 写的,虽然是开源的,我也还没有具体看过,到目前为止还没有碰到过任何这方面的问题。

Elm 现在的版本是 0.18,并不会保证新版本的完全向后兼容,像是之前 0.17 更新的时候把原本的 JavaScript 互操作方式改了,又把 RFP (Reactive Functional Programming)的部分做了较大的调整,社区里也有些意见,有些包不更新的化,新的版本中也没法用。我看到的是改的结果确实很好,迁移的难度也不大,还是利大于弊的状态。

剩下的就是小众选择的通病了,难找人,难找资料,信息基本都是英文的。相关的包也少的多,不过另一方面,像 Node.js 或者 Python 这么多的包,要找到合用的也挺难的,做对比也费时费力,往往让人很焦虑。

痛点与将来

上面主要是优点,也还是遗留了一些痛点,篇幅所限,就不展开了。

  • JavaScript 互操作的限制
  • 相似类型的代码重用
  • 模式化的代码
  • 完全替代界面端 JavaScript 和 WXML?

附录

术语

  • 不可变数据 Immutable Data
  • 函数式语言 Functional Language
  • 强类型 Strong Type
  • 静态类型 Static Type
  • 柯里化 Currying
  • 运行时例外 Runtime Exception

链接