ReasonML-快速启动指南-全-

ReasonML 快速启动指南(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

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

本书的受众

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

本书涵盖的内容

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

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

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

第四章,BuckleScript,Belt 和互操作性,让我们全面了解 Reason 的生态系统和标准库。

第五章,有效的 ML,深入探讨了 Reason 类型系统的一些更高级特性,使用了商业示例。

第六章,CSS-in-JS(在 Reason 中),展示了 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. 选择“支持”选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明进行操作。

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

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

本书的代码包也托管在 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 可以理解的东西。”

警告或重要说明会显示为这样。

提示和技巧会显示为这样。

第一章:介绍 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 的抽象化使得组件变得简单、声明式,易于理解、组合和测试。

通过 React,Facebook 在教育前端开发人员社区方面做得非常出色,传统的函数式编程范式使得更容易理解和维护代码。现在,Facebook 认为是使用 ReasonML 的时机。

这是来自npmtrends.com的两年图表,显示了一些顶级 JavaScript 库和框架的每周 npm 下载次数。ReactJS 似乎是一个明显的赢家,每周下载量已经超过 250 万次:

npmtrends.com

在本章中,我们将做以下事情:

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

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

  • 在在线游乐场中尝试 ReasonML,并检查其编译(JavaScript)输出

什么是 ReasonML?

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

  • 郑楼,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 默认是不可变的。不可变性是函数式编程中的重要概念。在实践中,使用不可变数据结构(无法更改的数据结构)比可变数据结构产生更安全、更易于推理和更易于维护的代码。这将是本书中的一个重要主题。

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

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 中,所有模块都是全局可用的,当需要时,模块接口可以用来隐藏实现细节。我们将在第五章中探讨这个概念,Effective 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 等更多应用。事实上,Jared Forsyth 已经从一个 Reason 代码库中创建了一个名为 Gravitron 的游戏,可以编译成 iOS、Android、Web 和 macOS(github.com/jaredly/gravitron)。话虽如此,就目前而言,前端 JavaScript 的情况要更加完善。

可维护性

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

只要你百分之百使用 Reason 并且你的代码编译通过,类型系统保证不会有运行时类型错误。当你与非 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),目前正在向 TC39 委员会提议将其添加到 JavaScript 语言中。通过 Reason,我们可以立即利用这些特性以及更多。

社区

Reason 社区无疑是我参与过的最乐于助人、支持和包容的社区之一。如果你有问题或遇到困难,Reason Discord 频道是实时支持的好去处。

原因 Discord 频道:

discord.gg/reasonml

通常,当开始使用新技术时,与有经验的人交谈五分钟可以节省你几个小时的挫败感。我个人在一天(和夜晚)的所有时间都问问题,并对有多快有人帮助我感到非常感激和惊讶。花点时间加入 Discord 频道,介绍自己,提问,并分享如何使 Reason 变得更好的反馈!

ReactJS 的未来

实际上,很少有真实世界的应用程序仅使用 ReactJS。通常会引入其他技术,如 Babel、ESLint、Redux、Flow/TypeScript 和 Immutable.js,以帮助增加代码库的可维护性。Reason 通过其核心语言特性取代了对这些额外技术的需求。

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

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

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

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

  • Cheng Lou

(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 */

元组是一个不可变的数据结构,可以容纳不同类型的值,并且可以是任意长度的:

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 表达式,也称为模式匹配,比我们迄今为止看到的更强大。

数据结构和类型

类型是一组值。更具体地说,42具有int类型,因为它是包含在整数集合中的值。浮点数是包含小数点的数字,即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);

模式匹配

我们可以在我们的人身上进行模式匹配:

