从鼓音序器学习榆树(第 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 交互。 敬请关注!