Elm-Web-开发-全-
Elm Web 开发(全)
原文:
zh.annas-archive.org/md5/24f389663f49b115ac5a6a70accc8d3b译者:飞龙
前言
Elm 是一个非常好的用于前端网页开发的函数式编程语言。学习 Elm 将是一个有用且愉快的体验,这里有多个原因。
为什么学习 Elm?
Elm 很容易上手。学习曲线并不陡峭。Elm 并没有引入大量需要理解和学习的新概念;实际上,恰恰相反。一旦你学会了 Elm 的做事方式,专注于函数式编程的一些关键概念,就没有太多需要理解的新主题。
这本书可以是一个很好的途径,让你进入函数式编程的高级概念。这可能是开始以函数式编程范式思考的更容易的方法之一。
尽管学习起来很容易,但投资回报率相当高;想象一下,你将节省多少时间,不必进行 JavaScript 风格的调试!从长远来看,这种投资回报率会累积。Elm 实现这一点的方式是没有运行时错误。所有错误都在编译时被捕获(因此你可以在实际项目中忘记像 undefined is not a function 这样的错误)。编译时间非常快。此外,Elm 拥有最友好和最有帮助的编译器之一。
如果你已经对 JavaScript 有点厌倦,并想看看其他替代方案,你很可能会喜欢 Elm。同样,如果你在决定是否学习 Angular、React、Vue 或其他任何 JavaScript 框架时遇到了困难,Elm 是一个可行的替代方案。
如果你正在学习 React,理解 Elm 架构将帮助你理解 Redux。一个有趣的趣闻:Redux 是受到 Elm 架构的启发。
如果你已经有一些 JavaScript 的经验,这将为你提供一个新的视角,并实际上使你成为一个更好的 JavaScript 开发者。你甚至可以使用 Elm 和 JavaScript 一起!Elm 与 JavaScript 兼容,因此你可以在项目中并排使用这两种语言。
Elm 是声明式的。扩展你的 Elm 应用程序实际上是一种愉快的体验。Elm 拥有一个伟大的社区和优秀的文档。
本书的目标
本书的目标是向初学者介绍 Elm 语言及其基本概念,并从一开始就使用这些概念在 Elm 中构建简单的网页应用。
本书并不涵盖 Elm 语言的全部功能,而是为你提供了一个坚实的基础,你可以在此基础上构建。
本书的一个目标是为那些阅读它的人提供便利,以便他们能够更好地理解更高级的 Elm 应用程序和主题。另一个目标是,一旦完成本书,你将拥有足够的知识,可以自信地在 Elm 中构建自己的应用程序。
本书面向的对象
这本书旨在为所有级别的网页开发者提供使用,以理解 Elm 语言并在其中构建简单实用的网页应用。
第三章,使用 Elm 创建您的个人作品集,讨论了类型别名、映射和过滤值、将 Bootstrap 4 样式添加到您的 Elm 应用程序中、理解函数签名以及模块化您的 Elm 应用程序。
熟悉 HTML、CSS 和 DOM,以及网络的工作原理会有所帮助,但不是必需的。此外,一些 Bootstrap 框架和 Material Design 语言的实践经验会受到欢迎,但不是强制性的。
本书写作的目的是在提供足够知识以便读者能尽快变得高效和生产,以及避免信息过载之间找到一个平衡点。
本书还旨在让对网络开发一无所知的人也能轻松理解。因此,本书不假设读者了解 JavaScript,也不广泛比较 JavaScript 与 Elm,或者即使有比较,也会尽量将这种比较限制在广泛的概括水平上。
简而言之,本书旨在针对初学者和经验丰富的开发者,无论他们对 JavaScript 的了解程度如何。
本书涵盖的内容
第一章,为什么现在是学习 Elm 的好时机?,涵盖了入门主题:什么是 Elm,它有什么独特之处,它与 JavaScript 的比较,如何开始学习它,以及一些非常基础的 Elm 概念和简单的代码片段。
第二章,构建您的第一个 Elm 应用程序,描述了不可变数据结构、Elm 架构(模型、视图和更新)、单向数据流、在 Elm 中使用 HTML 函数、Elm 中的表达式(if-else 和 case 表达式)、Elm 数据结构、联合类型以及一些运算符。
Elm 语言之美,除了其他方面,还在于编写 Elm 时你不需要过多担心 HTML 和 CSS。因此,在这本书中,重点主要放在 Elm 语言本身上。
第四章,在 Elm 中准备单位转换网站,探讨了使用 Result 类型作为处理错误的方法,并在一个有趣的项目中巩固了前几章中介绍的概念。
第五章,在 Elm 中完成单位转换网站,解释了使用 Html.map,处理复杂视图和多个模型,并展示了如何改进现有的单位转换网站。
第六章,更深入地探索 Elm,涵盖了 Elm 中解构值的主题,Elm 处理随机性的方式(通过命令和订阅),理解部分应用,以及与 Html.program 一起工作。
第七章,在 Elm 中创建天气应用,展示了如何使用Result处理错误,如何使用Maybe处理可选值和空值,如何解码 JSON 字符串,如何使用 Http 包获取远程数据,以及如何与第三方 API 一起工作。
第八章,为天气应用添加更多功能,展示了如何通过添加elm-mdl使我们的 Elm 应用程序看起来更美观,以及如何使用Round模块,并加强了之前章节中涵盖的主题。
第九章,Elm 中的测试,涵盖了 Elm 单元测试以及describe、Expect.equals和test函数的作用;它还处理了一些特定运算符及其在 Elm 测试中的使用方法,如何编写解码 JSON 的测试,并介绍了模糊测试。
第十章,将 Elm 与 Web 框架集成,展示了如何使用 Webpacker 和 Yarn 在 Rails 5.1 中集成 Elm 应用程序,以及如何使用 Ubuntu 容器在基于 Web 的 IDE 中设置一切。
要充分利用本书
-
要充分利用本书,您应该熟悉 HTML、CSS、网络的工作方式、Bootstrap 4 和 MDL。
-
Elm 的安装和设置在第一章,为什么现在是学习 Elm 的绝佳时机?中有所介绍,并且如果代码有任何更改、更新或勘误,将会在本书提供的代码中进行更新。
下载示例代码文件
您可以从 www.packtpub.com 的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在 www.packtpub.com 登录或注册。
-
选择“支持”选项卡。
-
点击“代码下载与勘误”。
-
在搜索框中输入书籍名称,并遵循屏幕上的说明。
文件下载完成后,请确保使用最新版本的以下软件解压缩或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
本书代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Elm-Web-Development。如果代码有更新,它将在现有的 GitHub 仓库中进行更新。
我们还有其他来自我们丰富的图书和视频目录的代码包可供选择,请访问 github.com/PacktPublishing/。查看它们!
使用的约定
本书使用了多种文本约定。
CodeInText: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“在这个时候,你可能会开始欣赏elm-format为我们所做的这项工作。”
代码块设置如下:
main : HTML Never
main =
view
任何命令行输入或输出都按以下方式编写:
[3,6,9] : List Int
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中如下所示。以下是一个示例:“编译时,Ellie 应用将在右侧预览窗格中显示“用户体验”这个词。”
警告或重要注意事项如下所示。
技巧和窍门如下所示。
联系我们
我们始终欢迎读者的反馈。
一般反馈:请发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请发送电子邮件至questions@packtpub.com。
勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,请访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上发现我们作品的任何非法副本,如果您能提供地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并提供材料的链接。
如果您想成为作者:如果您在某个领域有专业知识,并且对撰写或为书籍做出贡献感兴趣,请访问authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
想了解更多关于 Packt 的信息,请访问packtpub.com。
第一章:为什么现在是学习 Elm 的好时机?
欢迎来到第一章。本章是对 Elm 语言的温和介绍。我们本章的目标是理解以下内容:
-
什么是 Elm?
-
Elm 在竞争激烈的前端开发领域中有什么独特之处
-
Elm 与 JavaScript 如何比较?
-
如何在 Ellie-app、Cloud9 和 Atom 编辑器的帮助下快速入门
完成本章后,你将能够:
-
在基本层面上比较 Elm 代码与 JavaScript 代码
-
以几种不同的方式设置和使用 Elm
什么是 Elm?
Elm 是一种面向前端网页开发的函数式编程语言。用 Elm 的发明者埃文·查普利斯基的话说:
“我想做前端工作,但我想……让前端编程感觉真的、真的愉快。”
埃文是一位函数式程序员,他希望在实践中应用一些函数式编程的学术概念,目标是使前端网页开发的整体体验更加愉快。
当前前端开发方式的一个重要问题是开发过程中意外引入和累积小错误的问题。在开发前端 JavaScript 时,我们的代码中会出现错误,而且非常常见的是,这些错误会被忽视。
在稍后的某个时间点,这些错误将导致我们的代码出错。这会导致我们在修复我们开发者无意中通过一系列晦涩的错误引入到代码中的问题上浪费很多时间。
Elm 使我们几乎无法将错误引入到我们的代码中。通过结合非晦涩的编译时错误和有用的类型系统,将错误引入我们的代码几乎变得不可能。
如果我们只需要指出 Elm 的一个令人惊叹的特点,那就是它缺乏运行时错误。想象一下在 JavaScript 中工作却从未看到运行时错误,你可能会开始欣赏使用 Elm 可以实现的时间节省。
在使用 Elm 了一段时间之后,你开始感觉 Elm 似乎不断缩小了错误出现在你代码中的机会窗口,从它的结构到你与之交互的方式。你可以感觉到,有意识的努力被放在了使错误发生的可能性降低上。
例如,Elm 是围绕函数式编程范式构建的,它使用纯函数。纯函数是没有副作用的功能。换句话说,纯函数没有状态。它们接受参数,并返回一个值。这就是它们所做的一切!
他们不会出去发起 HTTP 请求。他们不会修改变量。他们不会以任何方式改变世界的状态。他们只是返回一个值。这带给我们一个有趣的效果——只要我们在 Elm 中向一个函数提供相同的值,它就会返回相同的结果。
Elm 中纯函数的另一个美妙好处是,你可以确信你在代码中做出的所有更改都是局部的。更改应用中的代码不会导致应用中的其他代码停止工作。
公平地说,以这种限制性的函数式风格编写纯 JavaScript 应用程序是可能的。然而,语言本身并没有内置的错误处理机制,如果我们失去了注意力并开始编写非纯函数,它将抛出错误。此外,当与第三方库一起工作时,你无法确定它们是否遵循了函数式风格的纯度。与 Elm 相比,Elm 只强制执行纯函数。
对于纯函数的讨论有一个需要注意的地方。你可能知道,一个完全无状态的程序是没有意义的。Elm 的独创性在于它处理应用程序更新的一种非常严格的方式。从这个角度来看,Elm 不仅通过强制我们使用纯函数来强制执行函数式编程范式,而且还限制了处理外部世界的方法。
Elm 自然具有的数据不可变性带来另一个巨大的好处:调试。由于你可以确信你的代码中的任何随机部分都无法影响其他部分,因此根本不可能出现不知道代码的哪个部分破坏了应用程序的问题;也就是说,代码的哪个部分导致了错误。在 JavaScript 中,这是一个常见的问题,在 Elm 中实际上是不存在的。
Elm 的另一个特点是它以允许我们应对应用中发生的事件的同时,仍然保持不可变性。Elm 也是一个很好的开始学习函数式编程的方法,而不必理解很多高级概念(这在尝试学习其他更困难的函数式编程语言时是必须的)。
为什么学习 Elm 值得?
在本节中,我们将讨论一些原因,说明为什么 Elm 是一门如此令人兴奋的语言去学习。
这些原因如下:
-
风驰电掣的虚拟 DOM
-
友好的编译时错误
-
零运行时异常
-
约束作为保证
-
管道语法
-
容易重构
-
提高生产力
-
有用的类型系统
-
时间旅行调试器
现在我们已经概述了 Elm 带来的令人兴奋的概念,让我们更深入地检查每一个。
风驰电掣的虚拟 DOM
为了能够讨论虚拟文档对象模型(DOM),我们首先需要了解DOM本身是什么。它是由万维网联盟(W3C)提出并维护的一个标准。
如 W3C 在其网站上定义的:
“文档对象模型是一个平台和语言中立的接口,它将允许程序和脚本动态地访问和更新文档的内容、结构和样式。文档可以进一步处理,处理结果可以合并回显示的页面。”
根据 W3C 的 DOM 规范第 3 级核心,DOM 是一种以树状结构访问和操作文档的方式,该结构由节点组成。我们可以将 DOM 总结为具有以下特性:
-
DOM 是语言无关的,这意味着在理论上,任何语言都可以用来操作它
-
DOM 被设置为允许实时操作自身
-
DOM 由一组对象(一个嵌套的、树状的对象层次结构)组成
-
DOM 还有一些方法可以操作这个层次结构;换句话说,DOM 是一个应用程序编程接口(API)
在我们继续讨论 DOM 之前,非常重要的一点是要清楚地了解什么是 API。为了解释 API 缩写代表什么,我们可以使用开关灯泡的类比。为了开关灯泡,我们不需要知道它是由什么材料制成的,需要多少电力来运行,或者它有多亮。我们只需要知道如何让它照亮房间,以及如何关闭它;也就是说,如何控制它的行为。
当然,正如我们非常清楚的那样,要控制灯泡,我们需要使用开关。在前面的例子中,我们可以说开关是灯泡的 API。为了操作灯泡(使其做我们想要的事情),我们需要通过它的API(开关)来访问它。
更广义地说,我们可以将 API 视为一种以结构化方式访问和操作(或控制)我们想要与之工作的任何事物的途径。
在 DOM 的情况下,它的目的是双重的。首先,DOM 是 HTML 的 API。使用灯泡类比,HTML 就是灯泡,DOM 就是开关,而 JavaScript 几乎是访问开关的唯一方式。
其次,DOM 是网页的一种表示。上一句话对于我们理解 DOM 的工作方式至关重要,所以我们将再次重复它。
DOM 是网页的一种表示。这种表示是一个由构建块——节点对象——组成的树状结构。在 DOM 中,总共有 12 种类型的节点。然而,这种网页的表示不是静态的,这意味着它不仅是一个静态的网页层次结构的表示。每个节点还包含所有使我们能够改变这个树状结构内容(API 部分)的属性和方法。
实际上,访问 DOM 的唯一方式是通过 JavaScript。对此也有一些例外。例如,在 IE 10 及以下版本中,我们可以使用 VBScript,但就实际应用而言,可以说只有 JavaScript 能够直接与 DOM 交互。
为了更好地理解网页、HTML、DOM、JavaScript 和浏览器之间的相互作用,我们需要了解当你将你的 Web 浏览器指向一个网页时会发生什么。以下是一个非常简化的解释。
首先,浏览器会调用服务器,服务器会返回一些 HTML。然后,浏览器的渲染引擎会将原始 HTML 解析为其 DOM 表示形式。解析是将一种格式转换为另一种格式的过程。从网页上提供的原始 HTML 不能直接操作。而不是直接操作原始 HTML,我们必须使用 DOM API,因此,网页的 HTML 需要由浏览器的渲染引擎内部解析为其 DOM 表示形式。
一旦浏览器的渲染引擎将 HTML 文档解析为其对象表示形式(DOM),它现在就可以供 JavaScript 使用,JavaScript 可以操作 HTML 文档的 DOM 表示形式。
另一种看待 HTML、DOM 和浏览器之间关系的方式是理解 HTML 仅仅是文本。只有当浏览器将其解析为其 DOM 表示形式后,我们才能在显示器上看到它。
几年前,浏览器并没有遵守 W3C 制定的标准。近年来,它们赶上了这个步伐,所有主流浏览器的 DOM API 基本上是符合标准的。然而,DOM 存在一些问题。
HTML 和 CSS 并不是为现代 Web 应用设计的,这些应用有数千个节点,有时需要根据某些标准进行更新。然而,今天,现代网页上的这种行为正是用户所期望的。单页应用(SPA)是这方面的一个很好的例子。在 SPA 中,DOM 需要不断更新,而 DOM 的工作方式使得直接操作成本高昂。
几年前,我们会使用 jQuery 作为处理网页交互的默认标准。因此,如果我们想根据点击事件更新页面,我们会告诉 jQuery 首先找到所有需要对该点击事件做出反应的节点。然后,我们会更新这些节点。
因此,jQuery 范式是我们直接与 DOM 交互。例如,为了在点击时针对所有div元素执行某种更改,我们首先需要设置我们的div选择器,然后为其提供事件处理程序和要执行的操作,如下所示:
$("div").on("click", performAction())
这种情况的问题是,在你的 Web 应用中可能需要监听至少六种事件。可能还会有很多 DOM 节点需要更新。
直接修改 DOM 很慢。你的应用程序越大,直接 DOM 操作的资源需求就越高,因为当 DOM 发生变化时,需要进行更多的布局重新计算和重排。累积这些更改和网页重排可能会导致页面不如用户期望的那样迅速。
另一个问题是我们随着 Web 应用程序的增长,更难避免错误,因为当直接与 DOM 工作的时候,很难分离关注点。虚拟 DOM 的想法在简单性上非常出色。而不是不断触摸 DOM,一个更好的方法是将整个 DOM 结构以虚拟方式表示出来,然后保持 DOM 的快照,这些快照以虚拟 DOM 节点表示。
当页面需要根据事件重新渲染时,会对比之前的 DOM 快照和新 DOM 结构,然后只对实际 DOM 执行达到最终结果所需的变化。
为了更好地解释直接 DOM 操作和虚拟 DOM 之间的区别,让我们看看两种编程范式:命令式编程和声明式编程。
在命令式编程中,我们解释如何得到“什么”。换句话说,我们需要给我们的程序一套详细的指令,精确说明需要执行哪些步骤才能达到预期结果。
在声明式编程中,我们只需请求要执行的内容;程序如何达到预期结果的内幕运作不是我们的关注点。SQL 就是一种声明式语言的例子。例如,我们可以说:
SELECT * FROM Customers
WHERE Car='Mercedes' AND Color='Green';
我们不给出 SQL 指令来执行操作;我们只是告诉它我们想要的结果。换句话说,我们不需要指定 SQL 需要采取的所有步骤来搜索拥有绿色梅赛德斯的车主数据库。我们只需告诉它我们想要什么,但实现方式(SQL 在幕后是如何做的)不是我们必须知道的事情。
这与 Elm 中虚拟 DOM 的工作方式非常相似。我们指定我们的更改结果,然后让 Elm 运行时决定最有效的方式来实现。
为了总结本节内容,值得注意的是,Elm 拥有所有主要 JavaScript 框架中最快的虚拟 DOM 实现,这是一项相当了不起的成就。
友好的编译时错误
真是挺有意思的,竟然有一个围绕用户体验(UX)和用户界面(UI)等理念的整个运动。我们这些网络工匠努力为用户提供与我们的网站和应用程序互动时可能获得的最佳体验。但是,除了作为网络的建造者之外,我们最终也是用户。
在我们查看 JavaScript 消息的例子之前,让我们记住,我们之所以在这些例子中使用 JavaScript,仅仅是因为,正如我们在本章前面讨论的那样,它实际上是唯一一种可以与 DOM API 一起工作的语言。看看一些 JavaScript 错误消息,我们发现我们仍然被这些难以理解的错误所困扰,例如:
Uncaught TypeError: undefined is not a function
为什么这样的错误信息如此简短?显然不是面向初学者的。
让我们分解这个错误信息,从 Uncaught 开始。显然,我们的应用程序未能捕获这个特定的错误。接下来是 TypeError。这只是几种错误类型中的一种。其他还包括 SyntaxError、URIError、RangeError 等等。
最后,信息是 undefined is not a function。了解 undefined 是 JavaScript 的原始类型之一很有帮助。其他还有字符串、数字、null、布尔值和符号。当一个变量被声明但没有分配值时,JavaScript 引擎会将其分配为 undefined。undefined 值是 undefined 类型的唯一可能值。因此,undefined 永远不能是一个函数。
换句话说,你的代码试图调用一个值,就像这个值是一个函数一样。显然,你不能调用一个非函数,因此错误信息中的 not a function 部分就出现了。有了这些知识,让我们重新表述我们的错误,使其更加用户友好。
怎么样,这个怎么样?
Type Mismatch: Your code was trying to run a function call on the primitive type of undefined. That is not possible, since only functions can be called, and undefined is not a function.
不更好吗?这有助于我们更好地理解错误。但如果编译器能更进一步呢?如果它不仅告诉我们错误是如何发生的,还能指出我们代码中最可能的原因呢?
这正是 Elm 所追求的。以下是一个来自 Elm 文档的错误信息的简化示例:
-- TYPE MISMATCH -------------------------------------- types/list.elm
The 3rd element of this list is an unexpected type of value.
15| [ alice, bob, "/users/chuck/pic" ]
^^^^^^^^^^^^^^^^^^
All elements should be the same type of value so that we can iterate over the list without running into unexpected values.
As I infer the type of values flowing through your program, I see a conflict between these two types:
HTML
String
As I infer 感觉好像我们在这里有一个朋友,他/她在积极地试图帮助我们。关于 Elm 错误的另一个了不起的事情是,随着语言新版本的发布,我们可以看到持续改进编译器错误友好性和有用性的努力。这个想法在 Elm 官方网站上用一句话很好地总结了:
"编译器应该是助手,而不是对手。"
例如,在 Elm 0.15.1 中,错误信息得到了改进,以便列出行号和实际代码,就像我们在前面的例子中看到的那样。此外,它们还突出显示了导致错误的代码的精确部分(使用插入符号)。
另一个改进是向错误信息中引入有用的提示。因此,我们得到了具有精确定位的错误信息,这些提示和上下文有助于减少开发者找到错误根本原因和最可能解决问题的所需时间。相反,编译器为我们做了所有这些工作。
在 0.16 版本中,Elm 引入了类型 diffs,它借鉴了 Git 的一个想法。然而,编译器比较的是类型,而不是提交。它还引入了有用的初学者提示、更好的错误信息和许多其他改进,这进一步加强了 Elm 的编译时错误只会随着语言新版本的发布而变得更好的观点。
零运行时异常
与 JavaScript 相比,Elm 的编程范式更加限制性。它迫使我们以一种更加健壮且错误率更低的方式进行思考和操作。在编译时,Elm 会捕获错误,并通过有用的消息建议如何纠正这些错误。通过确保我们没有 null 值,Elm 消除了 JavaScript 的 十亿美元错误。
如果我们的程序确实出现了错误,它将无法编译。所有这些都导致 Elm 社区的资深成员大胆地宣称零运行时异常。根据我迄今为止使用 Elm 的经验,这一点确实是真的。
错误仍然可能发生。即逻辑错误。当开发者编写的代码能够运行但毫无意义时,就会发生逻辑错误。例如,有一个年龄为负数的用户。零运行时异常的重要之处在于,一旦编译成功,应用程序将始终平稳运行。不会出现运行时崩溃的情况。
然而,即使在未来某个时刻,出于某种奇怪的原因, Elm 极少出现运行时异常成为可能,我也会对此感到高兴,因为那仍然会比 Elm 开发者日常面临的状况要好得多。
约束作为保证
对于函数式编程方法的最简单解释是——函数式编程是一种函数没有副作用编程风格的编程方式。
最终,这意味着如果你有使用 JavaScript 的经验,开始使用 Elm 会更困难。为什么?简单来说,JavaScript 不会强迫你遵守某些约定或约束。因此,在 JavaScript 中做事的方法更多,当你第一次开始使用 Elm 时,没有那种选择自由可能会感觉有些不自在。
相反,一旦出现问题,JavaScript 初始感觉良好的选择自由现在却以一个简单的问题的形式回到我们面前——是什么依赖项破坏了我的代码?在 JavaScript 中,经常搜索这个依赖项会是一种令人沮丧的经历。
因此,当你第一次开始使用 Elm 时,一开始可能会有一些约束感,但当你未来的自己回来重新审视代码时,你会很高兴自己不得不与它们一起工作。
当你第一次开始使用 Elm 时,其函数式编程范式感觉像是一种约束,但它也充当了保证事情只能以特定方式工作的保证。有限的可能性去做事情是一个坚实的 保证,即我们可能会以有限的方式搞砸事情。简单来说,约束是好的。
管道语法
让我们在 JavaScript 中定义一个乘法函数:
function multiply(a, b) {
return a * b;
}
现在按照以下代码所示调用它:
multiply(8, "10");
在 JavaScript 中,像我们刚才那样混合类型会正常工作,因为 JS 引擎会将字符串 "10" 强制转换为数字 10。
现在让我们看看 Elm。导航到在线 Elm 编辑器elm-lang.org/try,并将以下代码输入其中:
import HTML exposing (text)
multiplyNumber x y =
x * y
main =
text ( toString ( multiplyNumber 8 10 ) )
首先,我们导入HTML库。然后,我们使用关键字exposing使text函数对我们可用。text函数将 Elm 的文本字符串转换为 HTML 字符串。
接下来,我们定义multiplyNumber函数。它接受两个参数,x和y。然后,在我们的应用程序入口点main中,我们只需将multiplyNumber的结果传递给toString函数,因为我们需要将multiplyNumber返回的数字转换为字符串,以便text函数能够使用它。是text函数将转换后的multiplyNumber的结果打印到网页上。
让我们再次看看我们的主入口点:
main =
text ( toString ( multiplyNumber 8 10 ) )
在这里使用括号,我们指定了运算的顺序。与 JavaScript 不同,Elm 不使用括号来列出要传递给函数的参数。它使用空格。因此,为了告诉multiplyNumber函数将8和10相乘,我们只需写下这段代码:
multiplyNumber 8 10
使用括号的唯一原因是在将前一个函数的结果传递给另一个函数时避免歧义,在我们的例子中,是toString函数:
toString ( multiplyNumber 8 10 )
如果我们没有使用括号,我们的代码将看起来像这样:
toString multiplyNumber 8 10
因此,编译器会认为我们在向toString函数传递三个参数——multiplyNumber、8和10,这显然不是我们想要做的。让我们再次看看这一行代码:
text ( toString ( multiplyNumber 8 10 ) )
显然,表达式是从multiplyNumber函数评估的。然后结果被返回,因此用作toString函数的参数,最后,toString函数返回的值用作text函数的参数,该函数在屏幕上打印数字80。
前面的表达式仍然很容易阅读。但如果我们在其他函数内部嵌套更多的函数呢?如果我们为了看到“到处都是括号”的效果,将数字 8 替换为 2 的倍数呢?换句话说,如果我们这样做:
text ( toString ( multiplyNumber 2 ( multiplyNumber 2 ( multiplyNumber 2 10 ) ) ) )
好的,那么让我们按照执行顺序来分析表达式,也就是说,从最内层的括号开始:
multiplyNumber 2 10
前面的表达式返回了20的值。然后我们将这个值作为下一个multiplyNumber函数的第二个参数传递:
multiplyNumber 2 ( multiplyNumber 2 10 )
这次,我们将数字 2 与最内层的multiplyNumber函数返回的值相乘(该值为 20)。最后,我们运行第三个multiplyNumber函数,然后是toString函数,最后是text函数以获取最终结果,该结果仍然是 80。
这是一个简单的例子,事情已经开始变得有些杂乱。如果我们以这种方式运行 20 个不同的函数呢?我们的一行代码会变成 500 个字符长吗?或者我们应该在文本编辑器中使用自动换行?即使我们这样做,它看起来和感觉也会很笨拙。
这就是管道语法发挥作用的地方。我们不需要像前面代码片段中描述的那样推理我们的代码,我们只需简单地将第一个函数的结果传递给下一个函数。就像这样:
import HTML exposing (text)
multiplyNumber x y =
x * y
main =
multiplyNumber 2 10
|> multiplyNumber 2
|> multiplyNumber 2
|> toString
|> text
随意花点时间思考一下在 Elm 中编写这种管道语法所减轻的认知负担。与 JavaScript 相反,管道函数只是 Elm 语言的一部分。为了总结本章的这一部分,让我们重写起始示例。这次,我们将使用管道语法:
import HTML exposing (text)
multiplyNumber x y =
x * y
main =
multiplyNumber 8 10
|> toString
|> text
喜欢管道语法的另一个原因是,如果我们有一个包含 20 个函数的列表,每个函数都连接到下一个函数,然后我们决定,比如说,删除第 5、7 和 15 个函数,我们只需简单地擦除那些有管道的相应行。对比一下,处理括号并确保它们都正确打开和关闭的混乱情况。
注意,当我们添加多个管道操作符,就像前面代码片段中做的那样,实际上发生的事情是前面的管道操作符 |>(正式称为前向函数应用操作符)评估其左侧的表达式,并将其作为右侧函数的最后一个参数传递。
简单的重构
简而言之,重构是一个术语,指的是在不改变代码产生的结果的情况下改变代码外观的简单过程。例如,假设你的代码中有一个函数变得越来越长。你可以用两个较小的函数替换掉这个长函数,每个函数执行一个更专业的任务。然而,这段代码的外部行为不会改变。通过替换应用程序代码的一部分,你可以使代码更容易使用、更容易理解、更容易推理或更容易维护。所有这些因素都是重构的重要原因。
Elm 中的重构之所以更容易,是因为编译器的工作方式。编译器会不断地提醒你所有你破坏的地方,直到你修复它们。此外,由于 Elm 是静态类型语言,它所具有的类型系统将处理你通常在使用 JavaScript 时必须自己处理的大多数怪癖。正如我们在上一节中看到的,管道语法是语言的核心特性,在某些情况下,这也可以加快重构的速度。
有用的类型系统
如前所述,Elm 是静态类型语言。它有一个类型注解的概念。这些注解不是强制性的,但出于清晰起见,推荐使用。如果你查看别人写的代码,能够看到类型签名总是很好的。那么,让我们看看我们的这个小乘法数字应用,这次添加了类型注解:
import HTML exposing (text)
multiplyNumber: Int -> Int -> Int
multiplyNumber x y =
x * y
main =
multiplyNumber 8 10
|> toString
|> text
与其他语言相比,它们的类型系统有冗长的类型声明和奇怪的错误信息,并且仍然存在运行时错误,而 Elm 的类型系统不仅有帮助,它真正增加了开发者的幸福感。查看 multiplyNumber 函数的函数签名,我们可以看到它接受两个 Int 值,并返回一个 Int,所以快速浏览就能确切地知道发生了什么。
提高生产力
这一点是我们从前一节提到的所有点中可以得出的逻辑结论。有了超级快速的虚拟 DOM、友好的编译时错误、零运行时异常、约束作为保证、管道语法和有帮助的类型系统,Elm 真正是一个令人愉快的合作伙伴。编译器是你的朋友,你会发现自己在编写代码时更加自信。由于不需要进行不必要的心理体操,编写 Elm 代码的效率更高。
开始编写 Elm 代码
导航到官方网站上的“Hello World”示例:
elm-lang.org/examples/hello-HTML.
代码如下:
import HTML exposing (text)
main =
text "Hello, World!"
前面的代码编译为简单的 Hello, World! 输出。但这个输出是什么?是一个 HTML 元素吗?不是。它实际上只是一个 DOM 文本节点。由于文本节点需要附加到一个元素上,这个文本节点就附加到了实际渲染的最顶层元素上,即 <body> 元素。你可以通过在浏览器开发者工具中检查渲染的文本来验证这一点。
让我们在这里做些其他的事情。让我们在页面上渲染一个实际的 HTML 元素。为此,我们需要将一个带有一些属性的函数传递给我们的主变量。例如,我们可以这样做:
import HTML exposing (..)
main =
h1 [] [ text "Hello, Elm!" ]
我们在这里做了什么?我们将 h1 函数传递给 main。h1 函数接受两个参数;第一个参数是空的,第二个参数接受 text 函数作为属性。这个 text 函数接受一个字符串作为自己的参数,在这种情况下,是 "Hello, Elm!"。
让我们在这次更改后检查开发者工具。我们可以看到,文本节点的父节点现在确实是一个 h1 HTML 标签。请确保保持开发者工具开启;我们稍后会使用它。
让我们将函数从 h1 改为 h2:
h2 [] [ text "Hello, Elm!" ]
在在线编辑器上按下编译按钮,你会得到预期的结果——文本现在略小,开发者工具显示我们的文本节点的父节点现在确实是一个 <h2> HTML 标签:

让我们尝试一个不同的标签,例如,一个 anchor 标签:
main =
a [] [ text "Hello, Elm!" ]
那么 li 呢?参考以下代码片段:
main =
li [] [ text "Hello, Elm!" ]
我们能否将其添加为一个段落?参考以下代码片段:
main =
p [] [ text "Hello, Elm!" ]
嵌套组件很容易。例如,如果我们想渲染一个包含两个段落的 div,我们将调用一个 div 函数,并在其括号内调用两个 p 函数,如下所示:
import HTML exposing (..)
main =
div []
[ p [] [text "1st paragraph" ]
, p [] [text "2nd paragraph" ]
]
在所有之前的例子中,我们都留空了第一个参数。这个参数用于添加 HTML 属性,例如,class。那么,现在让我们尝试给我们的div上色:
import HTML exposing (..)
import HTML.Attributes exposing (class)
main =
div [ class "danger" ]
[ p [] [text "1st paragraph" ]
, p [] [text "2nd paragraph" ]
]
我们给我们的div添加了danger类,并且我们还想给它添加 CSS 声明background: red。但是我们应该在哪里添加它?
做这件事最快的方式是使用一个在线的优秀的 Elm 编辑器,那就是 Ellie。
快速开始使用 Ellie-app
在您的网络浏览器中导航到以下地址:ellie-app.com/new。在代码的 Elm 部分,粘贴我们在上一节末尾讨论的 Elm 代码,其中我们将div的类设置为danger。在我们的代码的 HTML 部分,在已经包含的关闭</style>标签上方添加以下 CSS:
.danger {
background: red;
}
点击编译按钮,您将在 Ellie 右侧的窗口中看到结果。
现在我们已经成功完成并编译了这个非常简单的应用程序,让我们看看为什么 Ellie 编辑器比官方网站上提供的默认Try Elm编辑器更好用。
添加类型注解
尽管我们能够向我们的应用程序添加 CSS,但不幸的是,在 Ellie 中没有可用的编译器建议。
如果 Ellie 中可用一个 linter,我们会注意到main函数上的警告,因为 linter 会将其显示为下划线。如果我们有 linter 可用,将鼠标悬停在警告下划线上会弹出一个显示以下消息的提示:
Top-level value main does not have a type annotation.
I inferred the type annotation so you can copy it into your code:
main : HTML msg
有趣的是,Ellie 应用程序之前自动启用了 linter,但出于某种原因,目前它没有在 Ellie 编辑器中使用。在本章的后面部分,我们将看到如何为我们的 Elm 应用程序设置一个更健壮的开发环境。
让我们假设我们确实在我们的 Ellie 应用程序中有一个 linter,并包含前面的类型注解,这样我们的代码将看起来像这样:
import HTML exposing (..)
import HTML.Attributes exposing (class)
main : HTML msg
main =
div [ class "danger" ]
[ p [] [text "1st paragraph" ]
, p [] [text "2nd paragraph" ]
]
注意,我们还在前面的代码中暴露了class函数。再次点击编译按钮。如前所述,添加类型注解虽然不是强制性的,但被认为是最佳实践。
到这一点,故意省略了main : HTML msg的含义。本章的目标是向您介绍 Elm 中事物工作的一般思想,而不涉及所有细节,这样您可以掌握最重要的概念,然后再查看其他更复杂的设计模式。
Ellie 还有更多功能,使其成为开始熟悉 Elm 的最佳编辑器。
然而,为了确保您充分利用这一章的介绍,我们还将查看如何使用神奇的create-elm-app npm 包来设置 Elm。
最后,我们将通过查看如何在您的计算机上设置 Elm 以与代码编辑器(GitHub 的 Atom)一起工作来结束这一章。
快速开始使用 create-elm-app
要快速使用 npm 创建 Elm 应用程序,您需要在您的计算机上安装 Node 和 npm。使用 elm-app npm 包的优势在于设置非常简单。
您只需通过命令行使用以下命令进行安装:
npm install create-elm-app -g
上述命令将在您的系统上全局安装 create-elm-app 包。
接下来,打开您希望安装 Elm 应用的文件夹。假设您想将您的应用命名为 elm-fun。在这种情况下,运行以下命令来安装您的 Elm 应用:
create-elm-app elm-fun
最后,要运行您的应用程序,请切换到 elm-fun 文件夹,并运行以下命令:
elm-app start
由于您的应用还没有代码,您将只会看到一个 Elm 标志。这是确认一切运行正常的一个信号。如果您想看到您的应用至少做些其他事情,尝试在 Main.elm 中添加以下代码片段:
import HTML exposing (..)
import HTML.Attributes exposing (class)
import HTML exposing (..)
main : HTML msg
main =
div [ ]
[ h1 [] [text "Elm is fun!" ]
, p [] [text "Let's learn some more!" ]
]
当您的 elm-app start 命令在控制台中运行时,它会在保存时重新编译项目,并显示一个简单的网页。
要了解更多关于此 npm 包的信息,请将您的浏览器指向此 URL:www.npmjs.com/package/create-elm-app
在 Windows 10 上开始使用 Elm
不幸的是,要在 Windows 上设置 Elm 开发环境需要涉及许多步骤。幸运的是,一旦设置完成,使用起来就非常愉快。在本节中,我们将介绍所有必要的步骤,以便尽可能容易地在 Windows 10 上设置您的 Elm 开发环境。
首先,打开您的命令提示符并运行以下命令:
npm install -g elm
从 atom.io 下载 Atom 并运行安装程序。通过 Atom 包管理器(CTRL + ,)安装 language-elm 以进入设置,点击“安装包”,并输入 language-elm。输入以下命令:
npm install -g elm-oracle
在 PowerShell 中,where.exe elm-oracle 将返回以下内容:
C:\Users\PC\AppData\Roaming\npm\elm-oracle
C:\Users\PC\AppData\Roaming\npm\elm-oracle.cmd
在 Atom 中,按 CTRL , 进入设置。一旦进入设置,点击“包”,然后在“已安装包”中按包名过滤:elm。将打开 package-language-elm 窗口;点击其设置,并在该包的设置中粘贴 elm-oracle 可执行文件的路径。
安装 apm (Atom 包管理器)
这是 Atom 默认安装 apm 的位置:
C:\User\PC\AppData\Local\atom\app-1.19.7
C:\User\PC\AppData\Local\atom\app-1.19.7\resources\app\apm\bin
现在,在 PowerShell 中输入 apm 将会显示一系列 apm 命令选项,这意味着它已经成功添加到路径中。让我们通过控制台中的 apm 命令来安装 atom-beautify:
apm install atom-beautify
继续安装 elm-format:
apm install elm-format
从guide.elm-lang.org/install.HTML获取 Elm 平台的 Windows 安装程序,点击 Windows 安装程序链接,它将下载Elm-Platform-0.18.exe。运行它以安装 Elm 平台。安装完成后,点击完成按钮关闭安装窗口。确保将其添加到C:\Program Files (x86)\Elm Platform\0.18\bin的路径中。
现在是下载 elm-format 的时候了。要获取 Windows 版本的elm-format.exe,请导航到github.com/avh4/elm-format/releases,然后滚动到下载部分。点击与你的操作系统相关的链接。在我们的例子中,我们使用的是 Windows(具体是 Windows 10),因此我们将点击elm-format-0.18-0.7.0-exp-win-i386.zip下载链接。
为了在 Windows 上使用 elm-format,我们需要将我们的 PATH 变量指向 elm-format 的可执行文件。然而,由于 Elm 平台安装程序会自动指向其可执行文件(在安装过程中,它会向 PATH 添加一个新的变量),我们只需将未压缩的可执行文件粘贴到C:\Program Files (x86)\Elm Platform\0.18\bin。换句话说,我们需要将其解压缩到包含 Elm 安装的文件夹中。
如果你不确定你的 Elm 程序安装在哪里,请在 PowerShell 中运行where.exe elm。
现在打开 Atom 编辑器,转到设置 | 包,并在已安装包搜索字段中输入elm。
包含的包列表应该包括elm-format包;点击其设置。当elm-format包设置打开时,在二进制路径字段中粘贴你的 elm 可执行文件路径C:\Program Files (x86)\Elm Platform\0.18\bin\elm-format.exe,然后关闭设置标签。无需保存,Atom 会自动完成。
现在,你可以通过在任意 Elm 文档上运行它来测试elm-format是否工作。为了论证,让我们创建一个新的格式不佳的 Elm 文档:
module Main exposing (..)
import HTML exposing (HTML, text)
main =
text "Hello, Elm!"
让我们在控制台中运行elm-format对此文件进行格式化。首先,你需要将你的控制台指向包含格式错误的 Elm 文件的文件夹,然后运行:
elm-format .\poorly-formatted-file.elm
你将收到以下警告:
This cannot be undone! Make sure to back up these files before proceeding.
Are you sure you want to overwrite these files with formatted versions? (y/n)
输入y以执行格式化,然后检查文件以查看结果。现在让我们继续安装。为了继续,我们将在控制台中运行以下命令来安装 apm linter:
apm install linter
确保 Atom 保持开启状态,因为它将从 Atom 界面直接安装一些依赖项,即linter-ui-default及其默认依赖项(intentions, busy-signal)。一旦完成,你就可以在控制台中运行以下命令:
apm install linter-elm-make
上述命令将linter-elm-make安装到类似以下的位置:
C:\Users\PC\.atom\packages
注意:在先前的示例中,PC是用户名。
现在,让我们对我们的设置进行测试。创建一个新的文件夹,让我们称它为elmtest。在文件夹内创建一个文件,命名为Main.elm,并在 Atom 编辑器中打开它。
一旦你这样做,你将收到以下警报:
No elm-package.json beneath or above the edited file.
You can generate an 'elm-package.json' file by running elm-package install' from the command line.
因此,让我们运行elm-package install。首先,我们将控制台指向elmtest文件夹,然后执行:
elm-package install -y
控制台将报告:“Packages configured successfully!”。它还将列出已安装的包。
现在,让我们向Main.elm添加一些代码以确保其正常工作:
module Main exposing (..)
import HTML exposing (HTML, text)
main =
text "Hello, Elm!"
摘要
在本章中,我们讨论了许多重要主题,具体包括:
-
什么是 Elm?
-
在竞争激烈的前端 Web 开发领域,Elm 有什么独特之处?
-
Elm 与 JavaScript 相比如何?
-
如何借助 Ellie-app、create-elm-app 和 Atom 编辑器快速入门
在下一章中,我们将探讨如何使用elm-reactor、elm-make、elm-repl和elm-package,并开始编写 Elm 代码。
第二章:构建你的第一个 Elm 应用
欢迎来到第二章。本章的目标是理解以下主题:
-
不可变数据结构
-
Elm 架构基础——与模型、视图和更新一起工作
-
Elm 中的 Messages
-
单向数据流
-
理解
beginnerProgram函数 -
加强 Elm 中 HTML 函数的使用
-
Elm 中的
If-else表达式 -
Elm 中的
Case表达式 -
Elm 数据结构的基础(列表、元组、记录、集合、数组和字典)
-
Elm 中的联合类型
-
在 Elm 中使用模运算进行计算
完成本章后,你将能够做到以下事情:
-
与 Elm 架构一起工作
-
在你的应用中使用
if-else表达式和case表达式 -
在 Elm 中构建一个非常简单的 Fruit Counter 应用
-
在 Elm 中构建一个非常简单的 FizzBuzz 应用
让我们构建一个应用
在本节中,我们将讨论不可变数据结构和 Elm 架构的基础。为了使事情更加实用,我们将构建一个非常简单的应用,这将有助于我们强化这些重要概念。
不可变数据结构
Elm 是一种函数式编程语言。函数式编程语言的信条之一是数据结构是不可变的。一旦创建,它们就不能改变。在实践中,这意味着 Elm 中的函数将接受一个数据结构作为其参数,然后返回一个全新的数据结构。
如果你这么想,这完全合理。如果所有数据结构都是不可变的,我们如何处理变化?更具体地说,我们如何处理 Elm 函数对现有数据引入的变化?唯一的明显答案是创建全新的数据。
Elm 如何在实际中应用这一点?
假设我们想要在健康领域构建一个应用。这个应用是一个简单的倒计时应用,只显示一个按钮和一个数字。应用从数字 5 开始。应用的想法是用户每次吃健康小吃时(比如,一块水果)就按按钮。这样,用户将通过确保每天吃五块水果来改善他们的健康习惯。
Elm 架构
我们对 Elm 架构需要了解的最基本的东西是它由四个部分组成:模型、视图、消息和更新。
需要注意的是,架构通常被描述为由仅三个部分组成:模型、视图和更新。然而,为了清晰和便于学习,在我们 Elm 之旅的这个阶段,我们可以将消息视为 Elm 架构的同等构建块。在后面的章节中,当我们深入理论和实践时,我们将澄清这些区别。然而,为了有一个清晰的思维模型,我们将 Elm 架构视为由四个组成部分构成。
模型
模型持有我们的应用程序状态。在我们的水果计数器应用程序中,模型非常简单:它只持有作为其唯一数据结构的一个整数。当我们首次运行我们的应用程序时,模型持有值为 5。
由于它是以数据结构的形式表示的,并且由于 Elm 中的数据结构是不可变的,因此我们的模型必须作为前一个数据的副本加上对其所做的更改来更新。模型在函数对其操作时更新。
函数何时会操作模型?
由于我们正在构建一个非常简单的应用程序,函数将仅在用户点击我们应用程序中的一个按钮时操作模型。点击此按钮将使模型中的当前值递减 1。由于 Elm 中的数据是不可变的,函数必须返回一个包含更新的新模型副本。
视图
视图可以被视为在屏幕上查看模型的方式。视图是一个函数,我们将其传递给模型。因此,视图将模型作为其参数,并返回 HTML,这些 HTML 将在浏览器中渲染。
另一种思考视图的方式是,它是一种允许用户与模型交互的方式。根据我们在上一章中制作的 API 参考,视图可以看作是模型的一个视觉 API。它是用户以结构化方式操作模型的一种方式。
当用户与视图交互时,他们会通过改变其状态来操作模型。由于我们的应用程序非常简单,改变状态的唯一方式是按下我们应用程序中的一个按钮,这将递减模型当前持有的值。
这一次按钮点击将使视图向更新发送一个消息。
消息
在用户与视图交互(按下按钮)之后,此操作的通告将以消息的形式发送到更新函数。由于只有一个可能发生的行为,该消息很简单:递减。
当然,在任何现实的应用中,消息将需要更多的逻辑,但为了简单起见,让我们将其保留为递减。
更新
更新函数接收消息。接下来,更新函数根据接收到的消息确定如何更新我们应用程序的状态,即模型。一旦更新完成,就会创建一个新的模型并渲染视图。然后用户与视图交互,这会导致再次发送消息。更新接收消息并更新模型,然后循环继续。
单向数据流
以数据流的方式看待我们的应用程序是一种有趣的方法。我们之所以有这个概念并在我们的讨论中使用它,是因为它是一种帮助我们观察 Elm 应用程序状态变化的有用方式。
由于 Elm 建立在许多限制之上,因此将其架构也应用于这种限制性的理念是有意义的。
如果你看看我们的简单应用中正在发生的事情,你会注意到数据总是单向移动:从模型到视图到消息到更新到模型。这就是单向数据流的基本原理。
这个概念使我们能够轻松跟踪状态变化,同时也使得对这些变化的推理变得容易得多。
我们的应用需求
在本书的这一部分,我们已准备好构建我们的第一个应用:
-
我们对函数式编程的工作方式有一个概念
-
我们理解了 Elm 架构运作的最基本概念(模型、视图、消息、更新)
-
我们知道我们想要我们的应用做什么
带着这些知识,我们现在可以构建我们的应用。
构建水果计数器应用
让我们从我们的应用骨架开始。让我们将浏览器指向 Ellie-app.com,我们将看到以下代码:
module Main exposing (main)
import HTML exposing (HTML, text)
main : HTML msg
main =
text "Hello, World!"
以下代码将是我们的应用起点。我们将通过逐渐添加功能来构建它,并在适当的时候解释底层概念,即当我们需要理解它们的时候。
展示我们所需要的一切
让我们从更新module Main开始,通过在括号内添加两个点来暴露一切,我们将对导入的HTML模块做同样的事情,因为我们想要能够使用所有可用的 HTML 函数。具体来说,我们需要访问h1、p和button函数。
接下来,读取为main : HTML msg的行是一个可选的类型注解,为了演示我们可以没有它工作,我们将通过在行首放置两个连字符和一个空格来注释掉它。
包含类型注解被认为是最佳实践,我们只是将其注释掉以显示我们的应用即使没有类型注解也可以运行(以及演示如何在 Elm 中添加单行注释)。
我们的应用现在看起来是这样的:
module Main exposing (..)
import HTML exposing (..)
-- main : HTML msg
main =
text "Hello, World!"
要预览这个阶段的 app,只需在 Ellie-app 中编译它。
模型
让我们添加我们的模型:
-- MODEL
type alias Model =
Int
我们的模型只是一个简单的整数类型。
现在,我们将简单地忽略这个type alias的含义,因为它只会让我们在理解基本概念时分心。我们将在本书的后面部分回到类型。
视图
我们的View函数将接收当前模型并将返回以下 HTML:
-- VIEW
view model =
div [] [ h1 [] [ text ("Fruit to eat: " ++ (toString model)) ] ]
在 第一章,为什么现在是学习 Elm 的绝佳时机?,我们探讨了使用 Elm 的 HTML 函数将一些基本的 HTML 渲染到屏幕上的情况。鉴于前面的代码,让我们快速讨论view函数将要渲染的 HTML。
我们分配给 view 函数的代码位于等号右侧(Elm 的赋值操作符)。在这段代码中,我们正在运行 div 函数。像 Elm 中的所有其他 HTML 函数一样,div 函数有两个方括号对。第一对方括号可选地列出 div 函数的 HTML 属性,第二对方括号列出实际的 div 内容。
我们留出了第一对方括号为空,这意味着我们没有给 div 函数指定任何属性。然后,在 div 函数的第二对方括号内,我们传递了 h1 函数。
像所有其他 HTML 函数一样,h1 函数也有两个方括号对。在上面的例子中,我们的 h1 函数没有指定任何属性(因为第一对方括号是空的——就像我们在 div 中做的那样,它的父函数/元素)。在 h1 函数的第二对方括号内,我们调用 text 函数。
text 函数将渲染一个文本节点。要输出到文本节点中的文本被括号包围。在括号内,我们使用一个字符串字面量,并将其与 model 的值(使用 toString 函数转换为字符串)连接起来。
我们刚刚学习了 Elm 的一个操作符,即 ++ 操作符。
在 Elm 中,++ 是字符串连接操作符,用于将两个独立的字符串连接在一起。
由于我们将模型初始化为 5 的值,因此 view 函数的前置代码最初将返回以下 HTML 代码:
<div>
<h1>Fruit to eat: 5</h1>
</div>
我们的观点函数现在在最低级别上已经准备好了。接下来,我们将处理消息部分。
消息
让我们现在看看消息部分,我们将声明一个新的类型,我们将其称为 Msg:
-- MESSAGE
type Msg =
Decrement
如本章前面所述,我们不会在此处解释类型。
更新
是时候添加我们的更新了。在上一个步骤中,我们已声明我们的特殊类型 Msg。我们将将其传递给 update 函数:
-- UPDATE
update msg model =
model - 1
到目前为止,你应该很容易猜到 update 函数将做什么:它将接受一个 msg 和一个 model,并将返回一个 model 的副本,其值减 1。
在我们 Fruit Counter 应用程序开发的这个阶段,我们只需要让模型(Model)、视图(View)和更新(Update)协同工作,为此,我们将使用 beginnerProgram 函数。
添加 beginnerProgram 函数
是时候添加 beginnerProgram 函数了,我们将将其分配给 main 函数。
我们当前的 main 函数看起来是这样的:
-- main : HTML msg
main =
text "Hello, World!"
让我们添加我们的更新后的 main 函数,现在它将分配给 beginnerProgram 函数:
-- main : HTML msg
main =
beginnerProgram { model = 5, view = view, update = update }
如您所见,我们只是调用了 beginnerProgram 函数。然后,我们传递 model、view 和 update 函数,并为每个函数分配一个值。
我们初始化模型为5的值。对于update,我们将其赋值给update函数的值。view同样设置为view函数的值。
在我们对main函数做出这些更改之后,我们应用的完整代码现在看起来是这样的:
module Main exposing (..)
import HTML exposing (..)
-- main : HTML msg
main =
beginnerProgram { model = 5, view = view, update = update }
-- MODEL
type alias Model =
Int
-- VIEW
view model =
div [] [ h1 [] [ text ("Fruit to eat: " ++ (toString model)) ] ]
-- MESSAGE
type Msg =
Decrement
-- UPDATE
update msg model =
model - 1
如果我们现在运行应用,一切都会正常工作,屏幕上会出现以下输出:要吃的水果:5。
尽管我们的应用非常基础,但一切正常,我们没有遇到任何编译器错误,这真是太好了。然而,我们还没有做的一件事是,我们没有添加按钮,这是启动我们应用当前状态改变的一个入口点。
在我们添加这个按钮之前,请随意查看前面的代码,并稍微思考一下一个完全无状态的应用。目前,我们的应用模型永远不会改变,因为我们的代码中的更新部分永远不会被执行。
让我们通过添加一个按钮来纠正这个问题。
视图、按钮和事件
让我们从简单地给我们的应用添加一个静态按钮开始。我们将通过更新视图函数来实现这一点,以下是相应的代码:
-- VIEW
view model =
div []
[ h1 [] [ text ("Fruit to eat: " ++ (toString model)) ]
, button [ onClick Decrement ] [ text "Eat fruit" ]
]
如果你现在保存并运行应用,编译器会抛出以下错误:
NAMING ERROR
Line 23, Column 20
Cannot find variable onClick
为什么我们会得到这个错误?因为我们还没有导入onClick函数。现在让我们通过在应用的第 3 行添加导入语句来解决这个问题。
看看我们代码的开头,更新后的前三条代码应该如下所示:
module Main exposing (..)
import HTML exposing (..)
import HTML.Events exposing (onClick)
现在运行我们的应用会给我们一个简单、基础但功能齐全的应用,它是用 Elm 构建的!
当你点击“吃水果”按钮时,view函数会调用button函数的第一对方括号,并监听点击事件。我们为onClick函数提供了在按钮点击时发送的Msg。由于我们的应用中只有一个可能的消息,一旦点击事件被触发,view函数将向update函数发送Decrement消息。
一旦update函数收到消息,它将返回一个新的model,新的model将被view函数渲染。
然而,有一个问题。如果我们继续点击按钮,我们的应用最终会进入计数负数的状态,这是不可能的。用户不应该剩下负 2 个要吃的水果。
在接下来的部分,我们将修复这个问题。
限制减少消息
为了限制减少消息,让我们首先看看当前的更新函数:
-- UPDATE
update msg model =
model - 1
现在,让我们引入一个if-else语句来应对不同的可能情况:
-- UPDATE
update msg model =
if model > 0 then model - 1 else model == 5
不幸的是,前面的代码并没有产生预期的结果。相反,我们得到了以下编译器消息:
TYPE MISMATCH
Line 39, Column 5
The branches of this if produce different types of values.
The then branch has type:
number
But the else branch is:
Bool
Hint: These need to match so that no matter which branch we take, we always get back the same type of value.
我们在 第一章 为什么现在是学习 Elm 的好时机? 中简要提到了 Elm 的约束。前面的问题是一个 Elm 约束在实践中的绝佳例子。由于 Elm 的设置方式,每个分支都必须返回相同的数据类型。在我们的情况下,我们可以为 if-else 表达式的任一分支返回布尔值,或者返回数字,但不能混合使用。
那么,我们如何纠正这个问题?为了使事情简单并仍然使用我们最初开始的 if-else 表达式,让我们考虑一种简洁的方式来避免类型不匹配。计数器的值永远不会低于零,所以我们只需这样做:
-- UPDATE
update msg model =
if model > 0 then model - 1 else model + 5
使用前面的代码,我们的 if-else 逻辑将始终返回一个数字。更新函数的消息值只要大于零就会增加 1。否则(如果它是零),它将增加 5。
为了总结这一部分,让我们看看我们还可以如何编写我们的代码中的主函数:
main =
HTML.beginnerProgram
{ model = 5
, update = update
, view = view
}
在我们完成本章的这一部分之前,让我们看看完成的 Fruit Counter 应用程序,因为我们将在本章后面提到它:
module Main exposing (..)
import HTML exposing (..)
import HTML.Events exposing (onClick)
-- main : HTML msg
main =
HTML.beginnerProgram
{ model = 5
, update = update
, view = view
}
-- MODEL
type alias Model =
Int
-- VIEW
view model =
div []
[ h1 [] [ text ("Fruit to eat: " ++ (toString model)) ]
, button [ onClick Decrement ] [ text "Eat fruit" ]
]
-- MESSAGE
type Msg =
Decrement
-- UPDATE
update msg model =
if model > 0 then model - 1 else model + 5
现在我们已经看到了一个简单应用程序的实际实现,让我们讨论一些背后的理论概念,即 Elm 中的值和类型、函数以及 if 表达式。我们将通过更深入地研究 Elm 消息来结束讨论。
Elm 中的值、表达式、数据结构和类型
Elm 中没有语句。在我们本章前面看到的 if-else 示例中,我们使用的是表达式,而不是语句。
这种差异很重要,因为它告诉我们关于 Elm 语言行为的一些信息:表达式总是会返回一个值。实际上,Elm 中的每件事都是一个表达式,因此每件事都会返回一个值,甚至不需要显式地使用 return 关键字(例如,在 JavaScript 中我们必须这样做)。
那么,这个值可以是什么?值只是计算的结果。它是运行表达式的结果。换句话说,当表达式被评估时,它将产生一个值。为了测试这一点,我们可以看看 Elm REPL。为了使事情简单,我们将使用可在线访问的 elm-repl,网址为 elmrepl.cuberoot.in。
Elm REPL 是 Elm 语言的解释器。区分解释器和编译器很重要。
为什么我们在以下示例中使用 Elm REPL?我们使用它是因为这是一种直接获取我们给 REPL 的每个表达式评估到的值类型信息的方法。换句话说,我们将给 Elm REPL 提供一系列表达式,主要是简单值的形式,REPL 将返回它们的类型。我们将从评估为原始类型的表达式开始。
Elm 中的原始类型
Elm 中的原始类型包括 Char、String、Bool 和 number(Int 和 Float)。
字符和字符串类型
让我们在在线 elm-repl 中输入以下内容:
'a'
Elm-repl 会给我们返回运行前面表达式的结果,并跟随着该值的类型。值 'a' 的类型是 Char:
'a' : Char
让我们再试一个,这次用双引号:
"a"
我们得到的是这个:
"a" : String
我们可以读作:值 ''a'' 的类型是 String。
显然,当我们使用单引号时,我们得到 Chars。
要从值中获取 String 类型,我们需要在该值周围加上双引号。
多行字符串通过将任意数量的行用三个连续的双引号括起来来编写。当使用 REPL 时,每一行也必须以反斜杠结尾。Elm REPL 会自动插入管道字符。输入的多行字符串值仍然会评估为 String 类型:
""" \
| This
| is
| a
| multi-line
| string
| """
" \n This \n is \n a \n multi-line \n string \n " : String
让我们看看 Elm 中的其他原始类型。
数字类型
如果我们只输入一个数字,我们将得到相同的数字,后面跟着一个冒号和 number 类型:
> 5
5 : number
如果我们测试一个十进制数,我们将得到 Float 类型的类型:
> 3.6
3.6 : Float
为什么有些类型是大写的,而有些不是?
如果一个类型是大写的,这意味着它是一个显式类型。
基本上,number 类型用于 Int 和 Float。它最终会变成哪种类型(即最终会变成哪种显式类型),取决于这个数字是如何使用的。
换句话说,number 是一个隐式类型,因为它最终可以是显式的 Int 或显式的 Float。
要获取类型为 Int 的值,请运行以下命令:
> truncate 3.14
3 : Int
布尔值
让我们看看布尔值:
> True
True : Bool
> False
False : Bool
大写很重要!例如,输入 true 会引发错误:
> true
-- NAMING ERROR ---------------------------------------------- repl-temp-000.elm
Cannot find variable `true`
3| true
^^^^
Maybe you want one of the following?
List.take
String.trim
通过这个,我们已经涵盖了 Elm 中的原始类型。接下来,我们将查看数据结构。由于 Elm 语言是静态类型的,并且 Elm 中的每件事都是表达式,因此我们可以推断出,无论我们放入 REPL 中的内容实际上都是一个表达式,其值将评估为某种类型。数据结构也是这种行为的组成部分。
数据结构:列表、元组、记录、集合、数组和字典
Elm 中的基本数据结构是列表、元组、记录、集合、数组和字典。在本节中,我们将使用 Elm REPL 和 Ellie-app 来查看这些数据结构的每个行为。
列表
Elm 中的列表类似于 JavaScript 中的数组。在我们的第一个例子中,让我们在 Elm REPL 中输入这个值:
[ 1, 2, 3, 4 ]
这是我们从 REPL 获取到的:
[1,2,3,4] : List number
太棒了,一个数字列表!
与浮点数进行对比:
[ 0.1, 0.2, 0.3, 0.4 ]
REPL 的响应是:
[0.1,0.2,0.3,0.4] : List Float
让我们尝试字符:
[ 'a', 'b', 'c' ]
REPL 返回这个:
['a','b','c'] : List Char
然而,在 Elm 的列表中混合值是不允许的:
> [ 1, 2, 3, 'c' ]
-- TYPE MISMATCH --------------------------------------------- repl-temp-000.elm
The 3rd and 4th entries in this list are different types of values.
3| [ 1, 2, 3, 'c' ]
^^^
The 3rd entry has this type:
number
But the 4th is:
Char
Hint: Every entry in a list needs to be the same type of value. This way you
never run into unexpected values partway through. To mix different types in a
single list, create a "union type" as described in:
<http://guide.elm-lang.org/types/union_types.HTML>
明显地,我们无法在 Elm 列表中混合类型。换句话说,Elm 中的每个列表都是一个表达式组,这些表达式必须始终评估为相同的类型。
让我们尝试字符串:
> [ "just", "a", "bunch", "of", "strings" ]
["just","a","bunch","of","strings"] : List String
那么,一个空列表呢?:
> []
[] : List a
List a 意味着这个列表是空的,也就是说,它可以包含任何东西。这总结了我们对 Elm 中列表的简要概述。接下来,我们将查看元组。
元组
在 Elm 中,元组是一种可以持有各种类型值的 数据结构。
要在 Elm REPL 中创建一个元组,我们只需在括号内放入一个 String 和一个布尔值:
( "abc", True )
REPL 将会响应:
("abc",True) : ( String, Bool )
元组可以持有最多九个值。有趣的是,不同长度的元组被认为是不同类型的。例如,让我们使用 Elm REPL 创建一个包含两个元组的 List:
[ ( 'a', 'b' ), ( 'c', 'd' ) ]
这个表达式将会计算为:
[('a','b'),('c','d')] : List ( Char, Char )
REPL 告诉我们的是,我们放入的值是一个包含两个元组的 List,这些元组持有 Char 类型的值。
让我们尝试改变第二个元组中 Chars 的数量:
[ ( 'a', 'b' ), ( 'c' ) ]
在 REPL 中运行前面的代码将会抛出以下错误:
-- TYPE MISMATCH --------------------------------------------- repl-temp-000.elm
The 1st and 2nd entries in this list are different types of values.
3| [ ( 'a', 'b' ), ( 'c' ) ]
^^^
The 1st entry has this type:
( Char, Char )
But the 2nd is:
Char
Elm 会查看我们给出的表达式,并返回类型不匹配错误。确实,为了使两个元组被认为是同一类型,它们必须持有相同数量的值,并且这些值 也 必须是同一类型的。
在前面的工作示例中,我们将两个包含两个 Chars 的元组添加到了一个 List 中,Elm REPL 返回了 List ( Char, Char )。
Elm 中元组能持有的最大值数是 9。如果你尝试向元组中添加 10 个或更多的值,Elm 将会抛出一个错误。让我们来试一试:
('1','2','3','4','5','6','7','8','9','0')
我们得到的是以下错误:
elm-make: Could not find `_Tuple10` when solving type constraints.
在接下来的章节中,我们将更深入地了解元组。现在,让我们只提一下一个常见的用例:由于元组作为数据结构可以持有各种值,因此它们作为存储计算结果的手段非常有用。
记录
Elm 中的记录使用花括号,并且每个值都必须有一个标签。记录也可以持有多个值。例如,我们可以在 Elm REPL 中输入以下记录:
{ color="blue", quantity=17 }
REPL 将返回以下内容:
{ color = "blue", quantity = 17 } : { color : String, quantity : number }
我们将在 Elm 程序中大量使用记录,因为记录允许我们在各种场景下建模数据。
在本章的代码中,我们已经使用了记录,在 添加 beginnerProgram 函数 部分中。让我们回顾一下我们使用的代码:
-- main : HTML msg
main =
beginnerProgram { model = 5, view = view, update = update }
要在 REPL 中测试代码,我们只需要使用记录,而不需要所有冗余的部分:
{ model = 5, view = view, update = update }
REPL 将会返回以下错误:
-- NAMING ERROR ---------------------------------------------- repl-temp-000.elm
Cannot find variable `update`
3| { model = 5, view = view, update = update }
^^^^
REPL 将会跟随前面的错误,并给出一个类似的错误,指出 'update' 变量也无法找到。为了纠正这个问题,作为一个练习,我们可以给记录中使用的变量赋值,如下所示:
> view = "view info"
"view info" : String
> update = "update info"
"update info" : String
> { model = 5, view = view, update = update }
{ model = 5, view = "view info", update = "update info" }
: { model : number, update : String, view : String }
我们已经将 String 类型的值赋给了 Elm REPL 中的 view 和 update 变量。然后我们输入了记录,REPL 返回了记录中使用的每个变量的类型。因此,在前面的例子中,model 是 number 类型,update 是 String 类型,而 view 也是 String 类型。
接下来,我们将探讨集合、数组和字典,虽然它们也需要导入,但它们同样内置在 Elm 中。
集合
集合是唯一值的集合。它们的唯一性由 Elm 编程语言保证。我们可以通过 fromList 函数实例化集合。创建一个空集合很简单:set = Set.empty。
让我们看看在 Elm 中创建集合的另一种方法,通过将浏览器指向:ellie-app.com/new。
打开的页面已经包含了一些 Elm 代码。让我们调整代码,使其看起来如下:
module Main exposing (main)
import HTML exposing (HTML, text)
import Set
set = Set.fromList [1,1,1,2]
main : HTML msg
main =
text (toString set)
在前面的代码中,我们在导入 Set(到我们命名为 set 的变量)之后,将评估表达式的返回值赋给了它:Set.fromList [1,1,1,2]。
接下来,我们将 set 变量传递给 main 函数,以将其渲染为文本节点。当然,在它被渲染出来之前,我们必须将其转换为 String。在 Ellie-app 中按下编译按钮后,我们应该看到以下结果:Set.fromList [1,2]。
当我们试图找出数据结构之间的差异时,集合非常有用。接下来,我们将探讨数组。
数组
Elm 中的数组是零基的,就像在 JavaScript 中一样。使用数组,我们可以根据它们的索引来处理元素。和集合一样,数组可以使用 fromList 函数创建。
或者,我们可以创建一个空数组,如下所示:array = Array.empty。仍然在 Ellie-app 中,让我们对我们的代码进行轻微的修改以测试数组:
module Main exposing (main)
import HTML exposing (HTML, text)
import Array
array = Array.fromList [1,1,1,2]
array2 = Array.get 0 array
main : HTML msg
main =
text ((toString array) ++ " " ++ (toString array2))
在前面的代码中,我们有一个小小的转折——我们将两个数组的连接和一个空格(都转换为 Strings)分组,然后对它们运行 text 函数,最后将表达式评估的返回值传递给主函数。
编译后的代码将显示以下结果:Array.fromList [1,1,1,2] Just 1。现在,让我们暂时忽略这个结果的意义,因为我们将在本书的后面部分再次回到它。接下来,我们将探讨 Elm 中的字典。
字典
字典也是使用 fromList 函数创建的。让我们回到 Ellie-app,我们的代码已按如下方式更改:
module Main exposing (main)
import HTML exposing (HTML, text)
import Dict
dict =
Dict.fromList
[ ("keyOne", "valueOne")
, ("keyTwo", "valueTwo")
]
main : HTML msg
main =
text (toString dict)
编译后,Ellie-app 将返回以下内容:
Dict.fromList [("keyOne","valueOne"),("keyTwo","valueTwo")]
Dict 是用于存储键值对的数据结构。键必须是唯一的。要了解更多关于这个数据结构的信息,请访问以下网址:package.elm-lang.org/packages/elm-lang/core/latest/Dict。
接下来,我们将探讨 Elm 语言中类型如何作用于函数和 if 表达式。
函数、if 表达式和类型
让我们在 Elm REPL 中创建一个新的函数。我们将我们的函数命名为 multiplyBy5:
multiplyBy5 num = 5 * num
REPL 将返回以下内容:
<function> : number -> number
前面的行表示我们的 multiplyBy5 函数的类型为 number -> number。让我们看看处理字符串的函数将返回什么类型:
appendSuffix n = n ++ "ing"
如我们所知,++ 运算符是 Elm 中的连接运算符;它将两个 Strings 连接在一起。因此,预期 Elm REPL 将返回以下内容:
<function> : String -> String
如我们所见,前面的函数类型为 String -> String。
但是,这个 String -> String 是什么意思?同样地,前一个例子中的 Int -> Int 又是什么意思?String -> String 简单来说就是该函数期望一个 String 类型的参数,并将返回一个 String。对于 Int -> Int 的例子,该函数期望一个 Int 类型的值,并将返回一个 Int 类型的值。
是时候看看 Elm 中 if 表达式的类型基础了。考虑以下代码片段和 REPL 给出的响应:
> time = 24
24 : number
> if time < 12 then "morning" else "afternoon"
"afternoon" : String
在前面的代码中,我们使用变量 time(我们将其赋值为 24)运行一个 if 表达式。然后,我们运行比较。请注意,if 表达式实际上应该被称为 if-else 表达式,因为 if 表达式必须有 else 部分,否则在 Elm 中将无法工作。if 和 else 分支必须是同一类型。这就是为什么在前面的例子中,我们确保我们得到的任何结果都是 String 类型。
重新审视 Elm 消息
我们本章从 Elm 架构:模型、视图和更新开始。我们还提到了另一个重要组成部分:消息,这是视图与更新进行通信的方式。
我们使用了一个非常简单的示例应用,水果计数器。该应用确实很简单:我们的视图可以向更新函数发送的唯一消息是Decrement。我们创建的应用的简单性是我们理解架构的好方法,无需引入太多会妨碍学习的概念。
然而,现在我们已经对所有的组成部分及其如何组合有了基本的理解,我们可以讨论 Elm 架构中与消息相关的另一个复杂层次。
要做到这一点,我们可以查看官方文档中的一个完成示例。这将有两个目的:首先,它将使我们养成尽可能经常参考优秀官方文档的习惯,其次,它将为我们提供一个基准,我们最初会参考,在本书的学习过程中,随着我们学习更高级的概念,我们将逐渐超越。
要开始,请打开按钮示例的文档,网址为:guide.elm-lang.org/architecture/user_input/buttons.HTML。
接下来,访问 Ellie 在线编辑器,网址为ellie-app.com/new,并将按钮示例中的代码粘贴进去:
import HTML exposing (HTML, button, div, text)
import HTML.Events exposing (onClick)
main =
HTML.beginnerProgram { model = model, view = view, update = update }
-- MODEL
type alias Model = Int
model : Model
model =
0
-- UPDATE
type Msg = Increment | Decrement
update : Msg -> Model -> Model
update msg model =
case msg of
Increment ->
model + 1
Decrement ->
model - 1
-- VIEW
view : Model -> HTML Msg
view model =
div []
[ button [ onClick Decrement ] [ text "-" ]
, div [] [ text (toString model) ]
, button [ onClick Increment ] [ text "+" ]
]
粘贴代码后,点击右侧面板中的编译按钮,应用将在那里编译并运行。该应用本身与我们的 水果计数器 非常相似,只是稍微复杂一些。让我们比较这些差异。
在 水果计数器 中,我们有以下消息:
-- MESSAGE
type Msg =
Decrement
在按钮应用中,消息如下:
-- UPDATE
type Msg = Increment | Decrement
首先要注意的是代码前面的注释。最初,我们使用MESSAGE作为注释文本。然而,在按钮示例中,他们使用UPDATE作为内联注释。原因是:传统上,Elm 架构被认为只由模型(Model)、视图(View)和更新(Update)组成。我们使用 Message 作为架构的独立部分只是为了更容易理解它。理解的最重要的一点是:视图向更新函数发送消息。从概念上讲,这两个例子是相同的。唯一的区别是现在消息被定义在我们应用的更新部分,正如它应该的那样。
第二点要注意的是,我们的消息只有Decrement的值。它只能是一个Decrement消息。在按钮应用中,我们有两种选择:消息可以是要么Decrement,要么Increment。
那么type关键字和管道字符是什么意思呢?这与一个称为联合类型(也称为代数数据类型或标记联合)的东西有关。
在 Elm 中,联合类型只是我们可以即时想出的自定义类型。在我们的Fruit Counter应用中,我们的Msg联合类型只有一个值:Decrement。在按钮应用中,Msg联合类型可以是两个值中的任何一个:Increment或Decrement。为了在联合类型中清楚地区分可能的值,我们使用管道字符。
让我们在 Elm REPL 中创建另一个自定义联合类型:
type Vehicle = Car | Bike | Boat | Helicopter
要创建一个联合类型,我们首先使用type关键字。接下来,我们提供实际类型,Vehicle。我们即时创建了一个自定义类型,并将其命名为Vehicle!在赋值运算符(等号=)的右侧,我们提供Vehicle联合类型可以具有的值。这些值被称为类型构造函数,因为你可以使用它们来构造新的Vehicle实例。
让我们在 REPL 中创建一个新的Vehicle实例:
> friendsRide = Helicopter
Helicopter : Repl.Vehicle
我们刚刚构建了一个新的Vehicle类型的实例。正如我们所见,REPL 以这条信息响应——值是Helicopter,其类型是Vehicle。
有时,用几种不同的方式解释相同的概念是有帮助的。看待联合类型的另一种方式是,它们是我们描述构造函数的方式,也就是说,定义它们。
在update函数中,我们有:
type Msg = Increment | Decrement
上述代码意味着要创建一个Msg,必须调用Increment函数或Decrement函数。
联合类型的理论基础根植于数学逻辑,即集合论,这基本上是研究事物集合的研究。因此,我们可以将联合类型看作是任何数量的事物集合的组合。联合类型和集合论都可以相当抽象,但在这个学习阶段,我们只需说联合类型是组织 Elm 应用中消息的一种方式。
函数、模式匹配和情况表达式
本章的目标是构建一个简单的应用,并学习其背后的重要理论。我们通过将我们的应用与官方文档中的应用进行比较来扩展了这个目标。
在本节中,我们将查看 Buttons 应用的更新函数,并对其进行拆解,以便完全理解其功能和运作方式。
这很重要,因为我们一旦理解了 Buttons 应用中的update函数是如何工作的,我们就可以自信地实现一个类似的解决方案,并改进我们的水果计数器。
让我们从检查 Buttons 应用的update函数开始:
update msg model =
case msg of
Increment ->
model + 1
Decrement ->
model - 1
我们在这里看到了一个新的关键字:case。
通常,case语法的运作方式如下——一个变量具有某个值。根据其值,将执行特定的代码块。当值不同时,要执行的代码块也会不同。最后,在case表达式的末尾,有一个代码块将执行所有在case表达式中未指定的值。换句话说,对于任何未指定的场景,都有一个在底部的 case 块来处理它。这个case块被称为通配符,并用下划线字符_标记。正如我们可以在前面的例子中看到的那样,在某些情况下,不需要添加通配符case,因为我们已经覆盖了所有可能性。
Elm 的case表达式通过模式匹配来评估,也就是说,通过验证case是否符合模式。if 表达式和case 表达式非常相似。一个主要区别是case表达式匹配模式,而if表达式检查真条件,作为确定要运行哪个代码块的方式。
查看 case 表达式的语法,我们可以看到它们以case关键字开头,后面跟着 case 表达式的名称(在我们的例子中,是msg)。名称是完全任意的;我们本可以使用任何其他名称,而不仅仅是msg。例如:
update whatever model =
case whatever of
Increment ->
model + 1
Decrement ->
model - 1
正如你可以在前面的代码片段中看到的那样,update函数的第一个参数和case 表达式的名称必须相同。为了避免混淆,最好在这里坚持使用msg作为第一个参数,因为这是规范,你将在大多数 Elm 程序中看到这种方式的使用。
因此,在case关键字和 case 表达式的名称之后,我们还有一个关键字of。
接下来,我们列出我们的 case。代码的结构始终相同:
Pattern -> Expression to evaluate
如果我们查看第一个 case,我们可以看到它被写成如下:
Increment ->
model + 1
在前面的代码片段中,要匹配的模式是Increment,要评估的表达式是model + 1。
改进水果计数器应用
基于我们迄今为止所学的一切,让我们改进水果计数器应用。以下是完整的代码:
module Main exposing (..)
import HTML exposing (..)
import HTML.Events exposing (onClick)
-- main : HTML msg
main =
HTML.beginnerProgram
{ model = 5
, update = update
, view = view
}
-- MODEL
type alias Model =
Int
-- VIEW
view model =
div []
[ h1 [] [ text ("Fruit to eat: " ++ (toString model)) ]
, button [ onClick Decrement ] [ text "Eat fruit" ]
, button [ onClick Reset ] [ text "Reset counter" ]
]
-- MESSAGE
type Msg = Decrement | Reset
-- UPDATE
update msg model =
case msg of
Decrement ->
if model >= 1 then
model - 1
else
5
Reset ->
5
让我们突出显示我们对应用所做的改进:
-
我们在
view函数中添加了另一个按钮。 -
当新按钮被点击时,我们添加了一个新的消息
Reset。 -
我们向我们的
Msg联合类型中添加了Reset构造函数。 -
在我们的
update函数中,我们给我们的模式表达式命名为msg,并且我们还给它提供了两个匹配模式:Decrement和Reset。 -
如果匹配到
Decrement模式,将评估一个if表达式(以确定模型是否应该减一或其值应该是5)。 -
如果匹配到
Reset模式,将评估一个5的表达式。
在前面的解释中,我们尽力按照本章学到的概念描述了所有的更新。理解前面的解释非常重要,因为它是我们在后续章节构建更复杂应用程序的基础。
在 Elm 中构建简单的 FizzBuzz 应用程序
FizzBuzz 是一种儿童文字游戏。其目的是教授数学,即除法。
游戏很简单:每个玩家从 1 开始报数。如果一个数可以被 3 整除,玩家需要报出 Fizz 而不是数字。此外,如果一个数可以被 5 整除,玩家需要报出 Buzz 而不是数字。
最后,如果一个数可以被 3 和 5 整除,玩家需要报出 FizzBuzz。
由于这是一个众所周知且相对简单的问题,因此它是测试程序员知识水平的好方法。
为了解决这个问题的方法,我们将引入一个新的运算符,即取模运算符 %。这个运算符返回除法的余数。
这种工作方式可以通过一个例子来最好地描述。打开在线 Elm REPL,并运行以下表达式:
12 % 10
REPL 将返回:
2 : Int
这意味着:如果我们用 12 除以 10,我们会得到 1,而剩下的就是 2。由于 2 小于 10,它不会被整除,因为在我们例子中的取模运算只会以 10 为增量进行除法,并返回余数。
那就是为什么我们说取模运算符返回除法的余数(也就是说,取模运算符右侧的内容决定了增量的大小)。
例如,如果我们运行这个:
10 % 9
我们将得到以下返回:
1 : Int
然而,如果我们运行这个:
19 % 9
前面的表达式将评估为 1 : Int,因为在前面的例子中,将要计算的增量大小是 9。由于 9 * 2 = 18,除法的余数是 1。
如果我们将数字除以它自己会发生什么?让我们尝试数字 3:
3 % 3
REPL 返回以下内容:
0 : Int
我们得到返回值 0,类型为 Int。我们可以对 5 和 15 做同样的操作:
> 5 % 5
0 : Int
> 15 % 15
0 : Int
再次,我们得到零,类型为 Int。
这在实践中意味着我们可以使用 if-else 表达式来检查所有除以 3 没有余数的数字。对于所有满足该条件的数字,我们将返回 Fizz。我们将根据游戏规则对数字 5 和 15 采用类似的方法。
这是我们创建简单的 FizzBuzz 应用所需的信息。将您的浏览器导航到 ;ellie-app.com/new 并输入以下代码:
module Main exposing (main)
import HTML exposing (text)
fizzBuzz = "FizzBuzz"
fizz = "Fizz"
buzz = "Buzz"
fizzBuzzInput value =
if value % 15 == 0 then
fizzBuzz
else if value % 3 == 0 then
fizz
else if value % 5 == 0 then
buzz
else (toString value)
main =
text (fizzBuzzInput 34567)
到目前为止,我们已经有足够的知识轻松理解前面的代码。查看前面的代码,需要提醒的一个重要概念是我们与 if 表达式一起工作时需要遵守的原则:它们应该始终返回相同类型的值。这就是为什么我们在 if 表达式的底部传递值到 toString 函数的原因。
作为对管道语法的快速提醒(在第一章 Why Is This a Great Time to Learn Elm? 中讨论),以下是另一种我们可以编写主函数的方式:
main =
fizzBuzzInput 34567
|> text
|> 被称为 前向函数应用操作符。
我们已经实现了在 Elm 中创建一个工作 FizzBuzz 应用的目标。在接下来的章节中,我们将探讨改进我们简单 FizzBuzz 应用的方法。
概述
在本章中,我们涵盖了多个重要主题,具体如下:
-
Elm 语法:值、类型、数据结构、
if-else表达式、case表达式以及一些操作符 -
TEA:Elm 架构
-
单向数据流的概念
-
使用 Elm REPL 和 Ellie-app 进行工作
我们还构建了两个工作应用,虽然简单,但已经展示了我们所涵盖的理论概念的实际应用。
在下一章中,我们将使用 Elm 创建我们自己的个人投资组合。
第三章:使用 Elm 创建您的个人作品集
欢迎来到 第三章,使用 Elm 创建您的个人作品集。本章的目标是使用 Elm 创建一个简单的个人作品集网站。
我们将涵盖的主题包括:
-
理解类型别名
-
详细讨论模型、视图、消息、更新工作流程(对 Elm 架构的深入了解)
-
使用
List.map和List.filter映射和过滤值列表 -
使用 elm-make-app
-
将 Bootstrap 4 样式添加到我们的应用程序中
-
在 Elm 中与
HTML模块一起工作 -
理解 HTML 元素的函数签名
-
通过将视图拆分为多个文件来模块化我们的应用程序
-
使用情况表达式动态渲染网页的各个部分
-
使用
List.map和String.concat改进我们的 FizzBuzz 应用程序
完成本章后,您将能够:
-
在 Elm 中使用类型别名
-
了解如何使用 Elm 架构
-
能够使用
List.map和List.filter在 Elm 中操作列表 -
使用 elm-make-app 构建 Elm 应用程序
-
将 Elm 应用程序模块化到多个文件中
- 使用
case表达式为您的 Elm 驱动的网页进行动态更新
- 使用
-
为您的 Elm 应用程序添加自定义样式
使用 elm-make-app 创建我们的作品集
要开始本章,让我们回顾一下在 第一章,为什么现在是学习 Elm 的好时机? 中,我们已经使用 Atom 编辑器设置了一个工作流程。这个设置的美妙之处在于,尽管它很复杂,但一旦设置完成,我们就拥有了所有额外的 Elm 工具。
要遵循本章的说明,使用所提到的 Atom 设置并非绝对必要,但它会使与我们的 Portfolio 应用程序一起工作变得更加容易。或者,您可以使用任何编辑器,因为这些步骤仍然有效,但那样的话,您将没有使用代码检查器和语法高亮功能。
因此,首先,让我们使用控制台创建一个新的项目文件夹。例如,我们可以将 bash 指向桌面,并在那里创建一个新的文件夹:
mkdir chapter3
接下来,让我们使用以下命令在我们的控制台中更改目录到 chapter3:cd chapter3。
在 第一章,为什么现在是学习 Elm 的好时机? 中,我们讨论了创建新 Elm 应用程序的几种方法。其中之一是使用 create-elm-app npm 包。如果您还没有安装该包,请参阅 第一章,为什么现在是学习 Elm 的好时机?,以确保您已正确设置一切。
使用 create-elm-app npm 包,让我们创建一个新的 Elm 应用程序,命名为 my-portfolio:
create-elm-app my-portfolio
控制台将打印出运行前面命令的结果,如下所示:
Creating my-portfolio project...
Starting downloads...
● elm-lang/HTML 2.0.0
● elm-lang/virtual-dom 2.0.4
● elm-lang/core 5.1.1
Packages configured successfully!
Project is successfully created in `C:\Users\PC\Desktop\chapter3\my-portfolio`.
Inside that directory, you can run several commands:
elm-app start
Starts the development server.
elm-app build
Bundles the app into static files for production.
elm-app test
Starts the test runner.
elm-app eject
Removes this tool and copies build dependencies, configuration files
and scripts into the app directory. If you do this, you can’t go back!
We suggest that you begin by typing:
cd my-portfolio
elm-app start
一旦创建了新的 Elm 应用程序,请打开 Atom 编辑器中的 my-portfolio 文件夹。
从 Atom 编辑器中,展开 src 文件夹,并打开 Main.elm 文件。删除现有的代码,并粘贴以下代码:
module Main exposing (main)
import HTML exposing (HTML, text)
main : HTML msg
main =
HTML.h1 []
[ text "My Portfolio" ]
或者,你也可以直接导入h1函数,并相应地更改main函数,如下所示:
module Main exposing (main)
import HTML exposing (HTML, h1, text)
main : HTML msg
main =
h1 []
[ text "My Portfolio" ]
上述两个示例之间的区别在于我们导入的函数。如果我们不导入h1函数,我们需要使用HTML.h1。
最后,使用你的控制台,cd进入my-portfolio文件夹,并运行以下命令:
elm-app start
运行前面的命令将打开你的默认浏览器,并显示localhost:3000的 URL。在打开的页面上,你会看到一个格式良好的h1标题:我的作品集。在下一节中,我们将为我们的网站添加更多内容。
构建静态单页作品集
我们正在构建一个简单的单页网站来展示一个作家的作品集。这将是一个有趣的练习,我们将通过它了解如何实际上使用 Elm 构建网站。同时,我们简化了网站的复杂性,因为我们的网站几乎全部由文本组成。当然,随着我们对 Elm 的深入,我们将引入更多概念,但就目前而言,我们将坚持基础。
让我们通过向我们的主页添加更多部分来扩展我们的网站。我们首先会犯一个错误。以下代码将无法工作:
module Main exposing (main)
import HTML exposing (HTML, div, h1, text)
-- NOTE: we imported the 'div' function here so we can use it below
main : HTML msg
main =
h1 []
[ text "My Portfolio" ]
div
[]
[ text "Just a bunch of text here " ]
当我们保存前面的代码时,我们会得到以下错误:
Function `h1` is expecting 2 arguments, but was given 5.
8| h1 []
9| [ text "My Portfolio" ]
10|> div
11|> []
12|> [ text "Just a bunch of text here " ]
Maybe you forgot some parentheses? Or a comma?
Detected errors in 1 module.
为了解决这个问题,我们需要稍微改变我们传递给main函数的代码结构。基本上,我们需要为所有的 HTML 添加一个包装 HTML 元素。我们将把传递给main的所有内容都包裹在包装div函数中:
module Main exposing (main)
import HTML exposing (HTML, div, h1, text)
main : HTML msg
main =
div []
[ h1 [] [ text "My Portfolio" ]
, div [] [ text "Just a bunch of text here " ]
]
现在,我们可以使用许多其他元素来丰富我们的作品集。为了使它们全部可用,我们将暴露HTML模块中的所有内容。更新后的代码现在看起来像这样:
module Main exposing (main)
import HTML exposing (..)
main : HTML msg
main =
div []
[ h1 [] [ text "My Portfolio" ]
, p [] [ text "Just Another Writer's Portfolio" ]
, div []
[ ul []
[ li [] [ text "About Me" ]
, li [] [ text "Poems" ]
, li [] [ text "Stories" ]
, li [] [ text "Contact" ]
]
]
]
在这个时候,你可能会开始欣赏elm-format为我们所做的努力。无论我们的 Elm 代码格式有多糟糕,它都会在每次保存时为我们修复。
在继续之前,让我们向我们的单页网站添加一个 CSS 框架。在这个例子中,我们将使用最新的 Bootstrap 框架版本,即 Bootstrap 4。为了获取框架 CSS 的链接,导航到以下网页:getbootstrap.com/docs/4.0/getting-started/introduction/?#css。
前面的链接指向一个 HTML link 元素,该元素从内容分发网络(CDN)提供 Bootstrap 4。右侧甚至还有一个方便的copy按钮。一旦你复制了链接,导航到你的项目public文件夹,并将复制的link标签粘贴到index.HTML文件中。将link标签粘贴的位置正好在title标签上方,文档的head部分。更新后的我们的index.HTML文件中的相关部分应该看起来像这样:
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.3/css/bootstrap.min.css" integrity="sha384-Zug+QiDoJOrZ5t4lssLdxGhVrurbmBWopoEl+M6BdEfwnCJZtKxi1KgxUyJq13dy" crossorigin="anonymous">
<title>Elm App</title>
</head>
完成这些后,保存index.HTML文件并关闭它。回到Main.elm,我们需要导入HTML.Attributes模块。我们还需要指定要使用的 CSS 类。我们将首先将.card类添加到包裹ul标签的div中。
更新后的代码应该看起来像这样:
module Main exposing (main)
import HTML exposing (..)
import HTML.Attributes exposing (..)
main : HTML msg
main =
div []
[ h1 [] [ text "My Portfolio" ]
, p [] [ text "Just Another Writer's Portfolio" ]
, div [ class "card" ]
[ ul []
[ li [] [ text "About Me" ]
, li [] [ text "Poems" ]
, li [] [ text "Stories" ]
, li [] [ text "Contact" ]
]
]
]
在我们对代码进行这次更新之后,你将看到我们页面中的ul元素发生了一些细微的变化。让我们更进一步,通过向我们的div传递几个 Bootstrap 4 CSS 类来实现这一点。为此,我们将简单地替换以下代码:
, div [ class "card" ]
现在,让我们通过添加以下代码来更改前面的行:
, div [ class "card text-white bg-primary mb-3" ]
保存Main.elm之后,你应该会在ul元素上看到漂亮的蓝色背景,以及白色的li项。
然而,我们正在使用错误的 Bootstrap 组件。我们正在使用卡片,但使用可用的nav组件会更为合适,该组件位于getbootstrap.com/docs/4.0/components/navs/#horizontal-alignment,以下 HTML 代码:
<ul class="nav justify-content-center">
<li class="nav-item">
<a class="nav-link active" href="#">Active</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item">
<a class="nav-link disabled" href="#">Disabled</a>
</li>
</ul>
然而,前面的代码是纯 HTML,我们需要将其转换为 Elm 代码。
尝试自己将上述 HTML 代码转换为 Elm 代码是一个很好的练习。然而,有一个在线转换器可供使用,位于:mbylstra.github.io/HTML-to-elm/。我们可以简单地粘贴 HTML 代码到提供的 URL 的左侧面板。
我们得到的代码,在右侧面板中,将看起来像这样:
ul [ class "nav justify-content-center" ]
[ li [ class "nav-item" ]
[ a [ class "nav-link active", href "#" ]
[ text "Active" ]
]
, li [ class "nav-item" ]
[ a [ class "nav-link", href "#" ]
[ text "Link" ]
]
, li [ class "nav-item" ]
[ a [ class "nav-link", href "#" ]
[ text "Link" ]
]
, li [ class "nav-item" ]
[ a [ class "nav-link disabled", href "#" ]
[ text "Disabled" ]
]
]
现在,我们只需将前面的代码粘贴到我们之前使用的子div的位置。完整的代码现在将看起来像这样:
module Main exposing (main)
import HTML exposing (..)
import HTML.Attributes exposing (..)
main : HTML msg
main =
div []
[ h1 [] [ text "My Portfolio" ]
, p [] [ text "Just Another Writer's Portfolio" ]
, ul [ class "nav justify-content-center" ]
[ li [ class "nav-item" ]
[ a [ class "nav-link active", href "#" ]
[ text "Active" ]
]
, li [ class "nav-item" ]
[ a [ class "nav-link", href "#" ]
[ text "Link" ]
]
, li [ class "nav-item" ]
[ a [ class "nav-link", href "#" ]
[ text "Link" ]
]
, li [ class "nav-item" ]
[ a [ class "nav-link disabled", href "#" ]
[ text "Disabled" ]
]
]
]
在我们继续之前,我们需要做一些清理工作并解释几个其他重要概念。
Elm 的 HTML
在我们通过 Elm 的旅程到目前为止,我们还没有以任何有意义的方式参考官方文档。在本节中,我们将专注于官方文档,并探讨它如何以实际的方式帮助我们。
首先,导航到 HTML Elm 包的官方 README,该包可在以下位置找到:
package.elm-lang.org/packages/elm-lang/HTML/2.0.0/. 正如网站所述,我们打开的页面描述了 Elm 的核心 HTML 库。在我们当前的学习阶段,提高我们的 Elm 技能的最佳方式是查看可用的实际 HTML 包,位于:package.elm-lang.org/packages/elm-lang/HTML/2.0.0/HTML。
具体来说,我们将查看本章中使用的所有 HTML 函数的类型定义:div、h1、text、p、ul、li和a。完全掌握我们在程序中使用的函数签名将使推理它们变得更加容易。
HTML 元素的函数签名
所有 HTML 元素都共享相同的函数签名模式:
<element> : List (Attribute msg) -> List (HTML msg) -> HTML msg
每个 HTML 元素接受两个列表:属性列表和子元素列表。然后,它们返回一个HTML msg。如果函数返回的值不发出消息,那么该代码将返回msg类型。
换句话说,每个元素都返回一个值,即HTML。这个HTML 值是msg类型,因为它们最终成为普通的 HTML 节点,它们将不会(不能!)改变我们的应用状态。
这就是为什么我们可以在main函数中继续编写 Elm 的 HTML 函数,而永远不会添加view或update,仍然有一个工作的网页。这是可能的,因为我们正在渲染我们的代码而不发出任何消息,所以实际上,永远不会有什么东西需要更新,因此我们可以不需要update函数。这就是为什么我们可以用a替换msg的隐式类型:
main : HTML a
按照惯例,a代表任何东西。由于main函数永远不会返回消息,我们可以更明确地表示它,并以下面的代码作为函数签名:
main : HTML Never
现在,我们明确声明我们永远不会返回一个消息。Never类型的值永远无法构造。
完全理解我们在程序中使用的函数的签名将使我们对它们的推理更加容易。
文本函数的函数签名
文本函数的签名,正如我们在官方文档中看到的,如下所示:
text : String -> HTML msg
因此,text函数接受一个String并返回一个HTML msg。
添加我们的视图函数
在本节中,我们将从直接将view代码传递给main函数中提取出来,而是将其分配给view函数,然后将其传递给main函数。这样,我们开始使我们的代码更加模块化和可重用。
首先,在我们的投资组合应用中,让我们将main函数重命名为view函数:
view : HTML Never
view =
接下来,在文件的底部,我们可以简单地添加一个更新的main函数:
main : HTML Never
main =
view
这个设置的一个好处是,我们现在可以将整个view传递给一个包装的div,例如,如下所示:
main : HTML Never
main =
div [] [ view ]
我们还可以通过从另一个文件调用它来使我们的view更加模块化和可重用。
从另一个文件调用视图函数
首先,让我们在项目的src文件夹中创建一个新的文件夹。在你的控制台中,从项目的根目录运行以下命令:
cd src && mkdir View
接下来,cd到View并使用touch命令创建一个新的View.elm文件。如果你在控制台中可用atom命令,你可以直接从命令行打开View.elm文件:
cd View && touch View.elm && atom View.elm
前面的命令做了什么?它通过命令行导航到View文件夹,然后创建一个名为View.elm的新文件,然后将其在Atom中打开。你根本不需要使用命令行——你可以使用操作系统的图形用户界面完成这些操作。
由于我们现在将 view 函数放在一个单独的文件中,我们将有效地使用视图函数作为 Elm 模块。由于我们将使用我们的 View.elm 文件作为模块,我们需要明确所有我们将使用的 HTML 函数,类似于原始的 Main.elm 文件中的方式。因此,我们的 View.elm 代码需要看起来像这样:
module View.View exposing (view)
import HTML exposing (..)
import HTML.Attributes exposing (..)
view : HTML Never
view =
div []
[ h1 [] [ text "My Portfolio" ]
, p [] [ text "Just Another Writer's Portfolio" ]
, ul [ class "nav justify-content-center" ]
[ li [ class "nav-item" ]
[ a [ class "nav-link active", href "#" ]
[ text "Active" ]
]
, li [ class "nav-item" ]
[ a [ class "nav-link", href "#" ]
[ text "Link" ]
]
, li [ class "nav-item" ]
[ a [ class "nav-link", href "#" ]
[ text "Link" ]
]
, li [ class "nav-item" ]
[ a [ class "nav-link disabled", href "#" ]
[ text "Disabled" ]
]
]
]
接下来,让我们更新我们的 Main.elm 文件,使其看起来像这样:
module Main exposing (main)
import HTML exposing (..)
import HTML.Attributes exposing (..)
import View.View exposing (..)
main : HTML Never
main =
div [] [ view ]
保存一切,应用程序仍然可以工作,尽管我们的 linter 会发出警告:
Module HTML.Attributes is unused.
Best to remove it. Don't save code quality for later!
基本上,这里发生的事情是我们正在将 HTML.Attributes exposing (..) 导入到我们的 Main.elm 中,但我们根本没用到它。我们可以用几种方法来处理这个问题。例如,我们可以简单地只将 view 函数传递给 main 函数。或者,我们可以在 div 上添加一个类,例如 bg-warning:
module Main exposing (main)
import HTML exposing (..)
import HTML.Attributes exposing (..)
import View.View exposing (..)
main : HTML Never
main =
div [ class "bg-warning" ] [ view ]
随意尝试其他解决方案来解决这个警告,因为这是一个有用的练习。
添加一些样式
接下来,让我们故意犯一个错误。如果你看过我们在 Portfolio 应用中使用 bg-warning 类的结果,你会看到它看起来像一条橙色条纹,而下面的其他一切只是普通的白色。假设我们想要增加包裹 div 的高度,使其覆盖 1,000 像素的高度,从而使一切变成橙色。在纯 HTML 中,我们可以使用 style 属性来实现这一点。让我们用 Elm 来做这件事。
在不知道 Elm 使用的 style 函数签名的情况下,让我们尝试在不查看文档的情况下解决这个问题:
main : HTML Never
main =
div [ class "bg-warning", style "height:1000px" ] [ view ]
如你所猜,保存前面的代码会导致错误。让我们看看在 Atom 中我们得到的 lint 消息:
The argument to function style is causing a mismatch.
Function style is expecting the argument to be:
List(String,String)
But it is:
String
太好了!现在,我们知道我们需要如何更改我们的 style 函数。它需要接受一个或多个括号中的 Strings 对,就像编译器之前说的那样。所以,我们将更新我们的函数如下:
main : HTML Never
main =
div [ class "bg-warning", style [("height","1000px")] ] [ view ]
现在,一切又恢复正常了,我们橙色背景的高度也增加了。更好的是,为了确保我们的屏幕完全变成橙色,让我们再次更新样式:
main : HTML Never
main =
div [ class "bg-warning", style [("height","100vh")] ] [ view ]
我们不是使用像素,而是使用 100vh,即 100 视口高度,这相当于屏幕的全高度。在下一节中,我们将通过将其拆分为多个文件来使我们的视图更加模块化。
将视图拆分为多个文件
在本节中,我们将把我们的视图拆分为多个文件。我们还将添加更多内容,这样当我们完成时,我们将拥有一个完整的单页网站。
首先,让我们考虑一下我们可以在单独的文件中拆分视图代码的哪些部分。很明显,ul 函数是一段独立的代码,所以让我们将其转换为一个模块。在你的 View 文件夹中,打开一个新文件,并将其命名为 Navigation.elm。接下来,粘贴以下代码:
module View.Navigation exposing (navigation)
import HTML exposing (HTML, a, li, text, ul)
import HTML.Attributes exposing (class, href)
navigation : HTML Never
navigation =
ul [ class "nav justify-content-center" ]
[ li [ class "nav-item" ]
[ a [ class "nav-link active", href "#" ]
[ text "Active" ]
]
, li [ class "nav-item" ]
[ a [ class "nav-link", href "#" ]
[ text "Link" ]
]
, li [ class "nav-item" ]
[ a [ class "nav-link", href "#" ]
[ text "Link" ]
]
, li [ class "nav-item" ]
[ a [ class "nav-link disabled", href "#" ]
[ text "Disabled" ]
]
]
我们刚刚创建了一个名为 View.Navigation 的新模块。保存文件,然后回到 View.elm,我们将导入这个新模块。
在 View.elm 中,我们的更新代码将看起来像这样:
module View.View exposing (view)
import HTML exposing (..)
import HTML.Attributes exposing (..)
import View.Navigation exposing (..)
view : HTML Never
view =
div []
[ h1 [] [ text "My Portfolio" ]
, p [] [ text "Just Another Writer's Portfolio" ]
, navigation
]
注意View.elm这个版本的更改——我们导入了View.Navigation模块,暴露了一切,并且我们还用模块化的ul [] []代码替换了导航函数。
我们可以遵循这个模式来构建整个页面。我们的页面接下来需要的是几首诗歌。
要添加它们,我们将使用 Bootstrap 4 的card组件。因此,让我们在我们的View文件夹中创建一个新文件,命名为Poem.elm,并将以下代码添加到其中:
module View.Poem exposing (poem)
import HTML exposing (HTML, div, h4, p, small, text)
import HTML.Attributes exposing (class)
poem : HTML Never
poem =
div [ class "card" ]
[ div [ class "card-header" ]
[ text "Summer poetry collection " ]
, div [ class "card-block" ]
[ h4 [ class "card-title" ]
[ text "Title of Poem" ]
, p [ class "card-text" ]
[ text "Lorem ipsum." ]
, p [ class "card-text" ]
[ text "Dolor." ]
, p [ class "card-text" ]
[ text "Sit amet." ]
, p [ class "card-text" ]
[ text "Consectetur." ]
, p [ class "card-text" ]
[ text "Adipiscing elit." ]
, p [ class "card-text" ]
[ text "Adipiscing elit." ]
, p [ class "card-text" ]
[ small [ class "text-muted" ]
[ text "Last updated 3 mins ago" ]
]
]
]
接下来,在View.elm文件中导入Poem模块:
module View.View exposing (view)
import HTML exposing (..)
import HTML.Attributes exposing (..)
import View.Navigation exposing (..)
import View.Poem exposing (..)
view : HTML Never
view =
div []
[ h1 [] [ text "My Portfolio" ]
, p [] [ text "Just Another Writer's Portfolio" ]
, navigation
, poem
]
如果你现在查看网站,你会看到诗歌正在拉伸整个屏幕的宽度,这并不是最佳的外观。让我们通过更新我们的View.elm文件,将诗歌包裹在一个容器div中:
view : HTML Never
view =
div [ class "container-fluid" ]
[ div [ class "container" ]
[ h1 [] [ text "My Portfolio" ]
, p [] [ text "Just Another Writer's Portfolio" ]
, navigation
, poem
]
]
让我们也更改我们的代码,以便我们可以在一行中放置多个卡片。我们将通过添加另一个默认的 Bootstrap 4 类row来实现这一点。在row内部,我们将添加三个等宽的列。为此,我们将更新View.elm文件如下:
view : HTML Never
view =
div [ class "container-fluid" ]
[ div [ class "container" ]
[ h1 [] [ text "My Portfolio" ]
, p [] [ text "Just Another Writer's Portfolio" ]
, navigation
, div [ class "row" ]
[ div [ class "col" ]
[ poem ]
, div [ class "col" ]
[ poem ]
, div [ class "col" ]
[ poem ]
]
]
]
如果你正确地遵循了到目前为止的所有步骤,你应该能在你的浏览器中看到以下网页:

现在,我们在页面上整齐地放置了三张卡片。在我们继续构建页面之前,是时候让它看起来更好了。回到Main.elm,让我们删除class函数,并给style函数添加另一个 CSS 声明:
main : HTML Never
main =
div [ style [ ( "height", "100vh" ), ( "background", "#eee" ) ] ] [ view ]
这将给我们的页面带来一个更平静、浅灰色的背景。
style函数接受一个2 元素元组的列表。在上面的例子中,列表由两个 2 元素元组组成,其中元组中的每个元素都是String类型。因此,第一个元组是("height", "100vh"),第二个是("background", "#eee")。
在下一节中,我们将通过添加剩余的view文件来完成我们的页面。
完成我们的作家组合
在本节中,我们将完成我们的作家组合。在我们继续之前,让我们列出我们还需要添加的内容。
正如我们在本章开头提到的,完成的组合应该包含以下部分:
-
关于我
-
诗歌
-
故事
-
联系方式
我们已经完成了诗歌部分。现在,让我们添加剩余的部分。因为我们已经知道如何这样做,所以让我们批量处理我们的过程以加快开发速度。
首先,在View文件夹中,让我们创建三个新文件:
-
About.elm -
Stories.elm -
Contact.elm
接下来,让我们将这些导入到View.elm中:
module View.View exposing (view)
import HTML exposing (..)
import HTML.Attributes exposing (..)
import View.Navigation exposing (..)
import View.About exposing (..)
import View.Poem exposing (..)
import View.Stories exposing (..)
import View.Contact exposing (..)
接下来,让我们为刚刚创建的每个文件添加一些内容。首先,是About.elm文件:
module View.About exposing (about)
import HTML exposing (..)
import HTML.Attributes exposing (class, href)
about : HTML Never
about =
div [ class "" ]
[ h1 [ class "display-2 mt-5" ]
[ text "John Doe" ]
, h3 [ class "display-5" ]
[ text "A man of words" ]
, p [ class "" ]
[ text "Etiam in libero gravida, viverra ex eu, vehicula leo. Vestibulum eget elementum velit. Fusce viverra erat quis felis auctor, id consequat augue sollicitudin. Vivamus accumsan mi metus, vitae lacinia metus aliquet ornare. Curabitur interdum scelerisque varius. Nullam consequat imperdiet massa eget eleifend." ]
]
接下来,让我们更新Stories.elm文件:
module View.Stories exposing (stories)
import HTML exposing (..)
import HTML.Attributes exposing (class, href)
stories : HTML Never
stories =
div [ class "" ]
[ h1 [ class "display-2 mt-5" ]
[ text "Stories" ]
, h3 [ class "display-5" ]
[ text "A List of My stories" ]
, p [ class "" ]
[ text "Etiam in libero gravida, viverra ex eu, vehicula leo. Vestibulum eget elementum velit. Fusce viverra erat quis felis auctor, id consequat augue sollicitudin. Vivamus accumsan mi metus, vitae lacinia metus aliquet ornare. Curabitur interdum scelerisque varius. Nullam consequat imperdiet massa eget eleifend." ]
]
此外,让我们更新Contact.elm文件:
module View.Contact exposing (contact)
import HTML exposing (..)
import HTML.Attributes exposing (class, href)
contact : HTML Never
contact =
div [ class "card" ]
[ div [ class "card-header" ]
[ text "Summer poetry collection " ]
, div [ class "card-block" ]
[ h4 [ class "card-title" ]
[ text "Title of Poem" ]
, p [ class "card-text" ]
[ text "Lorem ipsum." ]
, p [ class "card-text" ]
[ text "Dolor." ]
, p [ class "card-text" ]
[ text "Sit amet." ]
, p [ class "card-text" ]
[ text "Consectetur." ]
, p [ class "card-text" ]
[ text "Adipiscing elit." ]
, p [ class "card-text" ]
[ text "Adipiscing elit." ]
, p [ class "card-text" ]
[ small [ class "text-muted" ]
[ text "Last updated 3 mins ago" ]
]
]
]
我们还需要更新View.elm文件:
view : HTML Never
view =
div [ class "container-fluid" ]
[ div [ class "container" ]
[ h1 [] [ text "My Portfolio" ]
, p [] [ text "Just Another Writer's Portfolio" ]
, navigation
, about
, div [ class "row" ]
[ div [ class "col-md-4 mb-5" ]
[ poem ]
, div [ class "col-md-4 mb-5" ]
[ poem ]
, div [ class "col-md-4 mb-5" ]
[ poem ]
]
, stories
, contact
]
]
最后,让我们也更新一下Main.elm文件:
main : HTML Never
main =
div [ class "bg-light" ]
[ view ]
到目前为止,我们的 Elm 驱动的网站应该看起来像这样:

我们在上面的文件中进行了多次更新。尽管所有这些更新都涉及我们在本书中已经覆盖的概念,但在我们的应用程序中编写前面的代码是巩固我们之前所学知识并继续学习更高级主题的好方法。
在前面的代码中,可能引起混淆的一点是使用了各种 Bootstrap 4 类。有关这些类的更多信息,请参阅 Bootstrap 4 的官方文档,或 Packt 图书馆中详细介绍 Bootstrap 4 的多个标题。
现在我们所有的文件都已完成,让我们保存一切并查看结果。我们可以以许多方式改进我们的投资组合应用程序。例如,我们仍然需要给它一个工作的导航。不幸的是,在我们 Elm 之旅的这个阶段,向我们的网页添加导航需要熟悉许多相对复杂的话题,这只会造成混淆。这就是为什么我们将保持我们的静态页面不变。
如果您想为了练习而尝试在投资组合中改变一些东西,请记住,您可以使用mbylstra.github.io/HTML-to-elm/这个 HTML 到 Elm 网站,这应该会使事情更快、更容易。
在接下来的部分中,我们不会过早地涉及更高级的概念来构建复杂的导航解决方案,而是将查看一个简单的替代方案。这个替代方案将处理使用case表达式在屏幕上显示我们静态页面的不同部分。这种方法的优势在于,我们将加强 Elm 架构所有工作部分的协同作用。
使用case表达式渲染我们页面的部分
我们将从零开始这一节。创建一个新的文件夹,并在其上运行以下命令:create-elm-app alternative-to-navigation。一旦应用程序准备就绪,导航到alternative-to-navigation文件夹,在src中打开Main.elm,并删除所有代码。将以下代码添加到Main.elm中。以下代码按原样工作,所以您可以保存它并运行应用程序:
module Main exposing (main)
import HTML exposing (..)
import HTML.Attributes exposing (..)
import HTML.Events exposing (onClick)
model =
""
type Msg
= FirstButtonMessage
| SecondButtonMessage
| ThirdButtonMessage
| FourthButtonMessage
update msg model =
case msg of
FirstButtonMessage ->
"You've clicked the first button!"
SecondButtonMessage ->
"You've clicked the second button!"
ThirdButtonMessage ->
"You've clicked the third button!"
FourthButtonMessage ->
"You've clicked the fourth button!"
view model =
div []
[ h1 [ class "display-4 mt-5" ] [ text "My Portfolio" ]
, div [ class "container" ]
[ h2 [ class "strong" ] [ text "Just Another Writer's Portfolio" ]
, hr [] []
, div [ class "mt-5" ]
[ button
[ class "btn btn-primary mr-1", onClick FirstButtonMessage ]
[ text "First Button" ]
, button
[ class "btn btn-primary", onClick SecondButtonMessage ]
[ text "Second Button" ]
, button
[ class "btn btn-primary", onClick ThirdButtonMessage ]
[ text "3rd Button" ]
, button
[ class "btn btn-primary", onClick FourthButtonMessage ]
[ text "4th Button" ]
, p [ class "mt-5 lead" ] [ text model ]
]
]
]
main =
HTML.beginnerProgram
{ model = model
, view = view
, update = update
}
在我们可以改进前面的代码之前,我们首先需要确保我们理解它。正如我们之前讨论的,前五行处理导入我们将在应用程序中使用的模块。接下来,我们将model设置为空字符串的值。
我们接着创建一个Msg的联合类型,它可以等于以下四个值之一:FirstButtonMessage、SecondButtonMessage等等。为什么Msg值名称这么冗长?因为我们希望让它绝对清晰,Msg联合类型包含消息。一旦用户点击其中一个按钮,这些消息就会被发射到update函数中。
接下来,我们编写我们的update函数,它接受一个message和一个model。
提醒:update函数的第一个参数是更新函数内部case表达式的case名称。
然后,我们传递给它一个 case 表达式,根据接收到的消息执行四段代码中的一段。所以,如果 update 函数接收到的消息是我们恰当地命名的 FirstButtonMessage,则 case 表达式将评估为 You've clicked the first button! 字符串。
接下来,我们的 view 函数接收一个 model,我们传递给它一个 div 函数,它包含一个 h1 和一个具有 container 类的 div。然后,这个包装 div 包含一个 h2、一个 hr 和另一个 div,它包含四个按钮和一个 p 函数。
每个按钮的属性列表包含两个属性;第一个是按钮上要使用的类,第二个是 onClick 事件。每个 onClick 事件将发出不同的消息,以便 update 函数接受和使用。
最后,main 函数接收 HTML.beginnerProgram,这在第二章《构建你的第一个 Elm 应用》中有解释。现在我们了解了前面的代码究竟在做什么,我们可以对其进行一些改进。
改进我们的消息
在开始本节之前,让我们看看经过所有改进后的完整代码,然后我们将讨论对代码所做的更改:
module Main exposing (main)
import HTML exposing (..)
import HTML.Attributes exposing (..)
import HTML.Events exposing (onClick)
model : String
model =
""
type Msg
= FirstButtonMessage
| SecondButtonMessage
| ThirdButtonMessage
| FourthButtonMessage
update : Msg -> a -> String
update msg model =
case msg of
FirstButtonMessage ->
"""
You've clicked the "About Me" button! \n
I'm a successful writer.
"""
SecondButtonMessage ->
"""
You've clicked the "My Poems" button!
I've written over 50 poems in the last 10 years.
"""
ThirdButtonMessage ->
"""
You've clicked the "My Stories" button!
I've written a short story in the sci-fi genre
and it won the Nebula award.
"""
FourthButtonMessage ->
"""
You've clicked the "Contact Me" button!
To get in touch, send me an email.
My email address is: me@example.com
"""
view : String -> HTML Msg
view model =
div []
[ h1 [ class "display-4 mt-5" ] [ text "My Portfolio" ]
, div [ class "container" ]
[ h2 [ class "strong" ] [ text "Just Another Writer's Portfolio" ]
, hr [] []
, div [ class "mt-5" ]
[ button
[ class "btn btn-primary mr-1", onClick FirstButtonMessage ]
[ text "About Me" ]
, button
[ class "btn btn-primary mr-1", onClick SecondButtonMessage ]
[ text "My Poems" ]
, button
[ class "btn btn-primary mr-1", onClick ThirdButtonMessage ]
[ text "My Stories" ]
, button
[ class "btn btn-primary mr-1"
, style [( "cursor", "pointer" )]
, onClick FourthButtonMessage
]
[ text "Contact Me" ]
, p [ class "mt-5 lead" ] [ text model ]
]
]
]
main : Program Never String Msg
main =
HTML.beginnerProgram
{ model = model
, view = view
, update = update
}
在我们应用的这次迭代中,重点是改进消息。基本上,我们希望每条消息都包含一些有意义的文本,为了做到这一点,我们使用了多行字符串,这些字符串以三组双引号开始和结束。
虽然这使我们更容易看到输入的所有文本,但它并没有给我们提供一种灵活的方式来更好地格式化字符串,因为,正如你可以在前面的代码中看到的那样,Elm 中的多行引号不能通过使用 newline 字符换行。
我们做出的另一个改变是在第四个按钮上添加了一个 style 函数,这样当用户悬停在按钮上时,它显示一个指针而不是常规的光标。这个改进使得按钮的可点击性更加明显。
我们做出的另一个改进是为我们每个函数添加类型注解,这样我们就可以看到 model 函数接收一个 String。update 函数接收一个 message 和一个 model(可以是任何类型)并返回一个 String。view 函数接收一个 String 并返回一个 HTML Msg,这是我们之前讨论过的自定义联合类型。
当我们在浏览器中提供更新后的 Portfolio 应用时,点击导航中的第一个按钮,即“关于我”按钮,我们将得到以下输出:

点击其他按钮将迅速改变屏幕上显示的内容。
如果你看不到样式,请确保将 Bootstrap 4 CDN 链接添加到 public/index.HTML(就像我们为 Portfolio 应用所做的那样):
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.3/css/bootstrap.min.css" integrity="sha384-Zug+QiDoJOrZ5t4lssLdxGhVrurbmBWopoEl+M6BdEfwnCJZtKxi1KgxUyJq13dy" crossorigin="anonymous">
<title>Elm App</title>
</head>
接下来,我们将通过添加 Model 类型别名来进一步改进我们的投资组合。
添加模型类型别名
通过为我们的Model添加类型别名,如果我们决定这样做,将使我们的代码更容易从String之外的内容进行更改,这使得我们的代码更容易维护。
在模块导入下方,在我们的应用的第 8 行,添加以下代码片段:
type alias Model =
String
接下来,让我们更新整个应用中的类型注解:
model : Model
model =
...
update : Msg -> Model -> String
update msg model =
...
view : Model -> HTML Msg
view model =
...
代码中的操作很明显:我们正在将String的值与类型别名Model进行别名设置。理解这一点并记住它非常重要,因为这个简单的例子正好展示了类型别名的作用及其工作方式。
进行这些更改后,我们可以看到一个有趣的模式:update函数的类型注解使用了大写的Msg,而我们将小写的msg传递给它,因为它是最先的参数。
同样,我们将小写字母m的model传递给我们的view,但在我们的view函数的类型注解中,我们使用大写字母M引用Model。
这里发生了什么?解释很简单。我们可以将小写实例视为简单的泛型标签,可以是任何东西。例如,考虑以下代码的以下更改。
首先,让我们用这个来替换现有的更新函数的msg:
update a model =
case a of
接下来,让我们将现有的view函数的model替换为a,如下所示:
view a =
...
, p [ class "mt-5 lead" ] [ text a ]
]
]
]
在前面的代码中,我们将msg和model都替换为更通用的a,并且没有破坏它,这是一个很好的事实。编译器愉快地执行其任务,我们仍然有一个工作的应用。
类型别名用于使复杂的类型注解更容易阅读。此外,类型注解默认为大写,联合类型也是如此。因此,在我们的代码中,Model和Msg都是大写的,我们不能更改它们。
使用List.map和List.filter
如前几章所述,Elm 使用不可变数据结构。那么我们如何使用现有的不可变数据结构来找到满足特定条件的成员,或者根据现有值产生不同的值呢?
为了实现这两个目标,我们可以使用map和filter函数。为了保持简单,我们将查看List.map和List.filter函数,尽管.map和.filter也可以与 Elm 中的某些其他数据结构一起使用。
假设我们的目标是取一个List的numbers并找到仅能被 3 整除的数。首先,让我们定义一个函数,该函数将接受一个Int并返回一个Boolean(True或False),基于传递给函数的数字是否可以被 3 整除。导航到elmrepl.cuberoot.in,并输入以下函数定义:
findThrees num = num % 3 == 0
按下回车键后,Elm REPL 将返回以下内容:
<function> : Int -> Bool
我们的 findThrees 函数接受一个 Int 并返回一个 Boolean。换句话说,表达式 num % 3 == 0 首先被评估。假设 num 是 3,使表达式看起来像这样:3 % 3 == 0。这个表达式是真的,因此表达式评估为 True 的值,它是一个 Boolean 类型的值。接下来,这个值被分配给 findThrees 函数。
换句话说,如果我们调用 findThrees 函数并给它数字 3 作为参数,findThrees 函数将返回 True 的值,它是一个 Boolean 类型的值。
接下来,让我们将我们的 findThrees 函数传递给 List.map。以下代码将无法正常工作。在阅读解释之前,试着猜测一下原因:
List.map findThrees 3
如果你已经在 REPL 中运行了前面的代码,你会得到以下错误:
-- TYPE MISMATCH --------------------------------------------- repl-temp-000.elm
The 2nd argument to function `map` is causing a mismatch.
3| List.map findThrees 3
^
Function `map` is expecting the 2nd argument to be:
List Int
But it is:
number
显然,我们不能只给 List.map 函数的第二个参数提供一个数字。相反,为了使它工作,我们需要提供一个包含 numbers 的 List。就像这样:
List.map findThrees [1,2]
这次,成功了!REPL 返回以下内容:
[False, False] : List Bool
让我们尝试给它一个包含三个数字的列表:
List.map findThrees [1,2,3]
这次,REPL 返回:
[False,False,True] : List Bool
接下来,让我们输入一个包含 10 个数字的列表,并将其存储在一个变量中:
ourList = [1,2,3,4,5,6,7,8,9,10]
在 REPL 中运行前面的代码将返回:
[1,2,3,4,5,6,7,8,9] : List number
观察前面的代码,我们可以这样说,一个数字的 List 存储在一个我们称之为 ourList 的变量中。现在,让我们将 findThrees 函数传递给 List.map 函数,并将 ourList 作为第二个参数传递:
List.map findThrees ourList
REPL 返回一个 Bool 值的列表:
[False,False,True,False,False,True,False,False,True,False] : List Bool
最后,让我们尝试用 List.filter 替换 List.map:
List.filter findThrees ourList
REPL 返回 ourList 的一个过滤结果:
[3,6,9] : List Int
现在我们已经稍微练习了使用 List.map,让我们看看它的结构。List.map 接受两个参数,第一个是一个函数,第二个是实际的 List。
传递给 List.map 的第一个参数的函数用于根据函数中的逻辑将第二个参数(List)转换为一个新的 List。List.map 通过运行我们提供的函数来遍历提供的 List 的每个单独成员来实现这一点。List.map 的这种行为使其成为改进我们的 FizzBuzz 应用程序的一个很好的候选者,我们将在下一节中这样做。
现在,让我们在我们的 Elm-REPL 中运行一个 List.map。为了能够运行 List.map,我们需要定义一个它将使用的函数。所以,让我们打开 Elm-REPL 并定义我们的自定义 fizzBuzzer 函数:
fizzBuzzer number = \
if number % 15 == 0 then \
"fizzBuzz" \
else if number % 5 == 0 then \
"fizz" \
else if number % 3 == 0 then \
"buzz" \
else \
toString number
REPL 给我们返回以下内容:
<function> : Int -> String
现在,我们可以借助 fizzBuzzer 和 List.map 在我们的 List 上进行映射:
List.map fizzBuzzer [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17 ]
在 REPL 中我们得到的是这个:
["1","2","buzz","4","fizz","buzz","7","8","buzz","fizz","11","buzz","13","14","fizzBuzz","16","17"]
: List String
随意尝试我们之前讨论的各种构建块组合。在下一节中,我们将回顾我们的 FizzBuzz 应用程序,并应用我们在实践中学到的一些东西。
回顾 FizzBuzz 应用程序
我们在 第三章 中覆盖了很多内容,使用 Elm 创建您的个人投资组合。我们能用我们新获得的知识来制作一个更好的 FizzBuzz 应用程序实现吗?让我们来看看!
让我们从 第二章,构建您的第一个 Elm 应用程序中的 FizzBuzz 应用程序开始:
module Main exposing (main)
import HTML exposing (text)
fizzBuzz = "Fizz Buzz"
fizz = "Fizz"
buzz = "Buzz"
fizzBuzzInput value =
if value % 15 == 0 then
fizzBuzz
else if value % 3 == 0 then
fizz
else if value % 5 == 0 then
buzz
else (toString value)
main =
text (fizzBuzzInput 34567)
我们如何改进前面的代码?
首先,我们可以将我们的 FizzBuzz 应用程序实现为一个 beginnerProgram。我们还可以用 case 表达式替换 if 表达式。最后,我们可以引入按钮并将一切设计得非常优雅。
使用 List.map 实现 FizzBuzz
让我们先列出完成的、改进的 FizzBuzz 应用程序:
module Main exposing (main)
import HTML exposing (HTML, text)
ourList = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16]
fizzBuzzCheck fizz buzz fizzBuzz num =
if num % 15 == 0 then
toString fizzBuzz ++ ", "
else if num % 5 == 0 then
toString buzz ++ ", "
else if num % 3 == 0 then
toString fizz ++ ", "
else
(toString num) ++ ", "
main =
text (String.concat (List.map (fizzBuzzCheck "fizz" "buzz" "fizz buzz") ourList ) )
在我们开始讨论前面的代码中发生的事情之前,让我们快速使用前向函数应用操作符 |> 更新 main 函数:
module Main exposing (main)
import HTML exposing (HTML, text)
ourList = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16]
fizzBuzzCheck fizz buzz fizzBuzz num =
if num % 15 == 0 then
toString fizzBuzz ++ ", "
else if num % 5 == 0 then
toString buzz ++ ", "
else if num % 3 == 0 then
toString fizz ++ ", "
else
(toString num) ++ ", "
main =
List.map (fizzBuzzCheck "fizz" "buzz" "fizz buzz") ourList
|> String.concat
|> text
看到用这种不同符号编写的 main 函数可能会使理解前面的代码变得更加简单。在导入 Main 和 HTML 模块后,我们声明 ourList 变量和 fizzBuzzCheck 函数定义。
如我们所见,fizzBuzzCheck 函数接受四个参数,并返回一个 String 类型的值。
main 函数根据 fizzBuzzCheck 函数中的逻辑映射 ourList,然后我们使用 String.concat 函数将 List.map 生成的 String 列表合并成一个单一的 String,因为 text 函数接收一个单一的 String 值作为其参数。
摘要
在本章中,我们涵盖了多个重要主题,包括:
-
使用 elm-make-app
-
为我们的应用程序添加 Bootstrap 4 样式
-
在 Elm 中使用
HTML模块,并理解 HTML 元素的函数签名 -
通过将视图拆分为多个文件来模块化我们的应用程序
-
使用 case 表达式动态渲染网页部分
-
使用类型别名
-
映射和过滤值列表
-
使用
List.map和String.concat改进我们的 FizzBuzz 应用程序
在下一章中,我们将开始使用 Records 为我们的 model 和 update 函数构建单位转换网站。我们还将学习 let 表达式及其使用方法,并通过使用第三方 Elm 模块来结束这一章。
第四章:在 Elm 中准备一个单位转换网站
欢迎来到第四章,在 Elm 中准备一个单位转换网站。本章的目标是创建一个网站,将英里转换为公制系统测量,即千米。通过完成本章,你将获得 Elm 工作中不可或缺的实用技能,全部在一个有趣的项目中。
我们将涵盖的主题包括:
-
用于构建网站的 Elm 语言特性,包括类型注解、
case表达式、联合类型和消息 -
理解
Result作为处理错误的方式 -
讨论 Elm 架构和工作流程概念以构建网站
完成本章后,你将能够:
- 与类型注解、情况表达式、联合类型和消息一起工作
我们要构建什么?
在本章中,我们将构建一个简单单位转换网站的核心。我们将介绍许多新概念,并立即在实践中使用它们。为了将所有内容都放入一章中,我们只设置基本的基础设施。
在下一章中,我们将扩展我们已构建的内容,以创建一个更复杂的结构。为了开始,我们将启动一个新的 Elm 应用。
要做到这一点,请在您的控制台中运行以下命令:
create-elm-app unit-converter-simple
记住,为了运行你的应用,你需要将你的控制台指向你的新应用文件夹,然后在控制台中运行 elm-app start 命令。现在我们已经为开发设置了一切,是时候开始构建应用了。
构建我们的单位转换应用
首先,让我们删除 Main.elm 中的所有内容。接下来,让我们设置一个工作基础应用:
module Main exposing (..)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
-- Model
init =
{}
-- Update
type Msg
= Nothing
update msg model =
model
-- View
view model =
div [] [ text "Everything will go here" ]
-- Main
main =
beginnerProgram
{ model = init
, view = view
, update = update
}
到目前为止,我们有一个可以工作的应用,也就是说,它在屏幕上显示。然而,它实际上并没有做什么。尽管如此,看看前面代码的每一行实际上做了什么仍然是有价值的。我们像往常一样开始,通过声明 main 模块并导入我们应用中使用的所有其他模块。
init 函数是我们应用的初始模型,我们将其设置为空的 Record。接下来,我们设置一个 Msg 的联合类型,并给它 Nothing 的值。Nothing 简单地就是没有——我们的 Msg 联合类型目前没有任何值。
接下来,我们向 update 函数传递两个参数,msg 和 model,并返回 model。view 函数只是一个包含其第二个 List 中的文本节点的 div。
最后,让我们看看我们应用的入口点,即 main 函数。我们只需将其传递给 beginnerProgram,将 model 设置为 init 的值,view 设置为 view,update 设置为 update。接下来,让我们更新 init 函数。
更新 init 函数
我们的 init 函数将是我们的应用初始状态。让我们这样更新它:
init =
{ unit1 = "Kilometers"
, unit2 = "Miles"
, ratio = 1.608
, convertedValue = 0.0
}
init 函数被分配了一个包含两个 Strings 和两个 Floats 的 Record 的值。基本上,我们在这里模拟了应用将使用的数据。
类型注解和类型别名
在这个阶段,我们可能想要在init函数上方添加类型注解。如果你在使用 Atom 中的 lint 工具,你应该在编辑器中看到以下警告:
Top-level value 'init' does not have a type annotation.
I inferred the type annotation so you can copy it into your code:
init : { convertedValue : Float, ratio : Float, unit1 : String, unit2 : String }
注意,类型注解内部命名值的顺序与我们分配给init函数的Record的顺序不匹配。原因很简单:Elm 中的记录不是基于索引的。
因此,我们可以选择在init函数上方添加建议的类型注解,编译器将会很高兴。
然而,我们还可以做一件更好的事情:使用类型别名。使用类型别名,我们可以缩短类型注解,使其更容易在任何需要的地方使用。在这种情况下,我们需要创建一个类型别名来在init函数上使用。由于init函数基本上只是初始模型,因此创建一个名为Model的类型别名并按需使用是有意义的。
因此,让我们在我们的代码中init函数上方设置一个类型别名Model,如下所示:
type alias Model =
{ convertedValue : Float
, ratio : Float
, unit1 : String
, unit2 : String
}
现在,我们的更新后的Main.elm将看起来像这样:
module Main exposing (..)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
-- MODEL
type alias Model =
{ convertedValue : Float
, ratio : Float
, unit1 : String
, unit2 : String
}
init : Model
init =
{ unit1 = "Kilometers"
, unit2 = "Miles"
, ratio = 1.608
, convertedValue = 0.0
}
看看前面的代码,我们可以看到init的类型注解很简单,就是init : Model,因为我们现在正在使用Model的类型别名。现在让我们把注意力转向view和update函数。
更新视图和更新函数
重要的是要理解view和update函数是通过消息连接的。view函数发送的任何消息都会被update函数接收。这就是为什么view函数发送的消息类型应该与接收到的update消息类型相同。这看起来可能很明显,但对此没有疑问将使你成为一个更好的 Elm 开发者。
首先,让我们在我们的view函数中添加一些更多内容:
view model =
div []
[ p []
[ label [ for "unit1Input" ] [ text "Kilometers" ]
, br [] []
, input [ id "unit1Input" ] []
, button [ onClick Nothing ] [ text "Switch to Miles Input" ]
, p [ id "unit2Value" ] [ text "Result of calculation" ]
]
]
在这个阶段,我们的view视图已经在屏幕上显示,按钮向update发送了Nothing消息。如果我们在这个时候编译并运行应用程序,我们将在页面上看到一个改进的用户界面。然而,一切仍然是静态的,点击按钮在这个阶段不会做任何事情。
让我们从将Nothing值替换为不同的值开始,我们将它称为Swap:
view model =
div []
[ p []
[ label [ for "unit1Input" ] [ text "Kilometers" ]
, br [] []
, input [ id "unit1Input" ] []
, button [ onClick Swap] [ text "Switch to Miles Input" ]
, p [ id "unit2Value" ] [ text "Result of calculation" ]
]
]
由于我们的view函数现在正在向按钮的onClick发送Swap消息,让我们在update函数上方更新我们的联合类型Msg,添加值为Swap:
type Msg
= Swap
最后,让我们告诉update函数如何处理Swap消息:
update msg model =
case msg of
Swap ->
{ model | unit1 = model.unit2, unit2 = model.unit1 }
在前面的代码片段中,我们使用了一些之前没有见过的代码。让我们看看它是如何工作的。如果update函数接收到值为Swap的消息,它将运行以下表达式:
{ model | unit1 = model.unit2, unit2 = model.unit1 }
前面的代码做了什么?让我们从这一段代码开始:
{ model | ... }
前面的代码意味着——返回与之前相同的模型,只是更新了管道字符右侧的代码中的指定更新。
管道字符右侧的代码执行以下操作:将unit1的值设置为model.unit2,将unit2的值设置为model.unit1。
如果你在这一点上保存你的应用并运行它,你将看到它在浏览器中渲染 HTML 之后,没有其他变化。为了确保代码实际上做了些什么,我们需要将view中的text函数内的硬编码Strings替换为适当的model值,如下所示:
view model =
div []
[ div []
[ label [ for "unit1Input" ] [ text model.unit1 ]
, input [ id "unit1Input" ] []
, button [ onClick Swap ] [ text "Switch" ]
, label [ for "unit2" ] [ text model.unit2 ]
, div [ id "unit2Value" ] [ text "1234" ]
]
]
现在,运行我们的代码并点击按钮实际上会从view发送Swap消息到update,用户会在屏幕上看到“千米”和“英里”在按钮点击时交换位置。
添加转换逻辑
现在,我们可以使我们的应用在按钮点击时转换它接收到的输入。让我们首先更新我们的view函数,以便它显示初始转换值,即 0.0 的值,因为这是我们传递给init函数的Record的值。请注意,以下代码将无法编译:
view model =
div []
[ div []
[ label [ for "unit1Input" ] [ text model.unit1 ]
, input [ id "unit1Input" ] []
, button [ onClick Swap ] [ text "Switch" ]
, label [ for "unit2" ] [ text model.unit2 ]
, div [ id "unit2Value" ] [ text model.convertedValue ]
]
]
使用前面的update运行我们的应用会导致编译器抛出错误。为什么?简单地说,text函数必须始终接收一个String。因此,我们需要首先在model.convertedValue上运行toString函数,然后将该表达式的结果传递给text函数。如下所示:
...
, div [ id "unit2Value" ] [ text (toString model.convertedValue) ]
]
]
在这一点上运行应用只会使屏幕上出现微小的变化。屏幕上会显示一个零。然而,如果用户在输入字段中输入,这不会影响零——它仍然会静止在那里。现在让我们通过改进view中的input函数来修复这个问题。
优化输入函数
为了使我们的应用能够转换输入文本字段中输入的值,我们需要能够对那个输入做些什么。换句话说,我们需要将输入的值发送到update函数,然后告诉update函数如何处理它接收到的消息。
因此,首先,让我们向我们的view函数添加一些内容,以便当用户输入某些内容时,它将发送一个包含该事件的message到update函数:
view model =
div []
[ div []
[ label [ for "unit1Input" ] [ text model.unit1 ]
, input [ id "unit1Input", onInput Convert ] []
, button [ onClick Swap ] [ text "Switch" ]
, label [ for "unit2" ] [ text model.unit2 ]
, div [ id "unit2Value" ] [ text (toString model.convertedValue) ]
]
]
在前面的代码中,我们只是向input函数的第一个List中添加了另一个项目。我们添加的项目是onInput函数,它接收一个我们称之为Convert的参数。
障碍
到目前为止,在我们的应用开发中,我们没有遇到任何重大问题。我们即将遇到一个小小的障碍,并且重要的是要像后续部分中那样缓慢地走过这个障碍。
我们已经完成了view。现在,我们可以相应地更改我们的update函数:
update msg model =
case msg of
Swap ->
{ model | unit1 = model.unit2, unit2 = model.unit1, ratio = 1 / model.ratio }
Convert newValue ->
{ model | convertedValue = newValue }
如果我们现在运行我们的应用,它将无法编译。尽管前面的代码是有效的,但还缺少一部分。看看错误信息,并尝试理解这个错误的原因:
NAMING ERROR
Line 28, Column 9
Cannot find pattern Convert
NAMING ERROR
Line 36, Column 48
Cannot find variable Convert
显然,在第28行,我们的update函数正在尝试对名为Convert的模式进行模式匹配,但它找不到它。同样,在第36行,我们的view函数正在尝试使用一个名为Convert的变量,但它也找不到它。
为了解决这个错误,让我们简要地思考一下我们的view函数可以发送的消息。有多少个?
当然,这里只有两条信息:Swap 和 Convert。
如果你查看Swap消息,你会在我们的代码中看到它在三个地方被使用:在view函数中,作为onClick函数的参数。在update函数的case表达式中,它被用作Swap模式,并且在Msg联合类型中,它被用作其唯一可能的值。
因此,为了解决我们的错误,我们需要将Convert值作为Msg联合类型的另一个可能值添加进去,如下所示:
type Msg
= Swap
| Convert
现在运行应用程序将导致另一个错误,但解决这个问题的方法应该更加明显。以下是错误文本:
TOO MANY ARGUMENTS
Line 29, Column 9
Pattern Main.Convert has too many arguments.
Expecting 0, but got 1.
正如错误信息所示,编译器正在查看Convert模式,并看到它有一个我们称为newValue的参数。但是当它查看Msg联合类型时,它只看到Convert。那里没有参数!
为了修复当前的错误,我们需要指定Convert值必须携带的参数:
type Msg
= Swap
| Convert String
将前面的更改保存到我们的应用程序中,将使我们更接近完全工作的代码。
即使目前的代码经过改进,我们的应用程序仍然无法编译。此时,一个不耐烦的读者可能会开始对编译器感到有些恼火,尽管编译器很有帮助。
然而,接下来的内容可能是本章中最重要的知识点。它涉及到在我们应用程序中切换原始值——这个主题在在线资源中很少讨论,也许是因为它被认为是另一种“预期的理解”。
那么,让我们解决这个难题。首先,让我们回顾一下我们的model,即Model的类型别名:
type alias Model =
{ convertedValue : Float
, ratio : Float
, unit1 : String
, unit2 : String
}
正如我们在前面的代码中所看到的,convertedValue是一个Float,而不是一个String。我们可能会认为解决方案就是简单地将传递给Convert的值从String更改为Float,在我们的Msg联合类型中:
type Msg
= Swap
| Convert Float
不幸的是,这只会导致另一个错误:
TYPE MISMATCH
Line 37, Column 48
The argument to function onInput is causing a mismatch.
Function onInput is expecting the argument to be:
String -> msg
But it is:
Float -> Main.Msg
因此,显然,我们的onInput消息需要是一个String。让我们尝试将newValue转换为Float,它是一个String:
Convert newValue ->
{ model | convertedValue = String.toFloat newValue }
前面的代码将抛出另一个错误,这个错误一开始可能会看起来有些吓人:
TYPE MISMATCH
Line 47, Column 9
The argument to function beginnerProgram is causing a mismatch.
Function beginnerProgram is expecting the argument to be:
{ ..., update : Main.Msg -> Main.Model -> Main.Model }
But it is:
{ ...
, update :
Main.Msg
-> { convertedValue : Result.Result String Float
, ratio : Float
, unit1 : String
, unit2 : String
}
-> { convertedValue : Result.Result String Float
, ratio : Float
, unit1 : String
, unit2 : String
}
}
Hint: Problem at update.convertedValue...
这个消息可能看起来不如我们习惯的那样直观,原因有两个。首先,这个消息报告了一个与我们的类型别名Model相关的错误,它曾擅长隐藏应用程序的复杂性,但现在可能阻碍了我们理解问题的努力。其次,在错误信息中,我们可以看到一个新出现的 Elm 关键字:Result。
让我们一步一步地解决错误。我们首先尝试通过注释掉Model的类型别名以及init函数的类型注解来产生一个更易于理解的错误:
module Main exposing (..)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
-- Model
{--
type alias Model =
{ convertedValue : Float
, ratio : Float
, unit1 : String
, unit2 : String
}
init : Model
--}
现在,让我们尝试重新编译应用,以获得一个稍微不同的错误信息,如下所示:
TYPE MISMATCH
Line 49, Column 9
The argument to function beginnerProgram is causing a mismatch.
Function beginnerProgram is expecting the argument to be:
{ ...
, update :
Main.Msg
-> { ..., convertedValue : Float }
-> { ..., convertedValue : Float }
}
But it is:
{ ...
, update :
Main.Msg
-> { ..., convertedValue : Result.Result String Float }
-> { ..., convertedValue : Result.Result String Float }
}
Hint: Problem at update.convertedValue...
前面的错误信息使问题稍微明显一些。我们遇到了这个Result.Result问题。它究竟是什么?
处理Result.Result错误
Result是一个类型。每当我们的函数可能返回一个错误时,我们就使用Result类型。
在我们前面的例子中,我们有一个输入字段。用户在输入字段中输入,我们期望输入是Float类型。然而,如果用户输入一个或多个字母,或者字母和数字的组合,或者任何其他非数字的奇怪字符怎么办?
从概念上讲,这归结为两种可能性——一个函数通过用户在输入字段中输入意外的字符来返回一个错误,或者通过用户输入我们期望的Floats来返回一个结果。
这就是官方文档中定义的Result包的样式,可在以下链接找到:package.elm-lang.org/packages/elm-lang/core/latest/Result
“一个Result是可能失败的计算的结果。这是在 Elm 中管理错误的好方法。”
官方文档给出了以下联合类型定义:
type Result error value
= Ok value
| Err error
因此,如果我们的函数成功,我们将得到一个包含值的Ok;否则,我们将得到一个包含错误的Err。这引发了一个问题:我们如何在程序中使用Result来处理错误?
在其核心,解决方案简单而优雅,就像 Elm 中的其他一切一样:如果我们得到一个Ok,函数应该返回一个值;如果我们得到一个Err,函数应该返回一个默认值。让我们从官方文档中再举一个例子,并在 REPL 中运行它:
Result.withDefault 0 (String.toInt "123") == 123
在前面的代码中,我们正在将字符串"123"解析为Int类型,但为了安全起见,以防我们得到一个Err,我们将默认值设置为零。
这是 REPL 返回的内容:
True : Bool
现在我们来尝试第二个例子:
Result.withDefault 0 (String.toInt "abc") == 0
那么,REPL 现在会返回什么?正是同样的东西:
True : Bool
结论是,无论我们在双引号内写什么,只要我们为Err值提供一个默认解决方案,Elm 都会处理它。
现在我们已经理解了Result.Result是什么,让我们回到修复我们的应用。
使用Result类型修复我们的应用
在处理了大量的错误信息和一些枯燥的理论之后,让我们将新技能付诸实践,通过将update函数更改为以下代码:
update msg model =
case msg of
Swap ->
{ model | unit1 = model.unit2, unit2 = model.unit1 }
Convert newValue ->
{ model | convertedValue = Result.withDefault 10 (String.toFloat newValue) }
前面的代码现在应该更容易理解了。如果我们的update函数接收到与Convert模式匹配的消息,并且包含newValue字符串,我们将执行箭头后的代码。
箭头后面的代码表示:使用现有的模型,只需对模型的 convertedValue 进行更改。要分配给 convertedValue 的更新值是以下表达式的结果:
Result.withDefault 10 (String.toFloat newValue)
Elm 从括号开始评估表达式:它将 newValue 字符串转换为 Float。
如果操作成功,它返回评估表达式的给定 Float 值。如果操作不成功,它返回数字 10,如果你在运行的应用程序中的输入字段中输入除数字以外的任何内容,你确实会在屏幕上看到这个数字。
剩下的唯一一件事是:取消注释类型别名 Model 和 init 函数的类型注解。经过我们所有的更改,这是到目前为止我们应用程序的完整代码:
module Main exposing (..)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
-- Model
type alias Model =
{ convertedValue : Float
, ratio : Float
, unit1 : String
, unit2 : String
}
init : Model
init =
{ unit1 = "Kilometers"
, unit2 = "Miles"
, ratio = 1.608
, convertedValue = 0.0
}
-- Update
type Msg
= Swap
| Convert String
update msg model =
case msg of
Swap ->
{ model | unit1 = model.unit2, unit2 = model.unit1 }
Convert newValue ->
{ model | convertedValue = Result.withDefault 10 (String.toFloat newValue) }
-- View
view model =
div []
[ div []
[ label [ for "unit1Input" ] [ text model.unit1 ]
, input [ id "unit1Input", onInput Convert ] []
, button [ onClick Swap ] [ text "Switch" ]
, label [ for "unit2" ] [ text model.unit2 ]
, div [ id "unit2Value" ] [ text (toString model.convertedValue) ]
]
]
-- Main
main =
beginnerProgram
{ model = init
, view = view
, update = update
}
运行应用程序并测试它。
如果你将数字输入到输入字段中,你将在下一行看到相同的数字。然而,如果你在输入字段中输入任何其他字符,你将在下一行看到数字 10——这是一个明显的迹象,表明我们的函数的 Result 是 Err。
在下一节中,我们将使我们的应用程序实际上将接收到的输入字段中的值进行转换。
计算转换
让我们更新 update 函数的 case 表达式中的 Convert 模式。我们新的代码将如下所示:
Convert newValue ->
{ model | convertedValue = (Result.withDefault 10 (String.toFloat newValue)) * model.ratio }
更新后,保存应用程序并测试输入字段的性能。一旦你开始输入数字,你将立即看到它被转换为千米单位的转换值。
然而,我们还需要进行另一项改进。由于我们的 Result.withDefault 现在是 10,所以当用户输入除数字以外的任何内容,或者在他们开始输入之前,我们都会在屏幕上看到数字 16.08。
这是一个简单的修复。我们不再使用 10,而是简单地使用 0 作为默认的 Result。更新代码如下:
Convert newValue ->
{ model | convertedValue = (Result.withDefault 0 (String.toFloat newValue)) * model.ratio }
现在,应用程序具有预期的行为。
然而,-> 操作符后面的表达式有点难以处理。我们将通过使用 let 表达式使我们的代码看起来更美观,并在过程中学习 Elm 语言的一个新特性。
使用 let 表达式重构我们的应用程序
到目前为止,我们已准备好使用 let 表达式来改进 update 函数的 case 表达式中的 Convert 模式。
Elm 中的 let 表达式由两部分组成:let 和 in。let 部分允许我们声明将在 let 表达式的 in 部分中使用的变量和函数。需要注意的是,这里声明的变量仅限于它们被使用的函数的作用域内。我们的程序的其他部分对此一无所知。换句话说,这些变量是局部的,并不存在于我们的程序的其他部分。 与 update 或 view 函数等全局作用域中的函数形成对比。
let 表达式的 in 部分应该返回一个值,无论放置了什么表达式。
让我们看看我们如何将Convert模式写成let表达式。首先,让我们再次看看它:
Convert newValue ->
{ model | convertedValue = (Result.withDefault 0 (String.toFloat newValue)) * model.ratio }
现在,让我们考虑将前面代码的一部分放入一个变量中,该变量作用域为let表达式。一个明显的候选者是这段代码:
(Result.withDefault 0 (String.toFloat newValue))
我们能给前面的代码片段起什么名字?
floatValue怎么样?这是一个很好的、描述性的名字,因为我们确实将现有的newValue字符串转换为Float,并给它默认值零。因此,我们的更新后的代码将看起来像这样:
floatValue =
Result.withDefault 0 (String.toFloat newValue)
接下来,让我们将我们的Convert模式重写为一个let表达式:
Convert newValue ->
let
floatValue =
Result.withDefault 0 (String.toFloat newValue)
in
{ model | convertedValue = floatValue * model.ratio }
那里,好多了。
我们将尝试解读之前那个长的一行表达式的认知负荷减少,变成一个更易于推理的let表达式。使用let表达式将很快让你觉得如此自然,以至于你会想知道在没有它们的情况下是如何做到的。为什么?因为以这种方式编写我们的代码,在let表达式的let部分声明的变量和它的in部分的实际表达式之间做出了清晰的分离。
使我们的应用看起来更美观
在本章中,我们涵盖了大量的理论,并且也将其付诸实践。现在,让我们看看我们应用在这个阶段的完整代码:
module Main exposing (..)
import Html exposing (beginnerProgram, div, button, input, text, label, span)
import Html.Attributes exposing (for, id, value)
import Html.Events exposing (onClick, onInput)
type alias Model =
{ unit1 : String
, unit2 : String
, ratio : Float
, convertedValue : Float
}
initModel : Model
initModel =
{ unit1 = "Kilometers"
, unit2 = "Miles"
, ratio = 1.608
, convertedValue = 0.0
}
main =
beginnerProgram { model = initModel, view = view, update = update }
view model =
div []
[ div []
[ label [ for "unit1Input" ] [ text model.unit1 ]
, input [ id "unit1Input", onInput Convert ] []
, button [ onClick Swap ] [ text "Switch" ]
, label [ for "unit2" ] [ text model.unit2 ]
, div [ id "unit2Value" ] [ text (toString model.convertedValue) ]
]
]
type Msg
= Swap
| Convert String
update msg model =
case msg of
Swap ->
{ model | unit1 = model.unit2, unit2 = model.unit1, ratio = 1 / model.ratio }
Convert newValue ->
let
floatValue =
Result.withDefault 0 (String.toFloat newValue)
in
{ model | convertedValue = floatValue * model.ratio }
接下来,我们将通过使用 Bootstrap 4 来使应用看起来更美观。
添加 Bootstrap 样式
首先,导航到项目public文件夹中的index.html,并添加以下代码行:
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-alpha.6/css/bootstrap.min.css" integrity="sha384-rwoIResjU2yc3z8GV/NPeZWAv56rSmLldC3R/AZzGRnGxQQKnKkoFVhFQhNUwEyJ" crossorigin="anonymous">
我们代码更新的起点将是一个简单的 HTML 代码片段,从官方 Bootstrap 文档网站复制而来。我们将使用的代码是基于 Bootstrap 的输入组:
<div class="col-lg-offset-3 col-lg-6">
<div class="input-group">
<span class="input-group-btn">
<button class="btn btn-secondary" type="button">Hate it</button>
</span>
<input type="text" class="form-control" placeholder="Product name">
<span class="input-group-btn">
<button class="btn btn-secondary" type="button">Love it</button>
</span>
</div>
</div>
让我们将前面的代码片段转换为 Elm 代码,使用在mbylstra.github.io/html-to-elm/可用的 HTML 到 Elm 页面。在 HTML 被解析后,我们得到以下内容:
div [ class "col-lg-offset-3 col-lg-6" ]
[ div [ class "input-group" ]
[ span [ class "input-group-btn" ]
[ button [ class "btn btn-secondary", type_ "button" ]
[ text "Hate it" ]
]
, input [ class "form-control", placeholder "Product name", type_ "text" ]
[]
, span [ class "input-group-btn" ]
[ button [ class "btn btn-secondary", type_ "button" ]
[ text "Love it" ]
]
]
]
现在,我们需要将现有的 Elm 代码映射到前面的 Elm 代码。为了更容易工作,我们还将将其分配给一个变量。完成之后,我们的view函数将看起来像这样:
view model =
div []
[ div [ class "col-lg-offset-3 col-lg-6 mt5 pt5" ]
[ h1 []
[ text "Unit Converter App" ]
, div [ class "input-group" ]
[ span [ class "input-group-btn" ]
[ button [ class "btn btn-secondary", type_ "button" ]
[ text model.unit1 ]
]
, input [ onInput Convert, class "form-control", placeholder "Type a number to convert", type_ "text" ] []
, span [ class "input-group-btn" ]
[ button [ onClick Swap, class "btn btn-primary", type_ "button" ] [ text "Switch" ]
]
]
, div [ class "mt5 pt5" ] [ text model.unit2 ]
, div [ id "unit2Value" ] [ text (toString model.convertedValue) ]
]
]
之前的代码给出了与我们之前所用的略有不同的 HTML 结构,但它也更加语义化,并且对最终用户来说看起来更美观。现在,让我们看看我们应用的完整代码:
module Main exposing (..)
import Html exposing (beginnerProgram, div, button, input, text, label, span, h1)
import Html.Attributes exposing (for, id, value, class, placeholder, type_)
import Html.Events exposing (onClick, onInput)
type alias Model =
{ unit1 : String
, unit2 : String
, ratio : Float
, convertedValue : Float
}
initModel : Model
initModel =
{ unit1 = "Kilometers"
, unit2 = "Miles"
, ratio = 1.608
, convertedValue = 0.0
}
main =
beginnerProgram { model = initModel, view = view, update = update }
view model =
div []
[ div [ class "col-lg-offset-3 col-lg-6 mt5 pt5" ]
[ h1 []
[ text "Unit Converter App" ]
, div [ class "input-group" ]
[ span [ class "input-group-btn" ]
[ button [ class "btn btn-secondary", type_ "button" ]
[ text model.unit1 ]
]
, input [ onInput Convert, class "form-control", placeholder "Type a number to convert", type_ "text" ] []
, span [ class "input-group-btn" ]
[ button [ onClick Swap, class "btn btn-primary", type_ "button" ] [ text "Switch" ]
]
]
, div [ class "mt5 pt5" ] [ text model.unit2 ]
, div [ id "unit2Value" ] [ text (toString model.convertedValue) ]
]
]
type Msg
= Swap
| Convert String
update msg model =
case msg of
Swap ->
{ model | unit1 = model.unit2, unit2 = model.unit1, ratio = 1 / model.ratio }
Convert newValue ->
let
floatValue =
Result.withDefault 0 (String.toFloat newValue)
in
{ model | convertedValue = floatValue * model.ratio }
在这个阶段,我们的应用应该看起来像这样:

在这个阶段,我们可以对我们的应用进行一些改进。例如,切换按钮只切换单位名称在公里和英里之间,但当切换按钮被按下时,它不会更新输入字段中已经存在的值。此外,页面布局和样式,虽然比我们之前的好,但仍需要更多改进。
在下一章中,我们将添加这些功能并进一步改进应用,使其具有多个输入以转换多个单位。现在,我们将把重点转向查看如何应用本章学到的知识并更新我们的 FizzBuzz 应用。
重新审视 FizzBuzz 应用
在我们开始改进我们的 FizzBuzz 应用之前,让我们提醒自己上一章我们停在了哪里:
module Main exposing (main)
import Html exposing (Html, text)
ourList = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16]
fizzBuzzCheck fizz buzz fizzBuzz num =
if num % 15 == 0 then
toString fizzBuzz
else if num % 5 == 0 then
toString buzz
else
toString num
main =
List.map (fizzBuzzCheck "fizz" "buzz" "fizz buzz") ourList
|> String.concat
|> text
让我们将前面的应用转换为使用 Elm 架构,并使其根据用户输入打印出一个数字或一个单词。我们将从之前使用的裸骨应用开始,该应用利用 beginnerProgram 函数:
module Main exposing (..)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
-- Model
initModel =
{}
-- Update
type Msg
= Nothing
update msg model =
model
-- View
view model =
div [] [ text "Everything will go here" ]
-- Main
main =
beginnerProgram
{ model = initModel
, view = view
, update = update
}
接下来,让我们填充我们的初始模型:
initModel =
{ inputValue = ""
, outputValue = 0.0
}
现在,让我们添加一个 Model 的类型别名,并相应地更新 initModel 的类型注解:
type alias Model =
{ inputValue : String
, outputValue : Float
}
initModel : Model
initModel =
{ inputValue = ""
, outputValue = 0.0
}
接下来,让我们给 view 函数添加一些 HTML:
-- View
view model =
div []
[ div [ class "col-lg-6" ]
[ div [ class "input-group" ]
[ input
[ onInput DisplayInput, class "form-control", placeholder "Enter sth", type_ "text" ]
[]
, span [ class "input-group-btn" ]
[ button [ class "btn btn-secondary", type_ "button" ]
[ text "FizzBuzz It!" ]
]
]
, div [ class "display-4" ] [ text (toString model.outputValue) ]
]
]
如果我们现在编译应用,我们将在浏览器中看到一个输入和一个按钮。在本版本的 FizzBuzz 应用中,我们希望用户在输入框中输入一个数字,然后点击按钮后,应用将根据游戏规则打印出一个数字或一个单词。
现在,我们可以开始让应用接收用户输入了。让我们通过添加一个 onInput 消息来更新 view 函数内部的嵌套 input 函数:
[ input [ onInput DisplayInput, class "form-control", placeholder "Enter a number", type_ "text" ]
现在,我们需要允许 update 函数接收该消息:
update msg model =
case msg of
DisplayInput newValue->
{ model | outputValue = Result.withDefault 0 (String.toFloat newValue) }
当然,我们仍然需要修改 Msg 联合类型:
type Msg
= DisplayInput String
如果我们现在运行应用,我们将看到一个输入、一个按钮,以及其下方的数字零。如果我们输入字母,则不会发生变化;然而,如果我们输入数字,它们将显示在零的位置。
给我们的 FizzBuzz 应用添加一些逻辑
在本节中,我们将为我们的 FizzBuzz 应用添加一些逻辑,以便根据用户输入显示结果。让我们通过在更新函数的 case 表达式的 DisplayInput 模式中添加逻辑来实现这一点:
update msg model =
case msg of
DisplayInput newValue ->
let
condition =
if (Result.withDefault 1 (String.toInt newValue) % 15) == 0 then
"fizzBuzz"
else if (Result.withDefault 1 (String.toInt newValue) % 5) == 0 then
"buzz"
else if (Result.withDefault 1 (String.toInt newValue) % 3) == 0 then
"fizz"
else
newValue
in
{ model | outputValue = condition }
之前代码中所做的可以解释为以下几点:
-
我们已经给
DisplayInput模式赋予了一个我们称之为condition的作用域变量。 -
condition变量将根据用户输入到input字段中的数字(存储在newValue变量中)评估为其中的一个if表达式。 -
在
let表达式的in部分,我们简单地返回相同的模型加上基于条件变量在let表达式的let部分等价的价值更新的outputValue。
我们现在可以保存并运行我们的应用,观察它如何根据用户输入动态更新输入字段下 div 的文本节点。
我们可以观察到以下行为:
-
输入一个数字将导致从 FizzBuzz 游戏中正确计算出数字或单词。
-
输入任何其他内容将在输入下方的
div中返回相同的字符串
这意味着我们的应用仍有更多改进的空间。我们将通过简单地添加另一个 if 表达式到我们的条件变量中,并将剩余的解析为一个消息给用户,告诉他们需要输入一个数字,而不是其他字符。
我们需要进行的更新足够简单:
update msg model =
case msg of
DisplayInput newValue ->
let
condition =
if (Result.withDefault 1 (String.toInt newValue) % 15) == 0 then
"fizzBuzz"
else if (Result.withDefault 1 (String.toInt newValue) % 5) == 0 then
"buzz"
else if (Result.withDefault 1 (String.toInt newValue) % 3) == 0 then
"fizz"
else if (Result.withDefault 0 (String.toInt newValue)) /= 0 then
newValue
else
"Type a number, please!"
in
{ model | outputValue = condition }
到目前为止,我们的 FizzBuzz 应用表现得要好得多。让我们看看这个阶段应用的完整代码:
module Main exposing (..)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
type alias Model =
{ inputValue : String
, outputValue : String
}
initModel : Model
initModel =
{ inputValue = ""
, outputValue = ""
}
-- Update
type Msg
= DisplayInput String
update msg model =
case msg of
DisplayInput newValue ->
let
condition =
if (Result.withDefault 1 (String.toInt newValue) % 15) == 0 then
"fizzBuzz"
else if (Result.withDefault 1 (String.toInt newValue) % 5) == 0 then
"buzz"
else if (Result.withDefault 1 (String.toInt newValue) % 3) == 0 then
"fizz"
else if (Result.withDefault 0 (String.toInt newValue)) /= 0 then
newValue
else
"Type a number, please!"
in
{ model | outputValue = condition }
-- View
view model =
div []
[ div [ class "col-lg-6" ]
[ div [ class "pt-5 pb-5 display-4" ] [text "Fizz Buzz App, v4" ]
, div [ class "input-group" ]
[ input
[ onInput DisplayInput, class "form-control", placeholder "Enter sth", type_ "text" ]
[]
, span [ class "input-group-btn" ]
[ button [ class "btn btn-secondary", type_ "button" ]
[ text "FizzBuzz It!" ]
]
]
, div [ class "display-4" ] [ text (toString model.outputValue) ]
]
]
-- Main
main =
beginnerProgram
{ model = initModel
, view = view
, update = update
}
这是我们的改进版 FizzBuzz 应用的欢迎屏幕:

输入一个数字会产生期望的结果:

输入其他任何内容都会产生一个用户友好的错误信息:

我们还有很多可以改进的地方,正如我们在第五章,“在 Elm 中完成单位转换网站”中将会看到的。
摘要
在第四章,“在 Elm 中准备单位转换网站”中,我们学习了许多重要的概念,例如:
-
使用
Msg联合类型 -
使用
Records设置我们模型的数据 -
使用类型注解和类型别名
-
使用管道字符在我们的表达式中仅更新模型的一部分
-
使用
Result联合类型来处理我们应用中的潜在错误 -
使用
let表达式
在下一章中,我们将通过添加多个输入来改进我们的单位转换网站,以便转换各种单位。
第五章:完成 Elm 中的单位转换网站
欢迎来到 第五章,完成 Elm 中的单位转换网站。本章的目标是在讨论以下概念的同时完成单位转换网站:
-
使用
Html.map -
处理不同视图之间的复杂关系
-
处理多个模型
-
在一个相对复杂的应用中结合我们所学的所有内容
完成这一章后,你将能够:
- 构建具有多个模型且模块化的 Elm 应用程序,同时遵循最佳实践
我们将从这个章节开始,查看我们单位转换器应用的完成功能。这样做的原因是能够看到更大的图景,并在继续改进应用本身之前,理解我们更新应用的两个模块中正在发生什么。
改进单位转换器应用
在我们应用的这个迭代中,我们有两个单独的文件协同工作以生成我们的单位转换器应用。到目前为止,我们的应用中没有任何样式,因为重点是功能。现在,让我们从头开始重建我们的单位转换器应用。
我们将从裸骨 Elm 应用程序开始:
module Main exposing (..)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
-- Model
initModel =
{}
-- Update
type Msg
= Nothing
update msg model =
model
-- View
view model =
div [] [ text "Everything will go here" ]
-- Main
main =
beginnerProgram
{ model = initModel
, view = view
, update = update
}
现在,我们将对其进行改进,使其能够接受多个输入字段。第一次改进是将当前的单位转换器模块分离成不同的模块。
将 UnitConverter 分离到不同的模块
创建一个新文件,命名为 UnitConverter.elm,并将其保存在 src 文件夹中。接下来,也将裸骨应用复制粘贴到那里。
在 Main.elm 中,更改其导入部分,使其看起来如下所示:
module Main exposing (..)
import Html exposing (beginnerProgram, div, button, input, text, label, span)
import UnitConverter exposing (..)
在 UnitConverter.elm 内部,更改导入部分,使其看起来如下所示:
module UnitConverter exposing (..)
import Html exposing (beginnerProgram, div, button, input, text, label, span)
import Html.Attributes exposing (for, id, value)
import Html.Events exposing (onClick, onInput)
仍然在 UnitConverter.elm 中,添加以下代码:
type alias Model =
{ unit1 : String
, unit2 : String
, ratio : Float
, convertedValue : Float
}
init : String -> String -> Float -> Model
init unit1 unit2 ratio =
Model unit1 unit2 ratio ratio
view model =
div []
[ div []
[ label [ for "unit1Input" ] [ text model.unit1 ]
, input [ id "unit1Input", onInput Convert ] []
, button [ onClick Swap ] [ text "Switch" ]
, label [ for "unit2" ] [ text model.unit2 ]
, div [ id "unit2Value" ] [ text (toString model.convertedValue) ]
]
]
type Msg
= Swap
| Convert String
update msg model =
case msg of
Swap ->
{ model | unit1 = model.unit2, unit2 = model.unit1, ratio = 1 / model.ratio }
Convert newValue ->
let
floatValue =
Result.withDefault 1 (String.toFloat newValue)
in
{ model | convertedValue = floatValue * model.ratio }
注意,前面的代码几乎与我们在 第四章 的结尾所看到的内容相同,即 在 Elm 中准备单位转换网站,除了不再调用 main,init 函数也发生了变化。
现在,我们可以更新 Main.elm 文件,使其能够处理多个输入字段。
添加类型别名 Model、initModel 和 Main
我们将开始更新 Main.elm,通过添加一个新的类型别名 Model。将类型别名 Model 放在 initModel 之上。代码应如下所示:
type alias Model =
{ lengthConverter : UnitConverter.Model
, weightConverter : UnitConverter.Model
}
接下来,让我们更新 initModel 本身:
initModel : Model
initModel =
{ lengthConverter = UnitConverter.init "Miles" "Kilometers" 1.608
, weightConverter = UnitConverter.init "Kilograms" "Pounds" 2.2046
}
让我们讨论前面的代码。
我们的类型别名 Model 由一个 lengthConverter 和一个 weightConverter 组成,它们都接受 UnitConverter.Model 的值。查看类型别名 UnitConverter.Model,我们看到以下内容:
type alias Model =
{ unit1 : String
, unit2 : String
, ratio : Float
, convertedValue : Float
}
在 initModel 函数中,我们将 lengthConverter 的值从 UnitConverter.init 中获取,传递给它三个参数:“英里”、“千米”和 1.608,这与 UnitConverter 模块中 init 函数的类型注解一致:
init : String -> String -> Float -> Model
同样,我们为 weightConverter 设置了 initialModel 的值,并用以下表达式填充它:
UnitConverter.init "Kilograms" "Pounds" 2.2046
继续在Main.elm中的代码,我们可以将其添加到没有变化的main函数中:
main =
beginnerProgram { model = initModel, view = view, update = update }
如果我们现在运行我们的应用,我们将在浏览器中得到Everything will go here的文本。这意味着一切都在正常工作,并且已经准备好供我们改进。
在下一节中,我们将更新view函数。
更新Main.view函数
要更新view函数,我们将使用以下代码:
view model =
div []
[ UnitConverter.view model.lengthConverter |> Html.map LengthConverterMsg
, UnitConverter.view model.weightConverter |> Html.map WeightConverterMsg
]
让我们看看编译器为Main.view函数建议的类型注解:
view :
{ c
| lengthConverter : { b | convertedValue : a, unit1 : String, unit2 : String }
, weightConverter : { b1 | convertedValue : a1, unit1 : String, unit2 : String }
}
-> Html.Html Msg
view函数接受一个记录。记录的值将是当前模型的值,加上对lengthConverter或weightConverter所做的更新。lengthConverter函数将返回要么它的当前值,由类型变量b表示,要么更新后的值,将convertedValue、unit1和unit2设置为它们的相应值。对于weightConverter也是一样——它将返回要么原样,要么更新后的convertedValue、unit1和unit2的值。
因此,view函数接受一个记录并返回一个Html.Html Msg。为什么这里列出了Html模块?因为我们没有在导入Html模块时显式地暴露它。这种引用方式被称为完全限定风格。如果我们已经在Main.elm的顶部导入表达式中显式地暴露了Html函数,我们就可以直接使用-> Html Msg,这被称为非限定风格。
如果我们现在运行我们的应用,编译器将抛出这两个错误:
Cannot find variable LengthConverterMsg
Cannot find variable WeightConverterMsg
当然不行。我们还没有设置它。让我们开始做这件事。我们将首先添加type Msg的联合类型:
type Msg
= LengthConverterMsg UnitConverter.Msg
| WeightConverterMsg UnitConverter.Msg
Main.elm中的type Msg可以是LengthConverterMsg类型构造函数,它接受UnitConverter.Msg作为其参数,或者它可以是WeightConverterMsg类型构造函数,也接受UnitConverter.Msg。这两个类型构造函数都接受相同的值作为其单个参数。为什么是这样?
答案很简单:如果你查看UnitConverter.update函数,你会看到如果它的 Swap 模式匹配成功,评估的表达式将执行确切的操作,交换值,就像它在第四章中,在 Elm 中准备一个单位转换网站所做的那样。
如果UnitConverter.update函数的Convert模式匹配成功,它将返回它接收到的model,加上更新的convertedValue。这和我们在第四章中提到的相同,在 Elm 中准备一个单位转换网站,这意味着逻辑是相同的。
那么发生了什么变化?变化的是Main.elm中的view函数。让我们来分解它。
我们的Main.view只是一个div函数。我们传递给它的第一个List是空的。第二个List有两个参数。第一个参数看起来像这样:
UnitConverter.view model.lengthConverter |> Html.map LengthConverterMsg
这是我们已经讨论过的管道语法。另一种写法是使用括号,如下所示:
Html.map LengthConverterMsg (UnitConverter.view model.lengthConverter)
让我们从括号内的表达式开始:
UnitConverter.view model.lengthConverter
记住,在 UnitConverter.elm 的第 22 行,我们可以看到其 view 函数只接受一个参数:
view model =
div []
[ div []
...
因此,我们在 Main.elm 中传递给 UnitConverter.view 的参数 model.lengthConverter 实际上是模型本身,更具体地说,是模型的 lengthConverter 类型。
那么 lengthConverter 从哪里来呢?如果我们查看 Main.elm 中的数据流,我们就可以找到答案。
首先,Main.elm 的入口点,main 函数,接收评估以下表达式计算出的值:
beginnerProgram
{ model = initModel
, view = view
, update = update
}
我们需要关注上述记录的第一行:model = initModel。这意味着 model 将被填充为 initModel 的值:
{ lengthConverter = UnitConverter.init "Miles" "Kilometers" 1.608
, weightConverter = UnitConverter.init "Kilograms" "Pounds" 2.2046
}
这个记录将被存储在我们的 Main.model 中。接下来,Main.view 函数从模型中获取值,并将它们传递给 div 函数:
div []
[ UnitConverter.view model.lengthConverter |> Html.map LengthConverterMsg
, UnitConverter.view model.weightConverter |> Html.map WeightConverterMsg
]
我们回到了起点,但现在我们知道,在第一次运行时,我们的应用程序将给 UnitConverter.view 函数传递 model.lengthConverter 参数,该参数具有以下初始值:"Miles","Kilometers",以及 1.608。
类似地,我们的应用程序将给 UnitConverter.view 函数传递 model.weightConverter 参数,该参数具有以下初始值:"Kilograms","Pounds",以及 2.2046。
但那只是渲染过程的第一部分。接下来发生的事情是这段代码:Html.map LenghtConverterMsg 和 Html.map WeightConverterMsg。
让我们现在看看 Html.map 函数。
理解 Html.map
理解 Html.map 非常简单。让我们从官方文档开始,文档可在 package.elm-lang.org/packages/elm-lang/html/latest/Html#map 找到。文档说明 Html.map 用于:
"将某些 Html 产生的消息进行转换。"
那就是它所做的一切!
现在,为什么我们需要在 Main.elm 文件中转换 UnitConverter.view model.lengthConverter 产生的消息呢?因为为了使用从 UnitConverter.view model.lengthConverter 表达式返回的 Html Msg,我们需要将其匹配到 Main.update 函数将使用的 LengthConverterMsg 类型。
在下一节中,我们将向我们的应用程序添加 update 函数,然后从 Main.view 函数中移除 Html.map,以查看编译器将抛出什么样的错误。
更新 Main.update 函数
为了开始,让我们将以下代码添加到我们的 Main.update 函数中:
update msg model =
case msg of
LengthConverterMsg msg_ ->
let
newLengthConverter =
UnitConverter.update msg_ model.lengthConverter
in
{ model | lengthConverter = newLengthConverter }
WeightConverterMsg msg_ ->
let
newWeightConverter =
UnitConverter.update msg_ model.weightConverter
in
{ model | weightConverter = newWeightConverter }
与你第一次查看上述代码时可能想到的相反,那里并没有发生太多。像往常一样,我们的更新函数接受一个消息和一个模型。消息来自 Model.view 函数,可以是 LengthConverterMsg 或 WeightConverterMsg,这由更新函数上方的 Msg 联合类型决定。
如同在 第四章 中提到的,在 Elm 中准备一个单位转换网站,为了使 case 表达式成功编译,它需要覆盖所有可能的选项,而 LengthConverterMsg 和 WeightConverterMsg 模式正是这样做的。
进入 LenghtConverterMsg 模式,我们可以看到它接受一个我们称之为 msg_ 的参数。这个名称是为了区分传递给 update 函数的第一个参数,它只是简单地称为 msg(没有下划线)。
在 LenghtConverterMsg 模式中,我们有一个 let 表达式,在其 let 部分中,我们指定了一个局部作用域的 newLengthConverter,它从以下表达式中获取评估后的值:
UnitConverter.update msg_ model.lenghtConverter
然后,在 let 表达式的 in 部分中,我们返回这个:
{ model | lengthConverter = newLengthConverter }
上述代码很简单:lenghtConverter 通过存储在 newLengthConverter 中的表达式的计算结果进行更新。
如果我们的 Main.update 函数的 case 语句模式匹配 WeightConverterMsg 模式,执行的操作几乎相同——唯一的区别是 model.weightConverter 被作为第二个参数传递给 UnitConverter.update 函数。
为了总结本节,让我们看看编译器建议的 Main.update 函数的类型注解:
update :
Msg
->
{ c
| lengthConverter :
{ b1
| convertedValue : Float
, ratio : Float
, unit1 : a1
, unit2 : a1
}
, weightConverter :
{ b
| convertedValue : Float
, ratio : Float
, unit1 : a
, unit2 : a
}
}
->
{ c
| lengthConverter :
{ b1
| convertedValue : Float
, ratio : Float
, unit1 : a1
, unit2 : a1
}
, weightConverter :
{ b
| convertedValue : Float
, ratio : Float
, unit1 : a
, unit2 : a
}
}
更新函数接受一个 Msg 和一个 Record 并返回一个 Record。
如果我们现在保存所有文件并运行应用程序,我们会得到一个完全功能的单位转换器。然而,我们仍然需要从 Main.view 函数中删除 Html.map,正如几页前宣布的那样,因为我们想看到编译器会抛出什么样的错误。
在 Elm 中玩弄错误信息是学习更高级概念或加强某些熟悉概念的好方法。
从编译器错误信息中学习
让我们先从 Main.view 中移除一个 Html.map 函数,这样它的更新后的代码看起来就像这样:
view model =
div []
[ UnitConverter.view model.lengthConverter
, UnitConverter.view model.weightConverter |> Html.map WeightConverterMsg
]
确保也删除类型注解,以避免额外的错误信息。在这些更改后保存并运行应用程序将产生以下错误信息:
The 1st and 2nd entries in this list are different types of values.
The 1st entry has this type:
Html.Html UnitConverter.Msg
But the 2nd is:
Html.Html Main.Msg
Hint: Every entry in a list needs to be the same type of value. This way you
never run into unexpected values partway through. To mix different types in a
single list, create a "union type" as described in:
<http://guide.elm-lang.org/types/union_types.html>
如我们之前讨论的,第一个条目,因为它没有通过 Html.map 转换为 lengthConverterMsg,其类型为 Html UnitConverter.Msg。查看 UnitConverter 类型的 Msg 联合类型,我们可以看到它可以是 Swap 或 Convert String。
然而,查看 Main 的 Msg 联合类型,我们可以看到它可以是 LengthConverterMsg UnitConverter.Msg 或 WeightConverterMsg UnitConverter.Msg。换句话说,它可以是这两个函数构造器之一,以 UnitConverter.Msg 作为其参数。
为了强调这一点,让我们也从 Main.view 中删除另一个管道函数。这样做会产生以下错误信息:
The argument to function `beginnerProgram` is causing a mismatch.
Function `beginnerProgram` is expecting the argument to be:
{ ..., view : Main.Model -> Html.Html Main.Msg }
But it is:
{ ...
, view :
{ lengthConverter : UnitConverter.Model
, weightConverter : UnitConverter.Model
}
-> Html.Html UnitConverter.Msg
}
这就完成了我们对代码逻辑所做的更改。然而,还有很多工作要做。我们将通过使用免费的 Bootstrap 4 模板作为起点,然后调整以满足我们的需求,使我们的应用程序看起来更加美观。
基于 Bootstrap 的 HTML 布局
让我们看看我们应用程序完成的布局。布局基于官方 Bootstrap 文档提供的 Cover 模板,并包含几个部分。我们将按照它们在代码中出现的顺序,从上到下介绍它们。
首先,我们有 DOCTYPE 和 head 标签。这段代码将 Bootstrap 4 模板设置为 HTML5 页面,提供一些元信息,给我们的页面一个标题,并从 CDN(内容分发网络)调用 Bootstrap 4 CSS。
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta name="author" content="">
<title>Cover Template for Bootstrap</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.3/css/bootstrap.min.css" integrity="sha384-Zug+QiDoJOrZ5t4lssLdxGhVrurbmBWopoEl+M6BdEfwnCJZtKxi1KgxUyJq13dy" crossorigin="anonymous">
在 head 标签的上方,我们有一个相对较长的 style 标签。这个标签在从 CDN 导入的常规 Bootstrap 4 样式之上提供了自定义 CSS 样式,如前述代码所述。自定义样式具有以下代码:
<!-- Custom styles for this template -->
<style>
a,
a:focus,
a:hover {
color: #fff;
}
html,
body {
height: 100%;
background-color: powderblue;
}
body {
/*color: #3a7f3d;*/
text-align: center;
}
/* Extra markup and styles for table-esque vertical and horizontal centering */
.site-wrapper {
display: table;
width: 100%;
height: 100%; /* For at least Firefox */
min-height: 100%;
}
.site-wrapper-inner {
display: table-cell;
vertical-align: top;
}
.cover-container {
margin-right: auto;
margin-left: auto;
}
/* Padding for spacing */
.inner {
padding: 2rem;
}
/*
* Header
*/
.masthead {
margin-bottom: 2rem;
}
.masthead-brand {
margin-bottom: 0;
}
.nav-masthead .nav-link {
padding: .25rem 0;
font-weight: 700;
background-color: transparent;
border-bottom: .25rem solid transparent;
}
.nav-masthead .nav-link:hover,
.nav-masthead .nav-link:focus {
border-bottom-color: rgba(255, 255, 255, .25);
}
.nav-masthead .nav-link + .nav-link {
margin-left: 1rem;
}
.nav-masthead .active {
color: #111;
border-bottom-color: #fff;
}
@media (min-width: 48em) {
.masthead-brand {
float: left;
}
.nav-masthead {
float: right;
}
}
/*
* Cover
*/
.cover {
padding: 0 1.5rem;
}
.cover .btn-lg {
padding: .75rem 1.25rem;
font-weight: 700;
}
/*
* Footer
*/
.mastfoot {
color: rgba(255, 255, 255, .5);
}
/*
* Affix and center
*/
@media (min-width: 40em) {
/* Pull out the header and footer */
.masthead {
position: fixed;
top: 0;
}
.mastfoot {
position: fixed;
bottom: 0;
}
/* Start the vertical centering */
.site-wrapper-inner {
vertical-align: middle;
}
/* Handle the widths */
.masthead,
.mastfoot,
.cover-container {
width: 100%; /* Must be percentage or pixels for horizontal alignment */
}
}
@media (min-width: 62em) {
.masthead,
.mastfoot,
.cover-container {
width: 42rem;
}
}
</style>
</head>
接下来,我们有 body 标签,它包含页面的标题和我们的应用程序的输入字段:
<body>
<div class="site-wrapper">
<div class="site-wrapper-inner">
<div class="cover-container">
<header class="masthead clearfix">
<div class="inner">
<h3 class="masthead-brand">Unit Converter</h3>
<nav class="nav nav-masthead">
<a class="nav-link text-secondary" href="#">Home</a>
<a class="nav-link text-secondary" href="#">Features</a>
<a class="nav-link text-secondary" href="#">Contact</a>
</nav>
</div>
</header>
<div class="inner cover">
<h1 class="cover-heading">Distance converter</h1>
<div class="col-lg-12 mb-5">
<div class="input-group mb-3">
<div class="input-group-prepend">
<button class="btn btn-secondary" type="button">Kilometers:</button>
</div>
<input type="text" class="form-control" placeholder="" aria-label="" aria-describedby="basic-addon1">
<div class="input-group-append">
<button class="btn btn-secondary" type="button">Miles:</button>
<button class="btn btn-secondary" type="button">0.0</button>
</div>
</div>
</div>
</div>
<div class="inner cover">
<h1 class="cover-heading">Weight converter</h1>
<div class="col-lg-offset-12">
<div class="input-group mb-3">
<div class="input-group-prepend">
<button class="btn btn-secondary" type="button">Kilograms:</button>
</div>
<input type="text" class="form-control" placeholder="" aria-label="" aria-describedby="basic-addon1">
<div class="input-group-append">
<button class="btn btn-secondary" type="button">Pounds:</button>
<button class="btn btn-secondary" type="button">0.0</button>
</div>
</div>
</div>
</div>
<footer class="mastfoot">
<div class="inner text-secondary">
<p>Cover template for <a href="https://getbootstrap.com/">Bootstrap</a>, by <a href="https://twitter.com/mdo">@mdo</a>.</p>
</div>
</footer>
</div>
</div>
</div>
<!-- Bootstrap core JavaScript
================================================== -->
<!-- Placed at the end of the document so the pages load faster -->
<script src="img/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script>
<script src="img/popper.min.js" integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q" crossorigin="anonymous"></script>
<script src="img/bootstrap.min.js" integrity="sha384-a5N7Y/aK3qNeh15eJKGWxsqtnX/wWdSZSKp+81YjTmS15nvnvxKHuzaWwXHDli+4" crossorigin="anonymous"></script>
</body>
</html>
当我们将所有前面的部分组合在一起,并将它们保存为 HTML 文件,然后在浏览器中运行该文件时,浏览器将渲染一个看起来像这样的页面:

现在我们有一个很好的布局可以工作,让我们将其转换为 Elm 的 view 函数。
将 HTML 布局转换为 Elm 视图
现在我们已经完成了本章的前一部分,是时候从 Unit Converter 应用程序的功能中休息一下,并从完全不同的角度来审视我们的应用程序:设计。
为了完全理解为我们的应用程序创建设计的过程,我们将采取一种极端的方法:我们将从头开始构建一个新的应用程序,这次只关注设计。完成设计后,我们将添加更改到我们的 Unit Converter 的功能中。
为了能够跟上,你应该创建一个全新的应用程序。让我们称它为以设计为重点。将你的控制台指向你想要保存以设计为重点应用程序的文件夹,并运行以下命令:
create-elm-app design-focused;
cd design-focused;
elm-app start
正如我们之前多次看到的,这将运行一个新的 Elm 应用程序,只在浏览器窗口的上中部显示一个海军蓝色的 Elm 标志。
现在,我们可以开始构建我们应用程序的设计。首先,我们将回顾一下我们在上一章中使用的裸骨模板:
module Main exposing (..)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
-- Model
initModel =
{}
-- Update
type Msg
= Nothing
update msg model =
model
-- View
view model =
div [] [ text "Everything will go here" ]
-- Main
main =
beginnerProgram
{ model = initModel
, view = view
, update = update
}
现在我们有了我们的裸骨应用程序,是时候给它添加自定义样式了。在我们的 HTML 模板中,我们从 HTML 文档的头部提供了它们。这次,我们将做一些不同的事情。在裸骨应用程序的导入下面,添加以下函数:
bootstrapCss =
let
tag =
"link"
attrs =
[ attribute "rel" "stylesheet"
, attribute "property" "stylesheet"
, attribute "href" "//maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.3/css/bootstrap.min.css"
]
children =
[]
in
node tag attrs children
这所做的就是创建一个样式标签,该标签从 CDN 调用压缩后的 Bootstrap CSS 文件。
接下来,我们将更新我们应用的view函数,使其代码与我们的 HTML 布局中的代码相同。一旦从 HTML 转换为 Elm(使用mbylstra.github.io/html-to-elm/上的在线转换器),我们只需添加stylesheet函数,如下所示。布局的 HTML,以 Elm view函数的形式,将看起来像这样:
view model =
div [ class "site-wrapper" ]
[ bootstrapCss
, div [ class "site-wrapper-inner" ]
[ div [ class "cover-container" ]
[ header [ class "masthead clearfix" ]
[ div [ class "inner" ]
[ h3 [ class "masthead-brand" ]
[ text "Unit Converter" ]
, nav [ class "nav nav-masthead" ]
[ a [ class "nav-link text-secondary", href "#" ]
[ text "Home" ]
, a [ class "nav-link text-secondary", href "#" ]
[ text "Features" ]
, a [ class "nav-link text-secondary", href "#" ]
[ text "Contact" ]
]
]
]
, div [ class "inner cover" ]
[ h1 [ class "cover-heading" ]
[ text "Distance converter" ]
, div [ class "col-lg-12 mb-5" ]
[ div [ class "input-group mb-3" ]
[ div [ class "input-group-prepend" ]
[ button [ class "btn btn-secondary", type_ "button" ]
[ text "Kilometers:" ]
]
, input [ attribute "aria-describedby" "basic-addon1", attribute "aria-label" "", class "form-control", placeholder "", type_ "text" ]
[]
, div [ class "input-group-append" ]
[ button [ class "btn btn-secondary", type_ "button" ]
[ text "Miles:" ]
, button [ class "btn btn-secondary", type_ "button" ]
[ text "0.0" ]
]
]
]
]
, div [ class "inner cover" ]
[ h1 [ class "cover-heading" ]
[ text "Weight converter" ]
, div [ class "col-lg-offset-12" ]
[ div [ class "input-group mb-3" ]
[ div [ class "input-group-prepend" ]
[ button [ class "btn btn-secondary", type_ "button" ]
[ text "Kilograms:" ]
]
, input [ attribute "aria-describedby" "basic-addon1", attribute "aria-label" "", class "form-control", placeholder "", type_ "text" ]
[]
, div [ class "input-group-append" ]
[ button [ class "btn btn-secondary", type_ "button" ]
[ text "Pounds:" ]
, button [ class "btn btn-secondary", type_ "button" ]
[ text "0.0" ]
]
]
]
]
, footer [ class "mastfoot" ]
[ div [ class "inner text-secondary" ]
[ p []
[ text "Cover template for "
, a [ href "https://getbootstrap.com/" ]
[ text "Bootstrap" ]
, text ", by "
, a [ href "https://twitter.com/mdo" ]
[ text "@mdo" ]
, text "."
]
]
]
]
]
]
现在,我们已经为我们的应用添加了一个静态视图,该视图会被渲染到我们的网页上。然而,我们没有从官方 Bootstrap 网站获取的 Cover 模板中提供任何自定义样式。
有一个叫做 RawGit 的 CDN 可以帮我们解决这个问题,它可在rawgit.com/找到。RawGit 的作用是让您使用 GitHub 上的任何原始文件,粘贴其地址,然后获取一个您可以在应用中使用 CDN 链接。
因此,为了将我们的自定义 Cover 模板的 CSS 包含到我们的应用中,我们只需在 GitHub 上找到它的原始地址,如下所示:raw.githubusercontent.com/twbs/bootstrap/v4-dev/docs/4.0/examples/cover/cover.css。现在我们有了原始链接,我们只需将其粘贴到 RawGit 中,并使用它提供的以下链接:rawgit.com/twbs/bootstrap/v4-dev/docs/4.0/examples/cover/cover.css。
现在,我们可以在bootstrapCss函数下方添加 Cover 模板样式。
coverTemplateCss =
let
tag =
"link"
attrs =
[ attribute "rel" "stylesheet"
, attribute "property" "stylesheet"
, attribute "href" "//rawgit.com/twbs/bootstrap/v4-dev/docs/4.0/examples/cover/cover.css"
]
children =
[]
in
node tag attrs children
我们还需要在我们的view函数中调用这些样式。更新将在view函数的开始处发生,使其前几行看起来像这样:
view model =
div [ class "site-wrapper" ]
[ bootstrapCss
, coverTemplateCss
, div [ class "site-wrapper-inner" ]
...
现在我们已经保存了一切并运行了我们的应用,我们将看到带有所有样式的静态视图。只需要进行一点小的改进,那就是将具有site-wrapper-inner类的div设置为 100vh 的宽度。现在让我们在我们的视图函数中实现这一点。
在view函数中找到以下代码行:
, div [ class "site-wrapper-inner" ]
现在,将此代码行替换为使用style函数添加内联样式的改进代码:
, div [ style [( "height", "100vh" )], class "site-wrapper-inner" ]
最后,让我们通过给它们赋予不同的类来更改我们应用中的按钮。在 Main.elm 的view函数中,找到所有btn-secondary的实例,并将它们替换为btn-warning。
您还可能希望将那个深色背景颜色改为更令人愉悦的颜色。不幸的是,我们无法简单地用一个类替换另一个类,因为背景颜色是在我们从 CDN 提供的自定义 cover.css 文件中指定的。
为了使事情更有趣,我们将使用一个在线 CSS 渐变生成器,可在uigradients.com/#VeryBlue找到。此链接指向他们的#VeryBlue 渐变,但请随意使用适合您的另一个渐变。
从渐变网站生成的 CSS 如下所示:
background: #0575e6; /* fallback for old browsers */
background: -webkit-linear-gradient(to right, #0575e6, #021b79); /* Chrome 10-25, Safari 5.1-6 */
background: linear-gradient(to right, #0575e6, #021b79); /* W3C, IE 10+/ Edge, Firefox 16+, Chrome 26+, Opera 12+, Safari 7+ */
现在,我们可以将其转换为内联 Elm 样式,并更新我们 view 函数的最外层 div,使得我们的 view 函数的前十几行看起来像这样:
view model =
div
[ style
[ ( "background", "#0575e6" )
, ( "background", "-webkit-linear-gradient(to right, #0575e6, #021b79)" )
, ( "background", "linear-gradient(to right, #0575e6, #021b79)" )
]
, class "site-wrapper"
]
[ bootstrapCss
, coverTemplateCss
, div [ style [ ( "height", "100vh" ) ], class "site-wrapper-inner" ]
让我们更新输入字段:
, div []
[ div [ class "input-group-prepend" ] []
, button [ class "btn btn-warning", type_ "button" ] [ text model.unit1 ]
, input
[ attribute "aria-describedby" "basic-addon1"
, attribute "aria-label" ""
, class "form-control"
, placeholder "Enter a numerical value"
, type_ "text"
, onInput Convert
]
[]
, button [ onClick Swap ] [ text model.unit2 ]
, button [ id "unit2Value" ] [ text (toString model.convertedValue) ]
]
现在,让我们介绍附加的 div:
, div []
[ div [ class "input-group-prepend" ]
[ button [ class "btn btn-warning", type_ "button" ] [ text model.unit1 ]
, input
[ attribute "aria-describedby" "basic-addon1"
, attribute "aria-label" ""
, class "form-control"
, placeholder "Enter a numerical value"
, type_ "text"
, onInput Convert
]
[]
, div [ class "input-group-append" ] []
, button [ onClick Swap ] [ text model.unit2 ]
, button [ id "unit2Value" ] [ text (toString model.convertedValue) ]
]
]
]
上述代码产生了一个看起来很不错的网站。然而,我们仍然需要将上述 view 信息添加到我们的功能应用中。我们将在下一部分完成这项工作。
将改进的视图添加到我们的功能应用中
在本章的开头,我们构建了应用的功能。之后,我们从头开始构建了一个设计,基于一个免费的 Bootstrap 4 模板。现在,是时候将两者的优点结合起来。
首先,切换回我们称为 unit-converter-complex 的应用。接下来,只需简单地将略微改进的代码复制粘贴到更新的 UnitConverter.elm 文件中:
module UnitConverter exposing (..)
import Html exposing (beginnerProgram, button, div, input, label, node, span, text)
import Html.Attributes exposing (attribute, class, for, id, style, value)
import Html.Events exposing (onClick, onInput)
bootstrapCss : Html.Html msg
bootstrapCss =
let
tag =
"link"
attrs =
[ attribute "rel" "stylesheet"
, attribute "property" "stylesheet"
, attribute "href" "//maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.3/css/bootstrap.min.css"
]
children =
[]
in
node tag attrs children
coverTemplateCss : Html.Html msg
coverTemplateCss =
let
tag =
"link"
attrs =
[ attribute "rel" "stylesheet"
, attribute "property" "stylesheet"
, attribute "href" "//gitcdn.xyz/repo/twbs/bootstrap/v4-dev/docs/4.0/examples/cover/cover.css"
]
children =
[]
in
node tag attrs children
type alias Model =
{ unit1 : String
, unit2 : String
, ratio : Float
, convertedValue : Float
}
init : String -> String -> Float -> Model
init unit1 unit2 ratio =
Model unit1 unit2 ratio ratio
view : { b | convertedValue : a, unit1 : String, unit2 : String } -> Html.Html Msg
view model =
div
[ style
[ ( "background", "#0575e6" )
, ( "background", "-webkit-linear-gradient(to right, #0575e6, #021b79)" )
, ( "background", "linear-gradient(to right, #0575e6, #021b79)" )
, ( "height", "100vh" )
]
, class "site-wrapper"
]
[ bootstrapCss
, coverTemplateCss
, div []
[ label [ for "unit1Input" ] [ text model.unit1 ]
, input [ id "unit1Input", onInput Convert ] []
, button [ onClick Swap ] [ text "Switch" ]
, label [ for "unit2" ] [ text model.unit2 ]
, div [ id "unit2Value" ] [ text (toString model.convertedValue) ]
]
]
type Msg
= Swap
| Convert String
update :
Msg
-> { b | convertedValue : Float, unit1 : a, unit2 : a, ratio : Float }
-> { b | convertedValue : Float, unit1 : a, unit2 : a, ratio : Float }
update msg model =
case msg of
Swap ->
{ model | unit1 = model.unit2, unit2 = model.unit1, ratio = 1 / model.ratio }
Convert newValue ->
let
floatValue =
Result.withDefault 1 (String.toFloat newValue)
in
{ model | convertedValue = floatValue * model.ratio }
接下来,我们将关注更改以下部分:
, div []
[ label [ for "unit1Input" ] [ text model.unit1 ]
, input [ id "unit1Input", onInput Convert ] []
, button [ onClick Swap ] [ text "Switch" ]
, label [ for "unit2" ] [ text model.unit2 ]
, div [ id "unit2Value" ] [ text (toString model.convertedValue) ]
]
我们将进行的第一次改进将是将所需的其他函数调用简单地替换为按钮函数:
, div []
[ button [ for "unit1Input" ] [ text model.unit1 ]
, input [ id "unit1Input", onInput Convert ] []
, button [ onClick Swap ] [ text model.unit2 ]
, button [ id "unit2Value" ] [ text (toString model.convertedValue) ]
]
接下来,我们将逐步开始构建更类似 Bootstrap 的外观:
, div []
[ div [ class "input-group-prepend" ] []
, button [ class "btn btn-warning" ] [ text model.unit1 ]
, input [ id "unit1Input", onInput Convert ] []
, button [ onClick Swap ] [ text model.unit2 ]
, button [ id "unit2Value" ] [ text (toString model.convertedValue) ]
]
接下来,我们将将包装 div 内部的所有内容拉入:
, div []
[ div [ class "input-group-prepend" ]
[ button [ class "btn btn-warning", type_ "button" ] [ text model.unit1 ]
, input
[ attribute "aria-describedby" "basic-addon1"
, attribute "aria-label" ""
, class "form-control"
, placeholder "Enter a numerical value"
, type_ "text"
, onInput Convert
]
[]
, button [ onClick Swap ] [ text model.unit2 ]
, button [ id "unit2Value" ] [ text (toString model.convertedValue) ]
]
]
]
现在,让我们拉入附加的按钮:
, div [ class "input-group-append" ]
[ button [ onClick Swap ] [ text model.unit2 ]
, button [ id "unit2Value" ] [ text (toString model.convertedValue) ]
]
]
从内部到外部逐步构建网站后,最终的代码将如下所示。Main.elm 文件将看起来像这样:
module Main exposing (..)
import Html exposing (..)
import Html.Attributes exposing (..)
import UnitConverter exposing (..)
bootstrapCss : Html.Html msg
bootstrapCss =
let
tag =
"link"
attrs =
[ attribute "rel" "stylesheet"
, attribute "property" "stylesheet"
, attribute "href" "//maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.3/css/bootstrap.min.css"
]
children =
[]
in
node tag attrs children
coverTemplateCss : Html.Html msg
coverTemplateCss =
let
tag =
"link"
attrs =
[ attribute "rel" "stylesheet"
, attribute "property" "stylesheet"
, attribute "href" "//gitcdn.xyz/repo/twbs/bootstrap/v4-dev/docs/4.0/examples/cover/cover.css"
]
children =
[]
in
node tag attrs children
-- Model
type alias Model =
{ lengthConverter : UnitConverter.Model
, weightConverter : UnitConverter.Model
}
initModel : Model
initModel =
{ lengthConverter = UnitConverter.init "Miles" "Kilometers" 1.608
, weightConverter = UnitConverter.init "Kilograms" "Pounds" 2.2046
}
-- Update
type Msg
= LengthConverterMsg UnitConverter.Msg
| WeightConverterMsg UnitConverter.Msg
update :
Msg
->
{ c
| lengthConverter :
{ b1
| convertedValue : Float
, ratio : Float
, unit1 : a1
, unit2 : a1
}
, weightConverter :
{ b
| convertedValue : Float
, ratio : Float
, unit1 : a
, unit2 : a
}
}
->
{ c
| lengthConverter :
{ b1
| convertedValue : Float
, ratio : Float
, unit1 : a1
, unit2 : a1
}
, weightConverter :
{ b
| convertedValue : Float
, ratio : Float
, unit1 : a
, unit2 : a
}
}
update msg model =
case msg of
LengthConverterMsg msg_ ->
let
newLengthConverter =
UnitConverter.update msg_ model.lengthConverter
in
{ model | lengthConverter = newLengthConverter }
WeightConverterMsg msg_ ->
let
newWeightConverter =
UnitConverter.update msg_ model.weightConverter
in
{ model | weightConverter = newWeightConverter }
-- View
view :
{ c
| lengthConverter :
{ b | convertedValue : a, unit1 : String, unit2 : String }
, weightConverter :
{ b1 | convertedValue : a1, unit1 : String, unit2 : String }
}
-> Html Msg
view model =
div
[ style
[ ( "background", "#0575e6" )
, ( "background", "-webkit-linear-gradient(to right, #0575e6, #021b79)" )
, ( "background", "linear-gradient(to right, #0575e6, #021b79)" )
]
]
[ bootstrapCss
, coverTemplateCss
, div [ class "site-wrapper-inner" ]
[ div [ class "cover-container" ]
[ header [ class "masthead clearfix" ]
[ div [ class "inner" ]
[ h3 [ class "masthead-brand" ]
[ text "Unit Converter Site" ]
, nav [ class "nav nav-masthead" ]
[ a [ class "nav-link text-secondary", href "#" ]
[ text "Home" ]
, a [ class "nav-link text-secondary", href "#" ]
[ text "Features" ]
, a [ class "nav-link text-secondary", href "#" ]
[ text "Contact" ]
]
]
]
, h1 [ class "cover-heading" ]
[ text "Distance and Weight converter" ]
, UnitConverter.view model.lengthConverter |> Html.map LengthConverterMsg
, UnitConverter.view model.weightConverter |> Html.map WeightConverterMsg
]
]
]
-- Main
main : Program Never Model Msg
main =
beginnerProgram
{ model = initModel
, view = view
, update = update
}
UnitConverter.elm 文件将看起来像这样:
module UnitConverter exposing (..)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onClick, onInput)
type alias Model =
{ unit1 : String
, unit2 : String
, ratio : Float
, convertedValue : Float
}
init : String -> String -> Float -> Model
init unit1 unit2 ratio =
Model unit1 unit2 ratio ratio
view : { b | convertedValue : a, unit1 : String, unit2 : String } -> Html.Html Msg
view model =
div [ class "site-wrapper" ]
[ div [ class "inner cover" ]
[ div [ class "col-lg-12 mb-5" ]
[ div [ class "input-group mb-3" ]
[ div [ class "input-group-prepend" ]
[ button [ class "btn btn-warning", type_ "button" ] [ text model.unit1 ]
]
, input
[ attribute "aria-describedby" "basic-addon1"
, attribute "aria-label" ""
, class "form-control"
, placeholder "Enter a numerical value"
, type_ "text"
, onInput Convert
]
[]
, div [ class "input-group-append" ]
[ button [ onClick Swap, class "btn btn-warning", type_ "button" ] [ text model.unit2 ]
, button [ id "unit2Value", class "btn btn-warning", type_ "button" ] [ text (toString model.convertedValue) ]
]
]
]
]
]
type Msg
= Swap
| Convert String
update :
Msg
-> { b | convertedValue : Float, unit1 : a, unit2 : a, ratio : Float }
-> { b | convertedValue : Float, unit1 : a, unit2 : a, ratio : Float }
update msg model =
case msg of
Swap ->
{ model | unit1 = model.unit2, unit2 = model.unit1, ratio = 1 / model.ratio }
Convert newValue ->
let
floatValue =
Result.withDefault 1 (String.toFloat newValue)
in
{ model | convertedValue = floatValue * model.ratio }
虽然代码量很大,但我们已经讨论了所有这些内容。尽管与原始以设计为重点的文件相比有一些变化,但重要的是我们最终在 Elm 中构建了一个功能齐全、美观的单位转换网站。
摘要
在第五章,《在 Elm 中完成单位转换网站》,我们改进了我们的单位转换器应用,使其能够处理多个输入,也就是说,它可以转换多个测量单位。在这个过程中,我们学习了许多重要的概念:
-
使用
Html.map -
与不同视图之间复杂关系的处理
-
在视图中处理多个消息
-
处理多个模型
-
结合我们在一个相对复杂的应用中学到的所有知识
在本书的前五章中,我们采取了一种动手实践的方法,这涉及大量的编码和应用构建。大部分情况下,我们只在绝对必要时简要地提到了理论。在下一章中,我们将通过更详细地探索 Elm 语言来填补我们知识上的空白。
第六章:更深入地探索 Elm
欢迎来到第六章,更深入地探索 Elm。虽然上一章更侧重于实践方面,但这一章将涉及更多理论概念。
我们将涵盖的主题包括:
-
Elm 中的解构值
-
Elm 如何处理随机性?
-
Elm 中的命令
-
Elm 中的订阅
-
使用
Html.program进行操作
完成这一章后,你将能够:
-
通过理解更高级的 Elm 概念来改进你的应用
-
发送命令来处理随机性
-
订阅事件
-
解构值并与之交互
让我们从 Elm 中解构值的一个非常重要的主题开始这一章。
Elm 中的解构值
解构是 Elm 中从数据结构中提取值的一种优雅而有效的方法。解构也被称为模式匹配。语法简洁,对于初学者来说,可能会有些困惑,正是因为它如此简短。然而,一旦理解了,它感觉就像是从 Elm 数据结构中获取值的一种自然且实用的方法。
让我们通过一个 Elm 中解构元组的例子开始这一章。作为提醒,元组是一种类似于记录的数据结构。区别在于它通常更短,我们不需要关心存储在元组中的值的命名。如果你真的需要在你的数据结构中命名值,你需要使用记录。
使用 let-in 表达式在 Elm 中解构元组
让我们想象一场 400 米赛跑发生了。这场比赛涉及五位参赛者。我们将使用元组来存储所有参赛者的所有时间(以秒为单位)。让我们使用 Elm REPL 来存储这些时间:
aTuple = (60,65,71,75,90)
REPL 将返回以下内容:
(60,65,71,75,90) : ( number, number1, number2, number3, number4 )
现在我们有了时间,我们想要进行一些分析。然而,为了执行分析,我们首先需要知道如何提取这些值。
为了提取值,我们将使用一个let-in表达式。我们面临的第一项任务是计算所有赛跑者的总时间。我们将通过将所有单独的时间相加来完成这项任务,或者说,通过将元组中存储的所有值相加。这很简单:
let \
(a,b,c,d,e) = aTuple \
in \
a + b + c + d + e
REPL 返回以下内容:
271 : number
现在我们通过将结果除以5(因为有五个赛跑者)来计算平均时间:
let \
(a,b,c,d,e) = aTuple \
in \
(a + b + c + d + e) / 5
这次,REPL 返回以下内容:
72.2 : Float
我们是如何解构前面的元组的?为了理解前面代码中发生的事情,我们需要讨论这一行代码:
(a,b,c,d,e) = aTuple \
上一行代码遵循了let关键字,正如之前所解释的,这允许我们在let-in表达式的in部分作用域内使用变量。
因此,我们将一个五元组(包含变量 a、b、c、d 和 e)与之前声明的特定元组进行模式匹配,并给它命名为 aTuple。换句话说,如果我们把 Elm REPL 当作一个人,那么前面的代码行就相当于我们说:“嘿 Elm REPL,使用 aTuple 中的值,并将这些值与字母 a、b、c、d 和 e 匹配,因为我想要对这些值进行一些计算”。
然后,在我们的 let-in 表达式的 in 部分中,我们只需按需执行计算。前面的例子展示了解构本质上是什么。
在下一个示例中,让我们看看如何轻松地处理 aTuple 中的一些值。我们将计算前三名运动员的平均时间。这真的很简单。沿着我们之前做的路线,我们只需这样做:
let \
(a,b,c,d,e) = aTuple \
in \
(a+b+c) / 3
这就是 Elm REPL 返回的结果:
65.33333333333333 : Float
最后,让我们计算最后一名到达终点线和第一名冲过终点的运动员之间的时间差。我们将进行另一个简单的计算:
let \
(a,b,c,d,e) = aTuple \
in \
e-a
Elm REPL 将返回以下结果:
30 : number
太好了,一切如预期工作。现在,让我们想象一个类似的元组解构用法。这次,我们将使用一个字符串的五元组。目标是像之前一样,只解构元组中的某些值:
aTupleOfStrings = ("Hello", "Big", "Wild", "Funny", "World")
接下来,让我们在 REPL 中打印出 "Hello World":
let \
(a, b) = aTupleOfStrings
in \
a ++ e
不幸的是,这不会像我们想象的那样工作。错误是自解释的:
-- TYPE MISMATCH --------------------------------------------- repl-temp-000.elm
`aTupleOfStrings` is being used in an unexpected way.
5| (a,e) = aTupleOfStrings
^^^^^^^^^^^^^^^
Based on its definition, `aTupleOfStrings` has this type:
( String, String, String, String, String )
But you are trying to use it as:
( a, b )
这提出了一个问题。虽然我们已经从计算比赛时间的例子中知道,我们可以使用一对一映射和临时变量来匹配元组中的所有值,但这种方法可能并不总是最有效的。为什么?
因为有时我们可能需要在 let 表达式中分配更长、更具有表达性的变量名,以满足编译器的要求。然后,一旦我们不再收到错误,我们就永远不会真正使用这些变量名。这是不切实际的。现实情况下,我们不可能总是能够输入单字母的临时变量,同时还能轻松理解我们的程序。
幸运的是,解决方案很简单——当我们解构元组时,如果我们不关心其中的一些值,我们可以使用下划线字符来表示它。实际上,我们告诉 Elm REPL 的就是——不要注意这些值。让我们重写之前的例子,使其使用下划线工作:
let \
(a,_,_,_,e) = aTupleOfStrings \
in \
a ++ e
Elm REPL 以以下方式响应:
''HelloWorld'' : String
让我们用更有意义的东西替换单字母变量:
let \
(greeting,_,_,_,planetDescription) = aTupleOfStrings \
in \
greeting ++ planetDescription
Elm REPL 仍然会返回相同的结果。
使用 case-of 表达式在 Elm 中解构元组
在本节中,我们将使用 case-of 表达式解构元组。为了看到元组解构的效果,我们必须使用 Ellie 应用程序而不是 Elm REPL。首先,让我们在 Ellie 应用程序中设置以下基本示例:
module Main exposing (main)
import Html exposing (Html, text)
greeting = ("Hello", "World")
main : Html msg
main =
case greeting of
("Hello", "World") ->
text "Tuple contained: Hello, World!"
("Hello", _) ->
text "Tuple contained: Hello "
(_, "World") ->
text "Tuple contained: World!"
(_, _) ->
text "There were neither 'Hello' nor 'World' in the greeting Tuple"
前面的代码所做的事情应该很容易理解——使用问候变量中的值,主函数将使用 case-of 表达式进行模式匹配,并根据从元组中解构的值返回文本。
首先,我们在问候的双元组中的两个 Strings 上检查字面匹配。然后,我们只检查第一个 String 的字面匹配。接下来,我们只检查 "World" 的匹配——我们的问候双元组中的第二个 String。最后,我们通过使用以下模式匹配所有其他可能的案例:
(_, _) ->
现在让我们用各种组合测试我们的应用程序。首先,我们将使用原始代码。在 Ellie 应用程序中按下编译按钮将在屏幕上打印以下文本:
Tuple had: Hello, World!
让我们改变问候元组,使其看起来如下:
greeting = ("Howdy", "World")
现在,编译 Ellie 应用程序,你应该会得到以下内容:
Tuple had: World!
最后,让我们尝试将问候语改为以下内容:
greeting = ("Howdy", "Earth")
编译后,Ellie 应用程序将生成以下输出:
There were neither 'Hello' nor 'World' in the greeting Tuple
在下一节中,我们将使用我们新发现的元组解构知识,并将其应用于 FizzBuzz 应用程序的改进版本。
使用 case-of 表达式内部的元组解构构建 FizzBuzz 应用程序
让我们重新审视我们的 FizzBuzz 问题。这次我们可以使用我们的取模运算作为 case 表达式的条件。代码如下:
module Main exposing (main)
import Html exposing (..)
n = 100
modulusTest = (n % 3, n % 5)
main : Html msg
main =
text <| let fizzBuzz n =
case modulusTest of
(0, 0) -> "fizzBuzz"
(0, _) -> "fizz"
(_, 0) -> "buzz"
(_,_) -> toString n
in fizzBuzz n
为了使前面的代码简洁,我们将 n 的值设为单个固定的数字 100。请随意通过替换该值以不同的数字测试应用程序。
上述代码应该很容易理解——我们将 modulusTest 设置为一个包含 n % 5 和 n % 3 计算的元组。然后在 main 中,我们对 let-in 表达式使用 text 函数。<| 是向后函数应用操作符。它将评估为右侧的值,并将其作为参数传递给其左侧的任何内容——在这种情况下,是一个 text 函数。本质上,它用于替代括号。换句话说,我们本来可以像下面这样编写 main 函数:
main =
text (let fizzBuzz n =
case modulusTest of
(0, 0) -> "fizzBuzz"
(0, _) -> "buzz"
(_, 0) -> "fizz"
(_,_) -> toString n
in fizzBuzz n)
它将做与前面代码片段之前使用的代码完全相同的事情。最后,我们的 case 表达式将 modulusTest 中执行的计算返回的值与适当的值进行模式匹配。这种设置的优点是现在很容易检查不同的数字。例如,我们可以打印出三和七,如下所示:
module Main exposing (main)
import Html exposing (..)
n = 70
modulusTest = (n % 3, n % 7)
main : Html msg
main =
text (let fizzBuzz n =
case modulusTest of
(0, 0) -> "Divisible by 21, seven, and three"
(0, _) -> "Divisible by three"
(_, 0) -> "Divisible by seven"
(_,_) -> toString n
in fizzBuzz n)
现在让我们看看如何在元组内部解构元组。
使用 let-in 表达式解构嵌套元组
让我们在 Elm REPL 中尝试这个:
nestedTuples = (1, 2, 3, (5, 10, 15, (100, 50, 0)))
REPL 将返回以下内容:
(1,2,3,(5,10,15,(100,50,0)))
: ( number
, number1
, number2
, ( number3, number4, number5, ( number6, number7, number8 ) )
)
接下来,让我们从最内层的元组中解构 i 的值:
let \
(a, b, c, (d, e, f, (g, h, i))) = nestedTuples \
in \
i
REPL 返回以下内容:
0 : number
注意,我们甚至不需要声明 nestedTuples 变量。相反,我们可以在 let-in 语句中即时解构值。例如,让我们使用 Ellie 应用程序来编译以下代码:
module Main exposing (main)
import Html exposing (..)
main : Html msg
main =
text (toString (
let
(a,b,c,(d,e,f,(g,h,i))) = (1, 2, 3, (5, 10, 15, (100, 50, 0)))
in
i
))
最后,我们可以在适当的时候使用下划线,如下所示:
module Main exposing (main)
import Html exposing (..)
main : Html msg
main =
text (toString (
let
(_,_,c,(_,_,f,(_,_,i))) = (1, 2, 3, (5, 10, 15, (100, 50, 0)))
in
i
))
通过这种方式,我们结束了关于元组解构的讨论。接下来,我们将探讨记录解构。
使用 let-in 表达式在 Elm 中解构记录
Elm 中的记录解构类似于元组。让我们用记录而不是元组重写我们的比赛时间示例。在 Ellie 应用程序中编译以下应用程序:
module Main exposing (main)
import Html exposing (..)
raceTimes = { first = 60, second = 65, third = 71, fourth = 75, fifth = 90 }
averageRunnerTime record =
let
{first, second, third, fourth, fifth} = record
in
(first + second + third + fourth + fifth) / 5
main : Html msg
main =
text (toString (
averageRunnerTime raceTimes
))
编译完成后,屏幕上应显示以下文本:
72.2
在前面的应用程序中使用的代码基本上与我们用于解构元组的示例中使用的代码相同。区别在于 Elm 中记录的工作方式——由于它们必须有命名值,我们在解构记录中的值时必须使用这些名称。
在 Elm 中动态解构记录
在解构记录时,我们甚至不需要使用下划线字符。由于记录中的所有值都有名称,只需使用该名称即可从记录中提取值。以我们之前的比赛时间示例为例,让我们将存储这些时间的记录放入 Elm REPL 中:
raceTimes = { first = 60, second = 65, third = 71, fourth = 75, fifth = 90 }
REPL 返回以下内容:
{ first = 60, second = 65, third = 71, fourth = 75, fifth = 90 }
: { fifth : number
, first : number1
, fourth : number2
, second : number3
, third : number4
}
接下来,让我们看看如何提取第五名运动员的时间:
{ fifth } = raceTimes
REPL 返回以下内容:
90 : number
让我们使用 Ellie 应用程序来解构第五名运动员的时间:
module Main exposing (main)
import Html exposing (..)
extractorFunction { fifth } =
fifth
raceTimes = { first = 60, second = 65, third = 71, fourth = 75, fifth = 90 }
fifth = raceTimes
main : Html msg
main =
text (toString (
extractorFunction raceTimes
))
但为什么我们需要使用我们的自定义 extactorFunction?我们不能没有它吗?让我们试试这个:
module Main exposing (main)
import Html exposing (..)
raceTimes = { first = 60, second = 65, third = 71, fourth = 75, fifth = 90 }
fifth = raceTimes
main : Html msg
main =
text (toString (
fifth
))
编译后,Ellie 应用程序将在屏幕上打印以下内容:
{ first = 60, second = 65, third = 71, fourth = 75, fifth = 90 }
如果我们真的想不使用提取函数来做这件事,我们可以这样做:
module Main exposing (main)
import Html exposing (..)
raceTimes = { first = 60, second = 65, third = 71, fourth = 75, fifth = 90 }
fifth = raceTimes
main : Html msg
main =
text (toString (
raceTimes.fifth
))
编译后,Ellie 应用程序将在屏幕上打印出预期的 90 值。最后,让我们解构并只使用两个值。我们将从有错误的代码开始,这将抛出错误。代码如下所示:
module Main exposing (main)
import Html exposing (..)
extractorFunction { first, fifth } =
first, fifth
raceTimes = { first = 60, second = 65, third = 71, fourth = 75, fifth = 90 }
{first, fifth} = raceTimes
main : Html msg
main =
text (toString (
extractorFunction raceTimes
))
编译前面的代码时,将会抛出以下错误:
SYNTAX PROBLEM
Line 6, Column 10
I am looking for one of the following things:
"'"
a field access like .name
an expression
an infix operator like (+)
end of input
more letters in this name
whitespace
为什么?给自己一些时间思考。这将是一个有用的发现。对于急于求成的读者,以下是相应的代码:
module Main exposing (main)
import Html exposing (..)
extractorFunction { first, fifth } =
(first, fifth)
raceTimes = { first = 60, second = 65, third = 71, fourth = 75, fifth = 90 }
{first, fifth} = raceTimes
main : Html msg
main =
text (toString (
extractorFunction raceTimes
))
错误是由于我们最初给 Elm 编译器提供了一个令人困惑的表达式,它根本不知道如何处理它。让我们再次看看有问题的代码:
extractorFunction { first, fifth } =
first, fifth
编译器看到:一个值,一个逗号,然后是一个值,它不知道如何从这个代码行返回一个单一值。我们之前已经看到一种解决这个错误的方法;通过将记录中的值提取到元组中。
另一种方法是将这些值作为字符串打印出来,如下所示:
module Main exposing (main)
import Html exposing (..)
raceTimes = { first = 60, second = 65, third = 71, fourth = 75, fifth = 90 }
main : Html msg
main =
text <|
toString (raceTimes.first)
++ ", "
++ toString (raceTimes.fifth)
通过这种方式,我们完成了 Elm 中解构值的讨论。接下来,我们将探讨 Elm 处理随机性的方法。
在 Elm 中处理随机性
如前所述,Elm 中的值是不可变的。函数是纯函数;它们不允许任何副作用。当特定的值传入时,函数将对其操作,并始终返回另一个特定的值。
这提出了一个问题——我们该如何处理随机性?例如,我们如何生成随机数?或者,更确切地说,我们如何执行任何涉及副作用的事情?我们通过让函数返回命令来实现这一点。
Elm 中的命令
使用命令,你是在告诉 Elm 运行时做一些你被禁止做的事情,因为这样做会破坏保证的概念。因此,从函数返回的命令只是一个静态的、不可变的价值。这个值做什么?它只是命名了期望的结果。它不会告诉 Elm 如何实现它。它只是 Elm 运行时需要完成的一个或多个事情的名字。
例如,由于保证的概念指出,对于纯函数的每个输入,我们都应该收到相同类型的输出,因此我们不能让一个函数返回一个随机数,因为这样做会破坏保证的概念。为了使前面的场景成为可能,我们需要向 Elm 运行时发送一个命令,请求它给我们一个随机数。
因此,一旦 Elm 运行时收到命令,例如请求一个随机数,它将返回一条消息。然后我们可以使用这条消息在更新函数中,正如在第二章,“构建你的第一个 Elm 应用”中解释的那样。
本节对 Elm 命令的基本介绍的目标是,关于我们将要引入的新代码没有歧义,或者换句话说,我们至少对每段代码的作用有一些了解。接下来,我们将探讨订阅,以及它们如何融入 Elm 架构。
Elm 中的订阅
命令允许我们告诉 Elm 运行时执行一些随机的事情,而不会破坏 Elm 的保证。
然而,假设我们想让 Elm 运行时告诉我们外部世界发生了一些变化(即我们无法直接控制在我们应用中的任何东西)。我们不能用命令控制外部世界的变化——因为我们不是这些变化的来源。
正因为如此,订阅才存在。它们允许我们监听诸如鼠标移动、键盘按键、时间变化等等的事情。使用命令,我们指示 Elm 运行时执行随机的事情;使用订阅,Elm 运行时会告诉我们正在执行随机的事情。
例如,我们想要跟踪键盘按钮被按下时的情况。我们的应用将订阅这些键盘按键。一旦按下特定的键盘按钮,Elm 运行时会发送一条消息,在我们的更新函数中,我们指定当接收到此类消息时应该如何处理。
现在我们已经熟悉了 Elm 中的命令和订阅,我们可以看看它们如何用来扩展我们当前对 Elm 架构的概念。
通过添加效果改进 Elm 架构
在我们了解 Elm 的命令之前,我们通常使用 beginnerProgram 函数来实现 Elm 架构:
main =
Html.beginnerProgram
{ model = model
, view = view
, update = update
}
然而,仅使用 beginnerProgram,我们无法处理命令或订阅。实际上,我们的 beginnerProgram 函数只是 Html.program 函数的稀释版。
换句话说,使用 Html.program,我们有能力扩展我们的 Elm 架构,以便我们保持 Elm 的保证,同时仍然处理随机性,即副作用,无论这些副作用的来源如何。
因此,而不是使用 Html.beginnerProgram(就像我们之前做的那样),让我们看看我们如何设置主函数,使其使用 Html.program:
main =
Html.program
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
}
代码的变化很小,但我们的 Elm 程序的能力大大增强。这结束了我们对 Elm 中处理随机性的简要介绍。在下一节中,我们将探讨 Elm 中一个非常有用的概念:部分应用。
理解部分应用
假设我们有一个接受两个参数的函数。这个函数非常简单——它只是接受两个 Strings 并将它们连接起来,中间加上一个空格。
函数签名将如下所示:
concatTwoWords wordOne wordTwo = wordOne ++ " " ++ wordTwo
当我们调用函数时,我们可能会传递两个单词,如下所示:
concatTwoWords "User" "Experience"
前面的函数将评估为以下内容:
"User Experience"
现在我们将其实现为一个可以运行在 Ellie 应用程序中的小程序:
module Main exposing (main)
import Html exposing (Html, text)
concatTwoWords : String -> String -> String
concatTwoWords wordOne wordTwo = wordOne ++ " " ++ wordTwo
main : Html msg
main =
text (concatTwoWords "User" "Experience")
编译后,Ellie 应用程序将在右侧预览窗格中显示“用户体验”字样:

注意,我们还在函数定义之前添加了类型注解。类型注解如下:
concatTwoWords : String -> String -> String
观察类型注解,我们可以这样说,我们的 concatTwoWords 函数接受两个字符串并返回一个字符串。毕竟,类型注解看起来就是这样。如果我们只向我们的 concatTWoWords 函数传递一个参数会发生什么?让我们试一试。在 Ellie 应用程序中运行以下代码:
module Main exposing (main)
import Html exposing (Html, text)
concatTwoWords : String -> String -> String
concatTwoWords wordOne wordTwo = wordOne ++ " " ++ wordTwo
main : Html msg
main =
text (concatTwoWords "User")
这次我们得到了一个 Type Mismatch 错误,内容如下:
The argument to function text is causing a mismatch.
Function text is expecting the argument to be:
String
But it is:
String -> String
Hint: It looks like a function needs 1 more argument.
因此,显然,如果我们的函数期望两个参数,我们不能只传递一个参数并称之为结束,因为我们刚刚看到,这将导致编译器抛出错误。
而我们能够做的是一个小巧的技巧。我们可以定义一个新的函数。我们将定义的这个新函数将存储只传递一个 String 给 concatTwoWords 的结果。换句话说,它将存储 concatTwoWords 函数的部分应用结果。
由于没有更好的名字,我们将给这个新函数命名为 partiallyApply,并定义如下:
partiallyApply = concatTwoWords wordOne
我们在这里所做的是,我们通过只传递一个参数来部分应用我们的 concatTwoWords 函数。然后,我们将结果存储在另一个我们称之为 partiallyApply 的函数中。我们仍然可以向我们的 partiallyApply 函数传递一个参数,然后我们将它存储在 partiallyApplyAgain 函数中,如下所示:
partiallyApplyAgain = partiallyApply wordTwo
准备在 Ellie 应用程序中运行的更新后的代码现在如下所示:
module Main exposing (main)
import Html exposing (Html, text)
wordOne = "User"
wordTwo = "Experience"
concatTwoWords : String -> String -> String
concatTwoWords wordOne wordTwo = wordOne ++ " " ++ wordTwo
partiallyApply = concatTwoWords wordOne
partiallyApplyAgain = partiallyApply wordTwo
main : Html msg
main =
text (partiallyApplyAgain)
运行前面的代码将在 Ellie 应用右侧面板上显示单词用户体验。
摘要
在本章中,我们介绍了一些重要主题,包括:
-
Elm 中的值解构
-
Elm 如何处理随机性?
-
理解部分应用
-
通过使用
Html.program向我们的应用引入效果
在下一章中,我们将开始构建一个功能齐全的天气应用,该应用将连接到远程 API 以获取天气信息。
第七章:在 Elm 中制作天气应用
欢迎来到第七章,在 Elm 中制作天气应用。在本章中,我们将制作我们的 Elm 天气应用。这个应用的目的在于学习如何从 JSON 中获取信息并将其用于我们的应用中。
我们将涵盖的主题包括:
-
使用
Result处理错误 -
使用
Maybe处理可选值和空值 -
使用解码器解码 JSON 字符串
-
使用 HTTP 包帮助获取远程数据
-
与第三方 API 合作
完成这一章后,你将能够:
-
以 JSON 格式从互联网获取信息
-
让你的 Elm 应用使用解码器消费 JSON 数据
-
理解所有部件如何组合在一起以构建一个功能性的应用
首先,我们将探讨从第三方 API 获取天气数据。
从第三方 API 获取天气数据
为了使我们的天气应用成为可能,我们需要从某处获取天气数据。幸运的是,网上有大量的天气相关 API,这使得这项任务变得容易得多。
为了有一个工作的应用,我们需要连接到第三方数据提供商。在我们的案例中,我们将使用的数据提供商是 Open Weather Map。
Open Weather Map 的 API 信息可在openweathermap.org/api找到。
要访问 API,你需要获取 APPID。如何操作的说明可在openweathermap.org/appid找到。基本上,获取它的所有你需要做的就是创建一个新账户。
一旦你创建了账户,你将在 API keys 标签下找到生成的 API 密钥。例如,要获取芝加哥的数据,只需访问以下 URL:http://api.openweathermap.org/data/2.5/weather?q=chicago&APPID=abcdef1234567890
显然,前面的 URL 使用了错误的 APPID。将其替换为你的自己的,你应该就可以正常使用了。使用正确的 APPID 在前面 URL 中,你的浏览器将显示一个如下的 JSON 字符串:
{"coord":{"lon":-87.62,"lat":41.88},"weather":[{"id":802,"main":"Clouds","description":"scattered clouds","icon":"03n"}],"base":"stations","main":{"temp":268.9,"pressure":1025,"humidity":86,"temp_min":267.15,"temp_max":270.15},"visibility":16093,"wind":{"speed":2.1,"deg":260},"clouds":{"all":40},"dt":1518941700,"sys":{"type":1,"id":1030,"message":0.0033,"country":"US","sunrise":1518957687,"sunset":1518996470},"id":4887398,"name":"Chicago","cod":200}
我们将如何处理这个字符串将在本章后面讨论。
我们将要构建什么?
我们将构建一个非常简单的天气应用,它将与外部世界通信以获取 JSON 格式的天气数据。
一旦我们收到天气数据,我们将使用解码器将 JSON 字符串解码成我们的应用模型能够理解和处理的值。让我们直接进入正题。
构建我们的天气应用
我们将首先用之前章节中已经使用过的裸骨 Elm 代码替换Main.elm中的代码:
module Main exposing (..)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
-- Model
init =
{}
-- Update
type Msg
= Nothing
update msg model =
model
-- View
view model =
div [] [ text "Everything will go here" ]
-- Main
main =
beginnerProgram
{ model = init
, view = view
, update = update
}
让我们立即通过将beginnerProgram替换为Html.Program来改进我们的应用,因为,正如前一章所解释的,beginnerProgram没有处理副作用的方法:
module Main exposing (..)
import Html exposing (..)
-- Model
type alias Model =
{}
init : ( Model, Cmd Msg )
init =
( Model, Cmd.none )
-- Update
type Msg
= Noop
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Noop ->
( model, Cmd.none )
-- View
view : Model -> Html Msg
view model =
div [] [ text "Everything will go here, this time using Html.program" ]
-- Subscriptions
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.none
-- Main
main : Program Never Model Msg
main =
Html.program
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
}
让我们回顾一下前面的代码。
我们首先将应用程序模型描述为一个名为Model的类型别名,并将其设置为空记录。在类型别名之后,我们引入init函数,它包含我们应用的初始状态和要运行的初始命令。通过Cmd.none,我们基本上是在告诉 Elm 运行时不应该在这个时候运行任何命令。所以,尽管我们此刻不会请求运行任何命令,我们不能简单地省略它们,我们必须明确地声明我们目前不会运行任何命令。
注意init函数的变化:现在的init函数接受以下两个元素的元组:( Model, Cmd.none )。
接下来,在update函数中,我们使用一个联合类型Msg,它只能是NoOp值。基本上,每当我们要断言我们的应用不应该对收到的消息做任何事情时,我们都可以使用NoOp作为值。
更新函数使用case of表达式。我们向update函数传递两个参数:msg和model,这些参数用于更新model并返回一个命令。在这里,由于我们收到的是NoOp消息,唯一发生的事情就是:我们只返回model本身。通过在返回的两个元素元组中使用Cmd.none,我们告诉 Elm 运行时不应该运行任何命令。
在subscriptions函数中,我们所做的一切就是:我们告诉 Elm 运行时我们不想订阅任何事件。因此,我们将其赋值为Sub.none。最后,main函数被赋值为Html.program,所有上述内容都汇集在一起。
在下一节中,我们将安装 HTTP 包。
安装 HTTP 包
要安装 HTTP 包,我们需要将我们的控制台指向weather app文件夹的位置。接下来,在控制台中运行以下命令:
elm package install elm-lang/http 1.0.0
这就是控制台将返回的内容:
To install elm-lang/http I would like to add the following
dependency to elm-package.json:
"elm-lang/http": "1.0.0 <= v < 2.0.0"
May I add that to elm-package.json for you? [Y/n]
在确认y后,控制台将输出以下内容:
Some new packages are needed. Here is the upgrade plan.
Install:
elm-lang/http 1.0.0
Do you approve of this plan? [Y/n]
通过输入y批准计划将在控制台产生以下消息:
Starting downloads...
● elm-lang/http 1.0.0
Packages configured successfully!
接下来,让我们打开elm-package.json并验证它看起来如下:
{
"version": "1.0.0",
"summary": "helpful summary of your project, less than 80 characters",
"repository": "https://github.com/user/project.git",
"license": "BSD3",
"source-directories": [
"src"
],
"exposed-modules": [],
"dependencies": {
"elm-lang/core": "5.1.1 <= v < 6.0.0",
"elm-lang/html": "2.0.0 <= v < 3.0.0",
"elm-lang/http": "1.0.0 <= v < 2.0.0"
},
"elm-version": "0.18.0 <= v < 0.19.0"
}
通过这种方式,我们已经成功地将 HTTP 包添加到我们的项目中。
HTTP 包的官方文档可在package.elm-lang.org/packages/elm-lang/http/1.0.0/Http找到。
现在我们已经添加了所有必要的包,我们可以继续构建我们的应用。
添加所有导入
让我们从在Main.elm的顶部添加所有需要的导入开始:
module Main exposing (..)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Http
import Json.Decode as Decode
在您使用前面的更新保存代码后,代码检查器将抛出四个相同的警告。每个警告中唯一的区别是引用的模块。例如:
Module `Html.Attributes` is unused.
Best to remove it. Don't save code quality for later!
编译器鼓励我们在第一个可能的机会改进我们的代码质量真是太好了,但就目前而言,忽略这些警告是安全的。我们很快就会添加到我们的应用中的代码来解决这些问题。
概念化模型
由于我们将在我们的应用程序中更改很多东西,在这个时候,我们可以通过简单地临时移除类型注解来防止几个令人烦恼的错误。为此,只需从以下函数中移除类型注解:init、update、view、subscriptions和main。
现在我们来看看我们的更新后的模型:
type alias Model =
{ temperatureInfo : TemperatureInfo
, city : String
}
type alias TemperatureInfo =
{ name : String
, windSpeed : Float
, temperature : Float
, pressure : Float
, humidity : Float
}
我们模型的基本构建块应该是自解释的。然后我们将前面的代码添加到Main.elm中,并保存文件。我们的应用程序编译得很好,一切正常。现在让我们更新init函数:
init =
( Model (TemperatureInfo "Did not load" 0 0 0 0) ""
, Cmd.none
)
我们在这里所做的是,简单地将模型中引入的新变化反映到我们的init函数中。在下一节中,我们将设置Msg和update函数。
设置 Msg 联合类型
我们的Msg联合类型相当简单:
type Msg
= GetTemp
| CityInput String
| NewTemp (Result Http.Error String)
update函数可以接收三种可能的消息:GetTemp、CityInput和NewTemp。其中最特别的是第三个:
NewTemp (Result Http.Error String)
那里有一些我们之前没见过的代码。NewTemp值包含一个Result。这个结果可以是一个Http.Error,这意味着某些原因导致我们的 HTTP 请求失败。可能的原因有很多:URL 不存在,或者服务器没有响应,等等。还有可能发生的情况是:请求可以成功。
在那种情况下,我们将得到成功 HTTP 请求的结果String。
Result 和 Maybe
这是个好时机,让我们更详细地讨论一下Result和Maybe。为了处理错误,Elm 也有Tasks,但我们现在不会讨论它们。
使用 Result
Result的官方文档可以在以下网址找到:package.elm-lang.org/packages/elm-lang/core/latest/Result。每当我们的代码执行可能失败的操作时,我们都会使用Result。使用Result可以让我们更好地处理 Elm 中的错误。Result的定义表明它是一个联合类型,有两个类型构造函数:Ok和Err。看看下面的代码片段:
type Result error value
= Ok value
| Err error
如果一个操作成功,Result就是Ok。否则,Result就是Err。
要测试驱动Result,我们只需要运行一个可能失败的操作。REPL 是进行此类测试的完美场所。因此,让我们将浏览器指向elmrepl.cuberoot.in/并运行以下代码:
import Date
Date.fromString "2018-02-18"
在 REPL 中运行前面的代码,将返回以下内容:
Ok <Sun Feb 18 2018 00:00:00 GMT+0000 (UTC)> : Result.Result String Date.Date
太棒了,我们从可能失败的操作中得到了一个Ok结果。为了保险起见,我们再试一次:
Date.fromString "0"
你可能会认为前面的代码会失败,但实际上它是完全有效的:
Ok <Sat Jan 01 2000 00:00:00 GMT+0000 (UTC)> : Result.Result String Date.Date
太好了,我们仍然得到了一个Ok。这次,让我们使用一串字母,以确保 REPL 无法处理它:
Date.fromString "abc"
事实上,REPL 不知道如何处理这种输入。然而,而不是抛出一个丑陋的异常,我们得到了一个优雅的异常:
Err "Unable to parse 'abc' as a date. Dates must be in the ISO 8601 format."
: Result.Result String Date.Date
为了使观点更加明确,在前面例子中,我们运行了一个可能失败的表达式,即我们运行了 Date.fromString 函数。Date.fromString 函数接受一个类型为 String 的参数,并返回一个 Date。在前面代码片段中,我们给它了一个值为 abc 的字符串,它返回了一个 Err 作为 Result。
Result 有它自己的模块,这就是为什么类型定义读作 Result.Result。
接下来,我们将探讨如何使用 Maybe。
与 Maybe 一起工作
也是一个联合类型,Maybe 是 Elm 中处理空值的一种方式,或者说,处理可选值,即可能存在,也可能不存在。官方文档可在 package.elm-lang.org/packages/elm-lang/core/latest/Maybe 找到,它给出了 Maybe 联合类型的定义如下:
type Maybe a
= Just a
| Nothing
如我们在书中前面讨论的,在 Elm 中使用字母 a 是一种约定,它表示在该位置使用的值可以是任何东西。因此,前面定义中的 a 可以是 String,Int 或任何其他值。
因此,在 Elm 中,Maybe 只能是两种情况之一——Just 任何东西,或者 Nothing。更具体地说,Maybe 可以是以下之一:
-
Just一个String,或者Just一个Int,或者Just一个Float..., -
Nothing
现在,让我们转向 Elm REPL elmrepl.cuberoot.in/ 并看看 Maybe 类型在实际中的应用:
testingMaybe = Just 1
REPL 将返回以下内容:
Just 1 : Maybe.Maybe number
如我们所见,Just 1 是一个 Maybe 数字。让我们再做一个例子:
testingMaybe = Just 1.1
执行上一行将得到以下结果:
Just 1.1 : Maybe.Maybe Float
最后,让我们看看 Nothing 的实际应用:
testingMaybe = Nothing
REPL 回复如下:
Nothing : Maybe.Maybe a
由于 Nothing 没有值,我们回到了 Maybe a。Elm 保证要求我们在这里有一个 a,即使 Nothing 是 Nothing,因为它代表值的缺失,它不需要为其值指定类型。
要解构 Maybe,我们可以使用 case-of 表达式。让我们用一个例子来看看 Ellie:
module Main exposing (main)
import Html exposing (Html, text)
-- A person, but maybe we do not know their age.
type alias Person =
{ name : String
, age : Maybe String
}
tom = { name = "Tom", age = Just "42" }
sue = { name = "Sue", age = Nothing }
main =
text <|
case tom.age of
Nothing ->
"No age for this person"
Just val ->
val
在前面的例子中,我们使用的是 Maybe 官方文档中提供的稍作修改的人的例子。前面代码中的区别在于,我们不是使用 Int,而是使用 String 作为记录中的 age 条目。这样,我们可以避免使我们的代码比必需的更复杂,并且我们仍然从 case-of 表达式的所有分支中返回 String。
应用程序在编译时将打印出 42。现在,让我们修改应用程序,以便尝试打印 Sue 的年龄。需要做的唯一区别如下:
case sue.age of
这次,在编译时,应用程序将匹配 main 中的 case-of 表达式的 Nothing 分支,这将导致屏幕上打印出 "No age for this person" 消息。
结果和带有默认值的 Maybe
Result和Maybe非常相似。如果我们的操作成功,我们通过Result得到Ok a。当处理Maybe时,如果存在值,我们得到Just a。
如果发生错误,对于Result我们得到Err error。在Maybe中没有值的情况下,我们得到Nothing。我们已经在本书前面的章节中(在第四章[5c3a6b83-d672-49a1-9da9-355bb415b8c4.xhtml],在 Elm 中准备一个单位转换网站)查看过使用Result.withDefault。让我们通过在 Elm REPL 中运行以下代码来快速回顾:
Result.withDefault 0 (Ok 1)
REPL 响应如下:
1 : number
现在我们尝试使用Strings:
Result.withDefault "This is default." (Ok "This is OK")
REPL 返回以下内容:
"This is OK" : String
与Result类似,我们也可以在Maybe上使用withDefault。在 REPL 中运行以下代码:
Maybe.withDefault 0 Nothing
REPL 返回以下内容:
0 : number
让我们再来几个。首先,给它一个默认的String:
Maybe.withDefault "abc" Nothing
REPL 返回以下内容:
"abc" : String
那么一个默认的Record呢?
Maybe.withDefault {} Nothing
在 REPL 中运行前面的代码将产生以下内容:
{} : {}
在下一节中,我们将处理我们的update函数。
更新update函数
我们的update函数将更加复杂。首先,我们需要在我们的update函数中涵盖所有前面的消息。我们将通过添加一个case-of表达式来完成:
update msg model =
case msg of
GetTemp ->
( model, getTemperature model.city )
NewTemp (Ok json) ->
let
newTemperatureInfo =
decodeTemperatureInfo json
in
( { model | temperatureInfo = newTemperatureInfo }, Cmd.none )
NewTemp (Err _) ->
( model, Cmd.none )
CityInput city ->
( { model | city = city }, Cmd.none )
在前面的代码片段中,我们可以看到update函数的四种可能情况。如果我们的update函数收到GetTemp消息,它将返回一个包含model和getTemperature值的二元组。
如果update函数收到NewTemp (Ok json)消息,即如果我们从远程服务器成功接收 JSON 字符串,那么我们使用let-in表达式返回相同的model,并更新newTemperatureInfo。
第三个要匹配的模式是NewTemp (Err _)。这个模式将在我们收到远程服务器的错误时匹配,在这种情况下,我们将只返回现有的模型,即(model, Cmd.none)。
在update函数中我们可以进行模式匹配的最后一个可能的消息是CityInput消息。如果我们收到一个CityInput消息,我们将取传递给它的city字符串,并返回现有的模型,加上新的city字符串。
然而,如果我们现在运行我们的应用程序,编译器将抛出两个错误:
-
找不到变量
getTemperature -
找不到变量
decodeTemperatureInfo
显然,将复杂性抽象到单独的变量中是很好的,可以使我们更容易地推理我们的应用程序,但现在我们需要实际实现getTemperature和decodeTemperatureInfo,以便使我们的update函数工作。但在我们能够这样做之前,我们需要更详细地查看解码器和编码器。
解码器和编码器
为了从 JSON 解析数据,Elm 使用解码器。为了做相反的操作,Elm 使用编码器。使用解码器和编码器,我们可以将动态类型 JSON 数据转换为静态类型 Elm 数据结构,反之亦然。
首先,让我们看看 Elm 语言核心部分可用的 Json.Decode 包。Json.Decode 的官方文档可在:package.elm-lang.org/packages/elm-lang/core/5.1.1/Json-Decode 找到。官方文档定义解码器如下:
一个知道如何解码 JSON 值的值。
解码原始值
让我们通过 Elm REPL 的帮助来查看解码原始值的一些示例。首先,让我们解码一个简单的 Int:
import Json.Decode exposing (..)
decodeString int "100"
REPL 返回以下内容:
Ok 100 : Result.Result String Int
因此,我们得到了一个 Result,这是表示一个可能失败的操作的标志。让我们通过向前面的表达式提供一个 Bool 而不是 Int 来看看它如何失败:
decodeString int "true"
运行前面的代码会导致 REPL 返回以下内容:
Err "Expecting an Int but instead got: true" : Result.Result String Int
接下来,让我们解码一个 JSON float 到 Elm 的 Float:
decodeString float "1.234"
REPL 返回以下内容:
Ok 1.234 : Result.Result String Float
那么将 JSON string 解码为 Elm 的 String 呢?
decodeString string "abcd"
这次,REPL 响应如下:
Err "Given an invalid JSON: Unexpected token a" : Result.Result String String
为什么会出现这个错误?因为我们还需要提供转义引号,以便使其工作:
decodeString string "\"abcd\""
现在 REPL 不再抱怨了:
Ok "abcd" : Result.Result String String
最后,让我们看看如何将 JSON 布尔值解码为 Elm 的 Bool:
decodeString bool "false"
REPL 返回以下内容:
Ok False : Result.Result String Bool
让我们检查每个解码器的签名:
import Json.Decode exposing (..)
int
<decoder> : Json.Decode.Decoder Int
float
<decoder> : Json.Decode.Decoder Float
string
<decoder> : Json.Decode.Decoder String
bool
<decoder> : Json.Decode.Decoder Bool
如前代码片段所示,int 是 Int 的解码器,float 是 Float 的解码器,string 是 String 的解码器,而 bool 是 Bool 的解码器。现在我们了解了如何从 JSON 解码原始值到 Elm,让我们看看如何解码一个更复杂的 JSON 字符串,即本章开头从 API 得到的那个。
解码 API 返回的 JSON 字符串
首先,让我们看看我们从 API 得到的 JSON 字符串:
{
"coord":{
"lon":-87.62,
"lat":41.88
},
"weather":[
{
"id":802,
"main":"Clouds",
"description":"scattered clouds",
"icon":"03n"
}
],
"base":"stations",
"main":{
"temp":268.9,
"pressure":1025,
"humidity":86,
"temp_min":267.15,
"temp_max":270.15
},
"visibility":16093,
"wind":{
"speed":2.1,
"deg":260
},
"clouds":{
"all":40
},
"dt":1518941700,
"sys":{
"type":1,
"id":1030,
"message":0.0033,
"country":"US",
"sunrise":1518957687,
"sunset":1518996470
},
"id":4887398,
"name":"Chicago",
"cod":200
}
显然,我们已经格式化 JSON 字符串,使其更容易阅读。首先要注意的是;我们不必解码所有前面的代码。我们可以选择只解码我们需要的部分数据。所以,让我们假设我们唯一感兴趣的数据是倒数第二个键值对,即:''name'':''Chicago''。
我们如何从前面那块 JSON 中解码出来?我们将创建一个小 Ellie 应用来查看如何实现这一点:
import Html exposing (..)
import Json.Decode exposing (..)
main = text (toString (decodeString weatherDataDecoder (json)))
type alias WeatherData =
{ name : String }
json = """
{
"coord":{
"lon":-87.62,
"lat":41.88
},
"weather":[
{
"id":802,
"main":"Clouds",
"description":"scattered clouds",
"icon":"03n"
}
],
"base":"stations",
"main":{
"temp":268.9,
"pressure":1025,
"humidity":86,
"temp_min":267.15,
"temp_max":270.15
},
"visibility":16093,
"wind":{
"speed":2.1,
"deg":260
},
"clouds":{
"all":40
},
"dt":1518941700,
"sys":{
"type":1,
"id":1030,
"message":0.0033,
"country":"US",
"sunrise":1518957687,
"sunset":1518996470
},
"id":4887398,
"name":"Chicago",
"cod":200
}
"""
weatherDataDecoder =
Json.Decode.map
WeatherData
(field "name" string)
前面的这个小应用成功解码了提供的 JSON 字符串。正如我们所见,我们只解码了 JSON 对象中的 name 键,其值根据前面提供的 JSON 对象是 Chicago。在 Ellie 编辑器中编译应用的结果显示在屏幕上:
Ok { name = "Chicago" }
让我们看看我们如何改进前面的应用。这次,我们不仅想从我们的 JSON 对象中返回 name 键,还想返回 id 键:
import Html exposing (..)
import Json.Decode exposing (..)
main = text (toString (decodeString weatherDataDecoder (json)))
type alias WeatherData =
{ id : Int
, name : String
}
json = """
{
"coord":{
"lon":-87.62,
"lat":41.88
},
"weather":[
{
"id":802,
"main":"Clouds",
"description":"scattered clouds",
"icon":"03n"
}
],
"base":"stations",
"main":{
"temp":268.9,
"pressure":1025,
"humidity":86,
"temp_min":267.15,
"temp_max":270.15
},
"visibility":16093,
"wind":{
"speed":2.1,
"deg":260
},
"clouds":{
"all":40
},
"dt":1518941700,
"sys":{
"type":1,
"id":1030,
"message":0.0033,
"country":"US",
"sunrise":1518957687,
"sunset":1518996470
},
"id":4887398,
"name":"Chicago",
"cod":200
}
"""
weatherDataDecoder =
Json.Decode.map2
WeatherData
(field "id" int)
(field "name" string)
如果我们现在编译应用,我们会得到一个与上次略有不同的结果:
Ok { id = 4887398, name = "Chicago" }
在先前的应用程序中,有一些细微的变化,这使得我们可以提取两个值,而不是像我们之前的小型 JSON 解码应用程序版本中那样只提取一个值。让我们看看这些变化:
type alias WeatherData =
{ id : Int
, name : String
}
在WeatherData类型别名中,我们在Record中添加了一个id类型为Int:
weatherDataDecoder =
Json.Decode.map2
WeatherData
(field "id" int)
(field "name" string)
在解码器中,我们使用map2函数而不是map函数,并添加另一个带有id键的 JSON 字段,以及预期的 JSON 整数值。类似于我们所看到的,如果我们想要从我们的 JSON 对象中映射另一个值,我们需要使用map3函数。
解码嵌套对象
假设我们想要从返回的 JSON 中获取国家信息。我们需要对我们的解码器进行简单的更改:而不是使用field,我们将使用at。虽然field使用一个字符串,但at使用一个字符串列表,这允许我们进入嵌套对象的内部结构。让我们更新我们的 Ellie 应用程序的weatherDataDecoder,使其看起来如下:
weatherDataDecoder =
Json.Decode.map3
WeatherData
(field "id" int)
(field "name" string)
(at ["sys", "country"] string)
让我们也更新WeatherData类型别名:
type alias WeatherData =
{ id : Int
, name : String
, country: String
}
注意,在先前的代码片段中,我们保持了我们扁平的Record结构,并且它并不反映我们从其中获取数据的 JSON 对象。这是完全可以接受的。在编译后,应用程序将以下结果打印到屏幕上:
Ok { id = 4887398, name = "Chicago", country = "US" }
现在我们已经了解了解码器的工作原理,并在解码器上练习了一段时间后,理解下一节中我们将看到的代码应该会容易得多。
添加 getTemperature 和 decodeTemperatureInfo
我们的getTemperature是一个简单的let-in表达式,它将使用Http.send和Http.getString发送一个新的请求,以便从自定义 URL 获取天气数据。从自定义 URL 获取数据的字符串,取决于city变量,即我们从应用程序文本输入字段获取的用户输入值:
getTemperature city =
let
url =
"http://api.openweathermap.org/data/2.5/weather?q=" ++ city ++ "&APPID=b30c7031a64e301cb64ceaa346e24a83"
in
Http.send NewTemp (Http.getString url)
decodeTemperatureInfo也是一个let-in表达式,虽然乍一看可能有点令人畏惧,但实际上它只是尝试解码 JSON 数据的一系列重复操作:
decodeTemperatureInfo : String -> TemperatureInfo
decodeTemperatureInfo json =
let
name =
Result.withDefault "Error decoding data!" (Decode.decodeString (Decode.field "name" Decode.string) json)
windSpeed =
Result.withDefault 0 (Decode.decodeString (Decode.at [ "wind", "speed" ] Decode.float) json)
temperature =
Result.withDefault 0 (Decode.decodeString (Decode.at [ "main", "temp" ] Decode.float) json)
pressure =
Result.withDefault 0 (Decode.decodeString (Decode.at [ "main", "pressure" ] Decode.float) json)
humidity =
Result.withDefault 0 (Decode.decodeString (Decode.at [ "main", "humidity" ] Decode.float) json)
in
TemperatureInfo name windSpeed temperature pressure humidity
基本上,在先前的代码中发生的事情是——我们正在逐步解码从远程服务器接收到的 JSON 字符串。在这个阶段,保存并运行我们的应用程序,我们会看到与前完全相同的窗口:所有内容都将在这里,这次使用 Html.program。
这是一个好兆头。这意味着我们的应用程序编译成功。这也意味着在view和main函数中还有更多的工作要做。
更新视图
我们的view函数是用来给我们模型的可视表示的。为了使我们的应用程序工作,view函数需要一个输入字段,提交按钮,以及一些在联系远程服务器并解码 JSON 字符串时需要填充的值:
view model =
div []
[ input [ placeholder "City", onInput CityInput ] []
, br [] []
, button [ onClick GetTemp ] [ text "Get temperature" ]
, br [] []
, div [] [ text "Name: ", text model.temperatureInfo.name ]
, div [] [ text "Temp: ", text (toString model.temperatureInfo.temperature) ]
, div [] [ text "Wind: ", text (toString model.temperatureInfo.windSpeed) ]
, div [] [ text "Pressure: ", text (toString model.temperatureInfo.pressure) ]
, div [] [ text "Humidity: ", text (toString model.temperatureInfo.humidity) ]
]
让我们看看前面的代码是如何工作的。我们首先使用包裹div。接下来,我们有input函数,它将在文本输入字段中显示占位符单词City。我们还有一个消息,即这个input函数将发出一个CityInput类型的消息。
接下来,我们有br函数,然后是button函数,它将在onClick时发出GetTemp消息。下一个重要的函数是div函数,它将包含字符串''Name: ''和model.temperatureInfo.name的值。同样,我们接着使用其他div函数,这些函数根据当前model中包含的值拼接字符串。
保存所有内容并运行你的应用。结果将是一个完全工作的天气应用,它从远程服务器获取 JSON 字符串并正确显示结果。要使应用获取数据,只需在输入字段中输入一个主要城市名称,然后点击按钮。
在结束本节之前,让我们看看完整的天气应用代码:
module Main exposing (..)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Http
import Json.Decode as Decode
main =
Html.program
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
}
type alias Model =
{ temperatureInfo : TemperatureInfo
, city : String
}
type alias TemperatureInfo =
{ name : String
, windSpeed : Float
, temperature : Float
, pressure : Float
, humidity : Float
}
init : ( Model, Cmd Msg )
init =
( Model (TemperatureInfo "Did not load" 0 0 0 0) ""
, Cmd.none
)
type Msg
= GetTemp
| CityInput String
| NewTemp (Result Http.Error String)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
GetTemp ->
( model, getTemperature model.city )
NewTemp (Ok json) ->
let
newTemperatureInfo =
decodeTemperatureInfo json
in
( { model | temperatureInfo = newTemperatureInfo }, Cmd.none )
NewTemp (Err _) ->
( model, Cmd.none )
CityInput city ->
( { model | city = city }, Cmd.none )
view : Model -> Html Msg
view model =
div []
[ input [ placeholder "City", onInput CityInput ] []
, br [] []
, button [ onClick GetTemp ] [ text "Get temperature" ]
, br [] []
, div [] [ text "Name: ", text (model.temperatureInfo.name) ]
, div [] [ text "Temp: ", text (toString model.temperatureInfo.temperature) ]
, div [] [ text "Wind: ", text (toString model.temperatureInfo.windSpeed) ]
, div [] [ text "Pressure: ", text (toString model.temperatureInfo.pressure) ]
, div [] [ text "Humidity: ", text (toString model.temperatureInfo.humidity) ]
]
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.none
getTemperature : String -> Cmd Msg
getTemperature city =
let
url =
"http://api.openweathermap.org/data/2.5/weather?q=" ++ city ++ "&APPID=b30c7031a64e301cb64ceaa346e24a83"
in
Http.send NewTemp (Http.getString url)
decodeTemperatureInfo : String -> TemperatureInfo
decodeTemperatureInfo json =
let
name =
Result.withDefault "Error decoding data!" (Decode.decodeString (Decode.field "name" Decode.string) json)
windSpeed =
Result.withDefault 0 (Decode.decodeString (Decode.at [ "wind", "speed" ] Decode.float) json)
temperature =
Result.withDefault 0 (Decode.decodeString (Decode.at [ "main", "temp" ] Decode.float) json)
pressure =
Result.withDefault 0 (Decode.decodeString (Decode.at [ "main", "pressure" ] Decode.float) json)
humidity =
Result.withDefault 0 (Decode.decodeString (Decode.at [ "main", "humidity" ] Decode.float) json)
in
TemperatureInfo name windSpeed temperature pressure humidity
摘要
在本章中,我们介绍了很多新概念,并加强了一些我们在前几章中学到的概念。我们探讨了使用Result和Maybe来处理可能失败的操作以及处理可能缺失的数据。我们探讨了使用解码器和映射。
我们还探讨了使用Http包获取远程 JSON 数据。现在我们已经设置了天气应用的基础,我们将在下一章讨论改进它的方法。
第八章:为天气应用添加更多功能
欢迎来到 第八章,为天气应用添加更多功能。本章的目标是通过添加更多功能来改进我们的简单天气应用。
我们将涵盖的主题包括:
-
将
elm-mdl添加到我们的应用中 -
将温度显示从开尔文转换为摄氏度
-
使用 Round 模块
完成本章内容后,你将:
-
能够使用 Elm 的 Material Design 库,
elm-mdl -
理解 Round 模块的工作原理
导入 Material 和 Round 模块
首先,让我们创建一个新的文件夹,命名为 chapter8,将控制台指向这个新创建的文件夹,并运行以下命令:
create-elm-app improved-weather-app-ch8
一旦应用准备就绪,只需将我们在 第七章 的末尾所拥有的所有代码,即 在 Elm 中制作天气应用,复制到这个新的 Main.elm 文件中。这将是我们起点(而不是像在上一章中那样从样板应用开始)。
然而,我们需要解决一个轻微的问题。目前,我们的新应用将无法编译。相反,我们将收到以下警告:
Failed to compile
./src/Main.elm
I cannot find module 'Http'.
Module 'Main' is trying to import it.
Potential problems could be:
* Misspelled the module name
* Need to add a source directory or new dependency to elm-package.json
问题源于我们没有更新 Elm 包。让我们快速解决这个问题,运行以下命令:
elm package install elm-lang/http 1.0.0
在我们批准升级后,控制台将显示以下消息:
Packages configured successfully!
现在,我们可以开始为我们 Elm 应用提供服务,并在更新应用时监视变化。要开始提供服务,让我们将控制台指向 improved-weather-app-ch8 文件夹,并运行以下命令:
elm-app start
我们将通过使用 elm-mdl 模块开始对我们应用进行改进。该模块的预览效果可在 debois.github.io/elm-mdl/ 查看,官方文档可在 github.com/debois/elm-mdl 找到。
首先,让我们导入我们将要使用的所有依赖项。打开 Main.elm,找到代码的开始部分,其中包含导入:
module Main exposing (..)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Http
import Json.Decode as Decode
在导入的底部,我们将导入 elm-mdl 模块依赖项,然后,在最底部,导入 Round 包:
import Material
import Material.Button as Button
import Material.Card as Card
import Material.Color as Color
import Material.Elevation as Elevation
import Material.Options as Options
import Material.Scheme
import Material.Textfield as Textfield
import Material.Typography as Typography
import Round exposing (..)
官方的 elm-mdl 包也列在 Elm 包网站上,网址为 package.elm-lang.org/packages/debois/elm-mdl/latest,而 Round 包位于 package.elm-lang.org/packages/myrho/elm-round/latest。
不幸的是,在这个阶段我们的应用再次无法编译。原因是类似的:我们需要安装缺失的包。我们可以通过查看编译器的错误消息来验证这一点:
Failed to compile
./src/Main.elm
I cannot find module 'Material'.
Module 'Main' is trying to import it.
Potential problems could be:
* Misspelled the module name
* Need to add a source directory or new dependency to elm-package.json
为了解决这个问题,让我们在控制台运行以下命令:
elm package install debois/elm-mdl
接下来,让我们再次尝试运行我们的应用,使用 elm-app start。
再次,我们的应用无法编译;这次,我们缺少 Round 模块。让我们在控制台中使用以下命令添加它:
elm package install myrho/elm-round
在成功安装运行我们的应用所需的依赖项后,我们现在可以通过再次运行 elm-app start 来看到它成功编译并被浏览器提供服务。目前,应用看起来与 第七章 的末尾,在 Elm 中制作天气应用完全相同。
将 elm-mdl 添加到我们的 Model 中
虽然我们的主要功能将保持不变——这意味着它仍然会像前一章一样使用 Html.program,但我们不得不对我们的 Model 类型别名进行修改。
在 第七章 中,在 Elm 中制作天气应用,我们的 Model 看起来如下:
type alias Model =
{ temperatureInfo : TemperatureInfo
, city : String
}
我们需要做的只是向 Model 的记录中添加另一个条目,如下所示:
type alias Model =
{ temperatureInfo : TemperatureInfo
, city : String
, mdl : Material.Model
}
这个新条目 mdl : Material.Model 是包含显示 mdl 元素所需所有数据的类型。我们的 TemperatureInfo 类型别名将保持不变。
更新 init 函数
我们需要更新 init 函数,以便考虑到 Material.model 的初始值。我们在 第七章 的 在 Elm 中制作天气应用 中得到的 init 函数如下所示:
init : ( Model, Cmd Msg )
init =
( Model (TemperatureInfo "Did not load" 0 0 0 0) ""
, Cmd.none
)
更新很简单,它将反映我们对 Model 类型别名所做的更改:
init : ( Model, Cmd Msg )
init =
( Model (TemperatureInfo "Did not load" 0 0 0 0) "" Material.model
, Cmd.none
)
通过这次更新,我们已经整理出了我们的天气应用所需的初始值。
更新 Msg 联合类型和 update 函数
为了反映我们通过引入 elm-mdl 包所做出的改进,我们需要向我们的 Msg 联合类型添加另一个类型构造函数。Msg 联合类型将看起来与 第七章 中完全相同,在 Elm 中制作天气应用,除了新的 Mdl 类型构造函数,我们将简单地将其附加到联合类型的末尾,如下所示:
type Msg
= GetTemp
| CityInput String
| NewTemp (Result Http.Error String)
| Mdl (Material.Msg Msg)
Mdl 类型构造函数是 mdl 将生成的消息。
让我们现在改进我们的 update 函数,它在 第七章 的末尾,在 Elm 中制作天气应用看起来是这样的:
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
GetTemp ->
( model, getTemperature model.city )
NewTemp (Ok json) ->
let
newTemperatureInfo =
decodeTemperatureInfo json
in
( { model | temperatureInfo = newTemperatureInfo }, Cmd.none )
NewTemp (Err _) ->
( model, Cmd.none )
CityInput city ->
( { model | city = city }, Cmd.none )
如前述代码片段所示,我们为 GetTemp、NewTemp 和 CityInput 进行了模式匹配,这在 第七章 中有解释,在 Elm 中制作天气应用。我们现在需要修改 update 函数以进行对新的 Msg 类型构造函数 Mdl 的模式匹配。
实际上,这意味着 update 函数将保持完全不变,只是增加了一个简单的修改:为我们的 case-of 表达式添加 Mdl 分支:
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Mdl msg_ ->
Material.update Mdl msg_ model
GetTemp ->
( model, getTemperature model.city )
NewTemp (Ok json) ->
let
newTemperatureInfo =
decodeTemperatureInfo json
in
( { model | temperatureInfo = newTemperatureInfo }, Cmd.none )
NewTemp (Err _) ->
( model, Cmd.none )
CityInput city ->
( { model | city = city }, Cmd.none )
在下一节中,我们将添加对 view 函数的更新。
更新 view 函数
第七章《在 Elm 中制作天气应用》结尾的view函数看起来如下:
view : Model -> Html Msg
view model =
div []
[ input [ placeholder "City", onInput CityInput ] []
, br [] []
, button [ onClick GetTemp ] [ text "Get temperature" ]
, br [] []
, div [] [ text "Name: ", text (model.temperatureInfo.name) ]
, div [] [ text "Temp: ", text (toString model.temperatureInfo.temperature) ]
, div [] [ text "Wind: ", text (toString model.temperatureInfo.windSpeed) ]
, div [] [ text "Pressure: ", text (toString model.temperatureInfo.pressure) ]
, div [] [ text "Humidity: ", text (toString model.temperatureInfo.humidity) ]
]
由于在前面的代码中我们基本上没有使用样式,因此更新后的view函数在第一眼看起来可能有些令人畏惧:
view : Model -> Html Msg
view model =
div []
[ Textfield.render Mdl
[ 0 ]
model.mdl
[ Textfield.label "City"
, Textfield.floatingLabel
, Textfield.text_
, Textfield.value model.city
, Options.onInput CityInput
]
[]
, Button.render Mdl
[ 1 ]
model.mdl
[ Button.raised
, Button.colored
, Button.ripple
, Options.onClick GetTemp
]
[ text "Get temperature" ]
, br [] []
, Card.view
[ Options.css "width" "256px"
, Options.css "margin" "20px"
, Elevation.e8
, Elevation.transition 250
, Color.background (Color.color Color.LightBlue Color.S50)
]
[ Card.title
[ Options.css "flex-direction" "column" ]
[ Card.head [] [ text model.city ]
, Options.div
[ Options.css "padding" "2rem 2rem 0 2rem" ]
[ Options.span
[ Typography.display4
, Typography.contrast 0.87
, Color.text Color.primary
]
[ text (Round.round 0 model.temperatureInfo.temperature ++ "°") ]
]
]
, Card.actions []
[ div [] [ text "Wind: ", text (toString model.temperatureInfo.windSpeed) ]
, div [] [ text "Pressure: ", text (toString model.temperatureInfo.pressure) ]
, div [] [ text "Humidity: ", text (toString model.temperatureInfo.humidity) ]
]
]
]
|> Material.Scheme.top
在浏览器中检查您应用程序的外观,因为到目前为止,我们已经成功地将elm-mdl应用到它上面。
幸运的是,一旦你理解了它是如何工作的,前面的代码就相当直接了。让我们一步一步地来看。由于第七章,《在 Elm 中制作天气应用》,没有使用任何样式,我们使用的函数从一开始就很容易理解。我们只有一个input,一个button,以及四个包含文本节点的div。由于我们没有使用任何样式,我们使用了br函数来在视觉上分隔input、button和四个div。
第八章《向天气应用添加更多功能》中的改进包括添加mdl特定的元素,包括mdl特定的Textfield、Button和Card。让我们更仔细地看看Textfield。
理解Textfield函数
我们Textfield的代码如下:
Textfield.render Mdl
[ 0 ]
model.mdl
[ Textfield.label "City"
, Textfield.floatingLabel
, Textfield.text_
, Textfield.value model.city
, Options.onInput CityInput
]
[]
我们从Textfield.render Mdl开始,这意味着它的消息类型是Mdl,它只是简单地渲染Textfield组件。我们使用的每个mdl组件都需要有一个唯一的 ID,只需用一个数字标记。在先前的例子中,我们使用零为Textfield分配了一个唯一的 ID:[ 0 ]。
接下来,我们指定这个更新将应用到模型的哪个部分,即model.mdl。在第二个列表中,我们提供了我们希望Textfield具有的具体外观。
我们Textfield组件有关外观的所有选项都可以在以下 URL 找到,package.elm-lang.org/packages/debois/elm-mdl/8.1.0/Material-Textfield。
要了解提供给我们的Textfield的第二列表中外观如何变化,我们可以简单地关闭列表中的某些成员,如下所示:
Textfield.render Mdl
[ 0 ]
model.mdl
[ --Textfield.label "City"
--, Textfield.floatingLabel
--, Textfield.text_
Textfield.value model.city
, Options.onInput CityInput
]
[]
通过注释掉Textfield.label、Textfield.floatingLabel和Textfield.text_,我们实际上关闭了它们,只留下了我们应用程序仍然可以工作的功能所需的最小部分。如果我们注释掉Textfield.value model.city或Options.onInput CityInput中的任何一个,我们的应用程序将停止工作,所以在先前的案例中,我们必须保留这两个,而我们可以注释掉其余的。
我们的应用程序会停止工作的原因是,我们会破坏更新函数期望从视图接收的模型。
如本节前面所述,我们可以参考官方文档中 Textfield 的 选项、外观 和 HTML 属性 部分,以获取不同的结果和样式。例如,我们可以将 Textfield 的 maxlength 设置为五个字符。在这种情况下,我们的 Textfield 需要看起来如下所示:
Textfield.render Mdl
[ 0 ]
model.mdl
[ Textfield.maxlength 5
, Textfield.text_
, Textfield.value model.city
, Options.onInput CityInput
]
[]
现在,我们将能够输入一个最多五个字符的城市名称。例如,我们仍然可以查找巴黎的天气信息,但我们将无法查找伦敦,因为我们无法输入该城市名称的所有六个字符。
接下来,我们将通过 Button 函数。
理解 Button 函数
查看位于 Textfield 后面的 Button,我们可以看到以下代码:
Button.render Mdl
[ 1 ]
model.mdl
[ Button.raised
, Button.colored
, Button.ripple
, Options.onClick GetTemp
]
[ text "Get temperature" ]
在理解了 Textfield 的工作原理之后,我们可以几乎一眼就看出 view 函数前面的部分:我们的 Button 使用 Mdl 消息进行渲染。我们给 Button 分配一个 id 为 1,并使用 Button.raised、Button.colored 和 Button.ripple 的外观选项。我们通过使用 elm-mdl 的 Options.onClick 发送 GetTemp 消息来完成它。还有一个第三个列表,内容如下:
[ text "Get temperature" ]
如果我们本来就不打算使用它,为什么在 Textfield 中使用一个空列表?原因当然是满足 Elm 的静态类型系统。例如,如果我们从 Textfield 函数中删除最后一个空列表,编译器将抛出一个错误。接下来,我们将查看 Card 函数。
理解 Card 函数
作为组件,Card 相对复杂,因为它由几个子部分组成,因为它的作用是以连贯的方式显示相关信息。Card 函数的官方文档可在以下网址找到,package.elm-lang.org/packages/debois/elm-mdl/8.1.0/Material-Card。
理解 Card.view 的用法
让我们看看使用 Card 函数的前几行:
, br [] []
, Card.view
[ Options.css "width" "256px"
, Options.css "margin" "20px"
, Elevation.e8
, Elevation.transition 250
, Color.background (Color.color Color.LightBlue Color.S50)
]
首先要注意的是,这里的 br 函数是完全多余的,因此我们可以简单地将其删除,而不会对天气应用的函数性或布局产生任何影响。我们接下来的一行以 Card.view 开始。
正如官方文档所述,Card.view 函数用于构建卡片。虽然 Textfield 和 Button 是使用 render 函数构建的,但在这里我们使用的是 view。我们使用 Options.css 添加额外的样式,后面跟着两个字符串,前者设置 CSS 属性,后者设置属性的值。
Material.Elevation用于给我们的mdl组件添加阴影效果。有关可用的Elevations的完整描述,请参阅官方文档,该文档位于package.elm-lang.org/packages/debois/elm-mdl/8.1.0/Material-Elevation。例如,要使用Elevation获取最大可能的阴影,我们可以将读取Elevation.e8的行更改为新值Elevation.e24。与阴影类似,我们可以设置我们的过渡所需的时间,以毫秒为单位。我们使用Elevation.transition 250将过渡设置为四分之一秒。
下一行设置了卡片的颜色:
, Color.background (Color.color Color.LightBlue Color.S50)
处理Material.Color的elm-mdl包的官方文档位于package.elm-lang.org/packages/debois/elm-mdl/8.1.0/Material-Color。
渲染Card.title内容块
接下来,我们渲染一个Card.title,如下所示:
[ Card.title
[ Options.css "flex-direction" "column" ]
[ Card.head [] [ text model.city ]
, Options.div
[ Options.css "padding" "2rem 2rem 0 2rem" ]
[ Options.span
[ Typography.display4
, Typography.contrast 0.87
, Color.text Color.primary
]
[ text (Round.round 0 model.temperatureInfo.temperature ++ "°") ]
]
]
在逐行分析前面的代码之前,重要的是要注意elm-mdl中的每个Card都由内容块组成。内容块可以是Card.title、Card.media、Card.text和Card.actions。
Card.title内容块的类型签名如下:
title : List (Style a) -> List (Html a) -> Block a
这意味着每个标题内容块都包含两个列表并返回一个Block。
所有其他内容块(media、text和actions)具有相同的类型签名,除了内容块函数的名称之外。
由于我们知道title内容块需要有两个列表,并且第一个列表需要指定样式,因此现在很容易理解这一行代码的作用:
[ Options.css "flex-direction" "column" ]
上一行代码只是指定了要在我们的Card.title上使用的样式。如果我们想的话,例如,我们可以在这里添加另一个样式:
[ Options.css "flex-direction" "column"
, Options.css "padding-left" "100px"
]
显然,由于前面的更改,我们的卡片将获得 100 像素的左内边距。Card.title内容块内部的第二个列表指定了Html:
[ Card.head [] [ text model.city ]
, Options.div
[ Options.css "padding" "2rem 2rem 0 2rem" ]
[ Options.span
[ Typography.display4
, Typography.contrast 0.87
, Color.text Color.primary
]
[ text (Round.round 0 model.temperatureInfo.temperature ++ "°") ]
]
]
在第一行,我们可以看到使用了Card.head。Card.head的官方文档位于package.elm-lang.org/packages/debois/elm-mdl/8.1.0/Material-Card#head。Card.head函数的行为正如我们所期望的那样——它接受两个列表并返回一个Html a。第一个列表允许我们指定Style,第二个列表允许我们指定Html。
查看Material.Options的官方文档,该文档位于package.elm-lang.org/packages/debois/elm-mdl/8.1.0/Material-Options,我们可以导航到Elements部分,它以div开始。正如官方文档所述,div是一个:
"为将 elm-mdl 样式应用于 div 元素的超常见情况提供的便利函数。"
在 Options.div 中,我们首先在第一个 List 中指定样式,然后是第二个 List 中的 Html 和 Options.span。按照相同的模式,Options.span 本身包含两个 List。在第一个 List 中,我们通过调用 Material.Typography 和 Material.Color 来指定要使用的样式——总共三种不同的样式。然后,在第二个 List 中,如预期的那样,我们渲染一个 text 节点。
text 节点内的内容,括号中的内容可能看起来有点复杂,所以让我们来分析一下。首先,我们可以看到我们在调用 Round.round。让我们参考官方文档来获取更多关于这个包的信息。为此,将您的浏览器指向以下 URL,package.elm-lang.org/packages/myrho/elm-round/latest/Round。
如 Round 包的官方页面所述,它允许我们将 Float 转换为 String,并且额外的好处是可以设置小数点后数字的位数。它还允许我们指定如何对 Float 中的其余数字进行四舍五入。
在官方页面上我们看到的第一例子是:
x = 3.141592653589793
round 2 x -- "3.14"
上述代码显示 round 接收一个 Int 和一个 Float,并返回一个 String。您可以在以下链接中找到确切的类型签名,package.elm-lang.org/packages/myrho/elm-round/latest/Round#round。
在我们的情况下,我们有以下代码:
Round.round 0 model.temperatureInfo.temperature ++ "°"
查看类型别名 temperatureInfo,我们可以看到 temperatureInfo.temperature 是一个 Float。因此,我们确实是在接收一个 Int 和一个 Float,然后返回一个 String。这里有一个需要注意的地方,就是拼接的温度单位符号,虽然它是一个 String,但它会与一个 Float 拼接,这都要归功于 Round.round 函数。
第一个参数的值是零,这意味着我们不想在显示温度时有任何小数点。我们可以将其更改为,例如,小数点后一位数字,只需简单地替换零,如下所示:
Round.round 1 model.temperatureInfo.temperature ++ "°"
接下来,我们将查看 Card.actions 代码。
理解 Card.actions 代码
最后,是时候讨论我们的 Card 组件中的 Card.actions 部分:
, Card.actions []
[ div [] [ text "Wind: ", text (toString model.temperatureInfo.windSpeed) ]
, div [] [ text "Pressure: ", text (toString model.temperatureInfo.pressure) ]
, div [] [ text "Humidity: ", text (toString model.temperatureInfo.humidity) ]
]
Material.Card 的官方文档指定 Card.actions 允许我们生成一个 actions 块。正如我们之前所看到的,actions 块是 mdl 组件的四个可能内容块之一。
材料设计语言的官方文档——即 Google 文档,而不是 Elm 包文档——指出,卡片操作应用作与我们的卡片进行交互的方式。本质上,它是对动作按钮的调用。例如,如果我们在一个网站上以 MDL 卡的形式显示博客文章摘录的列表,卡片操作可以是包含 阅读更多 文本节点的按钮。
然而,在我们的例子中,我们并没有真正与卡片组件下温度提供的信息进行交互。换句话说,我们并没有让用户点击温度下方列出的风速、气压和湿度信息的意图。
因此,我们可以安全地将读取为, Card.actions []的行更改为以下代码:, Card.text []。我们可以保留所有其他代码不变,应用仍然可以工作。唯一的变化将与我们的天气应用的编译 HTML 结构有关。为了看到变化,我们需要检查dev工具中的代码,这可以通过在大多数主要浏览器中按F12按钮来访问。
如果在我们的 Elm 应用中使用Card.action,浏览器中的结果div将具有以下 CSS 类属性:mdl-card__actions。如果我们改为在视图中使用Card.text,浏览器中运行的应用的结果div将具有 CSS 类属性mdl-card__supporting-text。
我们通过在底部管道Material.Scheme.top来完成view函数的代码。这将向我们的应用添加mdl CSS。要了解更多信息,请参阅官方文档package.elm-lang.org/packages/debois/elm-mdl/8.1.0/Material-Scheme#top。
添加颜色方案
我们可以通过利用颜色方案几乎毫不费力地改变我们的 mdl 样式应用的外观。为了了解如何做到这一点,我们首先需要参考 elm-mdl 包的官方文档。更具体地说,我们对topWithScheme函数感兴趣,这是一个在elm-mdl包中提供的函数。此函数的官方文档可在package.elm-lang.org/packages/debois/elm-mdl/8.1.0/Material-Scheme#topWithScheme找到。如果您访问所引用的 URL,您将看到我们使它工作所需做的所有事情就是将主色和强调色作为参数提供给topWithScheme函数。
要更好地了解 mdl 中的颜色方案如何工作,请参阅颜色方案定制器[https://getmdl.io/customize/index.html]。
实际上,这意味着我们可以通过更改一行代码来更新view函数中的颜色方案。到目前为止,我们的view函数的前几行看起来如下:
view : Model -> Html Msg
view model =
div []
[ Textfield.render Mdl
要更新视图以使用topWithScheme函数,我们只需添加以下内容:
view : Model -> Html Msg
view model =
div []
Material.Scheme.topWithScheme Color.Orange Color.Red <|
Textfield.render Mdl
如我们所见,主色调现在是橙色,强调色是红色。到目前为止,我们几乎完成了天气应用的更新。对subscriptions函数或getTemperature函数都没有需要做的更改。
唯一需要更新的函数是 decodeTemperatureInfo 函数,其中我们需要进行一些小的调整,我们将在下一节中进行这些调整。
更新 decodeTemperatureInfo
我们在 [第七章 中停止了,在 Elm 中制作天气应用,其中 decodeTemperatureInfo 的以下代码:
decodeTemperatureInfo : String -> TemperatureInfo
decodeTemperatureInfo json =
let
name =
Result.withDefault "Error decoding data!" (Decode.decodeString (Decode.field "name" Decode.string) json)
windSpeed =
Result.withDefault 0 (Decode.decodeString (Decode.at [ "wind", "speed" ] Decode.float) json)
temperature =
Result.withDefault 0 (Decode.decodeString (Decode.at [ "main", "temp" ] Decode.float) json)
pressure =
Result.withDefault 0 (Decode.decodeString (Decode.at [ "main", "pressure" ] Decode.float) json)
humidity =
Result.withDefault 0 (Decode.decodeString (Decode.at [ "main", "humidity" ] Decode.float) json)
in
TemperatureInfo name windSpeed temperature pressure humidity
在 decodeTemperatureInfo 函数中需要更新的唯一事项是将温度从开尔文转换为摄氏度。幸运的是,这种转换非常直接:为了转换为摄氏度,我们只需从基于开尔文的值中减去 273.15。换句话说,如果当前温度是 293.15 开尔文,转换为摄氏度的过程如下所示:
293.15 - 273.15 = 20
因此,293.15 开尔文等于 20 摄氏度。这使得更新我们的 decodeTemperatureInfo 函数变得相当简单。我们只需用以下代码替换现有的代码:
temperature =
Result.withDefault 0 (Decode.decodeString (Decode.at [ "main", "temp" ] Decode.float) json) - 273.15
我们将保持其余代码与我们的应用前一个版本中的代码相同。
在本节中,我们更新了温度,以便在浏览器中显示摄氏度到开尔文的转换。如果你在浏览器中查看应用,你将能够看到这个变化。
摘要
在本章中,我们学习了如何使用神奇的 elm-mdl 包来改进现有应用的样式。我们探讨了如何使用 Result 和 Maybe 来处理可能失败的操作以及处理可能缺失的数据。我们还研究了使用解码器以及如何对它们进行映射。此外,我们还探讨了如何使用 HTTP 包来获取远程 JSON 数据。
在下一章中,我们将探讨如何为我们的 Elm 应用编写测试。
第九章:Elm 中的测试
在这一章中,我们将学习如何测试我们的 Elm 应用。
我们将涵盖的主题包括:
-
使用
elm-test测试 Elm 应用 -
理解
elm-package.json文件的结构和作用 -
理解
tests文件夹的结构和功能 -
使用
describe、test和Expect -
使用
left pipe operator编写更容易理解的测试 -
在我们的测试中使用
let-in和case-of表达式 -
Elm 中的模糊测试
完成这一章后,您将:
-
对单元测试的工作原理有一个大致的了解
-
了解单元测试和模糊测试在 Elm 中的工作原理
-
了解您可以对 Elm 应用进行测试的不同方式
-
能够成功部署各种测试到您的 Elm 应用中
Elm 测试简介
由于编译器在编译时捕捉所有错误,因此对于成功编译的 Elm 应用来说,零运行时异常是预期的结果。
既然如此,人们可能会问我们是否真的需要测试我们的 Elm 应用。答案是响亮的肯定,主要是因为编译器不会测试应用的行为。虽然 Elm 编译器的错误检查确实是一个非常好的工具,但它只会测试逻辑不一致性,这就是全部。
为了准备这一章,我们将从上一章的代码开始。只需复制整个名为improved-weather-app-ch8的文件夹,并将其粘贴到另一个位置。
与本书一起提供的代码文件在chapter9中包含了这个粘贴的文件夹,并且可以找到的复制粘贴的文件夹在chapter9文件夹内部已被重命名为weather-app-with-tests-ch9。
有时简单地复制粘贴一个现有的 Elm 项目到一个新文件夹,然后对其进行修改,而不是从头开始使用例如create-elm-app包来创建它,这是一种很有帮助的方法。
理解 Elm 中测试的工作原理
我们将通过将控制台指向weather-app-with-tests-ch9文件夹来开始使用 Elm 进行测试的工作。
一旦我们将终端指向正确的目录,我们则需要将elm-test作为一个 npm 包进行安装。Elm 社区中有许多工具,它们都有自己的 npm 版本,而elm-test只是其中之一。
因此,为了安装elm-test,让我们运行:
npm install -g elm-test
就这样!由于我们传递了-g标志到命令中,elm-test npm 包现在可以在全局范围内使用。这意味着我们可以在任何文件夹中使用 elm-test,而不仅仅是当前所在的文件夹。
注意,安装 elm-test npm 包将需要一些时间,所以当它正在安装时,您可以随时休息。
一旦安装开始,将会有许多消息被记录到控制台。这些消息看起来类似于以下内容:
C:\Users\PC\AppData\Roaming\npm\elm-test -> C:\Users\PC\AppData\Roaming\npm\node_modules\elm-test\bin\elm-test
> elm-test@0.18.12 install C:\Users\PC\AppData\Roaming\npm\node_modules\elm-test
> node install.js
Downloading binaries from https://dl.bintray.com/elmlang/elm-test/0.18.12/win32-x64.tar.gz
Successfully downloaded and processed https://dl.bintray.com/elmlang/elm-test/0.18.12/win32-x64.tar.gz
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.1.2 (node_modules\elm-test\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.1.2: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
+ elm-test@0.18.12
added 166 packages in 649.159s
注意上一段代码的最后一行,它读作:
added 166 packages in 649.159s
这意味着安装花费了超过 10 分钟的时间,这是在等待安装完成时制作一杯好饮料的完美机会。一旦elm-test的安装完成,我们可以通过运行以下命令将测试文件夹添加到现有的 elm 应用中:
elm-test init
一旦在控制台中运行前面的命令,控制台将通过记录以下消息来记录进度:
Starting downloads...
● debois/elm-dom 1.2.3
● eeue56/elm-html-query 3.0.0
● eeue56/elm-html-in-elm 5.2.0
● eeue56/elm-lazy 1.0.0
● eeue56/elm-lazy-list 1.0.0
● eeue56/elm-html-test 5.1.3
● eeue56/elm-shrink 1.0.0
● elm-community/elm-test 4.2.0
● elm-lang/core 5.1.1
● debois/elm-mdl 8.1.0
● elm-lang/dom 1.1.1
● elm-lang/html 2.0.0
● elm-lang/http 1.0.0
● elm-lang/mouse 1.0.1
● elm-lang/virtual-dom 2.0.4
● elm-lang/window 1.0.1
● mgold/elm-random-pcg 5.0.2
● myrho/elm-round 1.0.2
Packages configured successfully!
让我们运行dir命令来查看我们项目的结构,从其根目录开始:
dir
此命令将返回:
elm-package.json elm-stuff public README.md src tests
现在,让我们通过运行cd tests命令切换到tests文件夹。接下来,让我们再次运行dir命令来检查tests文件夹的结构。我们得到以下结果:
elm-package.json elm-stuff Example.elm Tests.elm
列表中的第一个文件,elm-package.json,列出了我们测试所需的所有依赖项。以下是位于tests文件夹内的elm-package.json文件的内容:
{
"version": "1.0.0",
"summary": "Test Suites",
"repository": "https://github.com/user/project.git",
"license": "BSD3",
"source-directories": [
"..\\src",
"."
],
"exposed-modules": [],
"dependencies": {
"debois/elm-mdl": "8.1.0 <= v < 9.0.0",
"eeue56/elm-html-test": "5.1.3 <= v < 6.0.0",
"elm-community/elm-test": "4.0.0 <= v < 5.0.0",
"elm-lang/core": "5.0.0 <= v < 6.0.0",
"elm-lang/html": "2.0.0 <= v < 3.0.0",
"elm-lang/http": "1.0.0 <= v < 2.0.0",
"myrho/elm-round": "1.0.2 <= v < 2.0.0"
},
"elm-version": "0.18.0 <= v < 0.19.0"
}
以下代码中的行用于我们包的语义版本控制:
"version": "1.0.0",
"summary": "Test Suites",
"repository": "https://github.com/user/project.git",
"license": "BSD3",
这些行仅在您打算在 Elm 包页面上发布包时才相关。源目录列出了我们的源文件所在的文件夹。在我们的tests文件夹的情况下,它需要访问自身,表示为.,并且需要访问其父文件夹的src文件夹,表示为..\\src:
"source-directories": [
"..\\src",
"."
],
暴露的模块列出了您希望公开的所有模块。这仅在发布包时使用,否则不需要指定。
依赖项列表列出了我们的测试所依赖的所有包。将我们的天气应用文件夹根目录下的依赖项列表与我们的测试所使用的依赖项列表进行比较是很有趣的。为了比较这两个列表,让我们首先列出天气应用文件夹中的依赖项:
"dependencies": {
"debois/elm-mdl": "8.1.0 <= v < 9.0.0",
"elm-lang/core": "5.0.0 <= v < 6.0.0",
"elm-lang/html": "2.0.0 <= v < 3.0.0",
"elm-lang/http": "1.0.0 <= v < 2.0.0",
"myrho/elm-round": "1.0.2 <= v < 2.0.0"
},
接下来,让我们列出test文件夹内的依赖项:
"dependencies": {
"debois/elm-mdl": "8.1.0 <= v < 9.0.0",
"eeue56/elm-html-test": "5.1.3 <= v < 6.0.0",
"elm-community/elm-test": "4.0.0 <= v < 5.0.0",
"elm-lang/core": "5.0.0 <= v < 6.0.0",
"elm-lang/html": "2.0.0 <= v < 3.0.0",
"elm-lang/http": "1.0.0 <= v < 2.0.0",
"myrho/elm-round": "1.0.2 <= v < 2.0.0"
},
如我们所见,tests文件夹包括了我们的天气应用所使用的所有依赖项,还有一些额外的依赖项。由于我们已经知道天气应用src文件夹内的包的功能,让我们专注于那些特定于我们的测试文件夹的依赖项:
"eeue56/elm-html-test": "5.1.3 <= v < 6.0.0",
"elm-community/elm-test": "4.0.0 <= v < 5.0.0",
如我们所见,有两个包是专门用于我们的tests文件夹的。在查看每个包的功能之前,让我们简要讨论包命名约定,以及我们之前列出的依赖项的版本。
Elm 包命名的方式很简单。包名中斜杠之前的部分是与此包关联的 GitHub 账户的名称。换句话说,它是包作者的 GitHub 用户名。包名中斜杠之后的部分应该尽可能具有描述性。因此,而不是为每个包强制唯一的命名,Elm 的理念是为包提供一个尽可能具有描述性的名称,这样它的潜在用户只需看一眼其名称就能辨别出包的用途。因此,完全有可能存在多个 elm-html-test 包,而名称的唯一性是由包作者的用户名决定的。
我们对 eeue56/elm-html-test 依赖项所需版本的范围在 5.1.3 和 6.0.0 之间,这意味着最低可接受的版本是 5.1.3,最高可接受的版本是 6.0.0。
要了解这两个包的功能,我们可以参考它们的官方文档,文档可在 package.elm-lang.org/packages/elm-community/elm-test/latest 和 package.elm-lang.org/packages/eeue56/elm-html-test/latest 找到。
elm-community/elm-test 包允许我们编写单元测试和模糊测试。eeue56/elm-html-test 允许我们通过指定我们期望的 HTML 值来测试视图。
继续检查测试文件夹的内容,tests/elm-stuff 文件夹包含下载的包以及我们依赖的确切版本列表,如 exact-dependencies.json 文件中所示。接下来,Example.elm 文件包含了我们运行第一个测试所需的所有设置。这是 Example.elm 文件包含的代码:
module Example exposing (..)
import Expect exposing (Expectation)
import Fuzz exposing (Fuzzer, int, list, string)
import Test exposing (..)
suite : Test
suite =
todo "Implement our first test. See http://package.elm-lang.org/packages/elm-community/elm-test/latest for how to do this!"
此文件中的代码遵循与其他 Elm 代码相同的规则。在第一行,我们暴露了 Example 模块。随后我们导入 Expect、Fuzz 和 Test,这些都是测试正常工作所必需的。
熟悉一些软件测试的术语会有所帮助,因为它将为我们编写测试提供更多上下文。测试被分组为测试套件。测试套件包含测试用例。测试用例是最小的测试单元。因此,适当地,我们在 Example.elm 中通过指定一个 suite 函数开始我们的测试,该函数将包含我们的测试。正如我们所看到的,suite 函数的类型是 Test。有关 Test 的官方文档可以在 package.elm-lang.org/packages/elm-community/elm-test/latest/Test#Test 找到。
如官方文档所述,一个 Test 至少会产生一个 Expectation。让我们看看我们测试中还将使用的一些其他函数。
描述函数
我们的测试被分组在一个List中。为了描述这个测试List的作用,我们使用describe函数。describe函数的签名可以在以下链接中找到package.elm-lang.org/packages/elm-community/elm-test/latest/Test#describe。
如我们从提供的 URL 中看到的那样,describe函数接受一个String和一个Tests的List,并返回一个Test。换句话说,它遵循以下结构:
describe "An arbitrary description of our test"
[ test ...
, test ...
, test ...
]
注意,这并不是实际的 Elm 代码,而是一种类似于 Elm 的伪代码,作为理解 Elm 中测试的中间步骤来编写的。
测试和 Expect 函数
test函数用于编写实际的单元测试。test函数只能有一个Expectation,并返回一个Test。
Expectation是一个类型别名。它可以有两种:pass或fail。Expect有几个函数,例如Expect.equal、Expect.notEqual、Expect.lessThan等等。有关Expect函数的完整列表,请参阅官方文档package.elm-lang.org/packages/elm-community/elm-test/latest/Expect。
现在我们已经介绍了一些概念并描述了我们将要使用的函数,是时候在Example.elm中编写我们的第一个单元测试了。
在 Elm 中编写单元测试
让我们更新suite函数的代码,使其看起来像这样:
suite : Test
suite =
describe "Zero is equal to zero"
[ test "Zero is equal to one minus one" <|
\_ -> Expect.equal 0 (1 - 1)
, test "Zero is equal to two minus two" <|
\_ -> Expect.equal 0 (2 - 2)
]
现在让我们从控制台运行这个测试,将控制台指向我们项目的根目录(在test文件夹之上的一级),并输入以下命令:
elm-test tests/Example.elm
控制台将记录以下消息:
$ elm-test tests/Example.elm
Success! Compiled 0 modules.
Successfully generated /dev/null
Success! Compiled 1 module.
Successfully generated C:\Users\PC\Desktop\improved-weather-app\elm-stuff\generated-code\elm-community\elm-test\elmTestOutput.js
elm-test 0.18.12
----------------
Running 2 tests. To reproduce these results, run: elm-test --fuzz 100 --seed 800244194 tests/Example.elm
TEST RUN PASSED
Duration: 517 ms
Passed: 2
Failed: 0
让我们看看我们刚刚测试的代码中的一个单个测试用例:
test "Zero is equal to one minus one" <|
\_ -> Expect.equal 0 (1 - 1)
让我们从<|管道操作符开始。它所做的就是评估其右侧的表达式,并将其作为参数传递给左侧的函数。换句话说,如果不使用<|操作符,我们可以这样编写完全相同的文本函数:
test "Zero is equal to one minus one"
( \_ -> Expect.equal 0 (1 - 1) )
接下来,让我们看看括号内部,并描述那里发生了什么。括号内的代码是test函数的第二个参数:
( \_ -> Expect.equal 0 (1 - 1) )
第二个参数是一个忽略其参数的匿名函数,这由以下代码表示:_。在本书早期,我们讨论了 Elm 中每个函数都是柯里化的,以及如何通过部分应用,我们可以得出结论,Elm 中的每个函数都可以被设计成只接受一个参数。使用_,这个参数就被忽略了。
匿名函数中的箭头(->)与常规函数中的等号(=)相同。Expect.equal将如之前所述,返回一个pass或fail。
Expect.equal 接受两个参数:第一个是期望值,第二个是要测试的表达式,该表达式要么与期望值相同(因此返回一个 pass),要么不同(因此返回一个 fail)。
由于零确实等于一减一,所以我们得到了一个 pass。
回到使用 <| 管道操作符,我们可以这样编写这个匿名函数:
( \_ -> Expect.equal 0 <| 1 - 1 )
因此,如果我们想的话,可以没有任何括号地重写整个 suite 函数,如下所示:
suite : Test
suite =
describe "Zero is equal to zero"
[ test "Zero is equal to one minus one" <|
\_ -> Expect.equal 0 <| 1 - 1
, test "Zero is equal to two minus two" <|
\_ -> Expect.equal 0 <| 2 - 2
]
一切仍然会按预期工作,如果我们再次运行测试,我们的测试仍然会通过。
现在,让我们将注意力转向 tests 文件夹中的另一个测试文件,名为 Tests.elm。Tests.elm 文件有以下代码:
module Tests exposing (..)
import Test exposing (..)
import Expect
-- Check out http://package.elm-lang.org/packages/elm-community/elm-test/latest to learn more about testing in Elm!
all : Test
all =
describe "A Test Suite"
[ test "Addition" <|
\_ ->
Expect.equal 10 (3 + 7)
, test "String.left" <|
\_ ->
Expect.equal "a" (String.left 1 "abcdefg")
, test "This test should fail" <|
\_ ->
Expect.fail "failed as expected!"
]
让我们在控制台中运行测试,只需从我们的应用程序文件夹的根目录运行 elm-test 命令:
elm-test
以下信息将被记录到控制台:
Success! Compiled 1 module.
Successfully generated /dev/null
Success! Compiled 1 module.
Successfully generated C:\Users\PC\Desktop\improved-weather-app\elm-stuff\generated-code\elm-community\elm-test\elmTestOutput.js
elm-test 0.18.12
----------------
Running 5 tests. To reproduce these results, run: elm-test --fuzz 100 --seed 1660804947
> Tests
> A Test Suite
> This test should fail
failed as expected!
TEST RUN FAILED
Duration: 696 ms
Passed: 4
Failed: 1
这个测试失败了,但正如控制台输出所示,这是预期的。正如在 Tests.elm 文件的代码中可以看到的,当我们需要编写一个失败的测试时,我们只需简单地使用 Expect.fail 函数。
在接下来的章节中,我们将继续使用 Tests.elm 文件,一个有用的命令是我们可以使用 elm-test --watch。如果你熟悉可以在控制台中运行的许多不同的实用工具,那么 --watch 标志会监视我们代码中的更改,并重新运行附加了 --watch 标志的命令。
这意味着我们只需要运行一次 elm-test --watch 命令,每次我们保存 Tests.elm 文件时,它都会再次运行我们的测试套件。
在我们的测试中使用 let-in 表达式
让我们在我们的 Example.elm 文件中添加一个 let-in 表达式。为了做到这一点,我们将使用另一个 describe 函数来指定另一个测试套件,然后我们将用另一个 describe 函数包裹所有的测试。最外层的 describe 函数将包含所有的其他 describe 函数,而这些函数反过来将包含所有的 test 函数的 Lists:
module Example exposing (..)
import Expect exposing (Expectation)
-- import Fuzz exposing (Fuzzer, int, list, string)
import Test exposing (..)
suite : Test
suite =
describe "A Test Suite"
[ describe "Testing addition"
[ test "Addition" <|
\_ ->
Expect.equal 10 (3 + 7)
]
, describe "Using let-in expression in a test suite"
[ test "Multiplication" <|
\_ ->
let
x = 2
y = 4
xy = x * y
in
xy
|> \_ -> Expect.equal 8 (2 * 4)
]
]
使用 elm-test tests/Example.elm 运行此 Example.elm 文件将在控制台产生以下输出:
Success! Compiled 1 module.
Successfully generated /dev/null
Success! Compiled 1 module.
Successfully generated C:\Users\PC\Desktop\improved-weather-app\elm-stuff\generated-code\elm-community\elm-test\elmTestOutput.js
elm-test 0.18.12
----------------
Running 2 tests. To reproduce these results, run: elm-test --fuzz 100 --seed 983607346 tests/Example.elm
TEST RUN PASSED
Duration: 334 ms
Passed: 2
Failed: 0
当然,这个例子很简单,但看到一个非常简单的实现,你可以从中构建出来,是有帮助的。
在我们的测试中解码 JSON
在我们的测试中解码 JSON 是简单的。
首先,让我们在我们的 tests 文件夹中添加一个新的文件。我们将把这个新文件命名为 DecoderTests.elm。
接下来,让我们将以下代码添加到 DecoderTests.elm:
module DecoderTests exposing (..)
import Expect exposing (Expectation)
import Fuzz exposing (Fuzzer, int, list, string)
import Main exposing (..)
import Test exposing (..)
suite : Test
suite =
describe "Decoder test"
[ test "Test decoding valid string" <|
\_ ->
let
jsonInput =
"{\"main\":{\"temp\":303.15,\"pressure\":30,\"humidity\":20},\"wind\":{\"speed\":15.3},\"name\":\"Rome\"}"
expectedResult =
TemperatureInfo "Rome" 15.3 30.0 30.0 20.0
in
Expect.equal expectedResult (decodeTemperatureInfo jsonInput)
{--
, test "Test decoding invalid string" <|
\_ ->
let
jsonInput =
"Scrabbled json message"
expectedResult =
TemperatureInfo "Error decoding data!" 0 0 0 0
in
Expect.equal expectedResult (decodeTemperatureInfo jsonInput)
, fuzz string "Fuzz test decoding invalid string" <|
\randomlyGeneratedString ->
let
expectedResult =
TemperatureInfo "Error decoding data!" 0 0 0 0
in
Expect.equal expectedResult (decodeTemperatureInfo randomlyGeneratedString)
-}
]
现在,我们可以运行我们的测试:
elm-test tests/DecoderTests.elm
控制台将记录以下信息:
Success! Compiled 2 modules.
Successfully generated /dev/null
Success! Compiled 3 modules.
Successfully generated C:\Users\PC\Desktop\elm-web-development\chapter9\weather-app-with-tests-ch9\elm-stuff\generated-code\elm-community\elm-test\elmTestOutput.js
elm-test 0.18.12
----------------
Running 1 test. To reproduce these results, run: elm-test --fuzz 100 --seed 1485733894 tests/DecoderTests.elm
TEST RUN PASSED
Duration: 663 ms
Passed: 1
Failed: 0
我们编写的测试此 JSON 的代码总共有三个测试。第一个测试编写得不会失败。其他两个测试编写得会失败,但它们被注释掉了。你可以随意取消注释其他两个测试,并在控制台中查看结果。
为了能够正确解析 JSON,我们需要在 JSON 字符串中转义双引号。这就是为什么 jsonInput 变量看起来是这样的:
jsonInput =
"{\"main\":{\"temp\":303.15,\"pressure\":30,\"humidity\":20},\"wind\":\"speed\":15.3},\"name\":\"Rome\"}"
另一种方法是,将整个 JSON 字符串放在多行引号的开头和结尾处,正如本书前面提到的,这些引号是三个双引号,如下所示:"""。我们将在下一节构建一些自定义期望时看到这个方法的示例。
在解码 JSON 时构建自定义期望
要构建自定义期望,我们可以参考前面提到的 pass 和 fail 函数。pass 函数总是通过,而 fail 函数会失败并显示一条消息。要生成自定义消息,可以使用 onFail 函数。
让我们通过在 tests 文件夹内创建一个新文件来查看一个示例。我们将这个新文件命名为 CustomExpectations.elm 并添加以下代码:
module CustomExpectations exposing (..)
import Expect exposing (Expectation)
import Json.Decode exposing (decodeString, field, int, list, map2, string)
import Test exposing (..)
type alias Player =
{ name : String
, language : String
}
playerDecoder =
map2 Player
(field "name" string)
(field "language" string)
suite : Test
suite =
describe "A Test Suite"
[ describe "A custom expectation with a custom decoder"
[ test "Decoding JSON strings" <|
\_ ->
let
json =
"""
{
"name" : "John Doe",
"language" : "English"
}
"""
in
case decodeString playerDecoder json of
Ok json ->
Expect.pass
Err err ->
Expect.fail err
]
]
如果我们使用命令 elm-test tests/CustomExpectations.elm 运行此测试,我们会得到以下输出:
Success! Compiled 0 modules.
Successfully generated /dev/null
Success! Compiled 1 module.
Successfully generated C:\Users\PC\Desktop\elm-web-development\chapter9\weather-app-with-tests-ch9\elm-stuff\generated-code\elm-community\elm-test\elmTestOutput.js
elm-test 0.18.12
----------------
Running 1 test. To reproduce these results, run: elm-test --fuzz 100 --seed 619873355 tests/CustomExpectations.elm
TEST RUN PASSED
Duration: 468 ms
Passed: 1
Failed: 0
在代码中,我们在全局作用域中设置类型别名 Player,以便在需要时在我们的测试中访问它。Person 类型别名是一个具有 name 字段的记录,该字段是 String 类型,还有一个 language 字段,也是 String 类型。
我们使用 describe 函数来描述我们的测试套件,然后描述第一个 List 测试,目前实际上只有一个测试。
让我们看看 playerDecoder 函数:
playerDecoder =
map2 Player
(field "name" string)
(field "age" string)
如我们在第六章中学习的,深入探索 Elm,string 是一个具有以下签名的解码器:
decoder : Json.Decode.Decoder String
因此,string 是 Strings 的解码器,意味着它将 JSON strings 转换为 Elm Strings,这个过程将 JSON strings 解析为 Elm Strings 称为 解码。
表达式 field "name" string 也是一个 Strings 的解码器,整个 playerDecoder 是一个由 field "name" string 解码器和 field "age" string 解码器组成的复杂解码器。
在匿名函数内部,我们提供 let-in 表达式。在 let-in 表达式的 let 部分,我们定义我们的 json 字符串。
最后,在 let-in 表达式的 in 部分,我们评估一个 case-of 表达式,并借助 pass 和 fail 函数,我们编写我们的自定义期望。
接下来,我们将探讨模糊测试。
在 Elm 中编写模糊测试
模糊测试允许我们通过在测试中随机化一些输入值,从而运行任意数量的值组合,将我们的单元测试提升到新的水平。
为了了解模糊测试是如何工作的,让我们比较单元测试和模糊测试。
让我们从本章前面已经使用过的常规单元测试开始。为了刷新我们对这个测试的记忆,让我们将其添加为 Example.elm 的内容,然后运行实际的测试:
module Example exposing (..)
-- import Fuzz exposing (Fuzzer, int, list, string)
import Expect exposing (Expectation)
import Test exposing (..)
suite : Test
suite =
describe "Zero is equal to zero"
[ test "Zero is equal to one minus one" <|
\_ -> Expect.equal 0 (1 - 1)
, test "Zero is equal to two minus two" <|
\_ -> Expect.equal 0 (2 - 2)
]
注意,在这段代码中,Fuzz 导入被注释掉了,因为编译器会给出关于此导入未使用的警告。
如果我们运行这个测试,它将在控制台输出一个成功的测试消息,显示两个测试通过,零个失败。
尽管这个例子非常简单,但它将帮助我们比较 Elm 中的单元测试和模糊测试。我们列出两个测试的事实本身就暗示了模糊测试的有用性。
如果我们想确保 Expect.equal 对于任何减去自身的数字都会返回零,我们可能需要编写多个测试来确认这种行为。
然而,编写模糊测试将是一个更好的解决方案。让我们看看更新后的代码:
module Example exposing (..)
-- import Fuzz exposing (Fuzzer, int, list, string)
import Expect exposing (Expectation)
import Test exposing (..)
suite : Test
suite =
describe "Zero is equal to zero"
[ test "Zero is equal to one minus one" <|
\_ -> Expect.equal 0 (1 - 1)
, test "Zero is equal to two minus two" <|
\_ -> Expect.equal 0 (2 - 2)
]
现在,让我们将这个单元测试重写为模糊测试的形式。
我们将取消注释 Fuzz 导入,并在我们的测试中随机化一些输入,如下所示:
module Example exposing (..)
import Expect exposing (Expectation)
import Fuzz exposing (Fuzzer, int, list, string)
import Test exposing (..)
suite : Test
suite =
describe "Zero is equal to zero"
[ fuzz int "Zero is equal to random number minus itself" <|
\randomNumber -> Expect.equal 0 (randomNumber - randomNumber)
]
这个测试将运行 100 次。这使得我们比编写普通的单元测试更有信心地得出通过测试的结论。
通过查看我们的模糊测试语法的结构,我们可以看到模糊函数接受三个参数:
-
一个
fuzzer -
一个用于描述模糊测试的
String -
一个匿名函数(与单元测试不同,它接受一个实际参数)
因此,这个模糊测试的模糊器用于生成 int 值。int 模糊器生成随机的 Ints。用于描述模糊测试的 String 是直接的。我们使用的是读取为“零等于随机数减去自身”的 String。最后,我们的匿名函数 \ 接收我们命名为 randomNumber 的参数。由于我们在实际的 Expect.equal 断言中使用 randomNumber,将此参数传递给匿名函数是完美的。
与多个模糊器一起工作
类似于我们可以使用 map2、map3、map4 等等,根据我们从 JSON 值解码到 Elm 值的字段数量,我们还有以下函数可供使用:fuzz2、fuzz3、fuzz4 和 fuzz5。
让我们更新我们的 Example.elm 测试,使其使用 fuzz2 函数。为了简化,我们将测试数字加法的交换律。这是数学中的规则,即如果我们交换相加的数字的顺序,结果将相同:
module Example exposing (..)
import Expect exposing (Expectation)
import Fuzz exposing (Fuzzer, int, list, string)
import Test exposing (..)
suite : Test
suite =
describe "Zero is equal to zero"
[ fuzz2 int int "Swiching positions on two numbers being added will still yield the same result" <|
\randomNumber1 randomNumber2 ->
Expect.equal (randomNumber1 + randomNumber2) (randomNumber2 + randomNumber1)
]
所有 100 个测试都如预期通过。重要的是要注意,控制台输出将只列出通过的一个测试,因为实际上就是这样。确实只有一个测试在运行,尽管有 100 种不同的值组合。
摘要
在本章中,我们学习了 Elm 中单元测试和模糊测试的基础。我们了解了 Elm 测试的结构和语法:
-
我们了解了
describe、Expect.equals和test -
我们讨论了如何在 Elm 测试中使用
\_和<| -
我们编写了将 JSON 字符串解码为 Elm 值的测试
-
我们研究了单元测试和模糊测试之间的区别
-
我们编写了接受多个参数的模糊测试
然而,本章所涵盖的内容仅仅是开始。TDD(测试驱动开发)是一种构建软件的方法,它在整个应用程序的生命周期中拥抱测试;实际上,正如其名称所暗示的,它是一种测试驱动整个开发过程的方法。通过本课程所涵盖的主题,你应该能够开始在 Elm 开发中例行实施测试。
在下一章中,我们将探讨 Elm 应用程序中的身份验证。
第十章:将 Elm 与 Rails 集成
欢迎来到第十章,将 Elm 与 Rails 集成。本章的目标是在前端使用 Elm 实现简单的用户认证。对于后端,我们将使用 Ruby on Rails 5.1.5。
我们将涵盖的主题包括:
-
使用 Codeanywhere 设置基本的 Rails 5.1.5 应用程序
-
在 Codeanywhere 上安装 Ruby 2.5.0 和 Rails 5.1.5
-
创建一个新的 Rails 项目
-
将 Elm 与 Rails 5.1.5 集成
-
将我们的 Elm 天气应用程序添加到我们的 Rails 应用程序中
完成本章后,您将能够:
-
在 Codeanywhere 上安装一个新的 Rails 项目
-
利用 webpacker 钩子更容易地与 Elm 一起工作
-
在现有的 Rails 应用程序中集成由 Elm 驱动的模块
使用 Codeanywhere 设置基本的 Rails 5.1.5 应用程序
Codeanywhere 是一个在线 集成开发环境(IDE),拥有许多优秀功能。您可以通过将浏览器指向 codeanywhere.com 来访问它。
要开始使用它,需要进行注册。
在实际上能够使用容器化的 Ubuntu 14.04 环境来运行我们的 Rails 5.1.5 应用程序之前,Codeanywhere 要求新注册用户确认他们的电子邮件。
要开始,请选择一个新的 Ubuntu 14.04 空白开发堆栈,给它起个名字,例如,elmrails,然后点击 CREATE 按钮。
点击 CREATE 按钮,将创建一个新的 Ubuntu 14.04 容器,供我们工作使用。
需要注意的一个重要事项是我们在 Codeanywhere 应用程序的编辑器面板中收到的消息。编辑器面板并不复杂;它只是打开的 Codeanywhere 应用程序中最大的面板,以标签的形式列出打开的文件。在成功创建容器后,我们收到的消息会列出容器的名称和一些附加信息,如下所示:
elmrails Container
Development Stack with your custom installed tools
This Codeanywhere Container comes with:
2GB of Disc Storage
256MB RAM (+ 512 MB swap)
Sudo access
SSH access on hostXX.codeanyhost.com:12345
Access to all HTTP and Websocket ports
The operating system running on this Container is Ubuntu 14.04 (64 bit). Ubuntu uses Advanced Packaging Tool (apt) package manager. You can read more here: apt-get
To access an application running on your Container use the following link (ports 1024-9999 available):
http://<containername>-<username><123456>.codeanyapp.com
To access your application over HTTPS, make sure your application is running on port 3000 and use the following link:
https://<containername>-<username><123456>.codeanyapp.com
If the port is blocked by your firewall you can connect through the standard HTTP port: (replace XX with port you have specified in your app)
http://port-XX.<containername>-<username><123456>.codeanyapp.com
在这里列出的所有信息中,以下信息最为重要:
To access an application running on your Container use the following link (ports 1024-9999 available):
http://<containername>-<username><123456>.codeanyapp.com
这条信息之所以重要,原因很简单:该地址将是您需要在浏览器中打开以查看您的工作 Rails 应用的地址。然而,现在还为时尚早,因为我们还需要安装 Ruby 2.5.0 和 Rails 5.1.5。
在 Codeanywhere 上安装 Ruby 2.5.0 和 Rails 5.1.5
完成容器设置后,点击编辑器链接,您就可以开始安装 Ruby 2.5.0 和 Rails 5.1.5。有关如何操作的详细说明,请参阅 gorails.com 网站上的优秀在线指南,地址如下:gorails.com/setup/ubuntu/14.04。
在这里,我们将只列出需要在控制台中运行的命令,以提供一个快速概览。请注意,以下命令也可以在本章附带代码文件中找到。
注意,在某个时刻,您将需要运行命令 rbenv install 2.5.0,这将使控制台看起来冻结。为了更好地了解正在发生的事情,您可以使用以下标志运行此命令:
rbenv install --verbose 2.5.0
使用rbenv install命令运行此标志将开启详细日志记录,这样你可以更详细地看到安装进度,而不用担心应用是否真的在做什么。
应该运行的完整命令列表如下:
curl -sL https://deb.nodesource.com/setup_8.x | sudo -E bash -
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
sudo apt-get update
sudo apt-get install git-core curl zlib1g-dev build-essential libssl-dev libreadline-dev libyaml-dev libsqlite3-dev sqlite3 libxml2-dev libxslt1-dev libcurl4-openssl-dev python-software-properties libffi-dev nodejs yarn
cd
git clone https://github.com/rbenv/rbenv.git ~/.rbenv
echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc
echo 'eval "$(rbenv init -)"' >> ~/.bashrc
exec $SHELL
git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
echo 'export PATH="$HOME/.rbenv/plugins/ruby-build/bin:$PATH"' >> ~/.bashrc
exec $SHELL
rbenv install 2.5.0
ruby -v
rbenv global 2.5.0
ruby -v
gem install bundler
node -v
gem install rails -v 5.1.5
rbenv rehash
rails -v
现在我们已经安装了所有先决条件,我们将安装一个 Rails 应用。
创建一个新的 Rails 项目
要安装一个新的 Rails 应用,你的bash需要在workspace文件夹内。为了验证你处于正确的位置,你应该在控制台中看到以下输出:
cabox@box-codeanywhere:~/workspace$
现在,只需运行以下命令:
rails new simple --webpack=elm
注意,单词simple可以是任何你想要的东西。你选择的单词将决定你的 Rails 5 应用将被安装的文件夹名称。
接下来,在 Codeanywhere 应用的左侧面板中,右键单击最底部的条目,即elmrails,然后在弹出的上下文菜单中点击refresh命令。
执行此操作将导致树结构刷新,现在你将能够看到在elmrails项目文件夹内列出的另一个名为simple的文件夹,如下所示:
∨ elmrails
> simple
点击左侧面板中simple文件夹的标题将切换文件夹结构的可见性。如果你点击它将其展开,你会看到以下结构:
∨ simple
> .git
> app
> bin
> config
> db
> elm-stuff
> lib
> log
> node_modules
> public
> test
> tmp
> vendor
.babelrc
.gitignore
.postcssrc.yml
config.ru
elm-package.json
Gemfile
Gemfile.lock
package.json
Rakefile
README.md
yarn.lock
现在你已经确认成功安装了 Rails,是时候试驾一下了。通过运行以下命令将 bash 指向simple文件夹:
cd simple
现在,输入启动 Rails 服务器的命令:
rails s
你应该在控制台中看到以下输出:
=> Booting Puma
=> Rails 5.1.5 application starting in development
=> Run `rails server -h` for more startup options
Puma starting in single mode...
* Version 3.11.3 (ruby 2.5.0-p0), codename: Love Song
* Min threads: 5, max threads: 5
* Environment: development
* Listening on tcp://0.0.0.0:3000
Use Ctrl-C to stop
现在是时候查看我们在线运行的样板 Rails 应用了。为了做到这一点,我们需要参考我们创建 Ubuntu 容器后得到的信息,也就是说,我们需要检查以下信息:
To access an application running on your Container use the following link (ports 1024-9999 available):
http://<containername>-<username><123456>.codeanyapp.com
注意,当我们运行rails s命令时,控制台输出以这一行结束:
* Listening on tcp://0.0.0.0:3000
这意味着我们的 Rails 应用正在以下地址提供服务:
http://<containername>-<username><123456>.codeanyapp.com:3000
如果你在新标签页的浏览器中指向此地址,你会看到 Rails 5 的欢迎屏幕:
<Rails logo>
Yay! You’re on Rails!
<A drawing>
Rails version: 5.1.5
Ruby version: 2.5.0 (x86_64-linux)
在本节中,我们成功安装了 Rails。不仅如此,通过在 Rails 项目创建命令中提供--webpack=elm标志,我们成功地将 Elm 与我们的新 Rails 项目集成。
这也是为什么我们不得不在本章开头进行相对较长的 Rails 5.1.5 安装:为了实现 Rails 和 Elm 的简单、无痛苦的集成。
在下一节中,我们将开始修改我们的 Rails 应用,并在其中开始使用 Elm。
集成 Elm 与 Rails 5.1.5
当不传递任何附加标志运行 rails new <projectname> 命令时,创建的 Rails 应用程序是一个默认的应用程序,没有 Elm 集成。删除我们在上一步中创建的新 Rails 项目的文件夹并运行一个新的 Rails 项目创建命令,这次不传递标志,可能是一个很好的练习。然而,为了避免浪费在这种事情上的时间,我们将在这里列出当我们向 rails new <projectname> 命令传递 --webpack=elm 标志时创建的一些附加文件。
要了解 Elm 如何与 Rails 集成,最好的起点是 app/javascript 文件夹,在该文件夹中,是 hello_elm.js 文件。
这是 hello_elm.js 的内容:
// Run this example by adding <%= javascript_pack_tag "hello_elm" %> to the
// head of your layout file, like app/views/layouts/application.html.erb.
// It will render "Hello Elm!" within the page.
import Elm from '../Main'
document.addEventListener('DOMContentLoaded', () => {
const target = document.createElement('div')
document.body.appendChild(target)
Elm.Main.embed(target)
})
这个 JavaScript 文件将 Main.elm 文件渲染的视图嵌入到一个 div 元素中。
但这个 div 元素将存在于哪里?这完全取决于我们。
例如,hello_elm.js 的前三条实际上是单行 JavaScript 注释,它们为我们提供了如何在 Rails 网站的布局文件中渲染 Elm 驱动的视图的精确指令。
如注释掉的指令所示,我们需要进入我们 Rails 应用程序的默认布局视图,即 application.html.erb。
此文件的完整路径是 simple/app/views/layouts/application.html.erb。因此,让我们打开这个文件,并在其 head 中添加 javascript_pack_tag,如下所示:
<!DOCTYPE html>
<html>
<head>
<title>Simple</title>
<%= csrf_meta_tags %>
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
<%= javascript_pack_tag "hello_elm" %>
</head>
...
最后,为了将所有这些连接起来,我们需要将 application#index 设置为我们的 Rails 应用程序将打开的默认路由。我们将通过更新位于 simple/config/ 文件夹中的 routes.rb 文件来实现这一点:
Rails.application.routes.draw do
# For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
root 'application#index'
end
在此文件中,我们仅在文件的最后一行之上添加了 root 'application#index' 路由。这样做告诉我们的 Rails 应用程序,我们希望将其路由到应用程序控制器的索引操作作为默认路由,或称为 root 路由。
如果我们在浏览器中刷新我们的运行中的应用程序,此时我们会得到以下错误:
Unknown action
The action 'index' could not be found for ApplicationController
这意味着我们需要为我们的 ApplicationController 添加索引视图。为此,我们只需在 views 文件夹中创建一个新的文件夹。我们将把这个新文件夹称为 application。接下来,在 application 文件夹中,让我们创建一个新的文件,我们将称之为 index.html.erb。
注意,为了在 Codeanywhere 中创建文件夹和文件,你需要右键单击应该包含它们的父文件夹。然后,根据你的需要选择“创建文件夹”或“创建文件”命令。一旦你做出选择,就会出现一个模态窗口,你将能够输入你的文件或文件夹的名称,具体取决于你选择的命令。
接下来,打开新创建的 index.html.erb 文件,并在其中仅输入一行代码:
<h1>Successfully integrated Elm and Rails!</h1>
<div></div>
接下来,保存所有更改,并在你查看你的 Rails 应用程序(带有 Rails 启动屏幕)的页面中刷新页面。
接下来,打开运行着 Rails 服务器的 bash 标签页。注意,控制台正在输出以下消息:
[Webpacker] Compiling…
[Webpacker] Compiled all packs in /home/cabox/workspace/simple/public/packs
Completed 200 OK in 187478ms (Views: 187460.0ms)
这些消息意味着 webpacker 正在工作,编译你的 Elm 代码并将其集成到你的 Rails 项目中。正如你所看到的输出,这次编译花费了大约 188 秒。一旦完成,你可以刷新你预览的 Rails 应用的页面,现在,你应该在屏幕的右上部分看到以下消息:
Hello Elm!
让你的 Rails 应用在默认路由上显示这条消息,意味着你已经成功地将 Elm 集成到 Rails 中。
现在,我们已经成功地将 Elm 集成到我们的 Rails 应用中,让我们通过构建一些更复杂的东西来进一步改进我们的结果。
将我们的 Elm 天气应用添加到我们的 Rails 应用中
在本节中,我们将通过添加 Elm 天气应用来改进我们的 Rails 应用。我们将使用 第八章 的 添加更多功能到天气应用 末尾所拥有的天气应用的完成版本。
为了做到这一点,让我们打开 Rails 应用的根目录中的 elm-package.json 文件,并添加额外的依赖项,以便完整的更新文件看起来如下:
{
"version": "1.0.0",
"summary": "helpful summary of your project, less than 80 characters",
"repository": "https://github.com/user/project.git",
"license": "BSD3",
"source-directories": [
"app/javascript"
],
"exposed-modules": [],
"dependencies": {
"debois/elm-mdl": "8.1.0 <= v < 9.0.0",
"elm-lang/core": "5.0.0 <= v < 6.0.0",
"elm-lang/html": "2.0.0 <= v < 3.0.0",
"elm-lang/http": "1.0.0 <= v < 2.0.0",
"myrho/elm-round": "1.0.2 <= v < 2.0.0"
},
"elm-version": "0.18.0 <= v < 0.19.0"
}
接下来,我们需要安装这些包。我们不能直接通过 Elm 来安装它们,而是需要使用 Yarn。我们需要运行的命令如下:
yarn run elm package install
控制台将打印以下内容到屏幕上:
yarn run v1.5.1
$ /home/cabox/workspace/simple/node_modules/.bin/elm package install
Some new packages are needed. Here is the upgrade plan.
Install:
debois/elm-dom 1.2.3
debois/elm-mdl 8.1.0
elm-lang/dom 1.1.1
elm-lang/mouse 1.0.1
elm-lang/window 1.0.1
myrho/elm-round 1.0.2
Do you approve of this plan? [Y/n] y
Starting downloads...
● debois/elm-dom 1.2.3
● elm-lang/mouse 1.0.1
● elm-lang/dom 1.1.1
● debois/elm-mdl 8.1.0
● elm-lang/window 1.0.1
● myrho/elm-round 1.0.2
Packages configured successfully!
Done in 25.23s.
现在,我们需要创建一个新文件,我们将称之为 WeatherApp.elm。接下来,我们可以简单地复制并粘贴完整的天气应用到这个文件中。我们唯一需要做的修改是在第一行,它需要看起来像这样:
module WeatherApp exposing (..)
WeatherApp.elm 文件的完整代码可以在 第八章 的代码文件中找到,添加更多功能到天气应用(因为我们使用的是在 第八章 的 添加更多功能到天气应用 末尾完成的改进天气应用的 Main.elm 文件中的代码)。
接下来,我们还需要添加一个 JS 文件,它将成为我们的 WeatherApp.elm 文件的入口点。我们将把这个 JS 文件命名为类似 hello_elm.js 的样子,这意味着我们将使用下划线字符分隔单词,并且文件名中不会使用大写字母。因此,在 javascript/packs 文件夹中创建一个新文件,并将其命名为 weather_app.js。
接下来,将以下代码添加到这个新文件中:
import Elm from './WeatherApp'
document.addEventListener('DOMContentLoaded', () => {
const target = document.getElementById('weather-app')
Elm.WeatherApp.embed(target);
})
接下来,我们需要通过添加 javascript_pack_tag 并传递一个具有 id 属性为 weather-app 的 div 来更新我们的 views/application/index.html.erb 文件:
<h1>
Successfully integrated Elm and Rails!
</h1>
<%= javascript_pack_tag "weather_app" %>
<div id="weather-app"></div>
<div></div>
现在,我们可以重新启动我们的 Rails 服务器:
rails s
注意,这次,webpacker 将花费相当长的时间来编译所有新添加的包(Http、Material 和 Round)。在运行 rails s 命令后,你应该在控制台中看到以下消息:
rails s
=> Booting Puma
=> Rails 5.1.5 application starting in development
=> Run `rails server -h` for more startup options
Puma starting in single mode...
* Version 3.11.3 (ruby 2.5.0-p0), codename: Love Song
* Min threads: 5, max threads: 5
* Environment: development
* Listening on tcp://0.0.0.0:3000
Use Ctrl-C to stop
Started GET "/" for ......... at 2018-03-10 04:04:30 -0500
Cannot render console from .........! Allowed networks: 127.0.0.1, ::1, 127.0.0.0/127.255.255.255
Processing by ApplicationController#index as HTML
Rendering application/index.html.erb within layouts/application
[Webpacker] Compiling…
最后,在编译完成后,我们将在控制台中看到以下消息:
[Webpacker] Compiled all packs in /home/cabox/workspace/simple/public/packs
Rendered application/index.html.erb within layouts/application (87118.0ms)
Completed 200 OK in 97585ms (Views: 96676.2ms)
如果我们在浏览器中打开我们的 Rails 应用程序并刷新它,我们将看到以下网页:

如您所见,我们目前由 Rails 驱动的网站有三个部分:一个静态的 h1 标题和两个独立的 Elm 应用程序,天气应用程序和问候应用程序。
这意味着我们已经成功地将两个独立的 Elm 应用程序添加到我们的 Rails 应用程序中。这样,我们可以逐渐开始将 Elm 小部件引入现有的 Rails 驱动的网站。如果我们是一个不知道 Elm 但想了解它如何与现有项目集成并从中学习的开发者团队,这将非常棒。这种方法的另一个可能用例是,如果我们公司的管理层决定尝试 Elm 而不是全盘投入。
摘要
在本章中,我们涵盖了以下主题:
-
使用 Codeanywhere 设置基本的 Rails 5.1.5 应用程序
-
在 Codeanywhere 上安装 Ruby 2.5.0 和 Rails 5.1.5
-
创建一个全新的 Rails 项目
-
将 Elm 与 Rails 5.1.5 集成
-
将我们的 Elm 天气应用程序添加到我们的 Rails 应用程序中
带着这些知识,我们可以开始向我们的 Rails 应用程序中添加独立的 Elm 驱动的模块。
这本书的内容到此结束。然而,您与 Elm 的旅程才刚刚开始。以下是一些如果您想了解更多关于这个优秀语言的有用资源(以下链接按无特定顺序列出):
访问 elmcasts.com
最后,这本书的作者启动了一个全新的项目。一个专门用于学习 Elm 的网站。
沿着类似 railscasts.com 和 laracasts.com 的网站的传统,我决定将其命名为 elmcasts.com。当这本书上市时,网站应该已经上线并运行。


浙公网安备 33010602011771号