ReasonML-快速启动指南-全-

ReasonML 快速启动指南(全)

原文:zh.annas-archive.org/md5/a51783842e7757440f86f3f42794c0d7

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

ReactJS 已经改变了我们所知的 frontend 开发世界。它的创造者 Jordan Walke 还创建了 ReasonML 和 ReasonReact 作为 React 的未来。React 对 DOM 的抽象允许使用强大的编程范式,有助于解决 JavaScript 的可维护性问题,本书将深入探讨 Reason 如何帮助你构建更简单、更可维护的 React 应用程序。本书是使用 ReasonML 构建 React 应用程序的实战指南。

本书面向的对象

本书的目标读者是熟悉 ReactJS 的 JavaScript 开发者。不需要有静态类型语言的前期经验。

本书涵盖的内容

第一章,ReasonML 简介,讨论了当前 Web 开发的状态以及为什么我们会考虑 ReasonML 用于前端开发(以及更多)。

第二章,设置开发环境,帮助我们开始运行。

第三章,创建 ReasonReact 组件,演示了如何使用 ReasonML 和 ReasonReact 创建 React 组件。在这里,我们开始构建一个应用外壳,并在本书的其余部分中添加到它。

第四章,BuckleScript、Belt 和互操作性,让我们对 Reason 的生态系统和标准库有一个全面的理解。

第五章,有效的机器学习,通过商业案例深入探讨了 Reason 的类型系统的更多高级特性。

第六章,Reason 中的 CSS-in-JS,展示了 CSS-in-JS 在 Reason 中的工作方式以及类型系统如何提供帮助。

第七章,Reason 中的 JSON,演示了如何将 JSON 转换为 Reason 中的数据结构,并说明了 GraphQL 如何提供帮助。

第八章,使用 Jest 进行单元测试,深入探讨了使用流行的 Jest 测试库进行测试。

为了充分利用本书

你应该熟悉以下内容:

  • 命令行界面

  • GitHub 和 Git

  • 一个文本编辑器,例如 Visual Studio Code

下载示例代码文件

你可以从www.packt.com的账户下载本书的示例代码文件。如果你在其他地方购买了本书,你可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给你。

你可以通过以下步骤下载代码文件:

  1. www.packt.com登录或注册。

  2. 选择 SUPPORT 标签页。

  3. 点击代码下载与勘误。

  4. 在搜索框中输入书名,并遵循屏幕上的说明。

下载文件后,请确保使用最新版本的以下软件解压缩或提取文件夹:

  • Windows 下的 WinRAR/7-Zip

  • Mac 下的 Zipeg/iZip/UnRarX

  • Linux 下的 7-Zip/PeaZip

书籍的代码包也托管在 GitHub 上,地址为 github.com/PacktPublishing/ReasonML-Quick-Start-Guide。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富图书和视频目录的代码包,可在 github.com/PacktPublishing/ 上找到。查看它们吧!

使用的约定

在这本书中使用了多种文本约定。

CodeInText: 表示文本中的代码单词、文件夹名称、文件名、文件扩展名、路径名和变量名。以下是一个示例:"运行 npm run build 编译 Demo.re 到 JavaScript。"

代码块设置如下:

"warnings": {
  "error": "A"
},

当我们希望将您的注意力引向代码块中的特定部分时,相关的行或项目将以粗体显示:

/* bsconfig.json */
...
"sources": {
  "dir": "src",
  "subdirs": true
},
...

任何命令行输入或输出都按以下方式编写:

bsb -init my-reason-react-app -theme react
cd my-reason-react-app

粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:"padLeft 的类型是 (string, some_variant) => string,其中 some_variant 使用一个高级类型系统特性,称为 多态变体,它使用 [@bs.unwrap] 转换为 JavaScript 可以理解的内容。"

警告或重要提示看起来像这样。

技巧和窍门看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈: 如果您对这本书的任何方面有疑问,请在邮件主题中提及书名,并给我们发送邮件至 customercare@packtpub.com

勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问 www.packt.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版: 如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能向我们提供位置地址或网站名称,我们将不胜感激。请通过链接至材料的方式与我们联系至 copyright@packt.com

如果您有兴趣成为作者: 如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com

评论

请留下评论。一旦您阅读并使用了这本书,为何不在购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

有关 Packt 的更多信息,请访问 packt.com

第一章:ReasonML 简介

过去十年,我们在构建用户界面的方式上经历了多次范式转变。Web 应用程序已经从服务器端框架转移到客户端框架,以提供更好的用户体验。设备和浏览器已经足够强大,可以运行健壮的客户端应用程序,而 JavaScript 语言本身在多年中也经历了许多改进。渐进式 Web 应用程序提供了类似原生的用户体验,WebAssembly 允许在 Web 平台上实现类似原生的性能。越来越多的应用程序正在为浏览器构建,导致需要维护的客户端代码库越来越大。

在这个时期,几个框架、库、工具和一般最佳实践获得了流行,然后又失去了流行,导致许多开发者感到JavaScript 疲劳。由于新技术对招聘和保留工程人才、生产力和可维护性的影响,公司越来越谨慎地承诺采用新技术。如果向团队引入错误的技术(或错误时机上的正确技术),这可能是一个代价高昂的错误。

对于许多公司和开发者来说,React 已经被证明是一个可靠的选择。2013 年,Facebook 在 2011 年开始内部使用该库后将其开源。他们挑战我们重新思考最佳实践(www.youtube.com/watch?v=DgVS-zXgMTk&feature=youtu.be),并且它已经接管了前端开发(medium.freecodecamp.org/yes-react-is-taking-over-front-end-development-the-question-is-why-40837af8ab76)。将标记、行为和样式封装到可重用组件中已经成为一个巨大的生产力和可维护性的胜利。DOM 的抽象使得组件可以简单地成为其 props 的声明性函数,这使得推理、组合和测试变得容易。

通过 React,Facebook 已经在前端开发者社区中做了令人难以置信的工作,教育他们关于传统的函数式编程范式,这些范式使得推理和维护代码变得更加容易。现在,Facebook 认为现在是 ReasonML 的时机。

这是一张来自npmtrends.com的两年图表,显示了某些顶级 JavaScript 库和框架每周的 npm 下载量。ReactJS 看起来是一个明显的赢家,每周下载量超过 2,500,000 次:

图片

npmtrends.com

在本章中,我们将进行以下内容:

  • 讨论 ReasonML 是什么以及它试图解决的问题

  • 了解 Facebook 选择 ReasonML 作为 ReactJS 未来的原因

  • 在在线沙盒中尝试 ReasonML 并检查其编译(JavaScript)输出

ReasonML 是什么?

Reason 是 OCaml 语法和工具层,Facebook 正在积极使用的一种语言。乔丹 [Walke] 在 React 之前就提出了 Reason 的概念。事实上,我们正在将其用作实际的(前端)语言(以及其他用途),因为我们认为经过三年半的时间,React 的实验已经成功,人们现在准备好使用 Reason 了...

– Cheng Lou,2017 年 1 月

(www.reactiflux.com/transcripts/cheng-lou/)

让我们进一步探讨这句话。ReasonML 不是一个新的语言;它是 OCaml 语言的全新语法,旨在让 JavaScript 开发者感到熟悉。从现在起,我们将称之为 Reason,它具有与 OCaml 完全相同的抽象语法树(AST),因此 Reason 和 OCaml 只在语法上有所不同。语义是相同的。通过学习 Reason,你也在学习 OCaml。实际上,有一个命令行工具可以在 OCaml 和 Reason 语法之间进行转换,称为 refmt,它将 Reason/OCaml 代码格式化为类似 JavaScript 的 prettier——实际上,prettier 就是受到了 refmt 的启发。

OCaml 是一种注重表达性和安全性的通用编程语言。它最初于 1996 年发布,拥有一个高级类型系统,可以帮助你捕捉错误而不会妨碍你的工作。和 JavaScript 一样,OCaml 具有垃圾回收功能,用于自动内存管理,以及可以像其他函数的参数一样传递的一等函数。

Reason 也是一个工具链,对于那些来自 JavaScript 背景的人来说,它使得入门变得更加容易。这个工具链允许我们利用 JavaScript 和 OCaml 生态系统的优势。我们将在第二章“设置开发环境”中深入探讨。现在,我们将直接在在线沙盒中通过访问 Reason 的在线沙盒 reasonml.github.io/try 来进行实验。

尝试将这个“Hello World”示例输入到在线沙盒中:

let message = "World";
Js.log("Hello " ++ message);

你会注意到两件事:

  • OCaml 语法将在编辑器的左下角自动生成(未显示)

  • Reason/OCaml 代码直接在浏览器中编译成 JavaScript:

// Generated by BUCKLESCRIPT VERSION 3.2.0, PLEASE EDIT WITH CARE
'use strict';

var message = "World";

console.log("Hello World");

exports.message = message;
/* Not a pure module */

你可能会想知道 Reason/OCaml 代码是如何在浏览器中编译的。BuckleScript,Reason 的合作伙伴项目,将 OCaml AST 编译成 JavaScript。由于 Reason 和 OCaml 都会被转换成相同的 OCaml AST,BuckleScript 支持 Reason 和 OCaml。此外,由于 BuckleScript 本身是用 OCaml 编写的,它可以编译成 JavaScript 并直接在浏览器中运行。

检查编译后的 JavaScript 可以发现它有多么易于阅读。仔细观察,你会发现编译后的输出也进行了优化:在 console.log 语句中,"Hello World" 字符串被直接内联,而不是使用 message 变量。

BuckleScript 利用 OCaml 类型系统和编译器实现的功能,能够在离线编译期间提供许多优化,使得运行时代码非常快速。

– BuckleScript 文档

