從鼓音序器學習榆樹(第 1 部分)
已發表: 2022-03-10如果您是一名關注單頁應用程序 (SPA) 發展的前端開發人員,那麼您可能聽說過 Elm,這是一種啟發 Redux 的函數式語言。 如果您還沒有,它是一種可編譯為 JavaScript 的語言,可與 React、Angular 和 Vue 等 SPA 項目相媲美。
與這些類似,它通過其虛擬 dom 管理狀態更改,旨在使代碼更具可維護性和性能。 它專注於開發人員的快樂、高質量的工具和簡單、可重複的模式。 它的一些主要區別包括靜態類型、非常有用的錯誤消息,以及它是一種函數式語言(與面向對象相反)。
我的介紹來自 Elm 的創建者 Evan Czaplicki 的一次演講,他談到了他對前端開發人員體驗的願景以及對 Elm 的願景。 由於有人也關注前端開發的可維護性和可用性,他的談話真的引起了我的共鳴。 一年前,我在一個副項目中嘗試了 Elm,並繼續以我第一次開始編程以來從未體驗過的方式享受它的功能和挑戰; 我又是個初學者了。 此外,我發現自己能夠將 Elm 的許多實踐應用到其他語言中。
培養依賴意識
依賴無處不在。 通過減少它們,您可以提高您的網站在最廣泛的場景中可供最多人使用的可能性。閱讀相關文章 →
在這篇由兩部分組成的文章中,我們將構建一個步進音序器來在 Elm 中編寫鼓節拍,同時展示該語言的一些最佳功能。 今天,我們將介紹 Elm 中的基本概念,即入門、使用類型、渲染視圖和更新狀態。 本文的第二部分將深入探討更高級的主題,例如輕鬆處理大型重構、設置重複事件以及與 JavaScript 交互。
在這裡玩最終項目,並在此處查看其代碼。
榆樹入門
為了繼續閱讀本文,我建議使用 Ellie,一種瀏覽器內的 Elm 開發人員體驗。 您無需安裝任何東西即可運行 Ellie,並且可以在其中開發功能齊全的應用程序。 如果您想在您的計算機上安裝 Elm,最好的設置方法是遵循官方入門指南。
在整篇文章中,我將鏈接到正在進行中的 Ellie 版本,儘管我在本地開發了音序器。 雖然 CSS 可以完全用 Elm 編寫,但我已經用 PostCSS 編寫了這個項目。 這需要對 Elm Reactor 進行一些配置以進行本地開發,以便加載樣式。 為簡潔起見,我不會在本文中涉及樣式,但 Ellie 鏈接包含所有縮小的 CSS 樣式。
Elm 是一個獨立的生態系統,包括:
- 榆樹
用於編譯 Elm 代碼。 雖然 Webpack 在生產 Elm 項目和其他資產方面仍然很受歡迎,但它不是必需的。 在這個項目中,我選擇排除 Webpack,並依賴elm make
來編譯代碼。 - 榆木包
一個類似於 NPM 的包管理器,用於使用社區創建的包/模塊。 - 榆木反應堆
用於運行自動編譯的開發服務器。 更值得注意的是,它包括時間旅行調試器,使您可以輕鬆地單步調試應用程序的狀態並重放錯誤。 - 榆樹
用於在終端中編寫或測試簡單的 Elm 表達式。
所有 Elm 文件都被視為modules
。 任何文件的開頭行都將包含module FileName exposing (functions)
,其中FileName
是文字文件名,而functions
是您希望其他模塊可以訪問的公共函數。 模塊定義後立即從外部模塊導入。 其餘功能如下。
module Main exposing (main) import Html exposing (Html, text) main : Html msg main = text "Hello, World!"
這個名為Main.elm
的模塊公開了一個函數main
,並從Html
模塊/包中導入Html
和text
。 main
功能由兩部分組成:類型註釋和實際功能。 類型註釋可以被認為是函數定義。 它們說明參數類型和返回類型。 在這種情況下,我們聲明main
函數不接受任何參數並返回Html msg
。 該函數本身會渲染一個包含“Hello, World”的文本節點。 要將參數傳遞給函數,我們在函數中的等號之前添加空格分隔的名稱。 我們還將參數類型添加到類型註釋中,按照參數的順序,後跟箭頭。
add2Numbers : Int -> Int -> Int add2Numbers first second = first + second
在 JavaScript 中,類似這樣的函數是可比較的:
function add2Numbers(first, second) { return first + second; }
在 Typed 語言中,比如 TypeScript,它看起來像:
function add2Numbers(first: number, second: number): number { return first + second; }
add2Numbers
接受兩個整數並返回一個整數。 註釋中的最後一個值始終是返回值,因為每個函數都必須返回一個值。 我們用 2 和 3 調用add2Numbers
來得到 5,就像add2Numbers 2 3
一樣。
就像綁定 React 組件一樣,我們需要將編譯後的 Elm 代碼綁定到 DOM。 綁定的標準方法是在我們的模塊上調用embed()
並將 DOM 元素傳遞給它。
<script> const container = document.getElementById('app'); const app = Elm.Main.embed(container); <script>
雖然我們的應用程序並沒有真正做任何事情,但我們有足夠的能力來編譯我們的 Elm 代碼並渲染文本。 在 Ellie 上查看並嘗試在第 26 行將參數更改為add2Numbers
。
使用類型進行數據建模
來自像 JavaScript 或 Ruby 這樣的動態類型語言,類型可能看起來是多餘的。 這些語言決定了函數在運行時從傳入的值中獲取什麼類型。 編寫函數通常被認為更快,但是您失去了確保函數可以正確交互的安全性。
相比之下,Elm 是靜態類型的。 它依賴於其編譯器來確保傳遞給函數的值在運行時之前是兼容的。 這意味著您的用戶不會出現運行時異常,這也是 Elm 可以做出“無運行時異常”保證的方式。 在許多編譯器中的類型錯誤可能特別神秘的地方,Elm 專注於使它們易於理解和糾正。
Elm 使開始使用類型非常友好。 事實上,Elm 的類型推斷非常好,您可以跳過編寫註釋,直到您對它們更熟悉為止。 如果您對類型不熟悉,我建議您依賴編譯器的建議,而不是嘗試自己編寫它們。
讓我們開始使用類型對我們的數據進行建模。 我們的步進音序器是特定鼓樣本何時播放的可視時間線。 時間線由軌道組成,每個軌道都分配有特定的鼓樣本和步驟序列。 一步可以被認為是時間或節拍。 如果 step處於活動狀態,則應在播放期間觸發樣本,如果 step處於非活動狀態,則樣本應保持靜音。 在播放期間,音序器將在每個步驟中移動,播放活動步驟的樣本。 播放速度由每分鐘節拍數 (BPM)設置。
用 JavaScript 建模我們的應用程序
為了更好地了解我們的類型,讓我們考慮如何在 JavaScript 中建模這個鼓音序器。 有一系列的軌道。 每個軌道對像都包含有關自身的信息:軌道名稱、將觸發的樣本/剪輯以及步進值的序列。
tracks: [ { name: "Kick", clip: "kick.mp3", sequence: [On, Off, Off, Off, On, etc...] }, { name: "Snare", clip: "snare.mp3", sequence: [Off, Off, Off, Off, On, etc...] }, etc... ]
我們需要管理播放和停止之間的播放狀態。
playback: "playing" || "stopped"
在播放過程中,我們需要確定應該播放哪一步。 我們還應該考慮播放性能,而不是每次增加步長時遍歷每個軌道中的每個序列; 我們應該將所有活動步驟減少到一個播放序列中。 播放序列中的每個集合代表應該播放的所有樣本。 例如, ["kick", "hat"]
表示應該播放 kick 和 hi-hat 樣本,而["hat"]
表示應該只播放 hi-hat。 我們還需要每個集合來限製樣本的唯一性,所以我們最終不會得到類似["hat", "hat", "hat"]
的東西。
playbackPosition: 1 playbackSequence: [ ["kick", "hat"], [], ["hat"], [], ["snare", "hat"], [], ["hat"], [], ... ],
我們需要設置播放速度,或 BPM。
bpm: 120
在 Elm 中使用類型建模
將這些數據轉錄成 Elm 類型本質上是在描述我們期望我們的數據由什麼組成。 例如,我們已經將我們的數據模型稱為model ,所以我們用類型別名來稱呼它。 類型別名用於使代碼更易於閱讀。 它們不是像布爾值或整數那樣的原始類型; 它們只是我們賦予原始類型或數據結構的名稱。 使用一個,我們將遵循我們模型結構的任何數據定義為模型而不是匿名結構。 在許多 Elm 項目中,主要結構被命名為 Model。
type alias Model = { tracks : Array Track , playback : Playback , playbackPosition : PlaybackPosition , bpm : Int , playbackSequence : Array (Set Clip) }
雖然我們的模型看起來有點像 JavaScript 對象,但它描述的是 Elm 記錄。 記錄用於將相關數據組織到具有自己類型註釋的多個字段中。 它們很容易使用field.attribute
訪問,並且很容易更新,我們稍後會看到。 對象和記錄非常相似,但有一些關鍵區別:
- 無法調用不存在的字段
- 字段永遠不會為
null
或undefined
-
this
和self
不能用
我們的軌道集合可以由三種可能的類型之一組成:列表、數組和集合。 簡而言之,Lists 是非索引的通用集合,Arrays 是索引的,Sets 只包含唯一值。 我們需要一個索引來知道哪個軌道步驟已被切換,並且由於數組被索引,這是我們最好的選擇。 或者,我們可以將 id 添加到軌道並從列表中過濾。
在我們的模型中,我們將軌道排版到一個軌道數組,另一個記錄: tracks : Array Track
。 Track 包含有關其自身的信息。 name 和 clip 都是字符串,但是我們輸入了 aliased clip,因為我們知道它會被其他函數在代碼中的其他地方引用。 通過給它起別名,我們開始創建自記錄代碼。 創建類型和類型別名允許開發人員將數據模型建模為業務模型,從而創建通用語言。
type alias Track = { name : String , clip : Clip , sequence : Array Step } type Step = On | Off type alias Clip = String
我們知道該序列將是一個開/關值數組。 我們可以將其設置為布爾數組,例如sequence : Array Bool
,但我們會錯過表達我們業務模型的機會! 考慮到步進音序器是由步進組成的,我們定義了一種稱為Step的新類型。 Step 可以是boolean
的類型別名,但我們可以更進一步: Steps 有兩個可能的值,on 和 off,這就是我們定義聯合類型的方式。 現在步驟只能是 On 或 Off,使所有其他狀態都不可能。
我們為PlaybackPosition
定義了另一種類型,為Playback
定義了一個別名,並在將playbackSequence
定義為包含剪輯集的數組時使用剪輯。 BPM 被分配為標準Int
。
type Playback = Playing | Stopped type alias PlaybackPosition = Int
雖然開始使用類型會有更多開銷,但我們的代碼更易於維護。 它是自我記錄的,並在我們的商業模式中使用無處不在的語言。 我們在了解我們未來的函數將以我們期望的方式與我們的數據交互而無需測試時獲得的信心非常值得花時間編寫註釋。 而且,我們可以依靠編譯器的類型推斷來建議類型,因此編寫它們就像複製和粘貼一樣簡單。 這是完整的類型聲明。
使用 Elm 架構
Elm 架構是一種簡單的狀態管理模式,自然而然地出現在語言中。 它圍繞業務模型創建焦點,並且具有高度可擴展性。 與其他 SPA 框架相比,Elm 對它的架構持固執己見——它是所有應用程序的結構方式,這使得入門變得輕而易舉。 該架構由三部分組成:
- 模型,包含應用程序的狀態,以及我們鍵入別名模型的結構
- 更新函數,更新狀態
- 以及視圖函數,它可以直觀地呈現狀態
讓我們開始構建我們的鼓音序器,在實踐中學習 Elm 架構。 我們將從初始化我們的應用程序開始,呈現視圖,然後更新應用程序狀態。 來自 Ruby 背景,我傾向於更喜歡較短的文件並將我的 Elm 函數拆分為模塊,儘管擁有大型 Elm 文件是很正常的。 我在 Ellie 上創建了一個起點,但在本地我創建了以下文件:
- Types.elm,包含所有類型定義
- Main.elm,初始化並運行程序
- Update.elm,包含管理狀態的更新函數
- View.elm,包含渲染成 HTML 的 Elm 代碼
初始化我們的應用程序
最好從小處著手,因此我們將模型簡化為專注於構建一個包含可打開和關閉的步驟的單一軌道。 雖然我們已經認為我們知道整個數據結構,但從小處著手可以讓我們專注於將軌道渲染為 HTML。 它降低了複雜性並且你不需要它的代碼。 稍後,編譯器將指導我們重構模型。 在 Types.elm 文件中,我們保留了 Step 和 Clip 類型,但更改了模型和軌跡。
type alias Model = { track : Track } type alias Track = { name : String , sequence : Array Step } type Step = On | Off type alias Clip = String
要將 Elm 呈現為 HTML,我們使用 Elm Html 包。 它具有創建三種類型的程序的選項,這些程序建立在彼此之上:
- 初學者課程
一個減少副作用的程序,對於學習 Elm 架構特別有用。 - 程序
處理副作用的標準程序,可用於處理 Elm 之外的數據庫或工具。 - 帶有標誌的程序
一個擴展程序,可以用真實數據而不是默認數據來初始化自己。
使用可能的最簡單類型的程序是一種很好的做法,因為以後可以很容易地使用編譯器對其進行更改。 這是在 Elm 中編程時的常見做法; 只使用你需要的東西,然後再改變它。 出於我們的目的,我們知道我們需要處理被認為是副作用的 JavaScript,因此我們創建了一個Html.program
。 在 Main.elm 中,我們需要通過向其字段傳遞函數來初始化程序。
main : Program Never Model Msg main = Html.program { init = init , view = view , update = update , subscriptions = always Sub.none }
程序中的每個字段都將一個函數傳遞給控制我們的應用程序的 Elm Runtime。 簡而言之,Elm 運行時:
- 使用來自
init
的初始值啟動程序。 - 通過將我們初始化的模型傳遞給 view 來渲染第一個
view
。 - 當消息從視圖、命令或訂閱傳遞到
update
時,不斷地重新呈現視圖。
在本地,我們的view
和update
函數將分別從View.elm
和Update.elm
導入,稍後我們將創建它們。 subscriptions
會監聽消息以引起更新,但現在,我們通過always Sub.none
來忽略它們。 我們的第一個函數init
初始化模型。 將init
第一次加載的默認值。 我們用一個名為“kick”的軌道和一系列 Off 步驟來定義它。 由於我們沒有獲取異步數據,因此我們顯式地忽略了帶有Cmd.none
的命令來初始化而沒有副作用。
init : ( Model, Cmd.Cmd Msg ) init = ( { track = { sequence = Array.initialize 16 (always Off) , name = "Kick" } } , Cmd.none )
我們的 init 類型註解與我們的程序相匹配。 它是一種稱為元組的數據結構,其中包含固定數量的值。 在我們的例子中, Model
和命令。 目前,我們總是使用Cmd.none
忽略命令,直到我們準備好稍後處理副作用。 我們的應用程序什麼也不渲染,但它可以編譯!
渲染我們的應用程序
讓我們建立我們的觀點。 此時,我們的模型只有一條軌道,所以這是我們唯一需要渲染的東西。 HTML 結構應如下所示:
<div class="track"> <p class "track-title">Kick</p> <div class="track-sequence"> <button class="step _active"></button> <button class="step"></button> <button class="step"></button> <button class="step"></button> etc... </div> </div>
我們將構建三個函數來呈現我們的視圖:
- 一個用於渲染單個軌道,其中包含軌道名稱和序列
- 另一個渲染序列本身
- 還有一個用於渲染序列中的每個單獨的步驟按鈕
我們的第一個視圖函數將渲染單個軌道。 我們依靠我們的類型註釋renderTrack : Track -> Html Msg
來強制通過單個軌道。 使用類型意味著我們總是知道renderTrack
會有一個軌道。 我們不需要檢查記錄中是否存在name
字段,或者我們是否傳入了字符串而不是記錄。 如果我們嘗試將Track
以外的任何內容傳遞給renderTrack
,Elm 將無法編譯。 更好的是,如果我們犯了一個錯誤,並且不小心嘗試將軌道以外的任何東西傳遞給函數,編譯器會給我們友好的消息,為我們指明正確的方向。
renderTrack : Track -> Html Msg renderTrack track = div [ class "track" ] [ p [ class "track-title" ] [ text track.name ] , div [ class "track-sequence" ] (renderSequence track.sequence) ]
這似乎很明顯,但所有 Elm 都是 Elm,包括編寫 HTML。 編寫 HTML 沒有模板語言或抽象——全是 Elm。 HTML 元素是 Elm 函數,它採用名稱、屬性列表和子項列表。 所以div [ class "track" ] []
輸出<div class="track"></div>
。 列表在 Elm 中以逗號分隔,因此向 div 添加 id 看起來像div [ class "track", id "my-id" ] []
。
div 包裝track-sequence
將軌道的序列傳遞給我們的第二個函數renderSequence
。 它接受一個序列並返回一個 HTML 按鈕列表。 我們可以在renderTrack
renderSequence
跳過附加函數,但我發現將函數分解成更小的部分更容易推理。 此外,我們還有另一個機會來定義更嚴格的類型註釋。
renderSequence : Array Step -> List (Html Msg) renderSequence sequence = Array.indexedMap renderStep sequence |> Array.toList
我們映射序列中的每個步驟並將其傳遞給renderStep
函數。 在帶有索引的 JavaScript 映射中,可以這樣寫:
sequence.map((node, index) => renderStep(index, node))
與 JavaScript 相比,Elm 中的映射幾乎是相反的。 我們調用Array.indexedMap
,它有兩個參數:要在映射中應用的函數 ( renderStep
) 和要映射的數組 ( sequence
)。 renderStep
是我們的最後一個函數,它確定按鈕是活動的還是非活動的。 我們使用indexedMap
是因為我們需要將步驟索引(我們用作 ID)傳遞給步驟本身,以便將其傳遞給更新函數。
renderStep : Int -> Step -> Html Msg renderStep index step = let classes = if step == On then "step _active" else "step" in button [ class classes ] []
renderStep
接受 index 作為它的第一個參數, step 作為第二個參數,並返回渲染的 HTML。 使用let...in
塊來定義本地函數,我們將_active
類分配給 On Steps,並在按鈕屬性列表中調用我們的類函數。
更新應用程序狀態
此時,我們的應用程序會渲染踢腿序列中的 16 個步驟,但單擊不會激活該步驟。 為了更新步驟狀態,我們需要將消息 ( Msg
) 傳遞給更新函數。 我們通過定義一條消息並將其附加到按鈕的事件處理程序來做到這一點。
在 Types.elm 中,我們需要定義我們的第一條消息ToggleStep
。 它需要一個Int
作為序列索引和一個Step
。 接下來,在renderStep
中,我們將消息ToggleStep
附加到按鈕的單擊事件,以及序列索引和步驟作為參數。 這會將消息發送到我們的更新函數,但此時,更新實際上不會做任何事情。
type Msg = ToggleStep Int Step renderStep index step = let ... in button [ onClick (ToggleStep index step) , class classes ] []
消息是常規類型,但我們將它們定義為導致更新的類型,這是 Elm 中的約定。 在 Update.elm 中,我們遵循 Elm 架構來處理模型狀態更改。 我們的更新函數將接受一個Msg
和當前的Model
,並返回一個新的模型和一個可能的命令。 命令處理副作用,我們將在第二部分進行研究。 我們知道我們會有多種Msg
類型,所以我們設置了一個模式匹配的 case 塊。 這迫使我們在處理所有情況的同時也分離狀態流。 編譯器將確保我們不會錯過任何可能改變我們模型的情況。
在 Elm 中更新記錄與在 JavaScript 中更新對象略有不同。 我們不能像record.field = *
這樣直接更改記錄上的字段,因為我們不能使用this
或self
,但 Elm 有內置的幫助器。 給定像brian = { name = "brian" }
這樣的記錄,我們可以像{ brian | name = "BRIAN" }
{ brian | name = "BRIAN" }
。 格式如下{ record | field = newValue }
{ record | field = newValue }
。
這是更新頂級字段的方法,但嵌套字段在 Elm 中更棘手。 我們需要定義自己的輔助函數,因此我們將定義四個輔助函數來深入嵌套記錄:
- 一個切換步長值
- 一個返回一個新序列,包含更新的步長值
- 另一個選擇序列屬於哪個軌道
- 最後一個函數返回一個新軌道,包含更新的序列,其中包含更新的步長值
我們從ToggleStep
開始,在 On 和 Off 之間切換軌道序列的步長值。 我們再次使用let...in
塊在 case 語句中創建更小的函數。 如果步驟已經關閉,我們將其設為開啟,反之亦然。
toggleStep = if step == Off then On else Off
toggleStep
將從newSequence
。 數據在函數式語言中是不可變的,因此我們實際上不是修改序列,而是使用更新的步長值創建一個新序列來替換舊序列。
newSequence = Array.set index toggleStep selectedTrack.sequence
newSequence
使用Array.set
找到我們想要切換的索引,然後創建新序列。 如果 set 沒有找到索引,則返回相同的序列。 它依賴selectedTrack.sequence
來知道要修改哪個序列。 selectedTrack
是我們使用的關鍵輔助函數,因此我們可以訪問嵌套記錄。 在這一點上,它非常簡單,因為我們的模型只有一條軌道。
selectedTrack = model.track
我們的最後一個輔助函數連接所有其餘部分。 同樣,由於數據是不可變的,我們將整個軌道替換為包含新序列的新軌道。
newTrack = { selectedTrack | sequence = newSequence }
newTrack
在let...in
塊之外被調用,我們返回一個新模型,其中包含新的軌道,它重新渲染視圖。 我們沒有傳遞副作用,所以我們再次使用Cmd.none
。 我們的整個update
函數如下所示:
update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of ToggleStep index step -> let selectedTrack = model.track newTrack = { selectedTrack | sequence = newSequence } toggleStep = if step == Off then On else Off newSequence = Array.set index toggleStep selectedTrack.sequence in ( { model | track = newTrack } , Cmd.none )
當我們運行我們的程序時,我們會看到一個帶有一系列步驟的渲染軌跡。 單擊任何步驟按鈕都會觸發ToggleStep
,它會觸發我們的更新函數來替換模型狀態。
隨著我們的應用程序擴展,我們將看到 Elm 架構的可重複模式如何使處理狀態變得簡單。 對其模型、更新和視圖功能的熟悉有助於我們專注於我們的業務領域,並且可以輕鬆地跳入其他人的 Elm 應用程序。
在休息
用新語言寫作需要時間和練習。 我從事的第一個項目是簡單的 TypeForm 克隆,我用來學習 Elm 語法、架構和函數式編程範式。 在這一點上,你已經學到了足夠的東西來做類似的事情。 如果您渴望,我建議您閱讀官方入門指南。 Elm 的創建者 Evan 使用實際示例向您介紹 Elm 的動機、語法、類型、Elm 架構、縮放等。
在第二部分中,我們將深入探討 Elm 的最佳特性之一:使用編譯器重構我們的步進音序器。 此外,我們將學習如何處理重複事件、使用命令產生副作用以及與 JavaScript 交互。 敬請關注!