Elm 語言開發微信小程式

YJ Park發表於2017-01-15

由於工作需要,最近進行了一些目前很熱門的微信小程式開發,技術選型的過程和結果都有些值得分享的體會,嘗試做個簡要的介紹。

先說結果,核心的邏輯採用了 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

連結

相關文章