switch (person) {
| ("Zoe", age) => {j|Zoe, $age years old|j}
| _ => "another 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类型需要一个int和一个age。几乎所有 JavaScript 的数据结构都是产品类型;唯一的例外是boolean类型,它要么是true,要么是false

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称为类型变量。类型变量总是以'开头。这种类型定义使用类型变量,以便它可以适用于任何类型。如果没有,我们将需要创建一个personOption类型,它只适用于person类型:

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

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

  • 使用 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 编译成了 ES5 版本的 JavaScript,所以不再需要 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:这本质上就是 JavaScript 的 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

警告字段

如果我们想要强制此警告抛出错误,我们可以注意到前面片段中的错误编号,并将bsconfig.jsonwarnings字段更改为以下内容:

"warnings": {
  "error": "+101+8" // added "+8"
},

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

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

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

包规范字段

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

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

in-source字段控制生成的 JavaScript 文件的目标;true会导致生成的文件放在源文件旁边,false会导致生成的文件放在lib/js中。将in-source设置为false对于在现有 JavaScript 项目中使用 Reason 非常有用,这样就可以在不进行更改的情况下使用现有的构建流程。

让我们暂时使用"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

  • 将该元素附加到文档的主体

在项目的根目录中创建一个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 ModuleESM)规范,并且它们都可以在浏览器内使用,那么您就不需要模块捆绑器来开始(假设您使用支持 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 强大的互操作功能(我们将在第四章中深入探讨),上述代码绑定到现有的浏览器 API,即document.createElementinnerTextdocument.body.appendChild,然后使用这些绑定创建一个带有一些文本的div,并将其附加到文档的主体。

运行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上提供index.html文件,而不是http://localhost:3000/src,我们需要安装并配置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

  • 了解 ReasonReact 如何通过 Reason 的类型系统帮助我们创建更健壮的组件

组件创建基础知识

让我们从分析一个简单的无状态组件开始。在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 />)来自其文件名(App.re)。返回的记录包含的字段取决于使用了哪个函数。在我们之前的例子中,我们可以覆盖以下字段:

  • 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的类型。

unit

在 Reason 中,unit是一个表示"nothing"的类型。返回类型为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([||]))

当组件将接收新的 props 时,它的make函数将再次被调用,并将新的 props 作为参数。make函数就像 ReactJS 的constructor和 ReactJS 的componentWillReceiveProps的组合。

Props

让我们给我们的<App />组件添加一些 props:

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 />提供任何 props,我们的示例也可以编译:

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

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

如果我们决定删除名称属性,编译器将告诉我们需要更新<App />的使用位置。这使我们可以自由地重构我们的组件,而不必担心忘记更新代码库中的某个区域。编译器支持我们!

子元素

make函数的最后一个参数始终是children属性-它是强制性的。与其他属性一样,子元素可以是任何数据结构。只要组件允许,我们就可以使用在 ReactJS 中流行的渲染属性模式。重要的是,ReasonReact 始终将子元素包装在数组中,因此如果我们不想要这种包装,就需要使用...语法来解包数组。

App.re中,我们将删除除了必需的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 />),甚至会收到类似的编译器消息,因为这会转换为空数组。这意味着我们保证组件的用户必须在<App />的子元素中提供类型为string => ReasonReact.reactElement的函数,如果它要进行类型检查的话。

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