(bucklescript.github.io/bucklescript/Manual.html#_why_bucklescript)

值得注意的是,BuckleScript 还支持字符串插值(bucklescript.github.io/docs/en/common-data-types.html#interpolation):

/* The message variable is interpolated */
{j|Hello $message|j}

为什么选择 Reason?

Reason 为什么如此吸引人?Reason 能做什么是 TypeScript 或 Flow 所不能做的?仅仅是拥有静态类型检查器吗?这些问题是我刚开始接触 Reason 时产生的疑问。

支持不可变性和纯度

Reason 不仅仅是拥有静态类型系统。同样重要的是,Reason 默认是不可变的。不可变性是函数式编程中的一个重要概念。在实践中,使用不可变的数据结构(无法改变的数据结构)比它们可变的对应物产生更安全、更容易推理和更易于维护的代码。这一点将在整本书中反复出现。

纯度是函数式编程中的另一个重要概念。如果一个函数的输出仅由其输入决定,没有可观察的副作用,那么这个函数被认为是纯函数。换句话说,一个纯函数除了返回一个值之外,不做任何其他事情。以下是一个纯函数的示例:

let add = (a, b) => a + b;

此外,这是一个不纯函数的示例:

let add = (a, b) => {
  Js.log("side-effect");
  a + b;
};

在这种情况下,副作用是向浏览器的控制台写入。这就是为什么在我们的前一个Hello World示例中,BuckleScript 在编译输出的末尾包含了/* Not a pure module */注释。

修改全局变量也是一种副作用。考虑以下 JavaScript 代码:

var globalObject = {total: 0};
const addAndMutate = (a, b) => globalObject.total = a + b;
addAndMutate(40, 2);
/* globalObject now is mutated */

全局对象被修改了,现在它的total属性是42。我们现在必须意识到所有在使用globalObject时可能对其进行修改的区域。忘记这个对象既是全局的又是可变的可能会导致难以调试的问题。解决这个问题的惯用方法是将globalObject移动到一个模块中,使其不再全局。这样,只有那个模块可以访问它。然而,我们仍然需要意识到这个模块内所有可以更新该对象的区域。

如果globalObject是不可变的,那么将无法对其进行修改。因此,我们不需要意识到所有可能修改globalObject的区域,因为这些区域将不存在。我们将看到,使用 Reason,通过创建原始数据的更新副本,以这种方式构建真实的应用程序相当简单且自然。考虑以下示例:

let foo = 42;
let foo = foo + 1;
Js.log(foo);
/* 43 */

语法感觉非常自然。正如我们将在本书后面看到的那样,不可变性——通过返回更新后的副本而不是在原地应用破坏性更改来改变——非常适合 React/Redux 的工作方式。

原始的foo没有被修改;它被阴影了。一旦被阴影,旧的foo绑定就不可用了。绑定可以在局部作用域以及全局作用域中被阴影:

let foo = 42;

{
  let foo = 43;
  Js.log(foo); /* 43 */
};

Js.log(foo); /* 42 */

let foo = 43;
Js.log(foo); /* 43 */

尝试修改foo会导致编译错误:

let foo = 42;
foo = 43;
/* compilation error */

我们可以看到不可变性和纯度是相关的话题。拥有支持不可变性的语言允许你在没有副作用的情况下以纯方式编程。然而,如果纯度会导致代码比使用副作用更复杂、更难以推理,你可能会感到欣慰。Reason(在本书的其余部分与 OCaml 可互换)是一种实用主义语言,它允许我们在需要时产生副作用。

当使用像[Reason]这样的语言时,关键不是避免副作用,因为避免副作用等同于避免做任何有用的事情。实际上,程序不仅仅是计算事物,它们事情。它们发送消息,写入文件,做各种各样的事情。做事情的过程会自动涉及副作用。支持纯度的语言给你带来的好处是,它让你能够大致地将涉及副作用的代码部分分割出来,使其成为清晰且可控的代码区域,这使得推理变得更加容易。

– Yaron Minsky

(www.youtube.com/watch?v=-J8YyfrSwTk&feature=youtu.be&t=47m29s)

还很重要的一点是,不可变性不会以性能为代价。在底层,有优化措施在位,以保持 Reason 的不可变数据结构快速。

模块系统

Reason 有一个复杂的模块系统,允许模块化开发和代码组织。在 Reason 中,所有模块都是全局可用的,并且当需要时,模块接口可以用来隐藏实现细节。我们将在第五章,有效的 ML中探讨这个概念。

类型系统

Reason 的类型系统是健全的,这意味着一旦编译,就不会有运行时类型错误。语言中没有null,也没有与null相关的任何错误。在 JavaScript 中,当某物是number类型时,它也可以是null。Reason 为也可以是null的事物使用一个特殊的类型,并强制开发者通过拒绝编译来适当地处理这些情况。

到目前为止,我们已经在不讨论类型的情况下编写了一些,尽管是基本的,Reason 代码。Reason 可以自动推断类型。正如我们将在整本书中学到的,类型系统是一个提供保证而不妨碍我们的工具,并且当正确使用时,可以让我们将以前需要记住的事情卸载到编译器中。

Reason 对不可变编程、稳健的类型系统和复杂的模块系统的支持是 Reason 之所以如此出色的主要原因之一,而且在使用一个考虑到这些特性的语言中同时使用所有这些特性确实有可说之处。当 Facebook 最初发布 React 时,他们要求我们给它五分钟的时间(signalvnoise.com/posts/3124-give-it-five-minutes),并且,希望同样的心态在这里也能得到回报。

跨平台

使用 Reason 构建 React 应用是一种愉快的体验,更重要的是,由于 OCaml 能够编译成原生代码,我们将能够使用这些相同的技能来构建编译成汇编、iOS/Android 等更多平台的 app。实际上,Jared Forsyth 已经创建了一个名为 Gravitron(github.com/jaredly/gravitron)的游戏,它可以从一个 Reason 代码库编译到 iOS、Android、网页和 macOS。尽管如此,截至本文撰写时,前端 JavaScript 的故事已经变得更加完善。

可维护性

Reason 可能需要一些时间才能适应,但你可以将这段时间视为对你未来产品维护和信心的投资。尽管像 TypeScript 这样的渐进式类型系统的语言可能更容易上手,但它们并不提供像 Reason 这样的稳健类型系统所能提供的保证。Reason 的真正好处不能完全通过简单的例子来传达,只有在节省你推理、重构和维护代码的时间和精力时才能真正显现。用这种方式来说;如果有人告诉我他们有 99%的把握床里没有蜘蛛,我仍然会检查整个床,因为我不喜欢虫子!

只要你在 Reason 中 100%投入并且代码能够编译,类型系统就能保证不会有运行时类型错误。确实,当你与非 Reason 代码(例如 JavaScript)进行互操作时,你会引入运行时类型错误的可能性。Reason 的稳健类型系统让你可以相信应用中的 Reason 部分不会引起运行时类型错误,因此你可以额外关注确保这些应用区域的安全性。根据我的经验,在动态语言中编程可能会感觉特别危险。另一方面,Reason 则感觉它总是在背后支持你。

互操作性

话虽如此,有时——尤其是在刚开始学习类型系统时——你可能会不确定如何让你的代码编译。通过 BuckleScript,Reason 允许你在需要时直接降级到原始 JavaScript,无论是通过绑定还是直接在你的 Reason(.re)文件中。这给了你在 JavaScript 中边走边解决问题的自由,一旦你准备好了,就可以将代码的这一部分转换为类型安全的 Reason。

BuckleScript 还允许我们以非常合理的方式将 JavaScript 的惯用语法绑定。正如你将在第四章,“BuckleScript,Belt 和互操作性”中了解到的那样,BuckleScript 是 Reason 的一个非常强大的组成部分。

ES2030

用 Reason 编写感觉就像是在编写 JavaScript 的未来版本。Reason 的一些语言特性,包括管道操作符(github.com/tc39/proposal-pipeline-operator)和模式匹配(github.com/tc39/proposal-pattern-matching),目前正在被提议添加到 JavaScript 语言中。有了 Reason,我们可以利用这些特性以及更多,今天就可以做到。

社区

毫无疑问,Reason 社区是我曾经参与过的最有帮助、最有支持性和最具包容性的社区之一。如果你有问题,或者在某件事上遇到了困难,Reason Discord 频道是获取实时支持的地方。

Reason Discord 频道:

discord.gg/reasonml

通常,当开始使用新技术时,与有经验的人交谈五分钟可以节省你数小时的挫败感。我亲自在一天中的任何时间(包括夜晚)提问,并对有人如此迅速地帮助我感到无比感激和惊讶。花点时间加入 Discord 频道,自我介绍,提问,并分享你对如何使 Reason 变得更好的反馈!

ReactJS 的未来

在实践中,很少有实际应用只使用 ReactJS。通常还会引入其他技术,如 Babel、ESLint、Redux、Flow/TypeScript 和 Immutable.js,以帮助提高代码库的可维护性。Reason 通过其核心语言特性取代了这些额外技术的需求。

ReasonReact 是一个 Reason 库,它将 ReactJS 绑定并提供了一种更简单、更安全的方式来构建 ReactJS 组件。就像 ReactJS 只是 JavaScript 一样,ReasonReact 只是 Reason。此外,它易于逐步采用,因为它是由创建了 ReactJS 的同一个人所制作的。

ReasonReact 自带内置路由器、类似 Redux 的数据管理以及 JSX。如果你有 ReactJS 背景,你会感到非常自在。

重要的是要提到,Reason/ReasonReact 已经被几家公司在生产中使用,包括世界上最大的代码库之一。Facebook 的 messenger.com 代码库已经超过 50% 转换为 ReasonReact。

每个 ReasonReact 功能都在 messenger.com 代码库上进行了广泛的测试。

– 程楼

(reason.town/reason-philosophy)

因此,Reason 和 ReasonReact 的新版本都附带代码修改,自动化了代码库升级过程的大部分,如果不是全部。在发布给公众之前,新功能在 Facebook 内部进行了彻底的测试,这为开发者带来了愉快的体验。

探索 Reason

请问以下内容是语句还是表达式:

let foo = "bar";

在 JavaScript 中,这是一个语句,但在 Reason 中,它是一个表达式。另一个表达式的例子是 4 + 3,它也可以表示为 4 + (2 + 1)

Reason 中的许多事物都是表达式,包括控制结构,如 if-elseswitchforwhile

let message = if (true) {
  "Hello"
} else {
  "Goodbye"
};

Reason 中也有三元运算符。以下是另一种表达前面代码的方式:

let message = true ? "Hello" : "Goodbye";

即使是匿名块作用域也是表达式,它们评估为最后一行的表达式:

let message = {
  let part1 = "Hello";
  let part2 = "World";
  {j|$part1 $part2|j};
};
/* message evaluates to "Hello World" */
/* part1 and part2 are not accessible here */

tuple 是一个不可变的数据结构,可以存储不同类型的值,并且可以是任何长度:

let tuple = ("one", 2, "three");

让我们利用目前已知的知识,直接从 Reason 在线沙盒中的 FizzBuzz 示例入手。FizzBuzz 是一个流行的面试问题,用于判断候选人是否具备编码能力。挑战是编写一个从 1100 打印数字的问题,但打印 Fizz 作为三的倍数,Buzz 作为五的倍数,以及 FizzBuzz 作为三和五的公倍数:

/* Based on https://rosettacode.org/wiki/FizzBuzz#OCaml */
let fizzbuzz = (i) =>
  switch (i mod 3, i mod 5) {
  | (0, 0) => "FizzBuzz"
  | (0, _) => "Fizz"
  | (_, 0) => "Buzz"
  | _ => string_of_int(i)
  };

for (i in 1 to 100) {
  Js.log(fizzbuzz(i))
};

在这里,fizzbuzz 是一个接受整数并返回字符串的函数。一个命令式的 for 循环将其输出记录到控制台。

在 Reason 中,函数的最后一个表达式成为函数的返回值。switch 表达式是唯一的 fizzbuzz 表达式,所以它评估的结果成为 fizzbuzz 的输出。像 JavaScript 一样,switch 评估一个表达式,第一个匹配的案例执行其分支。在这种情况下,switch 评估元组表达式:(i mod 3, i mod 5)

给定 i=1(i mod 3, i mod 5) 变为 (1, 1)。由于 (1, 1) 不匹配 (0, 0)(0, _)(_, 0),按照这个顺序,最后的情况 _(即任何东西)被匹配,并返回 "1"。同样,当给定 i=2 时,fizzbuzz 返回 "2"。当给定 i=3 时,返回 "Fizz"

或者,我们也可以使用 if-else 实现 fizzbuzz

let fizzbuzz = (i) =>
  if (i mod 3 == 0 && i mod 5 == 0) {
    "FizzBuzz"
  } else if (i mod 3 == 0) {
    "Fizz"
  } else if (i mod 5 == 0) {
    "Buzz"
  } else {
    string_of_int(i)
  };

然而,switch 版本的可读性更高。并且正如我们将在本章后面看到的那样,switch 表达式,也称为模式匹配,比我们之前看到的更强大。

数据结构和类型

类型是一组值。更具体地说,42int类型,因为它是一个包含在整数集合中的值。浮点数是一个包含小数点的数字,即42.42.0。在 Reason 中,整数和浮点数有单独的操作符:

/* + for ints */
40 + 2;

/* +. for floats */
40\. +. 2.;

对于-.-*.*/./也是如此。

Reason 使用双引号表示string类型,单引号表示char类型。

创建我们自己的类型

我们也可以创建我们的类型:

type person = (string, int);

/* or */

type name = string;
type age = int;
type person = (name, age);

这是创建person类型人的方法:

let person = ("Zoe", 3);

我们还可以用类型注解任何表达式:

let name = ("Zoe" : string);
let person = ((name, 3) : person);

模式匹配

我们也可以在我们的person上使用模式匹配:

switch (person) {
| ("Zoe", age) => {j|Zoe, $age years old|j}
| _ => "another person"
};

让我们用记录代替元组来表示我们的person。记录与 JavaScript 对象类似,但它们更轻量,默认是不可变的:

type person = {
  age: int,
  name: string
};

let person = {
  name: "Zoe",
  age: 3
};

我们也可以在记录上使用模式匹配:

switch (person) {
| {name: "Zoe", age} => {j|Zoe, $age years old|j}
| _ => "another person"
};

与 JavaScript 一样,{name: "Zoe", age: age}可以表示为{name: "Zoe", age}

我们可以使用扩展(...)操作符从一个现有记录创建一个新的记录:

let person = {...person, age: person.age + 1};

记录在使用之前需要类型定义。否则,编译器会报错,如下所示:

The record field name can't be found.

记录必须与它的类型形状相同。因此,我们不能随意向我们的person记录添加字段:

let person = {...person, favoriteFood: "broccoli"};

/*
  We've found a bug for you!

  This record expression is expected to have type person
  The field favoriteFood does not belong to type person
*/

元组和记录是产品类型的例子。在我们的最近例子中,我们的person类型需要intage。JavaScript 的大多数数据结构都是产品类型;一个例外是boolean类型,它可以是truefalse

Reason 的变体类型,它是和类型的一个例子,允许我们表达“这个”或“那个”。我们可以将boolean类型定义为变体:

type bool =
  | True
  | False;

我们可以有我们需要的构造函数数量:

type decision =
  | Yes
  | No
  | Maybe;

YesNoMaybe被称为构造函数,因为我们可以使用它们来构造值。它们也通常被称为标签。因为这些标签可以构造值,所以变体既是类型也是数据结构:

let decision = Yes;

当然,我们也可以在decision上使用模式匹配:

switch (decision) {
| Yes => "Let's go."
| No => "I'm staying here."
| Maybe => "Convince me."
};

如果我们忘记处理一个情况,编译器会警告我们:

switch (decision) {
| Yes => "Let's go."
| No => "I'm staying here."
};

/*
  Warning number 8

  You forgot to handle a possible value here, for example: 
  Maybe
*/

正如我们将在第二章《设置开发环境》中学习的,编译器可以被配置为将这个警告转换为错误。让我们看看一种利用这些完备性检查来帮助使我们的代码更适应未来重构的方法。

以以下例子为例,我们被要求根据座位所在的区域计算音乐厅座位的票价。楼层座位是$55,而其他所有座位是$45:

type seat =
  | Floor
  | Mezzanine
  | Balcony;

let getSeatPrice = (seat) =>
  switch(seat) { 
  | Floor => 55
  | _ => 45
  };

如果后来音乐厅允许以$65 的价格出售乐池区域的座位,我们首先会在seat中添加另一个构造函数:

type seat =
  | Pit
  | Floor
  | Mezzanine
  | Balcony;

然而,由于使用了通配符 _ 情况,编译器在这次更改后并没有抱怨。如果它真的这样做,那会更好,因为这有助于我们在重构过程中。在更改类型定义后逐步查看编译器消息是 Reason(以及一般而言的 ML 家族语言)使重构和扩展代码成为一个更安全、更愉快的过程。当然,这不仅仅限于变体类型。向我们的 person 类型添加另一个字段也会导致相同的逐步查看编译器消息的过程。

相反,我们应该保留使用 _ 来表示无限多种情况(例如我们的 fizzbuzz 示例)。我们可以重构 getSeatPrice 以使用显式的情况:

let getSeatPrice = (seat) =>
  switch(seat) { 
  | Floor => 55
  | Mezzanine | Balcony => 45
  };

这里,我们欢迎编译器友好地通知我们未处理的案例,然后添加它:

let getSeatPrice = (seat) =>
  switch(seat) {
  | Pit => 65
  | Floor => 55
  | Mezzanine | Balcony => 45
  };

现在让我们想象一下,每个座位,即使是同一区域(即具有相同标签)的座位,也可以有不同的价格。嗯,Reason 变体也可以存储数据:

type seat =
  | Pit(int)
  | Floor(int)
  | Mezzanine(int)
  | Balcony(int);

let seat = Floor(57);

我们可以通过模式匹配来访问这些数据:

let getSeatPrice = (seat) =>
  switch (seat) {
  | Pit(price)
  | Floor(price)
  | Mezzanine(price)
  | Balcony(price) => price
  };

变体不仅限于存储一条数据。让我们想象一下,我们希望我们的 seat 类型不仅存储价格,还要存储是否仍然可用。如果不可用,它应该存储持票人的信息:

type person = {
  age: int,
  name: string,
};

type seat =
  | Pit(int, option(person))
  | Floor(int, option(person))
  | Mezzanine(int, option(person))
  | Balcony(int, option(person));

在解释 option 类型之前,让我们看看它的实现:

type option('a)
  | None
  | Some('a);

在前面的代码中,'a 被称为类型变量。类型变量总是以 ' 开头。这个类型定义使用类型变量,以便它可以适用于任何类型。如果不这样做,我们就需要创建一个仅适用于 person 类型的 personOption 类型:

type personOption(person)
  | None
  | Some(person);

如果我们还想为另一种类型提供选项呢?而不是一遍又一遍地重复这个类型声明,我们应该声明一个多态类型。多态类型是一个包含类型变量的类型。在我们的例子中,'a(发音为 alpha)类型变量将被 person 替换。由于这个类型定义非常常见,它包含在 Reason 的标准库中,因此不需要在您的代码中声明 option 类型。

回到我们的 seat 示例,我们将其价格存储为 int,并将其持有人存储为 option(person)。如果没有持有人,它仍然可用。我们可以有一个 isAvailable 函数,它接受一个 seat 并返回一个 bool

let isAvailable = (seat) =>
  switch (seat) {
  | Pit(_, None)
  | Floor(_, None)
  | Mezzanine(_, None)
  | Balcony(_, None) => true
  | _ => false
  };

让我们退一步,看看 getSeatPriceisAvailable 的实现。很遗憾,这两个函数需要知道不同的构造函数,尽管它们与座位的价格或可用性无关。再次看看我们的 seat 类型,我们看到 (int, option(person)) 在每个构造函数中都被重复。此外,避免在 isAvailable 中使用 _ 情况并没有很好的方法。这些都是表明另一个类型定义可能更好地满足我们需求的迹象。让我们从 seat 类型中移除参数,并将其重命名为 section。我们将声明一个新的记录类型,称为 seat,它包含 sectionpriceperson 字段:

type person = {
  age: int,
  name: string,
};

type section =
 | Pit
 | Floor
 | Mezzanine
 | Balcony;

type seat = {
  section, /* same as section: section, */
  price: int,
  person: option(person)
};

let getSeatPrice = seat => seat.price;

let isAvailable = seat =>
  switch (seat.person) {
  | None => true
  | Some(_person) => false
  };

现在,我们的getSeatPriceisAvailable函数具有更高的信号与噪声比,并且当section类型改变时不需要更改。

作为旁注,_用于变量前缀,以防止编译器警告我们该变量未使用。

使无效状态成为不可能

假设我们想在seat中添加一个字段来保存座位购买日期:

type seat = {
  section,
  price: int,
  person: option(person),
  dateSold: option(string)
};

现在,我们已经在我们的代码中引入了无效状态的可能性。以下是一个此类状态的示例:

let seat = {
  section: Pit,
  price: 42,
  person: None,
  dateSold: Some("2018-07-16")
};

理论上,dateSold字段只应在person字段包含持票人时持有日期。票有售出日期,但没有所有者。我们可以检查我们的假设实现以验证这种状态永远不会发生,但仍然存在我们可能遗漏了某些内容,或者某些微小的重构引入了被忽视的错误的可能。

由于我们现在可以利用 Reason 的类型系统,让我们将这项工作委托给编译器。我们将使用类型系统来强制执行代码中的不变性。如果我们的代码违反了这些规则,它将无法编译。

这种无效状态可能存在的一个迹象是在我们的记录字段中使用option类型。在这些情况下,可能有一种方法可以使用一个变体,使得每个构造函数只包含相关的数据。在我们的例子中,我们的售出日期和持票人数据只应在座位售出时存在:

type person = {
  age: int,
  name: string,
};

type date = string;

type section =
  | Pit
  | Floor
  | Mezzanine
  | Balcony;

type status =
  | Available
  | Sold(date, person);

type seat = {
  section,
  price: int,
  status
};

let getSeatPrice = (seat) => seat.price;

let isAvailable = (seat) =>
  switch (seat.status) {
  | Available => true
  | Sold(_) => false
  };

查看我们的新status类型。Available构造函数不包含任何数据,而Sold构造函数包含售出日期以及持票人。

使用这种seat类型,无法表示没有持票人的售出日期的先前无效状态。这也表明我们的seat类型不再包含option类型。

摘要

在本章中,我们感受到了 Reason 是什么以及它试图解决的问题。我们看到了 Reason 的类型推断如何减轻与静态类型语言相关的许多负担。我们了解到类型系统是一个工具,可以用来为代码库提供强大的保证,从而提供出色的开发者体验。虽然 Reason 可能需要一些时间来适应,但对于中等规模到较大的代码库来说,这种投资是非常值得的。

在下一章中,我们将学习在设置我们的开发环境时如何使用 Reason 的工具链。在第三章“创建 ReasonReact 组件”中,我们将开始构建一个我们将在这本书的其余部分中使用的应用程序。到这本书的结尾,你将能够舒适地使用 Reason 构建真实的 React 应用程序。

第二章:设置开发环境

除了是 OCaml 的新语法之外,Reason 还是一个易于开始的工具链。在本章中,我们将做以下几件事:

  • 了解 Reason 工具链

  • 配置我们的编辑器

  • 使用 bsb 启动一个纯 Reason 项目

  • 了解 bsconfig.json

  • 编写一个示例纯 Reason 应用程序,该程序操作 DOM

  • 使用 bsb 启动一个 ReasonReact 项目

  • 在 Reason 项目中熟悉使用 webpack

要跟随,请克隆本书的 GitHub 仓库并从本章的目录开始。您也可以从空白项目开始:

git clone https://github.com/PacktPublishing/ReasonML-Quick-Start-Guide.git
cd ReasonML-Quick-Start-Guide
cd Chapter02/pure-reason-start
npm install

本章旨在让您熟悉 Reason 工具链。我们将为纯 Reason 项目和 ReasonReact 项目分别设置开发环境。在跟随之后,您将足够熟悉,可以调整开发环境以满足您的喜好。不用担心弄乱任何东西,因为我们将在第三章,创建 ReasonReact 组件,从另一个目录开始。

Reason 工具链

在撰写本文时,Reason 工具链基本上是 BuckleScript(Reason 的合作伙伴项目)和熟悉的 JavaScript 工具链,即 npmwebpack(或另一个 JavaScript 模块打包器)。

由于 BuckleScript 编译到 JavaScript 的 ES5 版本,因此不再需要 babel。编译输出可以配置为使用 CommonJS、AMD 或 ES 模块格式。Reason 强大的静态类型系统取代了 Flow 和 ESlint 的需求。此外,Reason 的编辑器插件都包含 refmt,这实际上是 Reason 的 prettier

安装 BuckleScript

BuckleScript 是一个编译器,它接受 OCaml AST 并生成干净、可读和高效的 JavaScript。它可以通过 npm 安装,如下所示:

npm install -g bs-platform

安装 bs-platform 提供了一个名为 bsb 的二进制文件,它是 BuckleScript 的构建系统。

在未来,Reason 工具链将使针对原生平台以及 JavaScript 的目标变得更加容易。目前,Reason 通过使用名为 bsb-nativebsb 分支来编译成原生代码。

编辑器配置

Reason 支持各种编辑器,包括 VSCode、Sublime Text、Atom、Vim 和 Emacs。VSCode 是推荐的编辑器。要配置 VSCode,只需安装 reason-vscode 扩展即可。就是这样!

查看编辑器特定的说明文档。

Reason 编辑器支持文档可以在reasonml.github.io/docs/editor-plugins.找到

设置纯 Reason 项目

bsb 二进制文件包括一个项目生成器。我们将使用它通过 basic-reason 主题创建一个纯 Reason 项目。运行 bsb -themes 以查看所有可用的项目模板:

Available themes: 
basic
basic-reason
generator
minimal
node
react
react-lite
tea

由于 BuckleScript 与 OCaml 和 Reason 都兼容,因此某些主题仅适用于 OCaml 项目。话虽如此,您可以在任何 BuckleScript 项目中自由地将 OCaml 的 .ml 文件与 Reason 的 .re 文件混合。

在本章中,我们将专注于使用 basic-reasonreact 模板。如果您好奇,react-lite 主题类似于 react,只是将 webpack 替换为更简单、更快、更可靠的模块打包器,该打包器仅用于开发目的。

让我们首先创建一个纯 Reason 项目:

bsb -init my-first-app -theme basic-reason
cd my-first-app

当我们在编辑器中打开项目时,我们看到以下项目结构:

├── .gitignore
├── README.md
├── bsconfig.json
├── node_modules
│   ├── .bin
│   │   ├── bsb
│   │   ├── bsc
│   │   └── bsrefmt
│   └── bs-platform
├── package.json
└── src
    └── Demo.re

总体来说,这里的内容并不多,这对于从 JavaScript 转过来的人来说是一种令人耳目一新的感觉。在 node_modules 中,我们看到 bs-platform 以及一些二进制文件:

  • bsb:构建系统

  • bsc:编译器

  • bsrefmt:这实际上是 Reason 的 prettier,但针对 Reason

正如我们很快就会看到的,bsb 二进制文件在 npm 脚本中使用。这个 bsc 二进制文件很少直接使用。bsrefmt 二进制文件由编辑器插件使用。

Demo.re 中,我们看到一条简单的日志消息:

/* Demo.re */
Js.log("Hello, BuckleScript and Reason!");

package.json 看起来有些熟悉。scripts 字段显示了当前可用的 npm 脚本:

/* package.json */
{
  "name": "my-first-app",
  "version": "0.1.0",
  "scripts": {
    "build": "bsb -make-world",
    "start": "bsb -make-world -w",
    "clean": "bsb -clean-world"
  },
  "keywords": [
    "BuckleScript"
  ],
  "author": "",
  "license": "MIT",
  "devDependencies": {
    "bs-platform": "⁴.0.5"
  }
}

运行 npm run buildDemo.re 编译成 JavaScript。默认情况下,编译后的输出文件会出现在源文件旁边,作为 Demo.bs.js。它是如何知道编译哪些文件以及输出到哪里的呢?这就是 bsconfig.json 发挥作用的地方。

bsconfig.json 文件

bsconfig.json 文件是所有 BuckleScript 项目的必需文件。让我们来探索它:

// This is the configuration file used by BuckleScript's build system bsb. Its documentation lives here: http://bucklescript.github.io/bucklescript/docson/#build-schema.json
// BuckleScript comes with its own parser for bsconfig.json, which is normal JSON, with the extra support of comments and trailing commas.
{
  "name": "my-first-app",
  "version": "0.1.0",
  "sources": {
    "dir" : "src",
    "subdirs" : true
  },
  "package-specs": {
    "module": "commonjs",
    "in-source": true
  },
  "suffix": ".bs.js",
  "bs-dependencies": [
      // add your dependencies here. You'd usually install them normally through `npm install my-dependency`. If my-dependency has a bsconfig.json too, then everything will work seamlessly.
  ],
  "warnings": {
    "error" : "+101"
  },
  "namespace": true,
  "refmt": 3
}

我们很快就会更改一些默认设置,以便更熟悉 BuckleScript 的配置文件。首先,让我们将以下代码添加到 Demo.re 中:

type decision =
  | Yes
  | No
  | Maybe;

let decision = Maybe;

let response =
  switch (decision) {
  | Yes => "Yes!"
  | No => "I'm afraid not."
  };

Js.log(response);

如您所见,switch 表达式并没有处理 decision 的所有可能情况。运行 npm run build 会导致以下输出:

ninja: Entering directory `lib/bs'
[3/3] Building src/Demo.mlast.d
[1/1] Building src/Demo-MyFirstApp.cmj

  Warning number 8
  .../Demo.re 9:3-12:3

   7 │ 
   8 │ let response =
   9 │ switch (decision) {
  10 │ | Yes => "Yes!"
  11 │ | No => "I'm afraid not."
  12 │ };
  13 │ 
  14 │ Js.log(response);

  You forgot to handle a possible value here, for example: 
Maybe

警告字段

warnings field of bsconfig.json to the following:
"warnings": {
  "error": "+101+8" // added "+8"
},

将所有警告转换为错误,请使用以下代码:

"warnings": {
  "error": "A"
},

要查看警告编号的完整列表,请查看 caml.inria.fr/pub/docs/manual-ocaml/comp.html#sec281(向下滚动一点)。

package-specs 字段

package-specs 字段包含两个字段:modulein-source

module 字段控制 JavaScript 模块格式。默认是 commonjs,其他可用选项包括 amdjsamdjs-globales6es6-global-global 部分告诉 BuckleScript 将 node_modules 解析为浏览器中的相对路径。

in-source 字段控制生成的 JavaScript 文件的输出位置;true 将导致生成的文件放置在源文件旁边,而 false 将导致生成的文件放置在 lib/js 中。将 in-source 设置为 false 在使用 Reason 在现有的 JavaScript 项目中非常有用,这样就可以使用现有的构建管道,而无需进行更改。

现在,让我们使用 "es6" 模块格式,并将我们的编译资产放置在 lib/js 中:

"package-specs": {
  "module": "es6",
  "in-source": false
},

后缀字段

suffix 字段配置生成的 JavaScript 文件的扩展名。通常最好保持 ".bs.js" 后缀,因为这有助于 bsb 更好地跟踪生成的工件。

源字段

BuckleScript 知道要查看 src 目录,是因为以下配置:

"sources": {
  "dir" : "src",
  "subdirs" : true
},

如果 subdirsfalse,则位于 src 子目录中的任何 .re.ml 文件都不会被编译。

关于 bsconfig.json 的更多信息,请参阅 BuckleScript 文档的以下部分:bucklescript.github.io/docs/build-configuration

与 DOM 交互

在跳入 ReasonReact 之前,让我们先尝试使用纯 Reason 与 DOM 进行交互。我们将编写一个模块来完成以下工作:

  • 创建一个 DOM 元素

  • 设置该元素的 innerText

  • 将该元素追加到文档的 body 中

在项目的根目录中创建一个 index.html 文件,内容如下:

<html>
  <head></head>
  <body>
    <!-- if "in-source": false -->
    <script type="module" src="img/Demo.bs.js"></script>

    <!-- if "in-source": true -->
    <!-- <script type="module" src="img/Demo.bs.js"></script> -->
  </body>
</html>

注意 script 标签上的 type="module" 属性。如果所有模块依赖项都符合 ES 模块ESM)规范,并且它们都可以从浏览器内部访问,那么你不需要模块打包器就可以开始(假设你使用的是支持 ES 模块的浏览器)。

Greeting.re 中添加以下问候函数:

let greeting = name => {j|hello $name|j};

Demo.re 中添加以下代码:

[@bs.val] [@bs.scope "document"]
external createElement : string => Dom.element = "";

[@bs.set] external setInnerText : (Dom.element, string) => unit = "innerText";

[@bs.val] [@bs.scope "document.body"]
external appendChild : Dom.element => Dom.element = "";

let div = createElement("div");
setInnerText(div, Greeting.greeting("world"));
appendChild(div);

使用 BuckleScript 强大的互操作性功能(我们将在第四章 BuckleScript, Belt 和互操作性 中详细介绍)将前面的代码绑定到现有的浏览器 API,即 document.createElementinnerTextdocument.body.appendChild,然后使用这些绑定创建一个包含一些文本的 div,并将其追加到文档的 body 中。

运行 npm run build,在项目根目录启动一个服务器(可能在新的控制台标签中运行 php -S localhost:3000),然后导航到 http://localhost:3000 来查看我们新创建的 DOM 元素:

总结来说,以这种方式与 DOM 交互真的很繁琐。由于 JavaScript 的动态特性,很难输入 DOM API。例如,Element.innerText 既可以用来获取也可以用来设置元素的 innerText,具体取决于它的使用方式,因此会产生两种不同的类型签名:

[@bs.get] external getInnerText: Dom.element => string = "innerText";
[@bs.set] external setInnerText : (Dom.element, string) => unit = "innerText";

幸运的是,我们已经有 React 了,它很大程度上抽象了 DOM。使用 React,我们不需要担心输入 DOM API。知道当我们想要与各种浏览器 API 交互时,BuckleScript 有我们完成任务所需的工具,这真是太好了。虽然用纯 Reason 编写前端 Web 应用程序是可能的,但使用 ReasonReact 会更加愉快,尤其是在刚开始使用 Reason 时。

设置 ReasonReact 项目

要创建一个新的 ReasonReact 项目,运行以下命令:

bsb -init my-reason-react-app -theme react
cd my-reason-react-app

打开我们的文本编辑器后,我们会看到一些变化。package.json 文件列出了相关的 React 和 webpack 依赖项。让我们来安装它们:

npm install

我们还有以下与 webpack 相关的 npm 脚本:

"webpack": "webpack -w",
"webpack:production": "NODE_ENV=production webpack"

bsconfig.json 中,我们有一个新字段,它为 ReasonReact 启用 JSX:

"reason": {
  "react-jsx": 2
},

我们有一个简单的 webpack.config.js 文件:

const path = require("path");
const outputDir = path.join(__dirname, "build/");

const isProd = process.env.NODE_ENV === "production";

module.exports = {
  entry: "./src/Index.bs.js",
  mode: isProd ? "production" : "development",
  output: {
    path: outputDir,
    publicPath: outputDir,
    filename: "Index.js"
  }
};

注意配置的入口点为 "./src/Index.bs.js",这在 bsconfig.json 中默认将 "in-source" 设置为 true 的情况下是有意义的。其余的都是正常的 webpack 东西。

要运行此项目,我们需要同时运行 bsbwebpack

npm start

/* in another shell */
npm run webpack

/* in another shell */
php -S localhost:3000

由于 index.html 文件位于 src 目录中,我们访问 http://localhost:3000/src 来查看默认的应用程序。

提升开发者体验

现在我们已经看到了工具链在基本层面的工作方式,让我们提升我们的开发者体验,以便我们可以通过一个命令开始我们的项目。我们需要安装几个依赖项,如下所示:

npm install webpack-dev-server --save-dev
npm install npm-run-all --save-dev

现在,我们可以更新我们的 npm 脚本:

"scripts": {
  "start": "npm-run-all --parallel start:*",
  "start:bsb": "bsb -clean-world -make-world -w",
  "start:webpack": "webpack-dev-server --port 3000",
  "build": "npm-run-all build:*",
  "build:bsb": "bsb -clean-world -make-world",
  "build:webpack": "NODE_ENV=production webpack",
  "test": "echo \"Error: no test specified\" && exit 1"
},

接下来,为了让 webpack-dev-serverhttp://localhost:3000 而不是 http://localhost:3000/src 上提供 index.html 文件,我们需要安装和配置 HtmlWebpackPlugin

npm install html-webpack-plugin --save-dev

我们可以移除 src/index.html 中的默认 <script src="img/Index.js"></script> 标签,因为 HTMLWebpackPlugin 会自动插入脚本标签。

我们还移除了 publicPath 设置,以便使用默认路径 "/"

const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");

const isProd = process.env.NODE_ENV === "production";

module.exports = {
  entry: "./src/Index.bs.js",
  mode: isProd ? "production" : "development",
  output: {
    path: path.join(__dirname, "build/"),
    filename: "Index.js"
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: "./src/index.html"
    })
  ]
};

现在,我们运行 npm start 并访问 http://localhost:3000 来查看相同的 ReasonReact 应用程序正在运行。

摘要

在本章中,我们看到了如何轻松地开始使用 Reason。在第三章,创建 ReasonReact 组件中,我们将开始构建一个将在整本书中使用的 ReasonReact 应用程序。这个应用程序将帮助我们更好地理解 Reason 语义、BuckleScript 互操作性和 ReasonReact 的具体细节。

如果您目前还不理解这些生成项目中的所有内容,请不要担心。到第三章,创建 ReasonReact 组件结束时,您会感到更加自在。然而,如果您在途中有任何问题,请不要犹豫,在 Reason 的 Discord 频道寻求实时帮助:discord.gg/reasonml

我希望您会发现 Reason 社区像我所经历的那样欢迎和有帮助。

第三章:创建 ReasonReact 组件

现在我们已经设置了我们的开发环境,我们就可以开始使用 ReasonReact 了——ReactJS 的未来。ReasonML 和 ReasonReact 都是由构建 ReactJS 的同一个人构建的。ReasonReact 就是 Reason,就像 ReactJS 就是 JavaScript 一样。在本书的其余部分,我们将使用一个应用程序,我们将在本章开始构建。以下是本章结束时我们将构建的内容的截图:

图片

要跟上进度,请克隆本书的 GitHub 仓库,并从Chapter03/start开始。在本书的其余部分,每个目录都与我们设置的开发环境相同,即在第二章的设置开发环境结束时设置的环境。

git clone https://github.com/PacktPublishing/ReasonML-Quick-Start-Guide.git
cd ReasonML-Quick-Start-Guide
cd Chapter03/start
npm install

我们首先将探索 ReasonReact,在本章大约一半的时候,我们将转向Chapter03/app-start目录,在那里我们将开始使用 ReasonReact 的内置路由构建应用程序。

在本章中,我们将进行以下操作:

  • 探索创建无状态和有状态 ReasonReact 组件

  • 创建一个包含导航和路由的应用程序

  • 看看您已经熟悉的许多 ReactJS 概念如何很好地映射到 ReasonReact

  • 学习如何利用 Reason 的类型系统,ReasonReact 可以帮助我们创建更健壮的组件

组件创建基础

让我们从分析一个简单的无状态组件开始。在App.re中,让我们渲染一个带有文本的<div />元素:

let component = ReasonReact.statelessComponent("App");

let make = _children => {
  ...component,
  render: _self => <div> {ReasonReact.string("hello world")} </div>,
};

Index.re中,将组件渲染到具有 ID "root"的 DOM 元素中:

ReactDOMRe.renderToElementWithId(<App />, "root");

由于 Reason 的模块系统,我们不需要在Index.re中声明import语句,也不需要在App.re中声明导出语句。每个 Reason 文件都是一个模块,每个 Reason 模块都是全局可用的。在本书的后面部分,我们将看到如何隐藏模块的实现细节,以便您的组件用户只能访问他们应该访问的内容。

组件模板

在 ReasonReact 中,所有组件都是通过以下四个函数之一创建的:

  • ReasonReact.statelessComponent

  • ReasonReact.statelessComponentWithRetainedProps

  • ReasonReact.reducerComponent

  • ReasonReact.reducerComponentWithRetainedProps

这四个函数中的每一个都接受一个string并返回一个对应不同组件模板的recordstring参数仅用于调试目的。组件从其文件名(App.re)中获取其名称(<App />)。返回的记录中的字段取决于使用了哪个函数。在我们的上一个例子中,我们有以下字段可以覆盖:

  • render

  • didMount

  • willReceiveProps

  • shouldUpdate

  • willUpdate

  • didUpdate

  • willUnmount

除了 render 字段外,其余的都是熟悉的 ReactJS 生命周期事件。要覆盖字段,请在 make 函数返回的 record 中添加该字段。在先前的例子中,组件模板的 render 字段被替换为自定义的 render 函数。

make 函数接受 props 作为参数,并返回一个与最初由四个组件创建函数之一创建的形状相同的 recordmake 函数的最后一个参数必须是 children 属性。你可能已经注意到,在先前的例子中 children 前面有一个下划线 _。如果你的组件不需要对 children 属性的引用,那么在参数前加上下划线 _ 可以防止编译器警告未使用的绑定。

可能一开始并不明显,但 make 函数的大括号属于返回的 record 文字。...component 表达式将原始 record 的内容扩展到这个新 record 中,这样就可以在不显式设置每个字段的情况下覆盖单个字段。

self

render 字段包含一个接受名为 self 的参数的回调函数,并返回类型为 ReasonReact.reactElement 的值。self 记录的三个字段如下:

  • state

  • handle

  • send

出于选择,ReasonReact 没有 JavaScript 的 this 的概念。相反,self 包含必要的信息,并提供给需要它的回调函数。当使用有状态组件时,我们将看到更多关于 self 的内容。

事件处理器

在我们的渲染函数中,我们可以像在 ReactJS 中一样将事件监听器附加到 DOM 元素上。例如,为了监听点击事件,我们添加一个 onClick 属性并将其值设置为事件处理器:

let component = ReasonReact.statelessComponent("App");

let make = _children => {
  ...component,
  render: _self =>
    <div onClick={_event => Js.log("clicked")}>
      {ReasonReact.string("hello world")}
    </div>,
};

然而,这个回调函数必须接受恰好一个参数(对应于一个 JavaScript DOM 事件)并且必须返回一个名为 unit 的类型。

单元

在 Reason 中,unit 是一个表示 "无" 的类型。其返回类型为 unit 的函数不能返回除 unit 之外的其他任何内容。unit 类型的值只有一个:()(即一对空括号,也称为 unit)。

相比之下,bool 类型的值正好有两个:truefalseint 类型的值有无限多个。

如 第一章 所述,在 ReasonML 的 介绍 中,表示 Reason 中可空值的惯用方法是使用 option 类型。option 类型与 unit 类型的主要区别在于,option 类型的值可以是空值,也可以是某个值,而 unit 类型的值总是 ()

接受和/或返回 unit 的函数可能引起副作用。例如,Js.log 是一个返回 unit 的函数。onClick 事件处理器也是一个返回 unit 的函数。

Random.bool 是一个接受 unit 作为参数并返回 bool 的函数示例。调用带有 unit 的函数的语法相当熟悉:

Random.bool()

由于 onClick 需要一个返回 unit 的函数,以下将导致类型错误:

let component = ReasonReact.statelessComponent("App");

let make = _children => {
  ...component,
  render: _self =>
    <div onClick={_event => 42}> {ReasonReact.string("hello world")} </div>,
};

类型错误在这里显示:

Error: This expression has type int but an expression was expected of type
  unit

在错误信息中,This expression 指的是 42

JSX

Reason 提供了 JSX 语法。ReasonReact 版本的 JSX 中的一个区别是我们不能在 ReasonReact 中做以下操作:

<div>"hello world"</div>

相反,我们需要使用 ReasonReact.string 函数将 string 转换为 ReasonReact.reactElement

<div>ReasonReact.string("hello world")</div>

然而,这仍然不起作用。我们还需要将表达式包裹在 { } 中,以帮助解析器区分多个可能的子元素:

<div> {ReasonReact.string("hello world")} </div>

你可以自由地创建一个更简洁的别名来使用:

let str = ReasonReact.string;
<div> {str("hello world")} </div>;

当在 JSX 中调用自定义组件时,它的 make 函数会被调用。<App /> 语法会解构为以下形式:

ReasonReact.element(App.make([||]))

当一个组件将接收新的属性时,它的 make 函数将再次被调用,并将新属性作为参数。make 函数类似于 ReactJS 的 constructor 和 ReactJS 的 componentWillReceiveProps 的组合。

属性

让我们在 <App /> 组件中添加一些属性:

let make = (~greeting, ~name, _children) => {
  ...component,
  render: _self => <div> {ReasonReact.string(greeting ++ " " ++ name)} </div>,
};

编译后,我们会得到一个编译错误,因为在 Index.re 中我们没有提供所需的 greetingname 属性:

We've found a bug for you!

1 │ ReactDOMRe.renderToElementWithId(<App />, "root");

This call is missing arguments of type:
(~greeting: string),
(~name: string)

greetingnamemake 函数的 标记参数,这意味着它们可以以任何顺序提供。要将一个参数转换为标记参数,在其前加上波浪号 (~)。Reason 还支持可选参数以及具有默认值的参数。让我们给 greeting 设置一个默认值,并使 name 可选:

let make = (~greeting="hello", ~name=?, _children) => {
  ...component,
  render: _self => {
    let name =
      switch (name) {
      | None => ""
      | Some(name) => name
      };
    <div> {ReasonReact.string(greeting ++ " " ++ name)} </div>;
  },
};

由于 name 是一个可选参数,它被包裹在 option 类型中,然后我们可以根据其值进行模式匹配。当然,这仅仅是一种提供 name 默认值为 "" 的冗长方式。

现在,即使 <App /> 没有提供任何属性,我们的示例也能编译成功:

ReactDOMRe.renderToElementWithId(<App />, "root");
/* hello */

ReactDOMRe.renderToElementWithId(
  <App greeting="welcome," name="reason" />,
  "root",
);
/* welcome, reason */

如果我们决定移除 name 属性,编译器会告诉我们需要更新 <App /> 的使用位置。这给了我们自由重构组件而不必担心忘记更新代码库中的某个区域。编译器是我们的后盾!

子元素

make 函数的最后一个参数总是 children 属性——这是强制性的。和其他属性一样,children 可以是任何数据结构。只要组件允许,我们就可以使用在 ReactJS 中流行的渲染属性模式。重要的是,ReasonReact 总是会将 children 包裹在一个数组中,所以如果我们不想有这种包裹,我们需要使用 ... 语法来解包数组。

App.re 中,我们将移除所有属性,除了必需的 children 属性。在渲染函数中,我们使用硬编码的问候语调用 children

/* App.re */
let component = ReasonReact.statelessComponent("App");

let make = children => {
  ...component,
  render: _self => children("hello"),
};

Index.re 中,我们添加了一个函数作为 <App /> 的子代,该函数接受提供的问候语并返回 JSX(类型为 ReasonReact.reactElement)。注意使用 ... 语法来展开所有 ReasonReact 子代都被包裹的数组:

/* Index.re */
ReactDOMRe.renderToElementWithId(
  <App> ...{greeting => <div> {ReasonReact.string(greeting)} </div>} </App>,
  "root",
);

如果我们忘记了 ...,编译器会友好地提醒我们:

We've found a bug for you!

1 │ ReactDOMRe.renderToElementWithId(
2 │ <App> {greeting => <div> {ReasonReact.string(greeting)} </div>} </App>,
3 │ "root",
4 │ );

This has type:
  array('a)
But somewhere wanted:
  string => ReasonReact.reactElement

如果我们不包含任何子代(即仅 <App />),我们甚至还会得到类似的编译器消息,因为这相当于一个空数组。这意味着我们保证我们的组件用户必须提供一个类型为 string => ReasonReact.reactElement 的函数作为 <App /> 的子代,以便进行类型检查。

我们还可以强制我们的组件接受其他子代类型,例如,两个字符串的元组:

/* App.re */
let component = ReasonReact.statelessComponent("App");

let make = children => {
  ...component,
  render: _self => {
    let (greeting, name) = children;
    <div> {ReasonReact.string(greeting ++ " " ++ name)} </div>;
  },
};
/* Index.re */
ReactDOMRe.renderToElementWithId(<App> ...("hello", "tuple") </App>, "root");

由于在 App.re 中的使用,Reason 能够推断出子代必须是类型为 (string, string) 的元组。例如,考虑以下用法:

ReactDOMRe.renderToElementWithId(<App> ("hello") </App>, "root");

这将导致友好的编译器错误,因为 App 组件要求其子代必须是元组,但 App 组件的子代不是元组。

We've found a bug for you!

1 │ ReactDOMRe.renderToElementWithId(<App> ("hello") </App>, "root");

This has type:
  array('a)
But somewhere wanted:
  (string, string)

这非常强大。由于我们在编译时获得这些保证,所以我们不必担心组件子代形状的运行时检查。同样,我们保证在编译时进行 props 类型检查。重构组件的压力大大减轻,因为编译器在这个过程中为我们提供指导。更重要的是,多亏了 Reason 强大的类型推断,我们迄今为止还没有需要显式注释任何类型。

生命周期

ReasonReact 支持熟悉的 ReactJS 生命周期事件。随着我们构建应用程序,我们将更深入地了解一些生命周期事件,但就目前而言,让我们看看我们如何实现 ReactJS 的 componentDidMount 生命周期钩子为 <App />

let make = _children => {
  ...component,
  didMount: _self => Js.log("mounted"),
  render: _self => <div> {ReasonReact.string("hello")} </div>,
};

我们不使用 componentDidMount,而是使用 didMount。再次强调,didMount 是组件 make 函数返回的记录中的一个字段。didMount 的类型是 self => unit,这是一个接受 self 并返回 unit 的函数。由于它返回 unit,因此 didMount 很可能引起副作用,在我们的例子中确实如此。在浏览器中运行此代码会在控制台输出 mounted

订阅助手

为了使编写清理代码更加方便和易于记忆,ReasonReact 提供了 self.onUnmount,它可以直接在组件的 didMount 生命周期(或任何可以访问 self 的地方)中使用。这允许你将清理代码与其对应的代码一起编写,而不是在 willUnmount 中单独编写:

didMount: self => {
  let intervalId = Js.Global.setInterval(() => Js.log("hello!"), 1000);
  self.onUnmount(() => Js.Global.clearInterval(intervalId));
},

有状态组件

到目前为止,我们只使用了 ReasonReact.statelessComponent 模板。要创建一个有状态的组件,我们将组件模板切换到 ReasonReact.reducerComponent 并覆盖其 make 函数返回的记录中的某些附加字段。因为它内置了状态、动作和 reducer 的概念,所以被称为 reducerComponent——就像 Redux 一样,只是状态、动作和 reducer 是局部于组件的。

这里展示了一个简单的计数器组件,它带有用于增加和减少当前计数的按钮:

type state = int;

type action =
  | Increment
  | Decrement;

let component = ReasonReact.reducerComponent("App");

let make = _children => {
  ...component,
  initialState: () => 0,
  reducer: (action, state) =>
    switch (action) {
    | Increment => ReasonReact.Update(state + 1)
    | Decrement => ReasonReact.Update(state - 1)
    },
  render: self =>
    <>
      <button onClick={_event => self.send(Decrement)}>
        {ReasonReact.string("-")}
      </button>
      <span> {ReasonReact.string(string_of_int(self.state))} </span>
      <button onClick={_event => self.send(Increment)}>
        {ReasonReact.string("+")}
      </button>
    </>,
};

在这里使用 ReactJS 片段语法(<></>)来包裹 <button><span> 元素,而不添加不必要的 DOM 节点。

状态、动作和 reducer

让我们分解一下。在文件顶部,我们看到两个类型声明,一个是状态,另一个是动作。stateaction 的名称是一个约定,但你可以使用任何你喜欢的名称:

type state = int;

type action =
  | Increment
  | Decrement;

正如在 Redux 中一样,事件触发动作,这些动作被发送到 reducer,然后更新状态。接下来,按钮的点击事件触发一个 Decrement 动作,并通过 self.send 发送到组件的 reducer。记住,渲染函数将 self 作为其参数提供:

<button onClick={_event => self.send(Increment)}>
  {ReasonReact.string("+")}
</button>

state 类型声明定义了我们的状态形状。在这种情况下,我们的状态只是一个整数,它持有组件的当前计数。组件的初始状态是 0

initialState: () => 0,

initialState 需要一个类型为 unit => state 的函数。

当被某个动作触发时,reducer 函数接受该动作以及当前状态,并返回一个新的状态。在当前动作上使用模式匹配,并使用 ReasonReact.Update 来返回一个新的状态:

reducer: (action, state) =>
  switch (action) {
  | Increment => ReasonReact.Update(state + 1)
  | Decrement => ReasonReact.Update(state - 1)
  },

为了帮助确保你的 ReasonReact 应用准备好即将到来的 ReactJS Fiber 发布,请确保 reducer 中的所有内容都是纯的。在保持 reducer 纯的同时间接触发副作用的一种方法是通过使用 ReasonReact.UpdateWithSideEffects

reducer: (action, state) =>
  switch (action) {
  | Increment =>
    ReasonReact.UpdateWithSideEffects(
      state + 1,
      (_self => Js.log("incremented")),
    )
  | Decrement => ReasonReact.Update(state - 1)
  },

reducer 的返回值必须是以下变体构造函数之一:

  • ReasonReact.NoUpdate

  • ReasonReact.Update(state)

  • ReasonReact.SideEffects(self => unit)

  • ReasonReact.UpdateWithSideEffects(state, self => unit)

我们可以在副作用内部触发新的动作,因为我们再次提供了 self

reducer: (action, state) =>
  switch (action) {
  | Increment =>
    ReasonReact.UpdateWithSideEffects(
      state + 1,
      (
        self =>
          Js.Global.setTimeout(() => self.send(Decrement), 1000) |> ignore
      ),
    )
  | Decrement => ReasonReact.Update(state - 1)
  },

在增加计数后,reducer 触发一个副作用,该副作用在一秒后触发 Decrement 动作。

重构

让我们假设我们现在需要我们的有状态组件显示一条消息,当用户计数达到 10 时,恭喜用户,一旦消息显示,用户可以通过点击关闭按钮来关闭消息。多亏了我们有帮助的编译器,我们可以遵循以下步骤:

  1. 更新 state 的形状

  2. 更新可用的 actions

  3. 步骤通过编译器错误

  4. 更新 render 函数

编译器消息会提醒我们更新组件的初始状态和 reducer。由于我们现在需要跟踪是否显示消息,让我们将 state 的形状更改为以下内容:

type state = {
  count: int,
  showMessage: bool
};

对于我们的动作,让我们将 IncrementDecrement 合并成一个接受 int 的构造函数,我们将有一个新的构造函数来切换消息:

type action =
  | UpdateCount(int)
  | ToggleMessage;

现在,我们不再有 IncrementDecrement,而是 UpdateCount,它包含一个表示当前计数增加或减少的整数值。

编译后,我们看到一个友好的错误消息,告诉我们之前的动作 Decrement 无法找到:

We've found a bug for you!
24 | render: self =>
25 | <>
26 | <button onClick={_event => self.send(Decrement)}>
27 | {ReasonReact.string("-")}
28 | </button>
The variant constructor Decrement can't be found.
- If it's defined in another module or file, bring it into scope by:
- Annotating it with said module name: let food = MyModule.Apple
- Or specifying its type: let food: MyModule.fruit = Apple
- Constructors and modules are both capitalized. Did you want the latter?
Then instead of let foo = Bar, try module Foo = Bar.

render 函数中,将 Increment 替换为 UpdateCount(+1),将 Decrement 替换为 UpdateCount(-1)

render: self =>
  <>
    <button onClick={_event => self.send(UpdateCount(-1))}>
      {ReasonReact.string("-")}
    </button>
    <span> {ReasonReact.string(string_of_int(self.state))} </span>
    <button onClick={_event => self.send(UpdateCount(1))}>
      {ReasonReact.string("+")}
    </button>
  </>,

再次编译,我们被告知在我们的 reducer 中,Increment 不属于 action 类型。让我们更新我们的 reducer 来处理 UpdateCountToggleMessage。如果我们忘记了一个构造函数,编译器会告诉我们 reducer 中的 switch 表达式不是详尽的:

reducer: (action, state) =>
  switch (action) {
  | UpdateCount(delta) =>
    let count = state.count + delta;
    ReasonReact.UpdateWithSideEffects(
      {...state, count},
      (
        self =>
          if (count == 10) {
            self.send(ToggleMessage);
          }
      ),
    );
  | ToggleMessage =>
    ReasonReact.Update({...state, showMessage: !state.showMessage})
  },

关于前面的代码片段,有几件事情要说明:

  • UpdateCount 中,我们声明了一个绑定 count,它反映了新的计数。

  • 我们使用 ... 来覆盖状态记录的一部分。

  • 由于记录 punning 支持功能,我们可以写 {...state, count} 而不是 {...state, count: count}

  • UpdateCount 使用 UpdateWithSideEffects 在计数达到 10 时触发一个 ToggleMessage 动作;我们本可以这样做:

| UpdateCount(delta) =>
  let count = state.count + delta;
  ReasonReact.Update(
    if (count == 10) {
      {count, showMessage: true};
    } else {
      {...state, count};
    },
  );

我更喜欢使用 UpdateWithSideEffects,这样 UpdateCount 只需要关心其计数字段,如果其他字段需要更新,UpdateCount 可以触发正确的动作来实现这一点,而无需知道它应该如何实现。

在这里编译后,我们得到了一个有趣的编译器错误:

We've found a bug for you!

16 | switch (action) {
17 | | UpdateCount(delta) =>
18 | let count = state.count + delta;
19 | ReasonReact.UpdateWithSideEffects(
20 | {...state, count},

This has type:
  int
But somewhere wanted:
  state

编译器将第 18 行(之前显示)的 state.count 中的 state 视为 int 类型而不是 state 类型。这是因为我们的 render 函数正在使用 string_of_int(self.state) 而不是 string_of_int(self.state.count)。在更新我们的 render 函数以反映这一点后,我们得到了另一个类似的错误消息,抱怨 int 类型与 state 类型不兼容。这是因为我们的初始状态仍然返回 0 而不是 state 类型的记录。

更新初始状态后,代码最终编译成功:

initialState: () => {count: 0, showMessage: false},

现在,我们准备更新 render 函数以在计数达到 10 时显示消息:

render: self =>
  <>
    <button onClick={_event => self.send(UpdateCount(-1))}>
      {ReasonReact.string("-")}
    </button>
    <span> {ReasonReact.string(string_of_int(self.state.count))} </span>
    <button onClick={_event => self.send(UpdateCount(1))}>
      {ReasonReact.string("+")}
    </button>
    {
      if (self.state.showMessage) {
        <>
          <p>
            {ReasonReact.string("Congratulations! You've reached ten!")}
          </p>
          <button onClick={_event => self.send(ToggleMessage)}>
            {ReasonReact.string("close")}
          </button>
        </>;
      } else {
        ReasonReact.null;
      }
    }
  </>,

由于 if/else 是 Reason 中的一个表达式,我们可以在 JSX 中使用它来渲染标记或 ReasonReact.null(它具有 ReasonReact.reactElement 类型)。

实例变量

尽管我们的示例在计数器第一次达到 10 时正确显示了消息,但没有任何东西可以阻止我们的ToggleMessage操作在UpdateCount案例中的 reducer 中再次触发。如果用户达到 10,然后递减再递增,消息会再次切换。为了确保UpdateCount只触发一次ToggleMessage操作,我们可以在状态中使用一个实例变量

在 ReactJS 中,每当状态中的某个东西发生变化时,组件都会重新渲染。在 ReasonReact 中,实例变量永远不会触发重新渲染,并且可以正确地放置在组件的状态中。

让我们添加一个实例变量来跟踪用户是否已经看到了消息:

type state = {
  count: int,
  showMessage: bool,
  userHasSeenMessage: ref(bool)
};

引用和可变记录

ReasonReact 实例变量和普通状态变量之间的区别在于ref的使用。之前,我们看到state.userHasSeenMessage的类型是ref(bool)而不是bool。这使得state.userHasSeenMessage成为一个实例变量。

由于ref只是具有可变字段的记录类型的语法糖,让我们首先讨论可变记录字段。

要允许记录字段可变,请在该字段名称前加上mutable前缀。然后,可以使用=运算符就地更新这些字段:

type ref('a) = {
  mutable contents: 'a
};

let foo = {contents: 5};
Js.log(foo.contents); /* 5 */
foo.contents = 6;
Js.log(foo.contents); /* 6 */

然而,类型声明已经包含在 Reason 的标准库中,因此我们可以省略它,前面的其余代码仍然可以工作,声明它覆盖了原始类型声明。我们可以通过用一个不可变记录覆盖ref类型来证明这一点:

type ref('a) = {contents: 'a};

let foo = {contents: 5};
Js.log(foo.contents); /* 5 */
foo.contents = 6;
Js.log(foo.contents); /* 6 */

编译器失败,出现以下错误:

We've found a bug for you!

The record field contents is not mutable

除了具有内置的类型定义外,ref还有一些内置函数。具体来说,ref用于创建类型为ref的记录,^用于获取ref的内容,而:=用于设置ref的内容:

type foo = ref(int);

let foo = ref(5);
Js.log(foo^); /* 5 */
foo := 6;
Js.log(foo^); /* 6 */

让我们回到我们的 ReasonReact 示例,并使用我们新的userHasSeenMessage实例变量。在更新状态的形状后,我们还需要更新组件的初始状态:

initialState: () => {
  count: 0,
  showMessage: false,
  userHasSeenMessage: ref(false),
},

现在,我们的代码再次编译成功,我们可以更新reducer以使用这个实例变量:

reducer: (action, state) =>
  switch (action) {
  | UpdateCount(delta) =>
    let count = state.count + delta;
    if (! state.userHasSeenMessage^ && count == 10) {
      state.userHasSeenMessage := true;
      ReasonReact.UpdateWithSideEffects(
        {...state, count},
        (self => self.send(ToggleMessage)),
      );
    } else {
      ReasonReact.Update({...state, count});
    };
  | ToggleMessage =>
    ReasonReact.Update({...state, showMessage: !state.showMessage})
  },

现在,消息正确显示了一次,而且只显示了一次。

导航菜单

让我们利用到目前为止所学的内容作为基础,在创建一个带有导航菜单和客户端路由的应用程序时进行构建。在触摸设备上,用户将能够滑动来关闭菜单,并且菜单将实时响应用户的触摸。如果用户在菜单超过 50%关闭时滑动并释放,菜单将关闭;否则,它将保持打开。唯一的例外是如果用户以足够高的速度滑动关闭菜单,它总是会关闭。

我们将在本书的其余部分使用这个应用程序。要跟上,请克隆 GitHub 仓库并导航到代表本章开始的目录:

git clone https://github.com/PacktPublishing/ReasonML-Quick-Start-Guide.git
cd ReasonML-Quick-Start-Guide
cd Chapter03/app-start
npm install

让我们花点时间看看我们有什么可以工作的。您将看到以下目录结构:

├── bsconfig.json
├── package-lock.json
├── package.json
├── src
│   ├── App.re
│   ├── App.scss
│   ├── Index.re
│   ├── Index.scss
│   ├── img
│   │   └── icon
│   │   ├── arrow.svg
│   │   ├── chevron.svg
│   │   └── hamburger.svg
│   └── index.html
└── webpack.config.js

我们的 bsconfig.json 已配置为将编译的 .bs.js 文件放置在 lib/es6/src 中,并且我们已经配置了 webpack 以查找 lib/es6/src/Index.bs.js 作为入口点。

运行 npm install 然后运行 npm start 以在 http://localhost:3000 上以 bsb 和 webpack 的监视模式提供我们的应用。

目前,我们的应用显示一个带有汉堡图标的天蓝色导航栏。点击图标会打开菜单,点击菜单外部会关闭它。

App.re 中,我们的状态目前是一个单字段记录,用于跟踪菜单的状态:

type state = {isOpen: bool};

我们有一个动作:

type action =
  | ToggleMenu(bool);

我们的 reducer 负责更新菜单的状态:

reducer: (action, _state) =>
  switch (action) {
  | ToggleMenu(isOpen) => ReasonReact.Update({isOpen: isOpen})
  },

虽然 Reason 支持记录欺骗,但它不适用于单字段记录,因为 Reason 将 {isOpen} 视为一个块而不是一个记录。

我们的渲染函数根据当前状态渲染一个带有条件类名的 <div /> 元素:

<div
  className={"App" ++ (self.state.isOpen ? " overlay" : "")}
  onClick={
    _event =>
      if (self.state.isOpen) {
        self.send(ToggleMenu(false));
      }
  }>

App.scss 使用 overlay 类在导航菜单打开时在其后面显示一个深色覆盖层:

.App {
  min-height: 100vh;

  &:after {
    content: "";
    transition: opacity 450ms cubic-bezier(0.23, 1, 0.32, 1),
      transform 0ms cubic-bezier(0.23, 1, 0.32, 1) 450ms;
    position: fixed;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    background-color: rgba(0, 0, 0, 0.33);
    transform: translateX(-100%);
    opacity: 0;
    z-index: 1;
  }

  &.overlay {
    &:after {
      transition: opacity 450ms cubic-bezier(0.23, 1, 0.32, 1);
      transform: translateX(0%);
      opacity: 1;
    }
  }
  ...
}

注意 transition 属性是如何在 .App:after.App.overlay:after 上定义的,前者在 transform 属性上包含一个 450ms 的延迟过渡,而后者则移除了这个过渡。这允许在菜单关闭时也能实现平滑过渡。

绑定

让我们检查 App.re 顶部对 JavaScript 的 require 函数的绑定。由于我们将在第四章 BuckleScript, Belt, and Interoperability 中更深入地探讨 BuckleScript,让我们先暂时不讨论细节,只简要看看这个绑定做了什么:

[@bs.val] external require: string => string = "";

require("../../../src/App.scss");

external 关键字创建了一个新的绑定,类似于 let 关键字。绑定到 JavaScript 的 require 函数后,只要我们使用 BuckleScript 编译器,我们就可以在 Reason 中使用它。我们用它来引入 App.scss 以及一些图片。检查 lib/es6/src/App.bs.js 中的编译输出显示,前面的 Reason 代码编译成了以下内容:

require("../../../src/App.scss");

Webpack 从那里处理其余部分。

事件

由于顶级 <div /> 元素有一个始终关闭菜单的点击事件处理器,因此对其子元素的任何点击也会触发该顶级点击事件处理器。为了允许菜单打开(或保持打开),我们需要在部分子元素的点击事件上调用 event.stopPropagation()

在 ReasonReact 中,我们可以使用 ReactEvent 模块来做这件事:

onClick=(event => ReactEvent.Mouse.stopPropagation(event))

ReactEvent 模块有与 ReactJS 的每个合成事件对应的子模块:

  • 剪贴板事件

  • 组合事件

  • 键盘事件

  • 焦点事件

  • 表单事件

  • 鼠标事件

  • 指针事件

  • 选择事件

  • 触摸事件

  • UI 事件

  • 滚轮事件

  • 媒体事件

  • 图片事件

  • 动画事件

  • 过渡事件

想了解更多关于 ReactJS 的合成事件的信息,请访问 reactjs.org/docs/events.html

要从触摸事件中获取 event.changedTouches.item(0).clientX 这样的值,我们使用 ReasonReact 和 BuckleScript 的组合。

Js.t 对象

BuckleScript 允许我们使用 ## 语法访问任意 JavaScript 对象字段。我们可以使用任何 Js.t 类型,这是一个表示任意 JavaScript 对象的 Reason 类型。我们将在 第四章,BuckleScript, Belt, 和互操作性 中了解更多关于这一点和其他互操作性功能。

由于 ReactEvent.Touch.changedTouches(event) 返回一个普通的 JavaScript 对象,我们可以使用以下方式访问其字段:

/* App.re */
ReactEvent.Touch.changedTouches(event)##item(0)##clientX

在编译输出中查看,我们看到这正是我们想要的:

/* App.bs.js */
event.changedTouches.item(0).clientX

我们将使用这个功能将触摸功能添加到我们的菜单中,以便用户可以滑动菜单关闭,并看到菜单在滑动时移动。

添加动作

让我们从添加 TouchStartTouchMoveTouchEnd 的动作开始:

type action =
  | ToggleMenu(bool)
  | TouchStart(float)
  | TouchMove(float)
  | TouchEnd;

我们只需要在 TouchStartTouchMove 中使用触摸事件的 clientX 属性。

让我们在顶层 <div /> 组件上添加事件监听器:

render: self =>
  <div
    className={"App" ++ (self.state.isOpen ? " overlay" : "")}
    onClick={
      _event =>
        if (self.state.isOpen) {
          self.send(ToggleMenu(false));
        }
    }
    onTouchStart={
      event =>
        self.send(
          TouchStart(
            ReactEvent.Touch.changedTouches(event)##item(0)##clientX,
          ),
        )
    }
    onTouchMove={
      event =>
        self.send(
          TouchMove(
            ReactEvent.Touch.changedTouches(event)##item(0)##clientX,
          ),
        )
    }
    onTouchEnd={_event => self.send(TouchEnd)}>

在我们的 reducer 中,现在我们只记录那些 clientX 值:

reducer: (action, state) =>
  switch (action) {
  | ToggleMenu(isOpen) => ReasonReact.Update({isOpen: isOpen})
  | TouchStart(clientX) =>
    Js.log2("Start", clientX);
    ReasonReact.NoUpdate;
  | TouchMove(clientX) =>
    Js.log2("Move", clientX);
    ReasonReact.NoUpdate;
  | TouchEnd =>
    Js.log("End");
    ReasonReact.NoUpdate;
  },

为了确定用户滑动的整体方向,我们需要该滑动的第一个和最后一个 clientX 值。菜单应该按照第一个和最后一个 clientX 值的差值成比例移动,但只有当用户向关闭菜单的方向滑动时。

我们的状态现在包含一个 touches 记录,它保存了第一个和最后一个 clientX 值:

type touches = {
  first: option(float),
  last: option(float),
};

type state = {
  isOpen: bool,
  touches,
};

由于我们无法嵌套记录类型定义,我们单独定义了 touches 类型并将其包含在 state 中。你会注意到 state.touches.firstoption(float) 类型,因为用户可能没有使用触摸设备,或者用户还没有进行交互。

改变我们状态的结构需要我们同时改变初始状态:

initialState: () => {
  isOpen: false,
  touches: {
    first: None,
    last: None,
  },
},

在 reducer 中,如果菜单是打开的,我们在 TouchStart 情况下使用一个新的记录更新 state.touches,但在 TouchMove 情况下,我们只更新 state.touches.last。如果菜单当前没有打开,则返回 ReasonReact.NoUpdate

reducer: (action, state) =>
  switch (action) {
  | ToggleMenu(isOpen) => ReasonReact.Update({...state, isOpen})
  | TouchStart(clientX) =>
    if (state.isOpen) {
      ReasonReact.Update({
        ...state,
        touches: {
          first: Some(clientX),
          last: None,
        },
      });
    } else {
      ReasonReact.NoUpdate;
    }
  | TouchMove(clientX) =>
    if (state.isOpen) {
      ReasonReact.Update({
        ...state,
        touches: {
          ...state.touches,
          last: Some(clientX),
        },
      });
    } else {
      ReasonReact.NoUpdate;
    }
  | TouchEnd => ReasonReact.NoUpdate
  },

我们很快将使用这个状态来有条件地设置 <nav /> 元素的内联样式。

内联样式

在 ReasonReact 中,我们可以通过 ReactDOMRe.Style.make 添加内联样式,它接受 CSS 属性作为可选的标签化参数。由于它们都是可选的,传递 unit 是必要的来调用该函数:

style={ReactDOMRe.Style.make(~backgroundColor="yellow", ())}

将此应用于我们的 <nav /> 元素,如果我们有状态中的第一个和最后一个触摸,我们可以有条件地添加一个样式:

style={
  switch (self.state.touches) {
  | {first: Some(x), last: Some(x')} =>
    ReactDOMRe.Style.make(
      ~transform=
        "translateX("
        ++ string_of_float(x' -. x > 0.0 ? 0.0 : x' -. x)
        ++ "0px)",
      ~transition="none",
      (),
    )
  | _ => ReactDOMRe.Style.make()
  }
}

transform 属性中,我们使用 "0px" 而不是仅仅 "px" 进行拼接,因为 float 类型总是包含小数点,但用户可能滑动了一个正好为一百像素的距离,transform: translateX(100.px) 不是一个有效的 CSS,但 transform: translateX(100.0px) 是。

在触摸设备上运行此代码显示,我们能够根据用户的滑动改变菜单的位置。现在,让我们专注于 reducer 中的 TouchEnd 情况。目前,让我们设置如果用户将菜单滑动不到一半就保持打开,否则关闭它。如果 state.touches.lastNone,则用户没有滑动,我们不更新 state

| TouchEnd =>
  if (state.isOpen) {
    let x = Belt.Option.getWithDefault(state.touches.last, 0.0);
    if (x < 300.0 /. 2.0) {
      ReasonReact.UpdateWithSideEffects(
        {
          ...state,
          touches: {
            first: None,
            last: None,
          },
        },
        (self => self.send(ToggleMenu(false))),
      );
    } else {
      ReasonReact.Update({
        ...state,
        touches: {
          first: None,
          last: None,
        },
      });
    };
  } else {
    ReasonReact.NoUpdate;
  }

注意我们将 state.touches 重置为一个新的记录 {first: None, last: None},这导致 <nav /> 元素上的样式属性为空。

当前实现假设导航的宽度为 300px。而不是假设,我们可以使用 React 引用来获取 DOM 节点的引用,然后获取它的 clientWidth

React 引用

React 引用只是 state 的一个实例变量:

type state = {
  isOpen: bool,
  touches,
  width: ref(float),
};

我们通过将 ref 属性设置为 self.handle((ref, self) => ...) 的结果,在 <nav /> 元素上附加 React 引用:

ref={
  self.handle((ref, self) =>
    self.state.width :=
      (
        switch (Js.Nullable.toOption(ref)) {
        | None => 0.0
        | Some(r) => ReactDOMRe.domElementToObj(r)##clientWidth
        }
      )
  )
}

由于 React 引用在 JavaScript 中可能是 null,我们将它转换为选项并对它的值进行模式匹配。

React 引用的类型取决于它是一个 DOM 元素还是一个 React 组件。前者的类型是 Dom.element,后者的类型是 ReasonReact.reactRef。要将 ReasonReact.reactRef 转换为 JavaScript 对象,请使用 ReasonReact.refToJsObj 而不是 ReactDOMRe.domElementToObj

然后,在 reducer 中,我们可以使用 state.width 而不是 300.0 作为菜单的宽度。由于 TouchStartTouchMove 动作在菜单打开时总是更新状态,因此 <App /> 组件总是重新渲染,这导致我们的 React 引用函数重新运行,我们可以合理地确信菜单的宽度始终是正确的。

速度

要获取用户滑动速度,我们需要存储当前时间以及触摸事件的 clientX。让我们绑定到浏览器的 performance.now() 方法:

[@bs.val] [@bs.scope "performance"] external now: unit => float = "";

我们在 touches 类型中为触摸的当前时间留出一些空间:

type touches = {
  first: option((float, float)),
  last: option((float, float)),
};

在 reducer 中,我们将 Some(clientX) 更改为 Some((clientX, now()))

现在,我们可以在 TouchEnd 情况下计算用户滑动的速度:

| TouchEnd =>
  if (state.isOpen) {
    let (x, t) =
      Belt.Option.getWithDefault(state.touches.first, (0.0, 0.0));
    let (x', t') =
      Belt.Option.getWithDefault(state.touches.last, (0.0, 0.0));
    let velocity = (x' -. x) /. (t' -. t);
    let state = {
      ...state,
      touches: {
        first: None,
        last: None,
      },
    };
    if (velocity < (-0.3) || x' < state.width^ /. 2.0) {
      ReasonReact.UpdateWithSideEffects(
        state,
        (self => self.send(ToggleMenu(false))),
      );
    } else {
      ReasonReact.Update(state);
    };
  } else {
    ReasonReact.NoUpdate;
  }

-0.3 像素每毫秒的速度对我来说感觉是正确的,但请随意使用您觉得合适的感觉。

注意我们如何可以使用模式匹配来解构 (x, t),这会在作用域中创建两个绑定。此外,x' 是 Reason 中一个绑定的有效名称,通常读作 x prime。最后,注意我们的状态是如何被阴影覆盖以防止编写重复代码的。

要完成速度功能,我们在渲染函数中更新 style 属性,将 state.touches.firststate.touches.last 都视为元组:

style=(
  switch (self.state.touches) {
  | {first: Some((x, _)), last: Some((x', _))} =>
    ReactDOMRe.Style.make(
      ~transform=
        "translateX("
        ++ string_of_float(x' -. x > 0.0 ? 0.0 : x' -. x)
        ++ "0px)",
      ~transition="none",
      (),
    )
  | _ => ReactDOMRe.Style.make()
  }
)

现在,当打开时,菜单对触摸反应良好——超级酷!

客户端路由

ReasonReact 包含一个内置的路由器,位于 ReasonReact.Router 模块中。它相当无偏见,因此很灵活。公共 API 只包含四个函数:

  • ReasonReact.Router.watchUrl: (url => unit) => watcherID

  • ReasonReact.Router.unwatchUrl: watcherID => unit

  • ReasonReact.Router.push: string => unit

  • ReasonReact.Router.dangerouslyGetInitialUrl: unit => url

watchUrl函数开始监视 URL 的变化。当 URL 发生变化时,会调用url => unit回调。unwatchUrl函数停止监视 URL。

push函数设置 URL,dangerouslyGetInitialUrl函数获取类型为url的记录。dangerouslyGetInitialUrl函数仅应在didMount生命周期钩子内与watchUrl一起使用,以防止与过时信息相关的问题。

url类型的定义如下:

type url = {
  path: list(string),
  hash: string,
  search: string,
};

我们将在第四章中了解更多关于list类型构造函数的内容,BuckleScript, Belt, and Interoperability。在url记录中的path字段是list(string)类型。如果window.location.pathname的值为"/book/title/edit",则url.path的值将是["book", "title", "edit"],这是一个字符串列表。语法看起来像 JavaScript 数组,但有一些区别。简而言之,Reason 列表是单链表,不可变且同构,这意味着所有元素必须是同一类型。

watcherID类型的定义是一个抽象类型。我们将在第六章中了解更多关于抽象类型的内容,CSS-in-JS (in Reason)。获取watcherID类型值的唯一方式是作为ReasonReact.Router.watchUrl的返回值。

让我们创建一个 router 组件,它包裹我们的<App />组件,并为其提供一个currentRoute属性。以下内容受到了 Khoa Nguyen(@thangngoc89)的一个示例的启发。

首先,让我们为<Home /><Page1 /><Page2 /><Page3 />创建占位组件。然后,在Router.re中,让我们创建一个表示路由及其路由列表的类型:

type route = {
  href: string,
  title: string,
  component: ReasonReact.reactElement,
};

let routes = [
  {href: "/", title: "Home", component: <Home />},
  {href: "/page1", title: "Page1", component: <Page1 />},
  {href: "/page2", title: "Page2", component: <Page2 />},
  {href: "/page3", title: "Page3", component: <Page3 />},
];

每个路由都有一个hreftitle以及一个相关的component,如果该路由是当前路由,它将被渲染在<App />中。

当前路由

Index.re中,让我们将<App />包裹在一个提供currentRoute属性的 router 组件中:

ReactDOMRe.renderToElementWithId(
  <Router.WithRouter>
    ...((~currentRoute) => <App currentRoute />)
  </Router.WithRouter>,
  "root",
);

Router.re中,我们使用module语法定义了三个组件——<WithRouter /><Link /><NavLink />。由于每个文件也是一个模块,这三个组件嵌套在Router模块下,在Index.re中我们需要告诉编译器在Router模块中查找<WithRouter />

module WithRouter = {
  type state = route;
  type action =
    | ChangeRoute(route);
  let component = ReasonReact.reducerComponent("WithRouter");
  let make = children => {
    ...component,
    didMount: self => {
      let watcherID =
        ReasonReact.Router.watchUrl(url =>
          self.send(ChangeRoute(urlToRoute(url)))
        );
      ();
      self.onUnmount(() => ReasonReact.Router.unwatchUrl(watcherID));
    },
    initialState: () =>
      urlToRoute(ReasonReact.Router.dangerouslyGetInitialUrl()),
    reducer: (action, _state) =>
      switch (action) {
      | ChangeRoute(route) => ReasonReact.Update(route)
      },
    render: self => children(~currentRoute=self.state),
  };
};

我们之前已经见过所有这些概念。《WithRouter />》只是一个 reducer 组件。组件的状态是之前定义的相同路由类型,并且只有一个动作可以更改路由。一旦<WithRouter />被挂载,ReasonReact.Router开始监视 URL,每当它发生变化时,就会触发ChangeRoute动作,这会调用 reducer,然后更新状态,然后使用更新的currentRoute属性重新渲染<App />

为了确保每次 <App /> 接收到新的 currentRoute 属性时,我们的菜单都会关闭,我们为 <App /> 添加了一个 willReceiveProps 生命周期钩子:

willReceiveProps: self => {...self.state, isOpen: false},

辅助函数

由于 ReasonReact.Routerurl.path 是一个字符串列表,而我们的 Router.route.href 是一个字符串,我们需要一种方法将字符串转换为字符串列表:

let hrefToPath = href =>
  Js.String.replaceByRe([%bs.re "/(^\\/)|(\\/$)/"], "", href)
  |> Js.String.split("/")
  |> Belt.List.fromArray;

我们将在第四章 BuckleScript、Belt 和互操作性 中讨论 Reason 的管道操作符 (|>) 和 JavaScript 互操作性。

我们还需要一种方法将 url 转换为 route,用于初始状态以及 watchUrl 的回调函数中:

let urlToRoute = (url: ReasonReact.Router.url) =>
  switch (
    Belt.List.getBy(routes, route => url.path == hrefToPath(route.href))
  ) {
  | None => Belt.List.headExn(routes)
  | Some(route) => route
  };

在第四章 BuckleScript、Belt 和互操作性 中,我们将更深入地探讨 BuckleScript、Belt 和 JavaScript 互操作性。urlToRoute 函数尝试在 routes 列表中找到一个 route,其 url.path 在转换为字符串列表后与 route.href 结构上相等。

如果不存在这样的 route,它将返回 routes 列表中的第一个 route,这是与 <Home /> 组件关联的。否则,返回匹配的 route

<Link /> 组件是一个简单的无状态组件,用于渲染锚点链接。注意点击处理程序如何阻止默认的浏览器行为并更新 URL:

module Link = {
  let component = ReasonReact.statelessComponent("Link");
  let make = (~href, ~className="", children) => {
    ...component,
    render: self =>
      <a
        href
        className
        onClick=(
          self.handle((event, _self) => {
            ReactEvent.Mouse.preventDefault(event);
            ReasonReact.Router.push(href);
          })
        )>
        ...children
      </a>,
  };
};

<NavLink /> 组件包装了 <Link /> 组件,并提供了当前路由作为属性,它使用该属性有条件地设置一个 active 类:

module NavLink = {
  let component = ReasonReact.statelessComponent("NavLink");
  let make = (~href, children) => {
   ...component,
   render: _self =>
    <WithRouter>
      ...(
          (~currentRoute) =>
            <Link
              href className=(currentRoute.href == href ? "active" : "")>
              ...children
            </Link>
          )
    </WithRouter>,
  };
};

使用方法

现在我们已经定义了路由器,我们可以将我们的导航菜单链接重写为使用 <NavLink /> 组件而不是直接使用原始的锚点链接:

<li>
  <Router.NavLink href="/">
    (ReasonReact.string("Home"))
  </Router.NavLink>
</li>

无论我们想在何处显示当前页面的标题,我们都可以简单地访问当前路由上的 title 字段:

<h1> (ReasonReact.string(currentRoute.title)) </h1>

同样,我们可以以类似的方式渲染与路由关联的组件:

<main> currentRoute.component </main>

强调这一点很重要,即 ReasonReact 的路由器并不规定 watchUrl 的回调应该做什么。在我们的例子中,我们触发一个更新当前路由的动作,这只是一个任意的记录。路由类型完全不同是完全合理的。也没有法律规定路由器应该是顶级组件。这里有很大的创意空间,我个人很期待看到社区会提出什么。

摘要

在本章中,我们看到了 ReasonReact 是构建 React 组件的一种更简单、更安全的方法。Reason 的类型系统在编译时强制执行正确的组件使用,这是一个巨大的胜利。它还使重构更安全、更便宜,并且体验更加愉快。ReasonReact 就是 Reason,就像 ReactJS 就是 JavaScript 一样。我们迄今为止所做的一切都是 Reason 和 ReasonReact,没有任何第三方库,如 Redux 或 React Router。

正如我们在第四章,“BuckleScript、Belt 和互操作性”中所见,我们也有在 Reason 中使用现有的 JavaScript(和 ReactJS)解决方案的选项。在更加熟悉 BuckleScript、Belt 标准库和 JavaScript 互操作性之后,我们将添加路由转换。

第四章:BuckleScript、Belt 和互操作性

在本章中,我们将更深入地了解 BuckleScript 特有的功能,这些功能对我们可用。我们还将学习递归和递归数据结构。在本章结束时,我们将完成 Reason 及其生态系统的介绍。在这个过程中,我们将完成以下工作:

  • 更深入地了解了 Reason 的模块系统

  • 探索了 Reason 的更多原始数据结构(数组和列表)

  • 看到了各种管道操作符如何使代码更易于阅读

  • 熟悉 Reason 和 Belt 标准库

  • 为在 Reason 中使用创建了绑定到 JavaScript 模块

  • 通过绑定到 React Transition Group 组件为我们的应用程序添加了路由转换

为了跟上进度,请使用您想要的任何环境。我们将要做的大部分内容都不是 ReasonReact 特有的。在本章的结尾,我们将继续构建我们的 ReasonReact 应用程序。

模块作用域

如您现在所知,所有.re文件都是模块,所有模块都是全局可用的——包括嵌套的模块。默认情况下,所有类型和绑定都可以通过提供命名空间在任何地方访问。然而,这样做会很快变得繁琐。幸运的是,我们有几种方法可以使这更加愉快:

/* Foo.re */
type fromFoo =
  | Add(int, int)
  | Multiply(int, int);

let a = 1;
let b = 2;

接下来,我们将以不同的方式使用Foo模块的fromFoo类型及其在另一个模块内的绑定:

  • 选项 1:没有任何糖:
/* Bar.re */
let fromFoo = Foo.Add(Foo.a, Foo.b);
  • 选项 2:将模块别名到更短的名字。例如,我们可以声明一个新的模块F并将其绑定到现有的模块Foo
/* Bar.re */
module F = Foo;
let fromFoo = F.Add(F.a, F.b);
  • 选项 3:使用Module.()语法在本地打开模块。这种语法只适用于单个表达式:
/* Bar.re */
let fromFoo = Foo.(Add(a, b));
  • 选项 4:在面向对象的意义上,使用includeBar扩展Foo
/* Bar.re */
include Foo;
let a = 4; /* override Foo.a */
let fromFoo = Add(a, b);
  • 选项 5:全局打开模块。在大范围内,open应谨慎使用,因为它会变得难以知道哪些类型和绑定属于哪个模块:
/* Bar.re */
open Foo;
let fromFoo = Add(a, b);

建议在局部作用域中使用open

/* Bar.re */
let fromFoo = {
  open Foo;
  Add(a, b);
};

上述语法将通过refmt重新格式化为选项 3 的语法,但请记住,选项 3 的语法只适用于单个表达式。例如,以下内容无法转换为选项 3 的语法:

/* Bar.re */
let fromFoo = {
  open Foo;
  Js.log("foo");
  let result = Add(a, b);
};

Reason 标准库位于我们已可用的各种模块中。例如,Reason 的标准库包括一个Array模块,我们可以使用点符号(即Array.length)来访问其函数。

在第五章《有效的 ML》中,我们将学习如何隐藏模块的类型和绑定,以便在不需要它们全局可用时不可用。

数据结构

我们已经看到了 Reason 的几个原始数据结构,包括字符串、整数、浮点数、元组、记录和变体。让我们再探索一些。

数组

Reason 数组编译为常规 JavaScript 数组。Reason 数组如下所示:

  • 同质(所有元素必须是同一类型)

  • 可变

  • 快速的随机访问和更新

它们看起来像这样:

let array = [|"first", "second", "third"|];

访问和更新数组元素的方式与 JavaScript 中的相同:

array[0] = "updated";

在 JavaScript 中,我们按照以下方式遍历数组:

/* JavaScript */
array.map(e => e + "-mapped")

在 Reason 中做同样的事情,我们有几种不同的选择。

使用 Reason 标准库

Reason 标准库的 Array 模块包含几个函数,但并非所有你从 JavaScript 中期望的函数都有。然而,它确实有一个 map 函数:

/* Reason standard library */
let array = [|"first", "second", "third"|];
Array.map(e => e ++ "-mapped", array);

Array.map 的类型如下:

('a => 'b, array('a)) => array('b);

类型签名表明 map 接受一个类型为 'a => 'b 的函数,一个类型为 'a 的数组,并返回一个类型为 'b 的数组。注意,'a'b类型变量。类型变量就像普通变量一样,只是它们是类型。在上面的例子中,map 的类型是:

(string => string, array(string)) => array(string);

这是因为类型变量 'a'b 都被一致地替换成了具体的 string 类型。

注意,当使用 Array.map 时,编译后的输出不会编译成 JavaScript 的 Array.prototype.map——它有自己的实现:

/* in the compiled output */
...
require("./stdlib/array.js");
...

Reason 标准库的文档可以在这里找到:

ReasonML API

使用 Belt 标准库

Reason 标准库实际上是 OCaml 标准库。它不是针对 JavaScript 创建的。Belt 标准库是由创建 BuckleScript 的同一人 Hongbo Zhang 创建的,并随 BuckleScript 一起提供。Belt 是针对 JavaScript 创建的,并且以其性能而闻名。Belt 标准库通过 Belt 模块访问:

/* Belt standard library */
let array = [|"first", "second", "third"|];
Belt.Array.map(array, e => e ++ "-mapped");

Belt 标准库的文档可以在这里找到:

BuckleScript API Belt

使用 BuckleScript 内置的 JavaScript 绑定

另一个很好的选择是使用 BuckleScript 内置的 JavaScript 绑定,这些绑定可以在 Js 模块中找到:

/* BuckleScript's JavaScript bindings */
let array = [|"first", "second", "third"|];
Js.Array.map(e => e ++ "-mapped", array);

这种选项的优点是不需要在编译后的输出中依赖任何依赖。它还有一个非常熟悉的 API。然而,由于并非所有的 Reason 数据结构都存在于 JavaScript 中,你可能会使用标准库。如果是这样,请优先选择 Belt。

BuckleScript 绑定文档可以在这里找到:

BuckleScript API Js

使用自定义绑定

没有任何阻止你编写自己的自定义绑定的:

[@bs.send] external map: (array('a), 'a => 'b) => array('b) = "";
let array = [|"first", "second", "third"|];
map(array, e => e ++ "-mapped")

当然,你应该优先使用 Js 模块中的内置绑定。我们将在本章后面探索更多自定义绑定。

使用原生 JavaScript

最后一个选择是在 Reason 中使用实际的 JavaScript:

let array = [|"first", "second", "third"|];
let map = [%raw {|
  function(f, array) {
    return array.map(f)
  }
|}];
map(e => e ++ "-mapped", array)

BuckleScript 让我们能够直接进入原生 JavaScript,这样我们可以在学习的同时保持高效。当然,当我们这样做的时候,我们放弃了 Reason 为我们提供的安全性。所以,一旦你准备好了,将任何原生 JavaScript 代码转换回更符合 Reason 习惯的代码。

当涉及到使用原生 JavaScript 时,使用 % 表示表达式,%% 表示语句。记住,{| |} 是 Reason 的多行字符串语法:

let array = [%raw "['first', 'second', 'third']"];
[%%raw {|
  array = array.map(e => e + "-mapped");
|}];

使用原始表达式语法,我们也能够注释类型:

let array: array(string) = [%raw "['first', 'second', 'third']"];

我们甚至可以注释函数类型:

let random: unit => float = [%raw
  {|
    function() {
     return Math.random();
    }
  |}
];

虽然数组在从 JavaScript 过来时很熟悉,但你可能会发现自己更倾向于使用列表,因为它们在函数式编程中无处不在。列表既是不可变的也是递归的。现在让我们看看如何使用这种递归数据结构。

列表

Reason 列表如下:

  • 同质

  • 不可变

  • 快速在列表前添加和访问头部

它们看起来像这样:

let list = ["first", "second", "third"];

在这种情况下,列表的头部是 "first"。到目前为止,我们已经看到处理不可变数据结构并不困难。我们不是进行修改,而是创建更新的副本。

在处理列表时,我们不能直接使用 JavaScript 绑定,因为列表在 JavaScript 中不是一个原始数据结构。然而,我们可以将列表转换为数组,反之亦然:

/* Belt standard library */
let list = ["first", "second", "third"];
let array = Belt.List.toArray(list);

let array = [|"first", "second", "third"|];
let list = Belt.List.fromArray(array);

/* Reason standard library */
let list = ["first", "second", "third"];
let array = Array.of_list(list);

let array = [|"first", "second", "third"|];
let list = Array.to_list(array);

但我们也可以直接在列表上使用 map

/* Belt standard library */
let list = ["first", "second", "third"];
Belt.List.map(list, e => e ++ "-mapped");

/* Reason standard library */
let list = ["first", "second", "third"];
List.map(e => e ++ "-mapped", list);

在控制台中记录 list 显示,列表在 JavaScript 中表示为嵌套数组,其中每个数组总是有两个元素:

["first", ["second", ["third", 0]]]

在理解列表是一个递归数据结构之后,这就有意义了。Reason 列表是单链表。列表中的每个元素要么是(在 JavaScript 中表示为 0),要么是值和另一个列表的组合。

list 的一个示例类型定义揭示了 list 是一个变体:

type list('a) = Empty | Head('a, list('a));

注意:类型定义可以是递归的。

Reason 提供了一些语法糖,简化了其更冗长的版本:

Head("first", Head("second", Head("third", Empty)));

递归

由于列表是一个递归数据结构,我们在处理它时通常使用递归。

为了热身,让我们编写一个(初级的)求整数列表总和的函数:

let rec sum = list => switch(list) {
  | [] => 0
  | [hd, ...tl] => hd + sum(tl)
};
  • 这是一个递归函数,因此需要 rec 关键字(即,let rec 而不是 let

  • 我们可以在列表上使用模式匹配(就像任何其他变体和许多其他数据结构一样)

  • 从示例类型定义中,Empty 表示为 []Head 表示为 [hd, ...tl],其中 hd 是列表的头部tl 是列表的其余部分(即,尾部

  • tl 可以是 [](即,Empty),当它是这样的时候,递归停止

将列表 [1, 2, 3] 传递给 sum,会产生以下步骤:

sum([1, 2, 3])
1 + sum([2, 3])
1 + 2 + sum([3])
1 + 2 + 3
6

让我们通过分析另一个(初级的)反转列表的函数来更熟悉列表和递归:

let rec reverse = list => switch(list) {
  | [] => []
  | [hd, ...tl] => reverse(tl) @ [hd]
};
  • 再次,我们使用 rec 来定义一个递归函数

  • 再次,我们在列表上使用模式匹配——如果它是空的,停止递归;否则,继续使用更小的列表

  • @ 操作符将第二个列表追加到第一个列表的末尾

将先前定义的列表(["first", "second", "third"])传递进去,会产生以下步骤:

reverse(["first", "second", "third"])
reverse(["second", "third"]) @ ["first"]
reverse(["third"]) @ ["second"] @ ["first"]
reverse([]) @ ["third"] @ ["second"] @ ["first"]
[] @ ["third"] @ ["second"] @ ["first"]
["third", "second", "first"]

这种 reverse 的实现是初级的,原因有两个:

  • 它不是尾调用优化(我们的 sum 函数也不是)

  • 它使用 append (@),这比 prepend

一个更好的实现是使用带有累加器的局部辅助函数:

let reverse = list => {
  let rec aux = (list, acc) => switch(list) {
    | [] => acc
    | [hd, ...tl] => aux(tl, [hd, ...acc])
  };
  aux(list, []);
};

现在,它的尾调用已优化,并使用 prepend 代替 append。在 Reason 中,您可以使用 ... 语法向列表中添加元素:

let list = ["first", "second", "third"];
let list = ["prepended", ...list];

将列表(["first", "second", "third"])传入大致相当于以下步骤:

reverse(["first", "second", "third"])
aux(["first", "second", "third"], [])
aux(["second", "third"], ["first"])
aux(["third"], ["second", "first"])
aux([], ["third", "second", "first"])
["third", "second", "first"]

注意,在非尾递归版本中,Reason 无法在递归完成之前创建列表。在尾递归版本中,累加器(即 aux 的第二个参数)在每次迭代后更新。

尾递归(即尾调用优化)函数的好处是能够重用当前的栈帧。因此,尾递归函数永远不会发生栈溢出,但非尾递归函数在迭代足够多的情况下可能会发生栈溢出。

管道运算符

Reason 有两个管道运算符:

|> (pipe)
-> (fast pipe)

两个管道运算符都将参数传递给函数。|> 管道运算符将参数传递给函数的最后一个参数,而 -> 快速管道运算符将参数传递给函数的第一个参数。

看看这些:

three |> f(one, two)
one -> f(two, three)

它们等同于以下内容:

f(one, two, three)

如果函数只接受一个参数,那么两个管道的工作方式相同,因为函数的第一个参数也是函数的最后一个参数。

使用这些管道运算符非常流行,因为一旦您掌握了它们,代码的可读性就会大大提高。

我们不需要使用这个:

Belt.List.(reduce(map([1, 2, 3], e => e + 1), 0, (+)))

我们可以以不需要读者从内向外阅读的方式编写它:

Belt.List.(
 [1, 2, 3]
 ->map(e => e + 1)
 ->reduce(0, (+))
);

如您所见,使用快速管道看起来类似于 JavaScript 中的链式调用。与 JavaScript 不同,我们可以传递 + 函数进行缩减,因为它只是一个接受两个参数并相加的正常函数。括号是必要的,以便让 Reason 将中缀运算符 (+) 视为一个标识符。

使用 Belt

让我们利用本章到目前为止所学的知识来编写一个小程序,该程序创建一副牌,将其洗牌,并从牌顶抽取五张牌。为此,我们将使用 Belt 的 OptionList 模块,以及快速管道运算符。

选项模块

Belt 的 Option 模块是一组用于处理 option 类型的实用函数。例如,要解包选项并抛出运行时异常,如果选项的值为 None,我们可以使用 getExn

let foo = Some(3)->Belt.Option.getExn;
Js.log(foo); /* 3 */

let foo = None->Belt.Option.getExn;
Js.log(foo); /* raises getExn exception */

能够抛出运行时异常的 Belt 函数总是带有 Exn 后缀。

解包无法抛出运行时异常的选项的替代函数是 getWithDefault

let foo = None->Belt.Option.getWithDefault(0);
Js.log(foo); /* 0 */

Option 模块提供了其他几个函数,如 isSomeisNonemapmapWithDefault 等。有关详细信息,请参阅文档。

Belt 选项模块文档可以在这里找到:

bucklescript.github.io/bucklescript/api/Belt.Option.html

列表模块

列表模块是用于列表数据类型的实用工具。要查看 Belt 为处理列表提供的函数,请检查 Belt 的 List 模块文档。

Belt 列表模块文档可以在这里找到:

bucklescript.github.io/bucklescript/api/Belt.List.html

让我们关注其中的一些。

make

make 函数用于创建一个已填充的列表。它接受一个整数作为列表的长度,以及列表中每个项目的值。其类型如下:

(int, 'a) => Belt.List.t('a)

Belt.List.t 被公开作为 list 类型的别名,因此我们可以说 Belt.List.make 的类型如下:

(int, 'a) => list('a)

我们可以用它来创建一个包含十个字符串的列表,如下所示:

let list = Belt.List.make(10, "string");

在 第五章,Effective ML,我们将学习如何显式地从一个模块中公开或隐藏类型和绑定。

makeBy

makeBy 函数类似于 make 函数,但它接受一个函数,用于根据项目的索引确定每个项目的值。

makeBy 函数的类型如下:

(int, int => 'a) => Belt.List.t('a)

我们可以用它来创建一个包含十个项目的列表,其中每个项目等于其索引:

let list = Belt.List.makeBy(10, i => i);

shuffle

shuffle 函数随机洗牌。它的类型如下:

Belt.List.t('a) => Belt.List.t('a)

它接受一个列表并返回一个新的列表。让我们用它来洗我们的整数列表:

let list = Belt.List.(makeBy(10, i => i)->shuffle);

take

take 函数接受一个列表和一个长度,并返回从列表头部开始的长度等于请求长度的子集。由于子集的请求长度可能超过原始列表的长度,因此结果被包裹在选项中。其类型如下:

(Belt.List.t('a), int) => option(Belt.List.t('a))

我们可以从洗好的列表中取出前两个项目,如下所示:

let list = Belt.List.(makeBy(10, i => i)->shuffle->take(2));

堆叠牌的示例

现在,我们已经准备好将之前章节中学到的知识结合起来。你会如何编写一个程序来创建一副牌,洗牌,并抽取前五张牌?在查看以下示例之前,先自己试一试。

type suit =
  | Hearts
  | Diamonds
  | Spades
  | Clubs;

type card = {
  suit,
  rank: int,
};

Belt.List.(
  makeBy(52, i =>
    switch (i / 13, i mod 13) {
    | (0, rank) => {suit: Hearts, rank: rank + 1}
    | (1, rank) => {suit: Diamonds, rank: rank + 1}
    | (2, rank) => {suit: Spades, rank: rank + 1}
    | (3, rank) => {suit: Clubs, rank: rank + 1}
    | _ => assert(false)
    }
  )
  ->shuffle
  ->take(5)
  ->Belt.Option.getExn
  ->(
      cards => {
        let rankToString = rank =>
          switch (rank) {
          | 1 => "Ace"
          | 13 => "King"
          | 12 => "Queen"
          | 11 => "Jack"
          | rank => string_of_int(rank)
          };

        let suitToString = suit =>
          switch (suit) {
          | Hearts => "Hearts"
          | Diamonds => "Diamonds"
          | Spades => "Spades"
          | Clubs => "Clubs"
          };

        map(cards, ({rank, suit}) =>
          rankToString(rank) ++ " of " ++ suitToString(suit)
        );
      }
    )
  ->toArray
  ->Js.log
);

这会产生一个包含五张随机字符串格式的牌的数组:

[
  "Queen of Clubs",
  "4 of Clubs",
  "King of Spades",
  "Ace of Hearts",
  "9 of Spades"
]

Currying

Belt 标准库的一些函数有 U 后缀,例如这个:

Belt.List.makeBy

你可以看到这里的后缀:

Belt.List.makeByU

U 后缀代表 uncurried。在继续之前,让我们定义 currying。

在 Reason 中,每个函数恰好接受一个参数。这似乎与我们之前的许多例子相矛盾:

let add = (a, b) => a + b;

前面的 add 函数看起来像接受两个参数,但实际上它只是以下内容的语法糖:

let add = a => b => a + b;

add 函数接受一个单一参数 a,它返回一个接受单一参数 b 的函数,然后返回 a + b 的结果。

在 Reason 中,两种版本都是有效的,并且有相同的编译输出。在 JavaScript 中,前两种版本都是有效的,但它们并不相同;它们需要以不同的方式使用才能得到相同的结果。第二个需要像这样调用:

add(2)(3);

这是因为 add 返回一个函数,然后需要再次调用,因此有两个括号组。理由接受两种用法:

add(2, 3);
add(2)(3);

Currying 的好处是它使函数组合更容易。你可以轻松创建一个部分应用的函数,addOne

let addOne = add(1);

这个 addOne 函数可以被传递给其他函数,例如 map。也许你想使用这个特性将一个函数传递给 ReasonReact 子组件,并使用父组件的 self 部分应用。

令人困惑的是,add 的任何版本的编译输出如下:

function add(a, b) {
  return a + b | 0;
}

中间函数在哪里?尽可能的情况下,BuckleScript 会优化编译输出以避免不必要的函数分配,从而提高性能。

记住,由于 Reason 的中缀运算符只是普通函数,我们本可以这样做:

let addOne = (+)(1);

未应用柯里化的函数

由于 JavaScript 的动态特性,BuckleScript 无法总是优化编译输出以删除中间函数。然而,你可以使用以下语法告诉 BuckleScript 未应用柯里化一个函数:

let add = (. a, b) => a + b;

未应用柯里化的语法是参数列表中的点。它需要在声明和调用位置都存在:

let result = add(. 2, 3); /* 5 */

如果调用位置没有使用未应用柯里化的语法,BuckleScript 将抛出编译时错误:

let result = add(2, 3);

We've found a bug for you!

This is an uncurried BuckleScript function. It must be applied with a dot.

Like this: foo(. a, b)
Not like this: foo(a, b)

此外,如果在调用位置缺少函数的一些参数,则会抛出编译时错误:

let result = add(. 2);

We've found a bug for you!

Found uncurried application [@bs] with arity 2, where arity 1 was expected.

术语 arity 指的是函数接受的参数数量。

makeByU

如果我们未应用其第二个参数,我们可以用 makeByU 替换 makeBy。这将提高性能(在我们的例子中是微不足道的):

...
makeByU(52, (. i) =>
  switch (i / 13, i mod 13) {
  | (0, rank) => {suit: Hearts, rank: rank + 1}
  | (1, rank) => {suit: Diamonds, rank: rank + 1}
  | (2, rank) => {suit: Spades, rank: rank + 1}
  | (3, rank) => {suit: Clubs, rank: rank + 1}
  | _ => assert(false)
  }
)
...

点语法需要在 i 的周围使用括号。

JavaScript 互操作性

术语 互操作性 指的是 Reason 程序在 Reason 中使用现有 JavaScript 的能力。BuckleScript 提供了一个出色的系统,用于在 Reason 中使用现有的 JavaScript 代码,并且也使得在 JavaScript 中使用 Reason 代码变得容易。

在 Reason 中使用 JavaScript

我们已经看到了如何在 Reason 中使用原始 JavaScript。现在让我们关注如何绑定到现有的 JavaScript。要将值绑定到命名引用,我们通常使用 let。然后,该绑定可以在后续代码中使用。当我们要绑定的值位于 JavaScript 中时,我们使用 externalexternal 绑定就像 let 一样,因为它可以在后续代码中使用。与 let 不同的是,external 通常伴随着 BuckleScript 装饰器,如 [@bs.val]

理解 [@bs.val] 装饰器

我们可以使用 [@bs.val] 来绑定到全局值和函数。通常,语法如下:

[@bs.val] external alert: string => unit = "alert";
  • 一个或多个 BuckleScript 装饰器(即 [@bs.val]

  • external 关键字

  • 绑定的命名引用

  • 类型声明

  • 一个等号

  • 一个字符串

external 关键字将 alert 绑定到类型为 string => unit 的值,并绑定到字符串 alert。字符串 alert 是上述外部声明的值,也是将在编译输出中使用的值。当外部绑定的名称与其字符串值相等时,字符串可以留空:

[@bs.val] external alert: string => unit = "";

使用绑定就像使用任何其他绑定一样:

alert("hi!");

理解 [@bs.scope] 装饰器

要绑定到 window.location.pathname,我们使用 [@bs.scope] 添加一个作用域。这定义了 [@bs.val] 的作用域。例如,如果你想绑定到 window.locationpathname 属性,你可以指定作用域为 [@bs.scope ("window", "location")]

[@bs.val] [@bs.scope ("window", "location")] external pathname: string = "";

或者,我们可以使用 [@bs.val] 仅在字符串中包含作用域:

[@bs.val] external pathname: string = "window.location.pathname";

理解 [@bs.send] 装饰器

[@bs.send] 装饰器用于绑定到对象的函数和方法。当使用 [@bs.send] 时,第一个参数总是对象。如果有剩余的参数,它们将被应用到对象的方法上:

[@bs.val] external document: Dom.document = "";
[@bs.send] external getElementById: (Dom.document, string) => Dom.element = "";
let element = getElementById(document, "root");

Dom 模块也由 BuckleScript 提供,并为 DOM 提供类型声明。

Dom 模块文档可以在这里找到:

bucklescript.github.io/bucklescript/api/Dom.html

此外,还有一个用于 Node.js 的 Node 模块:

bucklescript.github.io/bucklescript/api/Node.html

在编写外部声明时要小心,因为你可能会无意中向类型系统撒谎,这可能导致运行时类型错误。例如,我们告诉 Reason 我们的 getElementById 绑定总是返回一个 Dom.element,但当 DOM 找不到具有提供的 ID 的元素时,它返回 undefined。一个更正确的绑定方式如下:

[@bs.send] external getElementById: (Dom.document, string) => option(Dom.element) = "";

理解 [@bs.module] 装饰器

要导入一个节点模块,使用 [@bs.module]。编译输出取决于在 bsconfig.json 中使用的 package-specs 配置。我们使用 es6 作为模块格式。

[@bs.module] external leftPad: (string, int) => string = "left-pad";
let result = leftPad("foo", 6);

这编译成以下内容:

import * as LeftPad from "left-pad";

var result = LeftPad("foo", 6);

export {
  result ,
}

将模块格式设置为 commonjs 导致以下编译输出:

var LeftPad = require("left-pad");

var result = LeftPad("foo", 6);

exports.result = result;

当没有字符串参数传递给 [@bs.module] 时,默认值将被导入。

合理的 API

当绑定到现有的 JavaScript API 时,考虑你如何在 Reason 中使用该 API。即使那些严重依赖 JavaScript 动态类型的现有 JavaScript API 也可以在 Reason 中使用。BuckleScript 利用高级类型系统技术,让我们可以利用 Reason 的类型系统来使用这些 API。

从 BuckleScript 文档中,查看以下 JavaScript 函数:

function padLeft(value, padding) {
  if (typeof padding === "number") {
    return Array(padding + 1).join(" ") + value;
  }
  if (typeof padding === "string") {
    return padding + value;
  }
  throw new Error(`Expected string or number, got '${padding}'.`);
}

如果我们要在 Reason 中绑定到这个函数,使用 padding 作为变体会很方便。以下是实现方式:

[@bs.val]
external padLeft: (
  string,
  [@bs.unwrap] [
    | `Str(string)
    | `Int(int)
  ])
  => string = "";

padLeft("Hello World", `Int(4));
padLeft("Hello World", `Str("Message: "));

这编译成以下内容:

padLeft("Hello World", 4);
padLeft("Hello World", "Message: ");

padLeft 的类型是 (string, some_variant) => string,其中 some_variant 使用一个名为 多态变体 的高级类型系统特性,它使用 [@bs.unwrap] 将其转换为 JavaScript 可以理解的内容。我们将在 第五章,有效的 ML 中了解更多关于多态变体的内容。

BuckleScript 文档

虽然这只是一个简要的介绍,但你可以看出 BuckleScript 有很多工具可以帮助我们与惯用 JavaScript 进行通信。我强烈建议你阅读 BuckleScript 文档,以了解更多关于 JavaScript 互操作性的信息。

BuckleScript 文档可以在以下位置找到:

bucklescript.github.io/docs/interop-overview

绑定到现有的 ReactJS 组件

ReactJS 组件不是 Reason 组件。要使用现有的 ReactJS 组件,我们使用 [@bs.module] 来导入节点模块,然后使用 ReasonReact.wrapJsForReason 辅助函数将 ReactJS 组件转换为 Reason 组件。还有一个 ReasonReact.wrapReasonForJs 辅助函数用于在 ReactJS 中使用 Reason。

让我们从 第三章 中我们停止的地方继续构建我们的应用程序:创建 ReasonReact 组件

git clone https://github.com/PacktPublishing/ReasonML-Quick-Start-Guide.git
cd ReasonML-Quick-Start-Guide
cd Chapter03/app-end
npm install

在这里,我们将通过绑定现有的 React Transition Group 组件来添加路由转换:

React Transition Group 文档可以在以下位置找到:

reactcommunity.org/react-transition-group/

导入依赖

运行 npm install --save react-transition-group 来安装依赖。

让我们创建一个名为 ReactTransitionGroup.re 的新文件来存放这些绑定。在这个文件中,我们将绑定到 TransitionGroupCSSTransition 组件:

[@bs.module "react-transition-group"]
external transitionGroup: ReasonReact.reactClass = "TransitionGroup";

[@bs.module "react-transition-group"]
external cssTransition: ReasonReact.reactClass = "CSSTransition";

创建 make 函数

接下来,我们创建组件所需的 make 函数。这是我们使用 ReasonReact.wrapJsForReason 辅助函数的地方。

对于 TransitionGroup,我们不需要任何属性。由于需要 ~props 参数,我们传递 Js.Obj.empty()~reactClass 参数传递了我们之前步骤中创建的外部绑定:

module TransitionGroup = {
  let make = children =>
    ReasonReact.wrapJsForReason(
      ~reactClass=transitionGroup,
      ~props=Js.Obj.empty(),
      children,
    );
};

现在,ReactTransitionGroup.TransitionGroup 是一个 ReasonReact 组件,我们可以在我们的应用程序中使用它。

使用 [@bs.deriving abstract]

CSSTransitionGroup 需要以下属性:

  • _in

  • timeout

  • classNames

由于 in 是 Reason 的保留字,因此约定在 Reason 中使用 _in,并由 BuckleScript 编译为 JavaScript 中的 in 使用 [@bs.as "in"]

BuckleScript 提供 [@bs.deriving abstract] 以便于处理某些类型的 JavaScript 对象。我们不需要在 JavaScript 中创建对象并绑定到该对象,可以直接使用 BuckleScript 创建该对象:

[@bs.deriving abstract]
type cssTransitionProps = {
  [@bs.as "in"] _in: bool,
  timeout: int,
  classNames: string,
};

注意:cssTransitionProps 不是一个记录类型,它看起来像是一个。

当使用 [@bs.deriving abstract] 时,会自动提供一个辅助函数来创建该形状的 JavaScript 对象。这个辅助函数也命名为 cssTransitionProps。我们在组件的 make 函数中使用这个辅助函数来创建组件的属性:

module CSSTransition = {
  let make = (~_in: bool, ~timeout: int, ~classNames: string, children) =>
    ReasonReact.wrapJsForReason(
      ~reactClass=cssTransition,
      ~props=cssTransitionProps(~_in, ~timeout, ~classNames),
      children,
    );
};

使用组件

现在,在 App.re 中,我们可以更改渲染函数以使用这些组件。我们将更改如下:

<main> {currentRoute.component} </main>

现在它看起来如下所示:

<main>
  ReactTransitionGroup.(
    <TransitionGroup>
      <CSSTransition
        key={currentRoute.title} _in=true timeout=900 classNames="routeTransition">
        {currentRoute.component}
      </CSSTransition>
    </TransitionGroup>
  )
</main>

注意:键(key)属性是一个特殊的 ReactJS 属性,不应成为 ReasonReact.wrapJsForReason 组件的 props 参数的一部分。对于特殊的 ReactJS ref 属性也是如此。

为了完整性,以下是相应的 CSS,可以在 ReactTransitionGroup.scss 中找到:

@keyframes enter {
  from {
    opacity: 0;
    transform: translateY(50px);
  }
}

@keyframes exit {
  to {
    opacity: 0;
    transform: translateY(50px);
  }
}

.routeTransition-enter.routeTransition-enter-active {
  animation: enter 500ms ease 400ms both;
}

.routeTransition-exit.routeTransition-exit-active {
  animation: exit 400ms ease both;
}

一定要在 ReactTransitionGroup.re 中要求前面的内容:

/* ReactTransitionGroup.re */
[@bs.val] external require: string => string = "";
require("../../../src/ReactTransitionGroup.scss");

现在,当切换路由时,旧路由的内容会先向下动画并淡出,然后新路由的内容才会向上动画并淡入。

摘要

BuckleScript 非常强大,因为它让我们以一种非常愉快的方式与惯用的 JavaScript 进行交互。它还提供了 Belt 标准库,该库是考虑到 JavaScript 而创建的。我们学习了数组和列表,并看到了如何在 Reason 中使用现有的 ReactJS 组件是多么容易。

在 第五章,《有效的机器学习》中,我们将学习如何使用模块签名在构建自动完成输入组件时隐藏组件的实现细节。我们最初会使用硬编码的数据,然后在 第六章,《CSS-in-JS(在 Reason 中)》中,我们将把数据移动到 localStorage(客户端 Web 存储)。

第五章:有效 ML

到目前为止,我们已经学习了 Reason 的基础知识。我们看到了拥有一个健全的类型系统如何使重构变得更加安全、压力更小。在更改实现细节时,类型系统会帮助我们及时提醒代码库中需要更新的其他区域。在本章中,我们将学习如何隐藏实现细节,使重构变得更加容易。通过隐藏实现细节,我们保证更改它们不会影响代码库的其他区域。

我们还将学习类型系统如何帮助我们强制执行应用程序中的业务规则。隐藏实现细节也为我们提供了一种很好的方式来强制执行业务规则,通过确保模块不会被用户误用。我们将通过包含在此书 GitHub 仓库中的简单代码示例来阐述这一观点。

要继续学习,请从 Chapter05/app-start 开始。这些示例与我们一直在构建的应用程序是隔离的。

您可以使用以下方式访问本书的 GitHub 仓库:

git clone https://github.com/PacktPublishing/ReasonML-Quick-Start-Guide.git
cd ReasonML-Quick-Start-Guide
cd Chapter05/app-start
npm install

记住,所有模块都是全局的,并且默认情况下,模块的所有类型和绑定都会被暴露。正如我们很快就会看到的,模块签名可以用来隐藏模块的类型和/或绑定,使其对其他模块不可见。在本章中,我们还将学习高级类型系统特性,包括以下内容:

  • 抽象类型

  • 幻影类型

  • 多态变体

模块签名

模块签名以一种类似于接口在面向对象编程中约束类的方式约束模块。模块签名可以要求模块实现某些类型和绑定,并且也可以用来隐藏实现细节。假设我们有一个在 Foo.re 中定义的名为 Foo 的模块。其签名可以在 Foo.rei 中定义。任何列在模块签名中的类型或绑定都会暴露给其他模块。如果存在模块签名并且该类型或绑定不在模块签名中,则列在模块中的任何类型或绑定都会被隐藏。给定 Foo.re 中的绑定 let foo = "foo";,该绑定可以通过在 Foo.rei 中包含 let foo: string; 来由其模块签名既要求又暴露:

/* Foo.re */
let foo = "foo";

/* Foo.rei */
let foo: string;

/* Bar.re */
Js.log(Foo.foo);

在这里,Foo.rei 需要 Foo.re 包含一个名为 foolet 绑定,其类型为 string

如果模块的 .rei 文件存在且为空,则模块内的所有内容都将被隐藏,如下面的代码所示:

/* Foo.rei */
/* this is intentionally empty */

/* Bar.re */
Js.log(Foo.foo); /* Compilation error: The value foo can't be found in Foo */

模块的签名要求模块包含签名中列出的任何类型和/或绑定,如下面的代码所示:

/* Foo.re */
let foo = "foo";

/* Foo.rei */
let foo: string;
let bar: string;

这导致以下编译错误,因为模块签名要求有一个未在模块中定义的 string 类型的 bar 绑定:

The implementation src/Foo.re does not match the interface src/Foo.rei:
The value `bar' is required but not provided

模块类型

模块签名也可以使用 module type 关键字而不是单独的 .rei 文件来定义。模块类型必须以大写字母开头。一旦定义,一个模块可以使用 module <Name> : <Type> 语法被模块类型约束,如下所示:

module type FooT {
  let foo: (~a: int, ~b: int) => int;
};

module Foo: FooT {
  let foo = (~a, ~b) => a + b;
};

同一个模块类型可以用于多个模块,如下所示:

module Bar: FooT {
  let bar = (~a, ~b) => a - b;
};

我们可以将模块签名视为面向对象意义上的接口。接口定义了模块必须定义的属性和方法。然而,在 Reason 中,模块签名还隐藏了绑定和类型。但也许模块签名最有用的特性是能够公开抽象类型。

抽象类型

抽象类型是没有定义的类型声明。让我们探讨一下这为什么会很有用。除了绑定之外,模块签名还可以包括类型。在下面的代码中,你会注意到Foo的模块签名包括一个person类型,现在Foo必须包含这个类型声明:

/* Foo.re */
type person = {
  firstName: string,
  lastName: string
};

/* Foo.rei */
type person = {
  firstName: string,
  lastName: string
};

person类型的公开方式与没有定义模块签名时相同。正如你所期望的,如果定义了签名而类型未列出,则类型不会公开给其他模块。还有选择将类型保留为抽象。我们只保留等号后面的部分。让我们看看下面的代码:

/* Foo.rei */
type person;

现在,person类型对其他模块是公开的,但没有任何其他模块可以直接创建或操作person类型的值。person类型必须在Foo中定义,但它可以有任何定义。这意味着person类型可以随时间变化,并且Foo之外的任何模块都不会知道这一点。

让我们在下一节进一步探讨抽象类型。

使用模块签名

让我们假设我们正在构建一个发票管理系统,并且我们有一个Invoice模块,它定义了一个invoice类型以及一个其他模块可以使用来创建该类型值的函数。这种安排如下面的代码所示:

/* Invoice.re */
type t = {
  name: string,
  email: string,
  date: Js.Date.t,
  total: float
};

let make = (~name, ~email, ~date, ~total) => {
  name,
  email,
  date,
  total
};

假设我们还有一个负责向客户发送电子邮件的模块,如下面的代码所示:

/* Email.re */
let send = invoice: Invoice.t => ...
let invoice =
  Invoice.make(
    ~name="Raphael",
    ~email="persianturtle@gmail.com",
    ~date=Js.Date.make(),
    ~total=15.0,
  );
send(invoice);

由于Invoice.t类型是公开的,发票可以通过Email进行操作,如下面的代码所示:

/* Email.re */
let invoice =
  Invoice.make(
    ~name="Raphael",
    ~email="persianturtle@gmail.com",
    ~date=Js.Date.make(),
    ~total=15.0,
  );
let invoice = {...invoice, total: invoice.total *. 0.8};
Js.log(invoice);

尽管Invoice.t类型是不可变的,但没有任何东西阻止Email通过一些修改的字段来覆盖发票绑定。然而,如果我们使Invoice.t类型抽象化,这就不可能了,因为Email无法操作抽象类型。Email模块可以访问的所有函数都无法与Invoice.t类型一起工作。

/* Invoice.rei */
type t;
let make:
 (~name: string, ~email: string, ~date: Js.Date.t, ~total: float) => t;

现在,编译会给我们以下错误:

8 │ let invoice = {...invoice, total: invoice.total *. 0.8};
9 │ Js.log(invoice);

The record field total can't be found.

如果我们决定允许其他模块向发票添加折扣,我们需要创建一个函数并将其包含在Invoice模块的模块签名中。假设我们只想允许每个发票只有一个折扣,并且限制折扣金额为十、十五或二十个百分点。我们可以这样实现:

/* Invoice.re */
type t = {
 name: string,
 email: string,
 date: Js.Date.t,
 total: float,
 isDiscounted: bool,
};

type discount =
 | Ten
 | Fifteen
 | Twenty;

let make = (~name, ~email, ~date, ~total) => {
 name,
 email,
 date,
 total,
 isDiscounted: false,
};

let discount = (~invoice, ~discount) =>
 if (invoice.isDiscounted) {
 invoice;
 } else {
 {
 ...invoice,
 isDiscounted: true,
 total:
 invoice.total
 *. (
 switch (discount) {
 | Ten => 0.9
 | Fifteen => 0.85
 | Twenty => 0.8
 }
 ),
 };
 };

/* Invoice.rei */
type t;

type discount =
 | Ten
 | Fifteen
 | Twenty;

let make:
 (~name: string, ~email: string, ~date: Js.Date.t, ~total: float) => t;

let discount: (~invoice: t, ~discount: discount) => t;

/* Email.re */
let invoice =
 Invoice.make(
 ~name="Raphael",
 ~email="persianturtle@gmail.com",
 ~date=Js.Date.make(),
 ~total=15.0,
 );
Js.log(invoice);

现在,只要Invoice模块的公共 API(或模块签名)不改变,我们就可以自由地按照我们的意愿重构Invoice模块,而无需担心破坏其他模块的代码。为了证明这一点,让我们将Invoice.t重构为元组而不是记录,如下所示代码。只要我们不改变模块签名,Email模块就不需要做任何改变:

/* Invoice.re */
type t = (string, string, Js.Date.t, float, bool);

type discount =
  | Ten
  | Fifteen
  | Twenty;

let make = (~name, ~email, ~date, ~total) => (
  name,
  email,
  date,
  total,
  false,
);

let discount = (~invoice, ~discount) => {
  let (name, email, date, total, isDiscounted) = invoice;
  if (isDiscounted) {
    invoice;
  } else {
    (
      name,
      email,
      date,
      total
      *. (
        switch (discount) {
        | Ten => 0.9
        | Fifteen => 0.85
        | Twenty => 0.8
        }
      ),
      true,
    );
  };
};

/* Invoice.rei */
type t;

type discount =
  | Ten
  | Fifteen
  | Twenty;

let make:
  (~name: string, ~email: string, ~date: Js.Date.t, ~total: float) => t;

let discount: (~invoice: t, ~discount: discount) => t;

/* Email.re */
let invoice =
  Invoice.make(
    ~name="Raphael",
    ~email="persianturtle@gmail.com",
    ~date=Js.Date.make(),
    ~total=15.0,
  );
let invoice = Invoice.(discount(~invoice, ~discount=Ten));
Js.log(invoice);

此外,多亏了Invoice.t抽象类型,我们保证发票只能折扣一次,并且只能按指定的百分比折扣。我们可以进一步扩展这个例子,要求对发票的所有更改都要进行记录。传统上,这类要求会在数据库事务之后添加副作用来解决,因为在 JavaScript 中,我们否则无法确定我们会记录所有对发票的更改。有了模块签名,我们可以在应用层解决这类要求。

幻影类型

看看我们之前的实现,如果我们不需要在运行时检查发票是否已经折扣,那会很好。我们能否在编译时检查发票是否已经折扣呢?使用幻影类型,我们可以做到。

幻影类型是具有类型变量的类型,但这种类型变量在其定义中没有被使用。为了更好地理解,让我们再次看看option类型,如下所示代码:

type option('a) =
  | None
  | Some('a);

option类型有一个类型变量'a,并且这个类型变量在其定义中被使用。正如我们已经学到的,option是一个多态类型,因为它有一个类型变量。另一方面,幻影类型在其定义中不使用类型变量。让我们看看这在我们的发票管理示例中是如何有用的。

让我们改变Invoice模块的签名,使用幻影类型,如下所示:

/* Invoice.rei */
type t('a);

type discounted;
type undiscounted;

type discount =
  | Ten
  | Fifteen
  | Twenty;

let make:
  (~name: string, ~email: string, ~date: Js.Date.t, ~total: float) =>
  t(undiscounted);

let discount:
  (~invoice: t(undiscounted), ~discount: discount) => t(discounted);

抽象类型t现在是t('a)。我们还有两个更多的抽象类型,如下所示代码:

type discounted;
type undiscounted;

此外,请注意,make函数现在返回t(undiscounted)(而不是仅仅t),而discount函数现在接受t(undiscounted)并返回t(discounted)。记住,抽象t('a)接受一个类型变量,而这个类型变量恰好是discounted类型或undiscounted类型。

在实现中,我们现在可以去掉之前已有的运行时检查,如下所示代码:

if (isDiscounted) {
  ...
} else {
  ...
}

现在,这个检查是在编译时完成的,因为discount函数只接受undiscounted类型的发票,如下所示代码:

/* Invoice.re */
type t('a) = {
  name: string,
  email: string,
  date: Js.Date.t,
  total: float,
};

type discount =
  | Ten
  | Fifteen
  | Twenty;

let make = (~name, ~email, ~date, ~total) => {name, email, date, total};

let discount = (~invoice, ~discount) => {
  ...invoice,
  total:
    invoice.total
    *. (
      switch (discount) {
      | Ten => 0.9
      | Fifteen => 0.85
      | Twenty => 0.8
      }
    ),
};

这只是类型系统帮助我们更多地关注逻辑而不是错误处理的一种方式。以前,试图对发票进行两次折扣只会返回未更改的原始发票。现在,让我们在Email.re中尝试对发票进行两次折扣,使用以下代码:

/* Email.re */
let invoice =
  Invoice.make(
    ~name="Raphael",
    ~email="persianturtle@gmail.com",
    ~date=Js.Date.make(),
    ~total=15.0,
  );
let invoice = Invoice.(discount(~invoice, ~discount=Ten));
let invoice = Invoice.(discount(~invoice, ~discount=Ten)); /* discounted twice */
Js.log(invoice);

现在,试图对发票进行两次折扣将导致编译时错误,如下所示:

We've found a bug for you!

   7 │ );
   8 │ let invoice = Invoice.(discount(~invoice, ~discount=Ten));
   9 │ let invoice = Invoice.(discount(~invoice, ~discount=Ten));
  10 │ Js.log(invoice);

  This has type:
    Invoice.t(Invoice.discounted)
  But somewhere wanted:
    Invoice.t(Invoice.undiscounted)

这绝对是美丽的。然而,假设你想要能够发送任何发票——无论是打折的还是不打折的。我们使用幻影类型会导致问题吗?我们该如何编写一个接受任何发票类型的函数?记住,我们的发票类型是 Invoice.t('a),如果我们想接受任何发票,我们保持类型参数,如下面的代码所示:

/* Email.re */
let invoice =
  Invoice.make(
    ~name="Raphael",
    ~email="persianturtle@gmail.com",
    ~date=Js.Date.make(),
    ~total=15.0,
  );

let send: Invoice.t('a) => unit = invoice => {
 /* send invoice email */
 Js.log(invoice);
};

send(invoice);

因此,我们可以同时拥有我们的蛋糕并享用它。

多态变体

我们已经在上一章中简要地了解了多态变体。为了回顾,我们是在使用 [@bs.unwrap] 装饰器绑定到一些现有的 JavaScript 时了解到它们的。想法是 [@bs.unwrap] 可以用来绑定到一个现有的 JavaScript 函数,其中其参数可以是不同类型的。例如,假设我们想要绑定到以下函数:

function dynamic(a) {
  switch (typeof a) {
    case "string":
      return "String: " + a;
    case "number":
      return "Number: " + a;
  }
}

假设这个函数应该只接受 string 类型或 int 类型的参数,不接受其他类型的参数。我们可以这样绑定到这个示例函数:

[@bs.val] external dynamic : 'a => string = "";

然而,我们的绑定将允许无效的参数类型(例如 bool)。如果我们的编译器能够通过防止无效参数类型来帮助我们,那就更好了。一种方法是在多态变体上使用 [@bs.unwrap]。我们的绑定将如下所示:

[@bs.val] external dynamic : ([@bs.unwrap] [
  | `Str(string)
  | `Int(int)
]) => string = "";

我们会这样使用绑定:

dynamic(`Int(42));
dynamic(`Str("foo"));

现在,如果我们尝试传递一个无效的参数类型,编译器会告诉我们,如下面的代码所示:

dynamic(42);

/*
We've found a bug for you!

This has type:
  int
But somewhere wanted:
  [ `Int of int | `Str of string ]
*/

这里的权衡是我们需要通过将参数包裹在多态变体构造函数中来传递参数,而不是直接传递。

一开始,你就会注意到正常变体和多态变体之间的以下两个区别:

  1. 我们不需要显式声明多态变体的类型

  2. 多态变体以反引号字符(`)开头

无论何时你看到以反引号字符为前缀的构造函数,你就知道它是一个多态变体构造函数。多态变体构造函数可能或可能没有与之关联的类型声明。

这会与普通变体一起工作吗?

让我们尝试用普通变体来做这件事,看看会发生什么:

type validArgs = 
  | Int(int)
  | Str(string);

[@bs.val] external dynamic : validArgs => string = "";

dynamic(Int(1));

前面实现的问题在于 Int(1) 不会编译成 JavaScript 数字。普通变体被编译成 array,我们的 dynamic 函数返回 undefined 而不是 "Number: 42"。函数返回 undefined 因为在 switch 语句中没有匹配到任何情况。

使用多态变体,BuckleScript 将 dynamic(`Int(42)) 编译为 dynamic(42),并且函数按预期工作。

高级类型系统特性

Reason 的类型系统功能非常全面,并且在过去的几十年里得到了精炼。我们迄今为止所看到的内容只是 Reason 类型系统的一个简介。在我看来,你应该在继续学习更高级的类型系统功能之前,先熟悉基础知识。如果没有经历过一个健全的类型系统本可以防止的错误,就很难欣赏类型安全等特性。如果没有对这本书中迄今为止所学的内容感到些许挫败,就很难欣赏高级类型系统功能。本书的范围并不包括过多地详细讨论高级类型系统功能,但我想要确保那些正在评估 Reason 作为选项的人知道,它的类型系统还有更多内容。

除了幻影类型和多态变体之外,Reason 还有泛化代数数据类型GADTs)。模块可以使用函子(即在编译时间和运行时之间操作的模块函数)动态创建。Reason 还有类和对象——OCaml 中的 O 代表 objective。OCaml 的前身是一种名为 Caml 的语言,它首次出现在 20 世纪 80 年代中期。我们在本书中学到的内容在典型 React 应用程序的上下文中特别有用。我个人喜欢 Reason 是一种我可以不断成长并保持高效的语言。

如果你发现自己在类型系统上感到挫败,那么请通过 Discord 频道联系专家,他们很可能会帮助你解决你的问题。我总是对社区的帮助感到惊讶。而且别忘了,如果你只是想继续前进,你总是可以在需要时直接使用原始 JavaScript,并在准备好时再回来解决这个问题。

你可以在这里找到 Reason 的 Discord 频道:

discord.gg/reasonml

也不必使用 Reason 类型系统的更高级功能。我们迄今为止所学的知识在为我们的 React 应用程序添加类型安全方面提供了很多价值。

摘要

到目前为止,我们已经看到了 Reason 如何通过其类型系统帮助我们构建更安全、更易于维护的代码库。变体允许我们使无效状态无法表示。类型系统有助于使重构过程不那么可怕、不那么痛苦。模块签名可以帮助我们在应用程序中强制执行业务规则。模块签名还充当基本文档,列出模块公开的内容,并根据公开的函数名称及其参数类型以及公开的类型,给出模块应该如何使用的基本概念。

在第六章“CSS-in-JS(在 Reason 中)”,我们将探讨如何使用 Reason 的类型系统通过一个包装 Emotion(emotion.sh)的 CSS-in-Reason 库bs-css来强制执行有效的 CSS。

第六章:CSS-in-JS(在 Reason 中)

React 的一个优点是它允许我们将组件的标记、行为和样式放在一个文件中。这种组合对开发者的体验、版本控制和代码质量产生了连锁反应(无意中用了双关语)。在本章中,我们将简要探讨 CSS-in-JS 是什么以及我们如何在 Reason 中处理 CSS-in-JS。当然,如果你更喜欢,完全可以将组件拆分到单独的文件中,并/或使用更传统的 CSS 解决方案。

在本章中,我们将探讨以下主题:

  • 什么是 CSS-in-JS?

  • 使用styled-components

  • 使用bs-css

什么是 CSS-in-JS?

定义 CSS-in-JS 目前在 JavaScript 社区中是一个有争议的话题。CSS-in-JS 是在组件时代诞生的。现代网络主要使用组件模型构建。几乎所有的 JavaScript 框架都采用了它。随着其采用率的增长,越来越多的团队开始在同一项目的各个组件上工作。想象一下,你在一个分布式团队中工作,正在开发一个大型应用程序,每个团队都在并行地开发一个组件。如果没有团队标准化 CSS 约定,你将遇到 CSS 作用域问题。如果没有某种类型的标准化 CSS 风格指南,多个团队很容易对类名进行样式化,从而影响其他未预期的组件。随着时间的推移,出现了一些解决方案来解决这些问题以及其他与 CSS 相关的规模问题。

简要历史

一些流行的 CSS 约定包括 BEM、SMACSS 和 OOCSS。每个解决方案都要求开发者学习该约定并记住正确应用它;否则,仍然可能会遇到令人沮丧的作用域问题。

CSS 模块成为了一个更安全的选项,开发者可以将 CSS 导入 JavaScript 模块,构建步骤会自动将 CSS 局部作用域到该 JavaScript 模块。CSS 本身仍然是在一个正常的 CSS(或 SASS)文件中编写的。

CSS-in-JS 更进一步,允许你直接在 JavaScript 模块中编写 CSS,自动将 CSS 局部作用域到组件。这对许多开发者来说感觉是正确的;而有些人从一开始就不喜欢它。一些 CSS-in-JS 解决方案,如styled-components,允许开发者直接将 CSS 与组件耦合。你不必使用<header className="..." />,而是可以有<Header />,其中Header组件是用styled-components及其 CSS 定义的,如下面的代码所示:

import React from 'react';
import styled from 'styled-components';

const Header = styled.header`
  font-size: 1.5em;
  text-align: center;
  color: dodgerblue;
`;

曾经有一段时间,styled-components存在性能问题,因为 JavaScript 包必须下载、编译和执行,然后库才能在 DOM 中动态创建样式表。这些问题的现在已经基本得到解决,多亏了服务器端渲染的支持。那么,我们能在 Reason 中这样做吗?让我们看看吧!

使用 styled-components

styled-components最受欢迎的功能之一是能够根据组件的属性动态创建 CSS。使用这个功能的一个原因是可以创建组件的替代版本。这些替代版本将被封装在样式化组件本身中。以下是一个示例,其中文本可以是居中对齐或左对齐,并且可选地带有下划线。

const Title = styled.h1`
  text-align: ${props => props.center ? "center" : "left"};
  text-decoration: ${props => props.underline ? "underline" : "none"};
  color: white;
  background-color: coral;
`;

render(
  <div>
    <Title>I'm Left Aligned</Title>
    <Title center>I'm Centered!</Title>
    <Title center underline>I'm Centered & Underlined!</Title>
  </div>
);

在 Reason 的上下文中,挑战在于通过style-components API 创建一个可以动态处理属性的组件。考虑以下对styled.h1函数和我们的<Title />组件的绑定。

/* StyledComponents.re */
[@bs.module "styled-components"] [@bs.scope "default"] [@bs.variadic]
external h1: (array(string), array('a)) => ReasonReact.reactClass = "h1";

module Title = {
  let title =
    h1(
      [|
        "text-align: ",
        "; text-decoration: ",
        "; color: white; background-color: coral;",
      |],
      [|
        props => props##center ? "center" : "left",
        props => props##underline ? "underline" : "none",
      |],
    );

  [@bs.deriving abstract]
  type jsProps = {
    center: bool,
    underline: bool,
  };

  let make = (~center=false, ~underline=false, children) =>
    ReasonReact.wrapJsForReason(
      ~reactClass=title,
      ~props=jsProps(~center, ~underline),
      children,
    );
};

h1函数接受一个字符串数组作为其第一个参数,以及一个表达式数组作为其第二个参数。这是因为这是 ES5 对 ES6 标记模板字面量的表示。在h1函数的情况下,表达式数组是传递给 React 组件的属性所调用的函数。

我们使用[@bs.variadic]装饰器来允许任意数量的参数。在 Reason 端,我们使用一个数组,而在 JavaScript 端,这个数组被展开为任意数量的参数。

使用[@bs.variadic]

让我们快速偏离一下主题,进一步探讨[@bs.variadic]。假设你想要绑定到Math.max(),它可以接受一个或多个参数:

/* JavaScript */
Math.max(1, 2);
Math.max(1, 2, 3, 4);

这是一个完美的[@bs.variadic]用例。我们在 Reason 端使用一个数组来保存参数,这个数组将被展开以匹配上述 JavaScript 中的语法。

/* Reason */
[@bs.scope "Math"][@bs.val][@bs.variadic] external max: array('a) => unit = "";
max([|1, 2|]);
max([|1, 2, 3, 4|]);

好的,我们回到了styled-components的例子。我们可以这样使用<Title />组件:

/* Home.re */
let component = ReasonReact.statelessComponent("Home");

let make = _children => {
  ...component,
  render: _self =>
    <StyledComponents.Title center=true underline=true>
 {ReasonReact.string("Page1")}
 </StyledComponents.Title>,
};

上一段代码是一个样式化的 ReasonReact 组件,它使用 CSS 渲染了一个h1元素。该 CSS 之前在StyledComponents.Title模块中定义过。《Title />》组件有两个属性——center 和 underline,它们都默认为false

当然,这并不是编写样式化组件的优雅方式,但它在功能上与 JavaScript 版本相似。另一个选择是回到原始 JavaScript,以利用熟悉的标记模板字面量语法。让我们在Title.re中展示这个例子。

/* Title.re */
%bs.raw
{|const styled = require("styled-components").default|};

let title = [%bs.raw
  {|
     styled.h1`
       text-align: ${props => props.center ? "center" : "left"};
       text-decoration: ${props => props.underline ? "underline" : "none"};
       color: white;
       background-color: coral;
     `
   |}
];

[@bs.deriving abstract]
type jsProps = {
  center: bool,
  underline: bool,
};

let make = (~center=false, ~underline=false, children) =>
  ReasonReact.wrapJsForReason(
    ~reactClass=title,
    ~props=jsProps(~center, ~underline),
    children,
  );

使用方法类似,但现在<Title />组件不再是StyledComponents的子模块。

/* Home.re */
let component = ReasonReact.statelessComponent("Home");

let make = _children => {
  ...component,
  render: _self =>
    <Title center=true underline=true> {ReasonReact.string("Page1")} </Title>,
};

个人来说,我喜欢使用[%bs.raw]版本的开发者体验。我想对 Adam Coll (@acoll1)表示衷心的感谢,因为他想出了styled-components绑定的两个版本。我也非常期待看到社区会提出什么。

让我们探索社区最受欢迎的 CSS-in-JS 解决方案:bs-css

使用 bs-css

虽然 Reason 团队没有对 CSS-in-JS 解决方案提出官方推荐,但许多人目前都在使用一个名为bs-css的库,该库封装了 emotion CSS-in-JS 库(版本 9)。bs-css库为 Reason 提供了类型安全的 API。使用这种方法,我们可以让编译器检查我们的 CSS。我们将通过将我们的App.scss转换为App.scss,这是我们在第三章中创建的,创建 ReasonReact 组件,来了解这个库。

要跟上,请克隆这本书的 GitHub 仓库,并从Chapter06/app-start开始,使用以下代码:

git clone https://github.com/PacktPublishing/ReasonML-Quick-Start-Guide.git
cd ReasonML-Quick-Start-Guide
cd Chapter06/app-start
npm install

要开始使用bs-css,我们将将其作为依赖项包含在package.jsonbsconfig.json中,如下所示:

/* bsconfig.json */
...
"bs-dependencies": ["reason-react", "bs-css"],
...

在通过 npm 安装bs-css并配置bsconfig.json之后,我们将能够访问库提供的Css模块。定义自己的子模块Styles是标准做法,我们在其中打开Css模块,并将所有的 CSS-in-Reason 写在那里。由于我们将转换App.scss,我们将在App.re中声明一个Styles子模块,如下所示:

/* App.re */

...
let component = ReasonReact.reducerComponent("App");

module Styles = {
  open Css;
};
...

现在,让我们将以下 Sass 转换为:

.App {
  min-height: 100vh;

  &:after {
    content: "";
    transition: opacity 450ms cubic-bezier(0.23, 1, 0.32, 1),
      transform 0ms cubic-bezier(0.23, 1, 0.32, 1) 450ms;
    position: fixed;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    background-color: rgba(0, 0, 0, 0.33);
    transform: translateX(-100%);
    opacity: 0;
    z-index: 1;
  }

  &.overlay {
    &:after {
      transition: opacity 450ms cubic-bezier(0.23, 1, 0.32, 1);
      transform: translateX(0%);
      opacity: 1;
    }
  }
}

Styles内部,我们声明了一个名为app的绑定,它将在<App />组件的className属性中使用。我们将绑定到名为stylebs-css函数的结果。style函数接受一个 CSS 规则列表。让我们通过以下代码来探索其语法:

module Styles = {
  open Css;

  let app = style([
    minHeight(vh(100.)),
  ]);
};

起初可能有点奇怪,但用得越多,感觉越好。所有 CSS 属性和所有单位都是函数。函数有类型。如果类型不匹配,编译器会报错。考虑以下无效的 CSS:

min-height: red;

这在 CSS、Sass 甚至styled-components中都是静默失败的。使用bs-css,我们至少可以防止很多无效的 CSS。编译器还会通知我们任何未使用的绑定,这可以帮助我们维护 CSS 样式表,并且,像往常一样,我们有完整的 IntelliSense,这有助于我们在使用过程中学习 API。

个人来说,我是 Sass 嵌套 CSS 的大粉丝,我很高兴我们也可以用bs-css做到这一点。为了嵌套:after伪选择器,我们使用after函数。为了嵌套.overlay选择器,我们使用selector函数。就像在 Sass 中一样,我们使用&符号来引用父元素,如下所示:

module Styles = {
  open Css;

  let app =
    style([
      minHeight(vh(100.)),

      after([
 contentRule(""),
 transitions([
 `transition("opacity 450ms cubic-bezier(0.23, 1, 0.32, 1)"),
 `transition("transform 0ms cubic-bezier(0.23, 1, 0.32, 1) 450ms"),
 ]),
        position(fixed),
        top(zero),
        right(zero),
        bottom(zero),
        left(zero),
        backgroundColor(rgba(0, 0, 0, 0.33)),
        transform(translateX(pct(-100.))),
        opacity(0.),
        zIndex(1),
      ]),

      selector(
        "&.overlay",
        [ 
          after([
            `transition("opacity 450ms cubic-bezier(0.23, 1, 0.32, 1)"),
            transform(translateX(zero))),
            opacity(1.),
          ]),
        ],
      )
    ]);
};

注意我们是如何使用多态变体transition来表示过渡字符串的。否则,过渡是不有效的。

你可以在 GitHub 仓库的Chapter06/app-end/src/App.re文件中找到其余的转换。现在我们只剩下将样式应用到<App />组件的className属性上了,如下所示:

/* App.re */
...
render: self =>
  <div
    className={"App " ++ Styles.app ++ (self.state.isOpen ? " overlay" : "")}
...

删除App.scss后,一切看起来几乎都一样。太棒了!例外的是nav > ul > li:after选择器。在之前的章节中,我们使用内容属性来渲染图像,如下所示:

content: url(./img/icon/chevron.svg);

根据 Css.reicontentRule 函数接受一个字符串。因此,使用 url 函数不会进行类型检查,如下面的代码所示:

contentRule(url("./img/icon/chevron.svg")) /* type error */

作为一条逃生路线,bs-css 提供了 unsafe 函数(如下面的代码所示),这将绕过这个问题:

unsafe("content", "url('./img/icon/chevron.svg')")

然而,尽管我们之前的 webpack 配置会将前面的图像作为依赖项拉入,但在使用 bs-css 时,它不再这样做。

权衡

在 Reason 中使用 CSS-in-JS 显然是一个权衡。一方面,我们可以获得类型安全的、局部作用域的 CSS,并且可以将我们的 CSS 与组件一起定位。另一方面,语法稍微有点冗长,可能会有一些奇怪的边缘情况。选择 Sass 而不是 CSS-in-JS 解决方案是完全合理的,因为在这里没有明显的胜者。

其他库

我鼓励您尝试其他 CSS-in-JS Reason 库。并且无论您在寻找 Reason 库时,您的第一个目的地应该是 Redex(Reason Package Index**)。

您可以在以下位置找到 Redex(Reason Package Index**):

redex.github.io/

另一个有用的资源是 Reason Discord 频道。这是一个询问各种 CSS-in-JS 解决方案及其权衡的好地方。

您可以在以下位置找到 Reason Discord 频道:

discord.gg/reasonml

摘要

CSS-in-JS 仍然相对较新,在 Reason 社区中未来将会有很多关于它的实验。在本章中,我们了解了一些 CSS-in-JS(在 Reason 中)的好处和挑战。您站在哪一边?

在 第七章,“Reason 中的 JSON”,我们将学习如何在 Reason 中处理 JSON,并看看 GraphQL 如何帮助减少样板代码同时实现一些相当吸引人的保证。

第七章:Reason 中的 JSON

在本章中,我们将通过构建一个简单的客户管理应用程序来学习如何使用 JSON。此应用程序位于我们现有应用程序的 /customers 路径中,可以创建、读取和更新客户。JSON 数据持久化到 localStorage。在本章中,我们将以两种不同的方式将外部 JSON 转换为 Reason 可以理解的类型化数据结构:

  • 使用纯粹的理由

  • 使用 bs-json

我们将在本章末尾比较和对比每种方法。我们还将讨论 GraphQL 如何帮助在静态类型语言(如 Reason)中处理 JSON 时提供愉快的开发者体验。

要跟随构建客户管理应用程序,请克隆本书的 GitHub 仓库并从 Chapter07/app-start 开始:

git clone https://github.com/PacktPublishing/ReasonML-Quick-Start-Guide.git
cd ReasonML-Quick-Start-Guide
cd Chapter07/app-start
npm install

在本章中,我们将探讨以下主题:

  • 构建视图

  • 与 localStorage 集成

  • 使用 bs-json

  • 使用 GraphQL

构建视图

总共,我们将有三个视图:

  • 列表视图

  • 创建视图

  • 更新视图

每个视图都有自己的路由。创建和更新视图共享一个公共组件,因为它们非常相似。

文件结构

由于我们的 bsconfig.json 包含子目录,我们可以创建一个 src/customers 目录来存放相关组件,BuckleScript 将递归地在 src 的子目录中查找 Reason(和 OCaml)文件:

/* bsconfig.json */
...
"sources": {
  "dir": "src",
  "subdirs": true
},
...

让我们继续并将 src/Page1.re 组件重命名为 src/customers/CustomerList.re。在同一个目录中,我们稍后创建 Customer.re,它将用于创建和更新单个客户。

更新路由和导航菜单

Router.re 中,我们将用以下内容替换 /page1 路由:

/* Router.re */
let routes = [
  ...
  {href: "/customers", title: "Customer List", component: <CustomerList />}
  ...
];

我们还将添加 /customers/create/customers/:id 的路由:

/* Router.re */
let routes = [
  ...
  {href: "/customers/create", title: "Create Customer", component: <Customer />,},
  {href: "/customers/:id", title: "Update Customer", component: <Customer />}
  ...
];

路由已更新,以便它可以处理路由变量(例如 /customers/:id)。此更改已在 Chapter07/app-start 中为您完成。

最后,务必更新 <App.re /> 中的导航菜单:

/* App.re */
render: self =>
  ...
  <ul>
    <li>
      <NavLink href="/customers">
        {ReasonReact.string("Customers")}
      </NavLink>
    </li>
  ...

CustomerType.re

此文件将包含 <CustomerList /><Customer /> 都使用的客户类型。这样做是为了避免任何循环依赖编译器错误:

/* CustomerType.re */
type address = {
  street: string,
  city: string,
  state: string,
  zip: string,
};

type t = {
  id: int,
  name: string,
  address,
  phone: string,
  email: string,
};

CustomerList.re

目前,我们将使用硬编码的客户数组。很快,我们将从 localStorage 中检索这些数据。以下组件渲染一个样式化的客户数组。每个客户都被 <Link /> 组件包裹。点击客户将导航到更新视图:

let component = ReasonReact.statelessComponent("CustomerList");

let customers: array(CustomerType.t) = [
  {
    id: 1,
    name: "Christina Langworth",
    address: {
      street: "81 Casey Stravenue",
      city: "Beattyview",
      state: "TX",
      zip: "57918",
    },
    phone: "877-549-1362",
    email: "Christina.Langworth@gmail.com",
  },
  ...
];

module Styles = {
  open Css;

  let list =
    style([
      ...
    ]);
};

let make = _children => {
  ...component,
  render: _self =>
    <div>
      <ul className=Styles.list>
        {
          ReasonReact.array(
            Belt.Array.map(customers, customer =>
              <li key={string_of_int(customer.id)}>
                <Link href={"/customers/" ++ string_of_int(customer.id)}>
                  <p> {ReasonReact.string(customer.name)} </p>
                  <p> {ReasonReact.string(customer.address.street)} </p>
                  <p> {ReasonReact.string(customer.phone)} </p>
                  <p> {ReasonReact.string(customer.email)} </p>
                </Link>
              </li>
            )
          )
        }
      </ul>
    </div>,
};

Customer.re

此还原组件渲染一个表单,其中每个客户字段都可以在输入元素内编辑。组件有两个模式——CreateUpdate——基于 window.location.pathname

我们首先绑定到 window.location.pathname,并定义组件的动作和状态:

/* Customer.re */
[@bs.val] external pathname: string = "window.location.pathname";

type mode =
  | Create
  | Update;

type state = {
  mode,
  customer: CustomerType.t,
};

type action =
  | Save(ReactEvent.Form.t);

let component = ReasonReact.reducerComponent("Customer");

接下来,我们使用 bs-css 添加我们的组件样式。要查看样式,请查看 Chapter07/app-end/src/customers/Customer.re

/* Customer.re */
module Styles = {
  open Css;

  let form =
    style([
      ...
    ]);
};
Chapter07/app-end/src/customers/Customer.re:
/* Customer.re */
let customers: array(CustomerType.t) = [|
  {
    id: 1,
    name: "Christina Langworth",
    address: {
      street: "81 Casey Stravenue",
      city: "Beattyview",
      state: "TX",
      zip: "57918",
    },
    phone: "877-549-1362",
    email: "Christina.Langworth@gmail.com",
  },
  ...
|];

我们还提供了辅助函数,以下是一些原因:

  • window.location.pathname 提取客户 ID

  • 要通过 ID 获取客户

  • 要生成默认客户:

let getId = pathname =>
  try (Js.String.replaceByRe([%bs.re "/\\D/g"], "", pathname)->int_of_string) {
  | _ => (-1)
  };

let getCustomer = customers => {
  let id = getId(pathname);
  customers |> Js.Array.find(customer => customer.CustomerType.id == id);
};

let getDefault = customers: CustomerType.t => {
  id: Belt.Array.length(customers) + 1,
  name: "",
  address: {
    street: "",
    city: "",
    state: "",
    zip: "",
  },
  phone: "",
  email: "",
};

当然,以下是我们组件的 make 函数:

let make = _children => {
  ...component,
  initialState: () => {
    let mode = Js.String.includes("create", pathname) ? Create : Update;
    {
      mode,
      customer:
        switch (mode) {
        | Create => getDefault(customers)
        | Update =>
          Belt.Option.getWithDefault(
            getCustomer(customers),
            getDefault(customers),
          )
        },
    };
  },
  reducer: (action, state) =>
    switch (action) {
    | Save(event) =>
      ReactEvent.Form.preventDefault(event);
      ReasonReact.Update(state);
    },
  render: self =>
    <form
      className=Styles.form
      onSubmit={
        event => {
          ReactEvent.Form.persist(event);
          self.send(Save(event));
        }
      }>
      <label>
        {ReasonReact.string("Name")}
        <input type_="text" defaultValue={self.state.customer.name} />
      </label>
      <label>
        {ReasonReact.string("Street Address")}
        <input
          type_="text"
          defaultValue={self.state.customer.address.street}
        />
      </label>
      <label>
        {ReasonReact.string("City")}
        <input type_="text" defaultValue={self.state.customer.address.city} />
      </label>
      <label>
        {ReasonReact.string("State")}
        <input type_="text" defaultValue={self.state.customer.address.state} />
      </label>
      <label>
        {ReasonReact.string("Zip")}
        <input type_="text" defaultValue={self.state.customer.address.zip} />
      </label>
      <label>
        {ReasonReact.string("Phone")}
        <input type_="text" defaultValue={self.state.customer.phone} />
      </label>
      <label>
        {ReasonReact.string("Email")}
        <input type_="text" defaultValue={self.state.customer.email} />
      </label>
      <input
        type_="submit"
        value={
          switch (self.state.mode) {
          | Create => "Create"
          | Update => "Update"
          }
        }
      />
    </form>,
};

Save 动作还没有保存到 localStorage。当导航到 /customers/create 时,表单是空的,当导航到例如 /customers/1 时,表单会被填充。

与 localStorage 集成

让我们创建一个单独的模块来与数据层交互,我们将它称为 DataPureReason.re。在这里,我们公开 localStorage.getItemlocalStorage.setItem 的绑定,以及一个解析函数,用于将 JSON 字符串解析为之前定义的 CustomerType.t 记录。

填充 localStorage

你可以在 Chapter07/app-end/src/customers/data.json 中找到一些初始数据。请在浏览器控制台中运行 localStorage.setItem("customers", JSON.stringify(/* paste JSON data here */)) 以填充 localStorage 中的这些初始数据。

DataPureReason.re

记得当 BuckleScript 绑定感觉有点神秘的时候吗?希望现在它们开始感觉稍微直接一些:

[@bs.val] [@bs.scope "localStorage"] external getItem: string => string = "";
[@bs.val] [@bs.scope "localStorage"]
external setItem: (string, string) => unit = "";

要解析 JSON,我们将使用 Js.Json 模块。

Js.Json 文档可以在以下 URL 找到:

bucklescript.github.io/bucklescript/api/Js_json.html

很快,你就会看到一种使用 Js.Json 模块解析 JSON 字符串的方法。不过有一个注意事项:这有点繁琐。但了解正在发生的事情以及为什么我们需要为像 Reason 这样的类型化语言做这件事是很重要的。从高层次上讲,我们将验证 JSON 字符串以确保它是有效的 JSON,如果是的话,使用 Js.Json.classify 函数将 JSON 字符串(Js.Json.t)转换为标签类型(Js.Json.tagged_t)。可用的标签如下:

type tagged_t =
  | JSONFalse
  | JSONTrue
  | JSONNull
  | JSONString(string)
  | JSONNumber(float)
  | JSONObject(Js_dict.t(t))
  | JSONArray(array(t));

这样,我们可以将 JSON 字符串转换为 Reason 数据结构。

验证 JSON 字符串

上一节中定义的 getItem 绑定将返回一个字符串:

let unvalidated = DataPureReason.getItem("customers");

我们可以这样验证 JSON 字符串:

let validated =
  try (Js.Json.parseExn(unvalidated)) {
  | _ => failwith("Error parsing JSON string")
  };

如果 JSON 无效,它将生成一个运行时错误。在本章的结尾,我们将学习 GraphQL 如何帮助改善这种情况。

使用 Js.Json.classify

假设我们已经验证了以下 JSON(它是一个对象数组):

[
  {
    "id": 1,
    "name": "Christina Langworth",
    "address": {
      "street": "81 Casey Stravenue",
      "city": "Beattyview",
      "state": "TX",
      "zip": "57918"
    },
    "phone": "877-549-1362",
    "email": "Christina.Langworth@gmail.com"
  },
  {
    "id": 2,
    "name": "Victor Tillman",
    "address": {
      "street": "2811 Toby Gardens",
      "city": "West Enrique",
      "state": "NV",
      "zip": "40465"
    },
    "phone": "(502) 091-2292",
    "email": "Victor.Tillman30@gmail.com"
  }
]

现在我们已经验证了 JSON,我们准备对其进行分类:

switch (Js.Json.classify(validated)) {
| Js.Json.JSONArray(array) =>
  Belt.Array.map(array, customer => ...)
| _ => failwith("Expected an array")
};

我们对 Js.Json.tagged_t 的可能标签进行模式匹配。如果它是一个数组,我们就会使用 Belt.Array.map(或 Js.Array.map)遍历它。否则,在我们的应用程序上下文中会得到一个运行时错误。

map 函数传递一个对数组中每个对象的引用。但 Reason 还不知道每个元素实际上是一个对象。在 map 内部,我们再次对数组中的每个元素进行分类。分类后,Reason 现在知道每个元素实际上是一个对象。我们将定义一个名为 parseCustomer 的自定义辅助函数,用于与 map 函数一起使用:

switch (Js.Json.classify(validated)) {
| Js.Json.JSONArray(array) =>
  Belt.Array.map(array, customer => parseCustomer(customer))
| _ => failwith("Expected an array")
};

let parseCustomer = json =>
  switch (Js.Json.classify(json)) {
  | Js.Json.JSONObject(json) => (
      ...
    )
  | _ => failwith("Expected an object")
  };

现在,如果数组中的每个元素都是一个对象,我们希望返回一个新的记录。这个记录将是 CustomerType.t 类型。否则,我们会得到一个运行时错误:

let parseCustomer = json =>
  switch (Js.Json.classify(json)) {
  | Js.Json.JSONObject(json) => (
      {
        id: ...,
        name: ...,
        address: ...,
        phone: ...,
        email: ...,
      }: CustomerType.t
    )
  | _ => failwith("Expected an object")
  };

现在,对于每个字段(即idnameaddress等),我们使用Js.Dict.get来获取和分类每个字段:

Js.Dict的文档可以在以下 URL 找到:

bucklescript.github.io/bucklescript/api/Js.Dict.html

let parseCustomer = json =>
  switch (Js.Json.classify(json)) {
  | Js.Json.JSONObject(json) => (
      {
        id:
          switch (Js.Dict.get(json, "id")) {
          | Some(id) =>
            switch (Js.Json.classify(id)) {
            | Js.Json.JSONNumber(id) => int_of_float(id)
            | _ => failwith("Field 'id' should be a number")
            }
          | None => failwith("Missing field: id")
          },
        name:
          switch (Js.Dict.get(json, "name")) {
          | Some(name) =>
            switch (Js.Json.classify(name)) {
            | Js.Json.JSONString(name) => name
            | _ => failwith("Field 'name' should be a string")
            }
          | None => failwith("Missing field: name")
          },
        address:
          switch (Js.Dict.get(json, "address")) {
          | Some(address) =>
            switch (Js.Json.classify(address)) {
            | Js.Json.JSONObject(address) => {
                street:
                  switch (Js.Dict.get(address, "street")) {
                  | Some(street) =>
                    switch (Js.Json.classify(street)) {
                    | Js.Json.JSONString(street) => street
                    | _ => failwith("Field 'street' should be a string")
                    }
                  | None => failwith("Missing field: street")
                  },
                city: ...,
                state: ...,
                zip: ...,
              }
            | _ => failwith("Field 'address' should be a object")
            }
          | None => failwith("Missing field: address")
          },
        phone: ...,
        email: ...,
      }: CustomerType.t
    )
  | _ => failwith("Expected an object")
  };

查看src/customers/DataPureReason.re以获取完整的实现。DataPureReason.rei隐藏了实现细节,只暴露了localStorage绑定和一个解析函数。

哎呀,这有点繁琐,不是吗?不过现在完成了,我们可以将CustomerList.reCustomer.re中的硬编码客户数组替换为以下内容:

let customers =
  DataBsJson.(parse(getItem("customers")));

目前一切顺利!JSON 数据正在动态地被拉取、解析,现在的工作方式与硬编码时相同。

写入 localStorage

现在,让我们添加创建和更新客户的功能。为此,我们需要将我们的 Reason 数据结构转换为 JSON。在接口文件DataPureReason.rei中,我们将暴露一个toJson函数:

/* DataPureReason.rei */
let parse: string => array(CustomerType.t);
let toJson: array(CustomerType.t) => string;

然后我们将实现它:

/* DataPureReason.re */
let customerToJson = (customer: CustomerType.t) => {
  let id = customer.id;
  let name = customer.name;
  let street = customer.address.street;
  let city = customer.address.city;
  let state = customer.address.state;
  let zip = customer.address.zip;
  let phone = customer.phone;
  let email = customer.email;

  {j|
    {
      "id": $id,
      "name": "$name",
      "address": {
        "street": "$street",
        "city": "$city",
        "state": "$state",
        "zip": "$zip"
      },
      "phone": "$phone",
      "email": "$email"
    }
  |j};
};

let toJson = (customers: array(CustomerType.t)) =>
  Belt.Array.map(customers, customer => customerToJson(customer))
  ->Belt.Array.reduce("[", (acc, customer) => acc ++ customer ++ ",")
  ->Js.String.replaceByRe([%bs.re "/,$/"], "", _)
  ++ "]"
     ->Js.String.split("/n", _)
     ->Js.Array.map(line => Js.String.trim(line), _)
     ->Js.Array.joinWith("", _);

然后我们将在Customer.re的 reducer 中使用toJson函数:

reducer: (action, state) =>
  switch (action) {
  | Save(event) =>
    let getInputValue: string => string = [%raw
      (selector => "return document.querySelector(selector).value")
    ];
    ReactEvent.Form.preventDefault(event);
    ReasonReact.UpdateWithSideEffects(
      {
        ...state,
        customer: {
          id: state.customer.id,
          name: getInputValue("input[name=name]"),
          address: {
            street: getInputValue("input[name=street]"),
            city: getInputValue("input[name=city]"),
            state: getInputValue("input[name=state]"),
            zip: getInputValue("input[name=zip]"),
          },
          phone: getInputValue("input[name=phone]"),
          email: getInputValue("input[name=email]"),
        },
      },
      (
        self => {
          let customers =
            switch (self.state.mode) {
            | Create =>
              Belt.Array.concat(customers, [|self.state.customer|])
            | Update =>
              Belt.Array.setExn(
                customers,
                Js.Array.findIndex(
                  customer =>
                    customer.CustomerType.id == self.state.customer.id,
                  customers,
                ),
                self.state.customer,
              );
              customers;
            };

          let json = customers->DataPureReason.toJson;
          DataPureReason.setItem("customers", json);
        }
      ),
    );
  },

在 reducer 中,我们使用 DOM 中的值更新self.state.customer,然后调用一个更新localStorage的函数。现在,我们能够通过创建或更新客户来写入localStorage。导航到/customers/create以创建新客户,然后导航回/customers以查看您新添加的客户。点击客户以导航到更新视图,更新客户,点击更新按钮,然后刷新页面。

使用 bs-json

现在我们确切地了解了如何将 JSON 字符串转换为类型化的 Reason 数据结构,我们注意到这个过程有点繁琐。与 JavaScript 这样的动态语言相比,代码行数更多。此外,还有相当多的重复代码。作为替代方案,Reason 社区中的许多人已经采用了bs-json作为编码和解码 JSON 的“官方”解决方案。

让我们创建一个新的模块DataBsJson.re和一个新的接口文件DataBsJson.rei。我们将复制与DataPureReason.rei中完全相同的接口,这样我们就可以知道,一旦我们完成,我们就可以用DataBsJson替换所有对DataPureReason的引用,一切应该都能正常工作。

暴露的接口如下:

/* DataBsJson.rei */
[@bs.val] [@bs.scope "localStorage"] external getItem: string => string = "";
[@bs.val] [@bs.scope "localStorage"]
external setItem: (string, string) => unit = "";

let parse: string => array(CustomerType.t);
let toJson: array(CustomerType.t) => string;

让我们关注parse函数:

let parse = json =>
  json |> Json.parseOrRaise |> Json.Decode.array(customerDecoder);

在这里,我们接受与之前相同的 JSON 字符串,验证它,将其转换为Js.Json.t(通过Json.parseOrRaise),然后将结果传递给这个新的Json.Decode.array(customerDecoder)函数。Json.Decode.array将尝试将 JSON 字符串解码为数组,并使用一个名为customerDecoder的自定义函数解码数组的每个元素——我们将在下面看到:

let customerDecoder = json =>
  Json.Decode.(
    (
      {
        id: json |> field("id", int),
        name: json |> field("name", string),
        address: json |> field("address", addressDecoder),
        phone: json |> field("phone", string),
        email: json |> field("email", string),
      }: CustomerType.t
    )
  );

customerDecoder函数接受与数组中每个元素关联的 JSON,并尝试将其解码为CustomerType.t类型的记录。这基本上与我们之前所做的是完全相同的,但它更加简洁,更容易阅读。正如你所看到的,我们还有一个名为addressDecoder的客户解码器,用于解码CustomerType.address类型:

let addressDecoder = json =>
  Json.Decode.(
    (
      {
        street: json |> field("street", string),
        city: json |> field("city", string),
        state: json |> field("state", string),
        zip: json |> field("zip", string),
      }: CustomerType.address
    )
  );

注意自定义解码器是如何轻松组合的。每个记录字段都是通过调用Json.Decode.field,传递字段名称(在 JSON 方面),并传递一个Json.Decode函数来解码的,该函数最终将 JSON 字段转换为 Reason 可以理解的数据类型。

编码的工作方式类似,但顺序相反:

let toJson = (customers: array(CustomerType.t)) =>
  customers->Belt.Array.map(customer =>
    Json.Encode.(
      object_([
        ("id", int(customer.id)),
        ("name", string(customer.name)),
        (
          "address",
          object_([
            ("street", string(customer.address.street)),
            ("city", string(customer.address.city)),
            ("state", string(customer.address.state)),
            ("zip", string(customer.address.zip)),
          ]),
        ),
        ("phone", string(customer.phone)),
        ("email", string(customer.email)),
      ])
    )
  )
  |> Json.Encode.jsonArray
  |> Json.stringify;

客户数组被映射,每个客户都被编码为一个 JSON 对象。结果是 JSON 对象的数组,然后被编码为 JSON,并转换为字符串。这比我们之前的实现要好得多。

在将DataPureReason.re中的相同localStorage绑定复制过来之后,我们的界面现在已经实现。在将所有对DataPureReason的引用替换为DataBsJson之后,我们看到我们的应用程序仍然在正常工作。

使用 GraphQL

在 2018 年 ReactiveConf 上,Sean Grove 关于 Reason 和 GraphQL 的演讲非常精彩,标题为ReactiveMeetups w/ Sean Grove | ReasonML GraphQL.以下是从这次演讲中摘录的内容,很好地总结了在 Reason 中使用 JSON 的问题和解决方案:

因此,我认为,在像 Reason 这样的类型语言中,当你想要与现实世界交互时,有三个真正非常大的问题。第一个是,将数据放入和从你的类型系统中取出所需的所有样板代码。

第二个是,即使你可以通过编程方式摆脱样板代码,你仍然会担心转换的准确性和安全性。

最后,即使你得到了所有这些,并且你绝对确信你已经捕捉到了所有的变化,有人仍然可以在你不知情的情况下从下面改变它。

每当服务器更改字段时,我们有多少次会得到一个变更日志?在一个理想的世界里,我们会。但大多数时候我们不会。我们只能反向工程我们的服务器发生了什么变化。

因此,我认为,为了以广泛适用的方式解决这个问题,我们想要四件事情:

  1. 以编程方式访问 API 可以为我们提供的数据类型。

  2. 保证安全的自动转换。

  3. 我们希望有一个合同。我们希望服务器保证,如果它说一个字段不可为空,那么它们永远不会给我们空值。如果它们更改字段名称,那么我们立即知道,并且它们也知道。

  4. 我们希望以编程方式实现所有这些。

那就是 GraphQL。

-Sean Grove

你可以在以下 URL 找到ReactiveMeetups w/ Sean Grove | ReasonML GraphQL的视频:

youtu.be/t9a-_VnNilE

此外,这里是 ReactiveConf 的 YouTube 频道:

www.youtube.com/channel/UCBHdUnixTWymmXBIw12Y8Qg

本书的内容并不深入探讨 GraphQL,但鉴于我们在讨论在 Reason 中使用 JSON,一个高级介绍似乎很合适。

什么是 GraphQL?

如果你属于 ReactJS 社区,那么你很可能已经听说过 GraphQL。GraphQL 是一种查询语言和运行时,我们可以用它来满足这些查询,它也是由 Facebook 创建的。使用 GraphQL,ReactJS 组件可以包含 GraphQL 片段,用于组件所需的数据——这意味着组件可以将 HTML、CSS、JavaScript 和其外部数据全部放在一个文件中。

当使用 GraphQL 时,我需要创建 JSON 解码器吗?

由于 GraphQL 对你的应用程序的外部数据了如指掌,GraphQL 客户端(reason-apollo)将为你自动生成解码器。当然,解码器必须自动生成,这样我们才能确信它们反映了外部数据的当前形状。这又是考虑在需要处理外部数据时使用 GraphQL 与你的 Reason 应用程序的一个原因。

摘要

只要我们在 Reason 中工作,类型系统就会阻止你遇到运行时类型错误。然而,当我们与外部世界交互——无论是 JavaScript 还是外部数据——我们就失去了这些保证。为了能够在 Reason 的边界内保留这些保证,我们需要在 Reason 外部使用事物时帮助类型系统。我们之前学习了如何在 Reason 中使用外部 JavaScript,在本章中我们学习了如何在 Reason 中使用外部数据。虽然编写解码器和编码器更具挑战性,但它与编写 JavaScript 绑定非常相似。最终,我们只是在告诉 Reason 外部事物的类型。使用 GraphQL,我们可以扩展 Reason 的边界以包括外部数据。当然,这会有权衡,没有什么是完美的,但绝对值得一试。

在下一章中,我们将探讨 Reason 上下文中的测试。我们应该编写哪些测试?我们应该避免哪些测试?我们还将探讨单元测试如何帮助我们改进本章中编写的代码。

第八章:Reason 中的单元测试

在像 Reason 这样的类型语言中进行测试的主题是一个有些有争议的话题。有些人认为一个好的测试套件减少了类型系统的需求。另一方面,有些人比测试套件更重视类型系统。这些不同的观点可能导致一些相当激烈的辩论。

当然,类型和测试不是相互排斥的。我们可以同时拥有类型和测试。或许 Cheng Lou,Reason 核心团队成员之一,说得最好。

测试。这很简单,对吧?类型可以消除一类测试——不是所有的测试。这是一个人们不太重视的讨论。他们都将测试与类型对立起来。重点是:如果你有类型,并且你 添加 测试,你的测试将能够用更少的精力表达更多。你不再需要断言无效输入。你可以断言更重要的事情。如果你愿意,测试可以存在;你只是用它们表达得更多。

  • Cheng Lou

你可以在以下 URL 上观看 Cheng Lou 在 2017 年 React Conf 上的演讲:

youtu.be/_0T5OSSzxms

在本章中,我们将通过 bs-jest BuckleScript 绑定设置流行的 JavaScript 测试框架 Jest。我们将执行以下操作:

  • 学习如何使用 bs-jest 设置 es6commonjs 模块格式

  • 单元测试 Reason 函数

  • 看看编写测试如何帮助我们改进代码

要跟上进度,请克隆本书的 GitHub 仓库,并从 Chapter08/app-start 开始使用以下代码:

git clone https://github.com/PacktPublishing/ReasonML-Quick-Start-Guide.git
cd ReasonML-Quick-Start-Guide
cd Chapter08/app-start
npm install

使用 Jest 进行测试

Jest,由 Facebook 创建,可以说是最受欢迎的 JavaScript 测试框架之一。如果你熟悉 React,你很可能也熟悉 Jest。因此,我们将跳过正式介绍,直接开始使用 Jest 在 Reason 中的使用。

安装

就像任何其他包一样,我们从 Reason 包索引(或简称 Redex)开始。

Reason 包索引:

redex.github.io/

jest 中输入类型会揭示 Jest 的 bs-jest 绑定。按照 bs-jest 的安装说明,我们首先使用 npm 安装 bs-jest

npm install --save-dev @glennsl/bs-jest

然后,我们通过在 bsconfig.json 中包含它来让 BuckleScript 了解这个开发依赖项。请注意,键是 "bs-dev-dependencies" 而不是 "bs-dependencies"

"bs-dev-dependencies": ["@glennsl/bs-jest"]

由于 bs-jestjest 列为依赖项,npm 将安装 jest,因此我们不需要将 jest 作为应用程序的直接依赖项包含在内。

现在,让我们在 src 目录的兄弟目录中创建一个 __tests__ 目录:

cd Chapter08/app-start
mkdir __tests__

并告诉 BuckleScript 查找此目录:

/* bsconfig.json */
...
"sources": [
  {
    "dir": "src",
    "subdirs": true
  },
  {
    "dir": "__tests__",
    "type": "dev"
  }
],
...

最后,我们将更新 package.json 中的 test 脚本,以使用 Jest:

/* package.json */
"test": "jest"

我们的第一项测试

让我们在 __tests__/First_test.re 中创建我们的第一个测试,先从简单的内容开始:

/* __tests__/First_test.re */
open Jest;

describe("Expect", () =>
  Expect.(test("toBe", () =>
            expect(1 + 2) |> toBe(3)
          ))
);

现在,运行 npm test 会失败,出现以下错误:

 FAIL lib/es6/__tests__/First_test.bs.js
  ● Test suite failed to run

    Jest encountered an unexpected token

    This usually means that you are trying to import a file which Jest
    cannot parse, e.g. it's not plain JavaScript.

    By default, if Jest sees a Babel config, it will use that to transform
    your files, ignoring "node_modules".

    Here's what you can do:
     • To have some of your "node_modules" files transformed, you can
       specify a custom "transformIgnorePatterns" in your config.
     • If you need a custom transformation specify a "transform" option in
       your config.
     • If you simply want to mock your non-JS modules (e.g. binary assets)
       you can stub them out with the "moduleNameMapper" config option.

    You'll find more details and examples of these config options in the
    docs:
    https://jestjs.io/docs/en/configuration.html

    Details:

    .../lib/es6/__tests__/First_test.bs.js:3
    import * as Jest from "@glennsl/bs-jest/lib/es6/src/jest.js";
           ^

    SyntaxError: Unexpected token *

      at ScriptTransformer._transformAndBuildScript (node_modules/jest-
      runtime/build/script_transformer.js:403:17)

Test Suites: 1 failed, 1 total
Tests: 0 total
Snapshots: 0 total
Time: 1.43s
Ran all test suites.
npm ERR! Test failed. See above for more details.

这里的问题是 Jest 无法直接理解 ES 模块格式。记住,我们已经通过以下配置配置了 BuckleScript 使用 ES 模块(见第二章,设置开发环境):

/* bsconfig.json */
...
"package-specs": [
  {
    "module": "es6"
  }
],
...

解决这个问题的方法之一是配置 BuckleScript 使用"commonjs"模块格式:

/* bsconfig.json */
...
"package-specs": [
  {
    "module": "commonjs"
  }
],
...

然后,我们还需要更新 webpack 的entry字段:

/* webpack.config.js */
...
entry: "./lib/js/src/Index.bs.js", /* changed es6 to js */
...

现在,运行npm test会产生一个通过测试:

 PASS lib/js/__tests__/First_test.bs.js
  Expect
    ✓ toBe (4ms)

Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 1.322s
Ran all test suites.

或者,如果我们想继续使用 ES 模块格式,我们需要确保 Jest 首先通过 Babel 运行*test.bs.js文件。为此,我们需要遵循以下步骤:

  1. 安装babel-jestbabel-preset-env
npm install babel-core@6.26.3 babel-jest@23.6.0 babel-preset-env@1.7.0
  1. .babelrc中添加相应的 Babel 配置:
/* .babelrc */
{
  "presets": ["env"]
}
  1. 确保 Jest 通过 Babel 运行node_modules中的某些第三方依赖项。默认情况下,Jest 出于性能原因排除通过 Babel 运行node_modules中的任何内容。我们可以通过在package.json中提供自定义 Jest 配置来覆盖此行为。在这里,我们将告诉 Jest 仅忽略不匹配/node_modules/glennsl*/node_modules/bs-platform*等模式的第三方依赖项:
/* package.json */
...
"jest": {
 "transformIgnorePatterns": [
 "/node_modules/(?!@glennsl|bs-platform|bs-css|reason-react)"
 ]
}

现在,运行npm test在 ES 模块格式下工作:

 PASS lib/es6/__tests__/First_test.bs.js
  Expect
    ✓ toBe (7ms)

Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 1.041s
Ran all test suites.

测试业务逻辑

让我们编写一个测试来验证我们能否通过id获取正确的客户。在Customer.re中,有一个名为getCustomer的函数,它接受一个客户数组,并通过调用getId强制性地获取idgetId函数接受一个存在于getCustomer作用域之外的pathname

let getCustomer = customers => {
  let id = getId(pathname);
  customers |> Js.Array.find(customer => customer.CustomerType.id == id);
};

立即,我们发现这并不理想。如果getCustomer接受一个客户数组和id,并专注于通过id获取客户,那就更好了。否则,将更难为getCustomer编写测试。

因此,我们将getCustomer重构为也接受一个id

let getCustomerById = (customers, id) => {
 customers |> Js.Array.find(customer => customer.CustomerType.id == id);
};

现在,我们可以更容易地编写测试。遵循编译器错误以确保你已经将getCustomer替换为getCustomerById。对于id参数,传递getId(pathname)

让我们把我们的测试重命名为__tests__/Customers_test.re并包含以下测试:

open Jest;

describe("Customer", () =>
  Expect.(
    test("can create a customer", () => {
      let customers: array(CustomerType.t) = [|
        {
          id: 1,
          name: "Irita Camsey",
          address: {
            street: "69 Ryan Parkway",
            city: "Kansas City",
            state: "MO",
            zip: "00494",
          },
          phone: "8169271752",
          email: "icamsey0@over-blog.com",
        },
        {
          id: 2,
          name: "Luise Grayson",
          address: {
            street: "2756 Gale Trail",
            city: "Jacksonville",
            state: "FL",
            zip: "23566",
          },
          phone: "9044985243",
          email: "lgrayson1@netlog.com",
        },
        {
          id: 3,
          name: "Derick Whitelaw",
          address: {
            street: "45 Southridge Par",
            city: "Lexington",
            state: "KY",
            zip: "08037",
          },
          phone: "4079634850",
          email: "dwhitelaw2@fema.gov",
        },
      |];
      let customer: CustomerType.t =
        Customer.getCustomerById(customers, 2) |> Belt.Option.getExn;
      expect((customer.id, customer.name)) |> toEqual((2, "Luise 
       Grayson"));
    })
  )
);

使用我们现有的代码运行这个测试(通过npm test)会产生以下错误:

 FAIL lib/es6/__tests__/Customers_test.bs.js
  ● Test suite failed to run

    Error: No message was provided

Test Suites: 1 failed, 1 total
Tests: 0 total
Snapshots: 0 total
Time: 1.711s
Ran all test suites.

错误的原因是Customers.re在顶层调用了localStorage

/* Customer.re */
let customers = DataBsJson.(parse(getItem("customers"))); /* this is the problem */

由于 Jest 在 Node.js 中运行,我们没有访问浏览器 API。为了解决这个问题,我们可以将这个调用包裹在一个函数中:

/* Customer.re */
let getCustomers = () => DataBsJson.(parse(getItem("customers")));

我们可以在initialState中调用这个getCustomers函数。这将使我们能够避免在 Jest 中调用localStorage

让我们更新Customer.re,将客户数组移动到状态中:

/* Customer.re */
...
type state = {
  mode,
  customer: CustomerType.t,
  customers: array(CustomerType.t),
};

...

let getCustomers = () => DataBsJson.(parse(getItem("customers")));

let getCustomerById = (customers, id) => {
 customers |> Js.Array.find(customer => customer.CustomerType.id == id);
};

...

initialState: () => {
  let mode = Js.String.includes("create", pathname) ? Create : Update;
  let customers = getCustomers();
  {
    mode,
    customer:
      switch (mode) {
      | Create => getDefault(customers)
      | Update =>
        Belt.Option.getWithDefault(
          getCustomerById(customers, getId(pathname)),
          getDefault(customers),
        )
      },
    customers,
  };
},

...

/* within the reducer */
ReasonReact.UpdateWithSideEffects(
  {
    ...state,
    customer: {
      id: state.customer.id,
      name: getInputValue("input[name=name]"),
      address: {
        street: getInputValue("input[name=street]"),
        city: getInputValue("input[name=city]"),
        state: getInputValue("input[name=state]"),
        zip: getInputValue("input[name=zip]"),
      },
      phone: getInputValue("input[name=phone]"),
      email: getInputValue("input[name=email]"),
    },
  },
  self => {
    let customers =
      switch (self.state.mode) {
      | Create =>
        Belt.Array.concat(state.customers, [|self.state.customer|])
      | Update =>
        Belt.Array.setExn(
          state.customers,
          Js.Array.findIndex(
            customer =>
              customer.CustomerType.id == self.state.customer.id,
            state.customers,
          ),
          self.state.customer,
        );
        state.customers;
      };

    let json = customers->DataBsJson.toJson;
    DataBsJson.setItem("customers", json);
  },
);

这些更改之后,我们的测试通过了:

 PASS lib/es6/__tests__/Customers_test.bs.js
  Customer
    ✓ can create a customer (5ms)

Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 1.179s
Ran all test suites.

反思

在本章中,我们学习了如何使用 CommonJS 和 ES Module 格式设置bs-jest的基本知识。我们还了解到单元测试可以帮助我们写出更好的代码,因为大部分情况下,易于测试的代码也是更好的代码。我们将getCustomer重构为getCustomerById,并将客户数组移动到该组件的状态中。

由于我们在 Reason 中编写了单元测试,编译器也会检查我们的测试。例如,如果Customer_test.re使用了getCustomer,而我们把Customer.re中的getCustomerById改成了getCustomer,我们就会在编译时得到错误:

We've found a bug for you!
/__tests__/Customers_test.re 45:9-28

43  |];
44  let customer: CustomerType.t =
45  Customer.getCustomer(customers, 2) |> Belt.Option.getExn;
46  expect((customer.id, customer.name)) |> toEqual((2, "Luise Grayson")
      );
47  })

The value getCustomer can't be found in Customer

Hint: Did you mean getCustomers?

这意味着我们也不能编写某些单元测试。例如,如果我们想测试第五章,有效的 ML 代码,其中我们使用了类型系统来确保发票不会被两次打折,测试甚至无法编译。真是太棒了。

摘要

由于 Reason 的应用范围非常广泛,学习它的方法也有很多。这本书主要从前端开发者的角度来学习 Reason。我们使用了已经熟悉的技能和概念(例如使用 ReactJS 构建 Web 应用)并探讨了如何用 Reason 实现同样的功能。在这个过程中,我们学习了 Reason 的类型系统、其工具链和生态系统。

我相信 Reason 的未来是光明的。我们学到的许多技能可以直接转移到原生平台。目前 Reason 的前端故事比其原生故事更加完善,但已经可以编译成网页和原生应用。而且,它只会越来越好。自从我开始使用 Reason 以来,已经取得了巨大的进步,我非常期待看到未来会带来什么。

希望这本书能激发你对 Reason、OCaml 以及 ML 语言家族的兴趣。Reason 的类型系统已经经历了数十年的工程实践。因此,这本书没有涵盖的内容还有很多,我自己也在不断学习。然而,你现在应该已经建立了一个坚实的基础,可以继续你的学习之旅。我鼓励你通过在 Discord 频道提问、撰写博客文章、辅导他人、在聚会中分享你的经历等方式公开学习。

非常感谢您能阅读到这里,我们 Discord 频道见!

Reason Discord 频道:

discord.gg/reasonml

posted @ 2025-09-08 13:03  绝不原创的飞龙  阅读(11)  评论(0)    收藏  举报