/* 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)

这非常强大。由于我们在编译时获得了这些保证,因此我们不必担心组件子元素的形状是否符合运行时检查。同样,我们保证了属性在编译时进行类型检查。重构组件变得不那么紧张,因为编译器会指导我们。更重要的是,由于 Reason 的强大类型推断,到目前为止我们还没有必须明确注释任何类型。

生命周期

ReasonReact 支持熟悉的 ReactJS 生命周期事件。当我们构建我们的应用程序时,我们将更仔细地查看一些生命周期事件,但是现在,让我们看看如何为<App />实现 ReactJS 的componentDidMount生命周期挂钩:

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

我们使用didMount而不是componentDidMount。同样,didMount只是组件的make函数返回的记录中的一个字段。didMount的类型是self => unit,它是一个接受self并返回unit的函数。由于它返回unit,它很可能会导致副作用,在我们的示例中确实如此。在浏览器中运行结果会在控制台中记录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函数返回的记录中的一些附加字段。很快我们将看到,我们还需要声明自定义类型定义以在这些附加字段中使用。它被称为reducerComponent,因为它具有状态、操作和内置的 reducer 的概念-就像 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 节点。

状态、动作和减速器

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

type state = int;

type action =
  | Increment
  | Decrement;

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

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

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

initialState: () => 0,

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

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

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

为了帮助您的 ReasonReact 应用程序为即将到来的 ReactJS Fiber 发布做好准备,确保减速器中的一切都是纯的。间接触发副作用的一种方法是使用ReasonReact.UpdateWithSideEffects

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

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

  • 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)
  },

增加后,减速器触发一个副作用,在一秒后触发“减量”动作。

重构

现在,让我们想象我们现在需要我们的有状态组件在计数达到 10 时显示一条祝贺用户的消息,一旦消息显示出来,用户可以通过点击关闭按钮关闭消息。多亏了我们乐于助人的编译器,我们可以按照以下步骤进行操作:

  1. 更新state的形状

  2. 更新可用的动作

  3. 通过编译器错误进行步骤

  4. 更新render函数

编译器消息将提醒我们更新组件的初始状态和减速器。由于我们现在还需要跟踪是否显示消息,让我们将state的形状更改为这样:

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

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

type action =
  | UpdateCount(int)
  | ToggleMessage;

现在,我们不再有增量减量,而是有UpdateCount,它包含一个表示当前计数变化量的整数。

编译后,我们看到一个友好的错误提示,告诉我们之前的“减量”动作找不到:

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函数中,用UpdateCount(+1)替换增量,用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>
  </>,

再次编译,我们被告知在我们的减速器中,增量不属于类型动作。让我们更新我们的减速器来处理UpdateCountToggleMessage。如果我们忘记了一个构造函数,编译器会让我们知道减速器中的 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

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

  • 多亏了记录标点符号的支持,我们可以写{...state, count}而不是{...state, count: count}

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

| 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类型。这是因为我们的渲染函数使用string_of_int(self.state)而不是string_of_int(self.state.count)。在更新我们的渲染函数以反映这一点后,我们得到另一个类似的消息,抱怨类型int和类型state不兼容。这是因为我们的初始状态仍然返回0而不是state类型的记录。

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

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

现在,我们准备更新渲染函数,在计数达到 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操作在reducer中的UpdateCount情况下再次触发。如果用户达到 10,然后递减然后递增,消息将再次切换。为了确保UpdateCount只触发一次ToggleMessage操作,我们可以在状态中使用实例变量

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

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

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

Ref 和可变记录

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,以在监视模式下使用 bsb 和 webpack 为我们的应用提供服务,地址为http://localhost:3000

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

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

type state = {isOpen: bool};

我们有一个动作:

type action =
  | ToggleMenu(bool);

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

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

尽管 Reason 支持记录 pun,但对于单字段记录,它不起作用,因为 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.overly:after定义的,前者包括对transform属性的450ms延迟的过渡,而后者则移除了该过渡。这样做的效果是即使菜单关闭,也能实现平滑的过渡。

绑定

让我们检查App.re顶部对 JavaScript 的require函数的绑定。由于我们将在第四章中深入研究 BuckleScript,BuckleScript,Belt 和互操作性,让我们推迟讨论细节,只简要看一下这个绑定在做什么:

[@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.first的类型是option(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 ref 来获取对 DOM 节点的引用,然后获取它的clientWidth,而不是假设宽度。

React ref

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

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

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

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

由于在 JavaScript 中,React ref 可能为null,我们将其转换为一个选项,并对其值进行模式匹配。

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

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

速度

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

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

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

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

在减速器中,我们将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 => unit回调函数。unwatchUrl函数停止监视 URL。

push函数设置 URL,dangerouslyGetInitialUrl函数获取url类型的记录。dangerouslyGetInitialUrl函数仅在didMount生命周期钩子中使用,与watchUrl一起使用,以防止陈旧信息的问题。

url类型定义如下:

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

我们将在第四章中学习更多关于list类型构造函数的知识,BuckleScript,Belt 和互操作性url记录中的path字段是list(string)类型。如果window.location.pathname的值是"/book/title/edit",那么url.path的值将是["book", "title", "edit"],这是一个字符串列表。语法使它看起来像 JavaScript 数组,但有一些区别。简而言之,Reason 列表是不可变的同构单链表,意味着所有元素必须是相同类型的。

watcherID类型是一个抽象类型。我们将在第六章中学习更多关于抽象类型的知识,CSS-in-JS(在 Reason 中)。获取watcherID类型的值的唯一方法是作为ReasonReact.Router.watchUrl的返回值。

让我们创建一个路由器组件,它包装我们的<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属性:

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 />只是一个减速器组件。组件的状态是之前定义的相同路由类型,只有一个操作可以更改路由。一旦<WithRouter />被挂载,ReasonReact.Router开始监视 URL,每当 URL 更改时,就会触发ChangeRoute操作,这将调用减速器,然后更新状态,然后使用更新的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;

我们将在第四章中深入讨论 Reason 的管道运算符(|>)和 JavaScript 互操作性,BuckleScript,Belt 和互操作性

我们还需要一种方法,将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列表中url.path在结构上等于route.hrefroute

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

无论我们想要显示当前页面的标题,我们都可以简单地访问当前route上的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:在面向对象编程意义上,使用include使Bar扩展Foo
/* Bar.re */
include Foo;
let a = 4; /* override Foo.a */
let fromFoo = Add(a, b);
  • 选项 5:全局open模块。在大范围内谨慎使用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)访问其函数。

在第五章中,Effective 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.github.io/api

使用 Belt 标准库

Reason 标准库实际上是 OCaml 标准库。它并不是为 JavaScript 而创建的。Belt 标准库是由创建 BuckleScript 的同一个人——张宏波——创建的,并且随 BuckleScript 一起发布。Belt 是为 JavaScript 而创建的,尤其以其性能而闻名。Belt 标准库通过Belt模块访问:

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

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

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

使用 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.github.io/bucklescript/api/Js.html

使用自定义绑定

你可以自己编写自定义绑定:

[@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);

但我们也可以直接映射列表:

/* 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),当它是这样时,递归停止

传入sum函数的列表[1, 2, 3]会产生以下步骤:

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模块,以及快速管道操作符。

Option 模块

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 Option 模块的文档可以在这里找到:

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

List 模块

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

Belt List 模块的文档可以在这里找到:

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"
]

柯里化

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

Belt.List.makeBy

你可以在这里看到后缀:

Belt.List.makeByU

U 后缀代表 uncurried。在继续之前,让我们定义一下柯里化。

在 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 返回一个需要再次调用的函数,因此有两组括号。Reason 可以接受任何一种用法:

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

柯里化的好处在于它使得组合函数更容易。你可以轻松地创建一个部分应用的函数 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 使用以下语法对函数进行 uncurry:

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

uncurry 语法是参数列表中的点。它需要在声明和调用站点都存在:

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

如果调用站点没有使用 uncurry 语法,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

如果我们取消makeBy的第二个参数的柯里化,可以用makeByU替换它。这将提高性能(在我们的示例中可以忽略不计):

...
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 可以理解的内容。我们将在第五章中了解更多关于多态变体的知识,Effective 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。由于~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将需要以下 props:

  • _in

  • timeout

  • classNames

由于in是 Reason 中的保留字,惯例是在 Reason 中使用_in,并让 BuckleScript 将其编译为 JavaScript 中的in,使用[@bs.as "in"]

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

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

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

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

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 属性,不应该是组件 props 参数的一部分在ReasonReact.wrapJsForReason中。对于特殊的 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 组件。

在第五章 Effective ML中,我们将学习如何使用模块签名来隐藏组件的实现细节,同时构建一个自动完成输入组件。我们将首先使用硬编码数据,然后在第六章 CSS-in-JS (in 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的模块,在Foo.re中定义。它的签名可以在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有一个名为foostring类型的let绑定。

如果模块的.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;

这导致以下编译错误,因为模块签名要求bar绑定为string类型,而模块中未定义:

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必须包括这个type声明:

/* 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现在是type t('a)。我们还有两个更多的抽象类型,如下面的代码所示:

type discounted;
type undiscounted;

还要注意,make函数现在返回t(undiscounted)(而不仅仅是t),discount函数现在接受t(undiscounted)并返回t(discounted)。记住,抽象t('a)接受一个type变量,而type变量恰好是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 的类型系统来强制执行有效的 CSS,使用一个包装 Emotion(emotion.sh)的 CSS-in-Reason 库,名为bs-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 诞生于组件时代。现代 Web 主要是基于组件模型构建的。几乎所有的 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 />而不是<header className="..." />,其中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最受欢迎的功能之一是根据组件的 props 动态创建 CSS 的能力。使用此功能的一个原因是创建组件的备用版本。然后这些备用版本将被封装在样式化组件本身内。以下是一个<Title />的示例,其中文本可以居中或左对齐,也可以选择是否加下划线。

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-componentsAPI 创建一个可以动态处理 props 的组件。考虑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函数接受一个字符串数组作为其第一个参数,以及一个表达式数组作为其第二个参数。这是因为这是 ES6 标记模板字面量的 ES5 表示。在h1函数的情况下,表达式数组是传递给 React 组件的 props 的函数。

我们使用 [@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 来感受一下这个库。

要跟着做,克隆本书的 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 样式表,而且通常我们还有完整的智能感知,这有助于我们在学习 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 可以理解的类型化数据结构:

  • 使用纯 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

此 reducer 组件呈现一个表单,其中每个客户字段都可以在输入元素内进行编辑。该组件有两种模式——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(/*在此粘贴 JSON 数据*/))来填充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中相同的接口,以便知道一旦完成,我们将能够将所有引用DataPureReason替换为DataBsJson,并且一切都应该正常工作。

公开的接口如下:

/* 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

在 ReactiveConf 2018 上,Sean Grove 发表了一篇关于 Reason 和 GraphQL 的精彩演讲,题为ReactiveMeetups w/ Sean Grove | ReasonML GraphQL.以下是这次演讲的摘录,它很好地总结了在 Reason 中使用 JSON 的问题和解决方案:

因此,我认为,在像 Reason 这样的类型化语言中,当您想要与现实世界进行交互时,有三个非常大的问题。首先是将数据输入和输出到您的类型系统中所需的所有样板文件。

其次,即使您可以通过样板文件编程,您仍然担心转换的准确性和安全性。

最后,即使您完全理解了所有这些,并且绝对确定已经捕捉到了所有的变化,有人仍然可以在您不知情的情况下对其进行更改。

每当服务器更改字段时,我们会得到多少次更改日志?在理想的世界中,我们会得到。但大多数时候我们不会。我们需要逆向工程我们的服务器更改了什么。

因此,我认为,为了以广泛适用的方式解决这个问题,我们需要四件事:

1)以编程方式访问 API 可以提供给我们的所有数据类型。

2)保证安全的自动转换。

3)我们希望有一个合同。我们希望服务器保证如果它说一个字段是不可为空的,它们永远不会给我们 null。如果他们更改字段名称,那么我们立刻知道他们知道。

4)我们希望所有这些都以编程方式实现。

这就是 GraphQL。

-Sean Grove

您可以在以下网址找到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)将自动生成解码器。当然,解码器必须自动生成,以便我们确信它们反映了外部数据的当前形状。这只是在需要处理外部数据时考虑在 Reason 应用程序中使用 GraphQL 的另一个原因。

总结

只要我们在 Reason 中工作,类型系统将阻止您遇到运行时类型错误。然而,当与外部世界交互时,无论是 JavaScript 还是外部数据,我们都会失去这些保证。为了能够在 Reason 的边界内保留这些保证,我们需要在使用 Reason 之外的东西时帮助类型系统。我们之前学习了如何在 Reason 中使用外部 JavaScript,在本章中我们学习了如何在 Reason 中使用外部数据。尽管编写解码器和编码器更具挑战性,但它与编写 JavaScript 绑定非常相似。最终,我们只是告诉 Reason 某些外部于 Reason 的类型。使用 GraphQL,我们可以扩展 Reason 的边界以包含外部数据。存在权衡,没有什么是完美的,但尝试使用 GraphQL 绝对是值得的。

在下一章中,我们将探讨在 Reason 环境中进行测试。我们应该编写什么样的测试?我们应该避免哪些测试?我们还将探讨单元测试如何帮助我们改进本章中编写的代码。

第八章:Reason 中的单元测试

在像 Reason 这样的类型化语言中进行测试是一个颇具争议的话题。有些人认为一个良好的测试套件会减少对类型系统的需求。另一方面,有些人更看重类型系统而不是他们的测试套件。这些意见上的差异可能导致一些激烈的辩论。

当然,类型和测试并不是互斥的。我们可以同时拥有类型和测试。也许 Reason 核心团队成员之一郑楼说得最好。

测试。这很容易,对吧?类型会减少一类测试的数量,但并不是所有测试。这是一个人们不够重视的讨论。他们总是把测试和类型对立起来。关键是:如果你有类型,并且添加了测试,你的测试将能够用更少的精力表达更多。你不再需要断言无效的输入。你可以断言更重要的东西。如果你想要,测试可以存在;你只是用它们表达了更多。

  • 郑楼

您可以在以下 URL 上观看郑楼在 2017 年 React Conf 的演讲:

youtu.be/_0T5OSSzxms

在本章中,我们将通过bs-jest BuckleScript 绑定来设置流行的 JavaScript 测试框架 Jest。我们将进行以下操作:

  • 学习如何使用es6commonjs模块格式设置bs-jest

  • 对 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。因此,我们将跳过正式介绍,开始在 Reason 中使用 Jest。

安装

就像任何其他包一样,我们从Reason Package Index(或简称为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作为应用程序的直接依赖项。

现在让我们创建一个__tests__目录,作为src目录的同级目录:

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)"
 ]
}

现在,使用 ES 模块格式运行npm test可以正常工作:

 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的函数,接受一个customers数组,并通过调用getId来获取idgetId函数接受一个在getCustomer范围之外存在的pathname

let getCustomer = customers => {
  let id = getId(pathname);
  customers |> Js.Array.find(customer => customer.CustomerType.id == id);
};

我们立即注意到这不是理想的情况。如果getCustomer接受一个customers数组一个id,并专注于通过他们的id获取顾客,那将会更好。否则,仅仅为getCustomer编写测试会更加困难。

因此,我们重构getCustomer,也接受一个id

let getCustomerById = (customers, id) => {
 customers |> Js.Array.find(customer => customer.CustomerType.id == id);
};

现在,我们可以更容易地编写测试。遵循编译器错误,确保你已经用getCustomerById替换了getCustomer。对于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 模块格式设置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?

这意味着我们也无法编写某些单元测试。例如,如果我们想要测试第五章中的Effective ML代码,我们在那里使用类型系统来保证发票不会被打折两次,测试甚至无法编译。多么美好。

总结

由于 Reason 的影响如此广泛,有许多不同的学习方法。本书侧重于从前端开发人员的角度学习 Reason。我们学习了我们已经熟悉的技能和概念(如使用 ReactJS 构建 Web 应用程序),并探讨了如何在 Reason 中做同样的事情。在这个过程中,我们了解了 Reason 的类型系统、工具链和生态系统。

我相信 Reason 的未来是光明的。我们学到的许多技能都可以直接转移到针对本机平台。Reason 的前端故事目前比其本机故事更加完善,但已经可以编译到 Web 和本机。而且从现在开始,它只会变得更好。从我开始使用 Reason 的时候,已经有了巨大的改进,我很期待看到未来会有什么。

希望这本书能引起您对 Reason、OCaml 和 ML 语言家族的兴趣。Reason 的类型系统经过数十年的工程技术。因此,这本书没有涵盖的内容很多,我自己也在不断学习。然而,您现在应该已经建立了坚实的基础,可以继续学习。我鼓励您通过在 Discord 频道上提问、撰写博客文章、指导他人、在聚会中分享您的学习经历等公开学习。

非常感谢您能走到这一步,并在 Discord 频道上见到您!

Reason Discord 频道:

discord.gg/reasonml

posted @ 2024-05-22 12:07  绝不原创的飞龙  阅读(8)  评论(0编辑  收藏  举报