ReasonML-快速启动指南-二-

43 阅读25分钟

ReasonML 快速启动指南(二)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

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

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

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

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

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

  • 熟悉 Reason 和 Belt 标准库

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

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

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

模块作用域

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

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

let a = 1;
let b = 2;

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

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

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

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

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

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

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

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

数据结构

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

数组

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

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

  • 可变

  • 快速的随机访问和更新

它们看起来像这样:

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

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

array[0] = "updated";

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

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

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

使用 Reason 标准库

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

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

Array.map 的类型如下:

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

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

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

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

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

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

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

ReasonML API

使用 Belt 标准库

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

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

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

BuckleScript API Belt

使用 BuckleScript 内置的 JavaScript 绑定

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

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

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

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

BuckleScript API Js

使用自定义绑定

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

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

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

使用原生 JavaScript

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

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

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

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

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

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

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

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

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

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

列表

Reason 列表如下:

  • 同质

  • 不可变

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

它们看起来像这样:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

递归

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

管道运算符

Reason 有两个管道运算符:

|> (pipe)
-> (fast pipe)

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

看看这些:

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

它们等同于以下内容:

f(one, two, three)

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

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

我们不需要使用这个:

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

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

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

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

使用 Belt

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

选项模块

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

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

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

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

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

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

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

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

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

列表模块

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

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

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

让我们关注其中的一些。

make

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

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

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

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

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

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

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

makeBy

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

makeBy 函数的类型如下:

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

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

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

shuffle

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

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

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

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

take

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

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

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

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

堆叠牌的示例

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

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

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

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

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

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

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

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

Currying

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

Belt.List.makeBy

你可以看到这里的后缀:

Belt.List.makeByU

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

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

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

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

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

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

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

add(2)(3);

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

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

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

let addOne = add(1);

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

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

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

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

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

let addOne = (+)(1);

未应用柯里化的函数

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

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

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

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

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

let result = add(2, 3);

We've found a bug for you!

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

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

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

let result = add(. 2);

We've found a bug for you!

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

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

makeByU

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

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

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

JavaScript 互操作性

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

在 Reason 中使用 JavaScript

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

理解 [@bs.val] 装饰器

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

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

  • external 关键字

  • 绑定的命名引用

  • 类型声明

  • 一个等号

  • 一个字符串

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

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

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

alert("hi!");

理解 [@bs.scope] 装饰器

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

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

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

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

理解 [@bs.send] 装饰器

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

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

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

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

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

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

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

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

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

理解 [@bs.module] 装饰器

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

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

这编译成以下内容:

import * as LeftPad from "left-pad";

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

export {
  result ,
}

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

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

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

exports.result = result;

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

合理的 API

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

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

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

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

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

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

这编译成以下内容:

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

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

BuckleScript 文档

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

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

bucklescript.github.io/docs/interop-overview

绑定到现有的 ReactJS 组件

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

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

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

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

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

reactcommunity.org/react-transition-group/

导入依赖

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

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

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

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

创建 make 函数

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

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

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

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

使用 [@bs.deriving abstract]

CSSTransitionGroup 需要以下属性:

  • _in

  • timeout

  • classNames

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

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

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

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

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

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

使用组件

现在,在 App.re 中,我们可以更改渲染函数以使用这些组件。我们将更改如下:

<main> {currentRoute.component} </main>

现在它看起来如下所示:

<main>
  ReactTransitionGroup.(
    <TransitionGroup>
      <CSSTransition
        key={currentRoute.title} _in=true timeout=900 classNames="routeTransition">
        {currentRoute.component}
      </CSSTransition>
    </TransitionGroup>
  )
</main>

注意:键(key)属性是一个特殊的 ReactJS 属性,不应成为 ReasonReact.wrapJsForReason 组件的 props 参数的一部分。对于特殊的 ReactJS ref 属性也是如此。

为了完整性,以下是相应的 CSS,可以在 ReactTransitionGroup.scss 中找到:

@keyframes enter {
  from {
    opacity: 0;
    transform: translateY(50px);
  }
}

@keyframes exit {
  to {
    opacity: 0;
    transform: translateY(50px);
  }
}

.routeTransition-enter.routeTransition-enter-active {
  animation: enter 500ms ease 400ms both;
}

.routeTransition-exit.routeTransition-exit-active {
  animation: exit 400ms ease both;
}

一定要在 ReactTransitionGroup.re 中要求前面的内容:

/* ReactTransitionGroup.re */
[@bs.val] external require: string => string = "";
require("../../../src/ReactTransitionGroup.scss");

现在,当切换路由时,旧路由的内容会先向下动画并淡出,然后新路由的内容才会向上动画并淡入。

摘要

BuckleScript 非常强大,因为它让我们以一种非常愉快的方式与惯用的 JavaScript 进行交互。它还提供了 Belt 标准库,该库是考虑到 JavaScript 而创建的。我们学习了数组和列表,并看到了如何在 Reason 中使用现有的 ReactJS 组件是多么容易。

在 第五章,《有效的机器学习》中,我们将学习如何使用模块签名在构建自动完成输入组件时隐藏组件的实现细节。我们最初会使用硬编码的数据,然后在 第六章,《CSS-in-JS(在 Reason 中)》中,我们将把数据移动到 localStorage(客户端 Web 存储)。

第五章:有效 ML

到目前为止,我们已经学习了 Reason 的基础知识。我们看到了拥有一个健全的类型系统如何使重构变得更加安全、压力更小。在更改实现细节时,类型系统会帮助我们及时提醒代码库中需要更新的其他区域。在本章中,我们将学习如何隐藏实现细节,使重构变得更加容易。通过隐藏实现细节,我们保证更改它们不会影响代码库的其他区域。

我们还将学习类型系统如何帮助我们强制执行应用程序中的业务规则。隐藏实现细节也为我们提供了一种很好的方式来强制执行业务规则,通过确保模块不会被用户误用。我们将通过包含在此书 GitHub 仓库中的简单代码示例来阐述这一观点。

要继续学习,请从 Chapter05/app-start 开始。这些示例与我们一直在构建的应用程序是隔离的。

您可以使用以下方式访问本书的 GitHub 仓库:

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

记住,所有模块都是全局的,并且默认情况下,模块的所有类型和绑定都会被暴露。正如我们很快就会看到的,模块签名可以用来隐藏模块的类型和/或绑定,使其对其他模块不可见。在本章中,我们还将学习高级类型系统特性,包括以下内容:

  • 抽象类型

  • 幻影类型

  • 多态变体

模块签名

模块签名以一种类似于接口在面向对象编程中约束类的方式约束模块。模块签名可以要求模块实现某些类型和绑定,并且也可以用来隐藏实现细节。假设我们有一个在 Foo.re 中定义的名为 Foo 的模块。其签名可以在 Foo.rei 中定义。任何列在模块签名中的类型或绑定都会暴露给其他模块。如果存在模块签名并且该类型或绑定不在模块签名中,则列在模块中的任何类型或绑定都会被隐藏。给定 Foo.re 中的绑定 let foo = "foo";,该绑定可以通过在 Foo.rei 中包含 let foo: string; 来由其模块签名既要求又暴露:

/* Foo.re */
let foo = "foo";

/* Foo.rei */
let foo: string;

/* Bar.re */
Js.log(Foo.foo);

在这里,Foo.rei 需要 Foo.re 包含一个名为 foolet 绑定,其类型为 string

如果模块的 .rei 文件存在且为空,则模块内的所有内容都将被隐藏,如下面的代码所示:

/* Foo.rei */
/* this is intentionally empty */

/* Bar.re */
Js.log(Foo.foo); /* Compilation error: The value foo can't be found in Foo */

模块的签名要求模块包含签名中列出的任何类型和/或绑定,如下面的代码所示:

/* Foo.re */
let foo = "foo";

/* Foo.rei */
let foo: string;
let bar: string;

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

The implementation src/Foo.re does not match the interface src/Foo.rei:
The value `bar' is required but not provided

模块类型

模块签名也可以使用 module type 关键字而不是单独的 .rei 文件来定义。模块类型必须以大写字母开头。一旦定义,一个模块可以使用 module <Name> : <Type> 语法被模块类型约束,如下所示:

module type FooT {
  let foo: (~a: int, ~b: int) => int;
};

module Foo: FooT {
  let foo = (~a, ~b) => a + b;
};

同一个模块类型可以用于多个模块,如下所示:

module Bar: FooT {
  let bar = (~a, ~b) => a - b;
};

我们可以将模块签名视为面向对象意义上的接口。接口定义了模块必须定义的属性和方法。然而,在 Reason 中,模块签名还隐藏了绑定和类型。但也许模块签名最有用的特性是能够公开抽象类型。

抽象类型

抽象类型是没有定义的类型声明。让我们探讨一下这为什么会很有用。除了绑定之外,模块签名还可以包括类型。在下面的代码中,你会注意到Foo的模块签名包括一个person类型,现在Foo必须包含这个类型声明:

/* Foo.re */
type person = {
  firstName: string,
  lastName: string
};

/* Foo.rei */
type person = {
  firstName: string,
  lastName: string
};

person类型的公开方式与没有定义模块签名时相同。正如你所期望的,如果定义了签名而类型未列出,则类型不会公开给其他模块。还有选择将类型保留为抽象。我们只保留等号后面的部分。让我们看看下面的代码:

/* Foo.rei */
type person;

现在,person类型对其他模块是公开的,但没有任何其他模块可以直接创建或操作person类型的值。person类型必须在Foo中定义,但它可以有任何定义。这意味着person类型可以随时间变化,并且Foo之外的任何模块都不会知道这一点。

让我们在下一节进一步探讨抽象类型。

使用模块签名

让我们假设我们正在构建一个发票管理系统,并且我们有一个Invoice模块,它定义了一个invoice类型以及一个其他模块可以使用来创建该类型值的函数。这种安排如下面的代码所示:

/* Invoice.re */
type t = {
  name: string,
  email: string,
  date: Js.Date.t,
  total: float
};

let make = (~name, ~email, ~date, ~total) => {
  name,
  email,
  date,
  total
};

假设我们还有一个负责向客户发送电子邮件的模块,如下面的代码所示:

/* Email.re */
let send = invoice: Invoice.t => ...
let invoice =
  Invoice.make(
    ~name="Raphael",
    ~email="persianturtle@gmail.com",
    ~date=Js.Date.make(),
    ~total=15.0,
  );
send(invoice);

由于Invoice.t类型是公开的,发票可以通过Email进行操作,如下面的代码所示:

/* Email.re */
let invoice =
  Invoice.make(
    ~name="Raphael",
    ~email="persianturtle@gmail.com",
    ~date=Js.Date.make(),
    ~total=15.0,
  );
let invoice = {...invoice, total: invoice.total *. 0.8};
Js.log(invoice);

尽管Invoice.t类型是不可变的,但没有任何东西阻止Email通过一些修改的字段来覆盖发票绑定。然而,如果我们使Invoice.t类型抽象化,这就不可能了,因为Email无法操作抽象类型。Email模块可以访问的所有函数都无法与Invoice.t类型一起工作。

/* Invoice.rei */
type t;
let make:
 (~name: string, ~email: string, ~date: Js.Date.t, ~total: float) => t;

现在,编译会给我们以下错误:

8let invoice = {...invoice, total: invoice.total *. 0.8};
9Js.log(invoice);

The record field total can't be found.

如果我们决定允许其他模块向发票添加折扣,我们需要创建一个函数并将其包含在Invoice模块的模块签名中。假设我们只想允许每个发票只有一个折扣,并且限制折扣金额为十、十五或二十个百分点。我们可以这样实现:

/* Invoice.re */
type t = {
 name: string,
 email: string,
 date: Js.Date.t,
 total: float,
 isDiscounted: bool,
};

type discount =
 | Ten
 | Fifteen
 | Twenty;

let make = (~name, ~email, ~date, ~total) => {
 name,
 email,
 date,
 total,
 isDiscounted: false,
};

let discount = (~invoice, ~discount) =>
 if (invoice.isDiscounted) {
 invoice;
 } else {
 {
 ...invoice,
 isDiscounted: true,
 total:
 invoice.total
 *. (
 switch (discount) {
 | Ten => 0.9
 | Fifteen => 0.85
 | Twenty => 0.8
 }
 ),
 };
 };

/* Invoice.rei */
type t;

type discount =
 | Ten
 | Fifteen
 | Twenty;

let make:
 (~name: string, ~email: string, ~date: Js.Date.t, ~total: float) => t;

let discount: (~invoice: t, ~discount: discount) => t;

/* Email.re */
let invoice =
 Invoice.make(
 ~name="Raphael",
 ~email="persianturtle@gmail.com",
 ~date=Js.Date.make(),
 ~total=15.0,
 );
Js.log(invoice);

现在,只要Invoice模块的公共 API(或模块签名)不改变,我们就可以自由地按照我们的意愿重构Invoice模块,而无需担心破坏其他模块的代码。为了证明这一点,让我们将Invoice.t重构为元组而不是记录,如下所示代码。只要我们不改变模块签名,Email模块就不需要做任何改变:

/* Invoice.re */
type t = (string, string, Js.Date.t, float, bool);

type discount =
  | Ten
  | Fifteen
  | Twenty;

let make = (~name, ~email, ~date, ~total) => (
  name,
  email,
  date,
  total,
  false,
);

let discount = (~invoice, ~discount) => {
  let (name, email, date, total, isDiscounted) = invoice;
  if (isDiscounted) {
    invoice;
  } else {
    (
      name,
      email,
      date,
      total
      *. (
        switch (discount) {
        | Ten => 0.9
        | Fifteen => 0.85
        | Twenty => 0.8
        }
      ),
      true,
    );
  };
};

/* Invoice.rei */
type t;

type discount =
  | Ten
  | Fifteen
  | Twenty;

let make:
  (~name: string, ~email: string, ~date: Js.Date.t, ~total: float) => t;

let discount: (~invoice: t, ~discount: discount) => t;

/* Email.re */
let invoice =
  Invoice.make(
    ~name="Raphael",
    ~email="persianturtle@gmail.com",
    ~date=Js.Date.make(),
    ~total=15.0,
  );
let invoice = Invoice.(discount(~invoice, ~discount=Ten));
Js.log(invoice);

此外,多亏了Invoice.t抽象类型,我们保证发票只能折扣一次,并且只能按指定的百分比折扣。我们可以进一步扩展这个例子,要求对发票的所有更改都要进行记录。传统上,这类要求会在数据库事务之后添加副作用来解决,因为在 JavaScript 中,我们否则无法确定我们会记录所有对发票的更改。有了模块签名,我们可以在应用层解决这类要求。

幻影类型

看看我们之前的实现,如果我们不需要在运行时检查发票是否已经折扣,那会很好。我们能否在编译时检查发票是否已经折扣呢?使用幻影类型,我们可以做到。

幻影类型是具有类型变量的类型,但这种类型变量在其定义中没有被使用。为了更好地理解,让我们再次看看option类型,如下所示代码:

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

option类型有一个类型变量'a,并且这个类型变量在其定义中被使用。正如我们已经学到的,option是一个多态类型,因为它有一个类型变量。另一方面,幻影类型在其定义中不使用类型变量。让我们看看这在我们的发票管理示例中是如何有用的。

让我们改变Invoice模块的签名,使用幻影类型,如下所示:

/* Invoice.rei */
type t('a);

type discounted;
type undiscounted;

type discount =
  | Ten
  | Fifteen
  | Twenty;

let make:
  (~name: string, ~email: string, ~date: Js.Date.t, ~total: float) =>
  t(undiscounted);

let discount:
  (~invoice: t(undiscounted), ~discount: discount) => t(discounted);

抽象类型t现在是t('a)。我们还有两个更多的抽象类型,如下所示代码:

type discounted;
type undiscounted;

此外,请注意,make函数现在返回t(undiscounted)(而不是仅仅t),而discount函数现在接受t(undiscounted)并返回t(discounted)。记住,抽象t('a)接受一个类型变量,而这个类型变量恰好是discounted类型或undiscounted类型。

在实现中,我们现在可以去掉之前已有的运行时检查,如下所示代码:

if (isDiscounted) {
  ...
} else {
  ...
}

现在,这个检查是在编译时完成的,因为discount函数只接受undiscounted类型的发票,如下所示代码:

/* Invoice.re */
type t('a) = {
  name: string,
  email: string,
  date: Js.Date.t,
  total: float,
};

type discount =
  | Ten
  | Fifteen
  | Twenty;

let make = (~name, ~email, ~date, ~total) => {name, email, date, total};

let discount = (~invoice, ~discount) => {
  ...invoice,
  total:
    invoice.total
    *. (
      switch (discount) {
      | Ten => 0.9
      | Fifteen => 0.85
      | Twenty => 0.8
      }
    ),
};

这只是类型系统帮助我们更多地关注逻辑而不是错误处理的一种方式。以前,试图对发票进行两次折扣只会返回未更改的原始发票。现在,让我们在Email.re中尝试对发票进行两次折扣,使用以下代码:

/* Email.re */
let invoice =
  Invoice.make(
    ~name="Raphael",
    ~email="persianturtle@gmail.com",
    ~date=Js.Date.make(),
    ~total=15.0,
  );
let invoice = Invoice.(discount(~invoice, ~discount=Ten));
let invoice = Invoice.(discount(~invoice, ~discount=Ten)); /* discounted twice */
Js.log(invoice);

现在,试图对发票进行两次折扣将导致编译时错误,如下所示:

We've found a bug for you!

   7 │ );
   8 │ let invoice = Invoice.(discount(~invoice, ~discount=Ten));
   9 │ let invoice = Invoice.(discount(~invoice, ~discount=Ten));
  10 │ Js.log(invoice);

  This has type:
    Invoice.t(Invoice.discounted)
  But somewhere wanted:
    Invoice.t(Invoice.undiscounted)

这绝对是美丽的。然而,假设你想要能够发送任何发票——无论是打折的还是不打折的。我们使用幻影类型会导致问题吗?我们该如何编写一个接受任何发票类型的函数?记住,我们的发票类型是 Invoice.t('a),如果我们想接受任何发票,我们保持类型参数,如下面的代码所示:

/* Email.re */
let invoice =
  Invoice.make(
    ~name="Raphael",
    ~email="persianturtle@gmail.com",
    ~date=Js.Date.make(),
    ~total=15.0,
  );

let send: Invoice.t('a) => unit = invoice => {
 /* send invoice email */
 Js.log(invoice);
};

send(invoice);

因此,我们可以同时拥有我们的蛋糕并享用它。

多态变体

我们已经在上一章中简要地了解了多态变体。为了回顾,我们是在使用 [@bs.unwrap] 装饰器绑定到一些现有的 JavaScript 时了解到它们的。想法是 [@bs.unwrap] 可以用来绑定到一个现有的 JavaScript 函数,其中其参数可以是不同类型的。例如,假设我们想要绑定到以下函数:

function dynamic(a) {
  switch (typeof a) {
    case "string":
      return "String: " + a;
    case "number":
      return "Number: " + a;
  }
}

假设这个函数应该只接受 string 类型或 int 类型的参数,不接受其他类型的参数。我们可以这样绑定到这个示例函数:

[@bs.val] external dynamic : 'a => string = "";

然而,我们的绑定将允许无效的参数类型(例如 bool)。如果我们的编译器能够通过防止无效参数类型来帮助我们,那就更好了。一种方法是在多态变体上使用 [@bs.unwrap]。我们的绑定将如下所示:

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

我们会这样使用绑定:

dynamic(`Int(42));
dynamic(`Str("foo"));

现在,如果我们尝试传递一个无效的参数类型,编译器会告诉我们,如下面的代码所示:

dynamic(42);

/*
We've found a bug for you!

This has type:
  int
But somewhere wanted:
  [ `Int of int | `Str of string ]
*/

这里的权衡是我们需要通过将参数包裹在多态变体构造函数中来传递参数,而不是直接传递。

一开始,你就会注意到正常变体和多态变体之间的以下两个区别:

  1. 我们不需要显式声明多态变体的类型

  2. 多态变体以反引号字符(`)开头

无论何时你看到以反引号字符为前缀的构造函数,你就知道它是一个多态变体构造函数。多态变体构造函数可能或可能没有与之关联的类型声明。

这会与普通变体一起工作吗?

让我们尝试用普通变体来做这件事,看看会发生什么:

type validArgs = 
  | Int(int)
  | Str(string);

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

dynamic(Int(1));

前面实现的问题在于 Int(1) 不会编译成 JavaScript 数字。普通变体被编译成 array,我们的 dynamic 函数返回 undefined 而不是 "Number: 42"。函数返回 undefined 因为在 switch 语句中没有匹配到任何情况。

使用多态变体,BuckleScript 将 dynamic(`Int(42)) 编译为 dynamic(42),并且函数按预期工作。

高级类型系统特性

Reason 的类型系统功能非常全面,并且在过去的几十年里得到了精炼。我们迄今为止所看到的内容只是 Reason 类型系统的一个简介。在我看来,你应该在继续学习更高级的类型系统功能之前,先熟悉基础知识。如果没有经历过一个健全的类型系统本可以防止的错误,就很难欣赏类型安全等特性。如果没有对这本书中迄今为止所学的内容感到些许挫败,就很难欣赏高级类型系统功能。本书的范围并不包括过多地详细讨论高级类型系统功能,但我想要确保那些正在评估 Reason 作为选项的人知道,它的类型系统还有更多内容。

除了幻影类型和多态变体之外,Reason 还有泛化代数数据类型GADTs)。模块可以使用函子(即在编译时间和运行时之间操作的模块函数)动态创建。Reason 还有类和对象——OCaml 中的 O 代表 objective。OCaml 的前身是一种名为 Caml 的语言,它首次出现在 20 世纪 80 年代中期。我们在本书中学到的内容在典型 React 应用程序的上下文中特别有用。我个人喜欢 Reason 是一种我可以不断成长并保持高效的语言。

如果你发现自己在类型系统上感到挫败,那么请通过 Discord 频道联系专家,他们很可能会帮助你解决你的问题。我总是对社区的帮助感到惊讶。而且别忘了,如果你只是想继续前进,你总是可以在需要时直接使用原始 JavaScript,并在准备好时再回来解决这个问题。

你可以在这里找到 Reason 的 Discord 频道:

discord.gg/reasonml

也不必使用 Reason 类型系统的更高级功能。我们迄今为止所学的知识在为我们的 React 应用程序添加类型安全方面提供了很多价值。

摘要

到目前为止,我们已经看到了 Reason 如何通过其类型系统帮助我们构建更安全、更易于维护的代码库。变体允许我们使无效状态无法表示。类型系统有助于使重构过程不那么可怕、不那么痛苦。模块签名可以帮助我们在应用程序中强制执行业务规则。模块签名还充当基本文档,列出模块公开的内容,并根据公开的函数名称及其参数类型以及公开的类型,给出模块应该如何使用的基本概念。

在第六章“CSS-in-JS(在 Reason 中)”,我们将探讨如何使用 Reason 的类型系统通过一个包装 Emotion(emotion.sh)的 CSS-in-Reason 库bs-css来强制执行有效的 CSS。

第六章:CSS-in-JS(在 Reason 中)

React 的一个优点是它允许我们将组件的标记、行为和样式放在一个文件中。这种组合对开发者的体验、版本控制和代码质量产生了连锁反应(无意中用了双关语)。在本章中,我们将简要探讨 CSS-in-JS 是什么以及我们如何在 Reason 中处理 CSS-in-JS。当然,如果你更喜欢,完全可以将组件拆分到单独的文件中,并/或使用更传统的 CSS 解决方案。

在本章中,我们将探讨以下主题:

  • 什么是 CSS-in-JS?

  • 使用styled-components

  • 使用bs-css

什么是 CSS-in-JS?

定义 CSS-in-JS 目前在 JavaScript 社区中是一个有争议的话题。CSS-in-JS 是在组件时代诞生的。现代网络主要使用组件模型构建。几乎所有的 JavaScript 框架都采用了它。随着其采用率的增长,越来越多的团队开始在同一项目的各个组件上工作。想象一下,你在一个分布式团队中工作,正在开发一个大型应用程序,每个团队都在并行地开发一个组件。如果没有团队标准化 CSS 约定,你将遇到 CSS 作用域问题。如果没有某种类型的标准化 CSS 风格指南,多个团队很容易对类名进行样式化,从而影响其他未预期的组件。随着时间的推移,出现了一些解决方案来解决这些问题以及其他与 CSS 相关的规模问题。

简要历史

一些流行的 CSS 约定包括 BEM、SMACSS 和 OOCSS。每个解决方案都要求开发者学习该约定并记住正确应用它;否则,仍然可能会遇到令人沮丧的作用域问题。

CSS 模块成为了一个更安全的选项,开发者可以将 CSS 导入 JavaScript 模块,构建步骤会自动将 CSS 局部作用域到该 JavaScript 模块。CSS 本身仍然是在一个正常的 CSS(或 SASS)文件中编写的。

CSS-in-JS 更进一步,允许你直接在 JavaScript 模块中编写 CSS,自动将 CSS 局部作用域到组件。这对许多开发者来说感觉是正确的;而有些人从一开始就不喜欢它。一些 CSS-in-JS 解决方案,如styled-components,允许开发者直接将 CSS 与组件耦合。你不必使用<header className="..." />,而是可以有<Header />,其中Header组件是用styled-components及其 CSS 定义的,如下面的代码所示:

import React from 'react';
import styled from 'styled-components';

const Header = styled.header`
  font-size: 1.5em;
  text-align: center;
  color: dodgerblue;
`;

曾经有一段时间,styled-components存在性能问题,因为 JavaScript 包必须下载、编译和执行,然后库才能在 DOM 中动态创建样式表。这些问题的现在已经基本得到解决,多亏了服务器端渲染的支持。那么,我们能在 Reason 中这样做吗?让我们看看吧!

使用 styled-components

styled-components最受欢迎的功能之一是能够根据组件的属性动态创建 CSS。使用这个功能的一个原因是可以创建组件的替代版本。这些替代版本将被封装在样式化组件本身中。以下是一个示例,其中文本可以是居中对齐或左对齐,并且可选地带有下划线。

const Title = styled.h1`
  text-align: ${props => props.center ? "center" : "left"};
  text-decoration: ${props => props.underline ? "underline" : "none"};
  color: white;
  background-color: coral;
`;

render(
  <div>
    <Title>I'm Left Aligned</Title>
    <Title center>I'm Centered!</Title>
    <Title center underline>I'm Centered & Underlined!</Title>
  </div>
);

在 Reason 的上下文中,挑战在于通过style-components API 创建一个可以动态处理属性的组件。考虑以下对styled.h1函数和我们的<Title />组件的绑定。

/* StyledComponents.re */
[@bs.module "styled-components"] [@bs.scope "default"] [@bs.variadic]
external h1: (array(string), array('a)) => ReasonReact.reactClass = "h1";

module Title = {
  let title =
    h1(
      [|
        "text-align: ",
        "; text-decoration: ",
        "; color: white; background-color: coral;",
      |],
      [|
        props => props##center ? "center" : "left",
        props => props##underline ? "underline" : "none",
      |],
    );

  [@bs.deriving abstract]
  type jsProps = {
    center: bool,
    underline: bool,
  };

  let make = (~center=false, ~underline=false, children) =>
    ReasonReact.wrapJsForReason(
      ~reactClass=title,
      ~props=jsProps(~center, ~underline),
      children,
    );
};

h1函数接受一个字符串数组作为其第一个参数,以及一个表达式数组作为其第二个参数。这是因为这是 ES5 对 ES6 标记模板字面量的表示。在h1函数的情况下,表达式数组是传递给 React 组件的属性所调用的函数。

我们使用[@bs.variadic]装饰器来允许任意数量的参数。在 Reason 端,我们使用一个数组,而在 JavaScript 端,这个数组被展开为任意数量的参数。

使用[@bs.variadic]

让我们快速偏离一下主题,进一步探讨[@bs.variadic]。假设你想要绑定到Math.max(),它可以接受一个或多个参数:

/* JavaScript */
Math.max(1, 2);
Math.max(1, 2, 3, 4);

这是一个完美的[@bs.variadic]用例。我们在 Reason 端使用一个数组来保存参数,这个数组将被展开以匹配上述 JavaScript 中的语法。

/* Reason */
[@bs.scope "Math"][@bs.val][@bs.variadic] external max: array('a) => unit = "";
max([|1, 2|]);
max([|1, 2, 3, 4|]);

好的,我们回到了styled-components的例子。我们可以这样使用<Title />组件:

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

let make = _children => {
  ...component,
  render: _self =>
    <StyledComponents.Title center=true underline=true>
 {ReasonReact.string("Page1")}
 </StyledComponents.Title>,
};

上一段代码是一个样式化的 ReasonReact 组件,它使用 CSS 渲染了一个h1元素。该 CSS 之前在StyledComponents.Title模块中定义过。《Title />》组件有两个属性——center 和 underline,它们都默认为false

当然,这并不是编写样式化组件的优雅方式,但它在功能上与 JavaScript 版本相似。另一个选择是回到原始 JavaScript,以利用熟悉的标记模板字面量语法。让我们在Title.re中展示这个例子。

/* Title.re */
%bs.raw
{|const styled = require("styled-components").default|};

let title = [%bs.raw
  {|
     styled.h1`
       text-align: ${props => props.center ? "center" : "left"};
       text-decoration: ${props => props.underline ? "underline" : "none"};
       color: white;
       background-color: coral;
     `
   |}
];

[@bs.deriving abstract]
type jsProps = {
  center: bool,
  underline: bool,
};

let make = (~center=false, ~underline=false, children) =>
  ReasonReact.wrapJsForReason(
    ~reactClass=title,
    ~props=jsProps(~center, ~underline),
    children,
  );

使用方法类似,但现在<Title />组件不再是StyledComponents的子模块。

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

let make = _children => {
  ...component,
  render: _self =>
    <Title center=true underline=true> {ReasonReact.string("Page1")} </Title>,
};

个人来说,我喜欢使用[%bs.raw]版本的开发者体验。我想对 Adam Coll (@acoll1)表示衷心的感谢,因为他想出了styled-components绑定的两个版本。我也非常期待看到社区会提出什么。

让我们探索社区最受欢迎的 CSS-in-JS 解决方案:bs-css

使用 bs-css

虽然 Reason 团队没有对 CSS-in-JS 解决方案提出官方推荐,但许多人目前都在使用一个名为bs-css的库,该库封装了 emotion CSS-in-JS 库(版本 9)。bs-css库为 Reason 提供了类型安全的 API。使用这种方法,我们可以让编译器检查我们的 CSS。我们将通过将我们的App.scss转换为App.scss,这是我们在第三章中创建的,创建 ReasonReact 组件,来了解这个库。

要跟上,请克隆这本书的 GitHub 仓库,并从Chapter06/app-start开始,使用以下代码:

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

要开始使用bs-css,我们将将其作为依赖项包含在package.jsonbsconfig.json中,如下所示:

/* bsconfig.json */
...
"bs-dependencies": ["reason-react", "bs-css"],
...

在通过 npm 安装bs-css并配置bsconfig.json之后,我们将能够访问库提供的Css模块。定义自己的子模块Styles是标准做法,我们在其中打开Css模块,并将所有的 CSS-in-Reason 写在那里。由于我们将转换App.scss,我们将在App.re中声明一个Styles子模块,如下所示:

/* App.re */

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

module Styles = {
  open Css;
};
...

现在,让我们将以下 Sass 转换为:

.App {
  min-height: 100vh;

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

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

Styles内部,我们声明了一个名为app的绑定,它将在<App />组件的className属性中使用。我们将绑定到名为stylebs-css函数的结果。style函数接受一个 CSS 规则列表。让我们通过以下代码来探索其语法:

module Styles = {
  open Css;

  let app = style([
    minHeight(vh(100.)),
  ]);
};

起初可能有点奇怪,但用得越多,感觉越好。所有 CSS 属性和所有单位都是函数。函数有类型。如果类型不匹配,编译器会报错。考虑以下无效的 CSS:

min-height: red;

这在 CSS、Sass 甚至styled-components中都是静默失败的。使用bs-css,我们至少可以防止很多无效的 CSS。编译器还会通知我们任何未使用的绑定,这可以帮助我们维护 CSS 样式表,并且,像往常一样,我们有完整的 IntelliSense,这有助于我们在使用过程中学习 API。

个人来说,我是 Sass 嵌套 CSS 的大粉丝,我很高兴我们也可以用bs-css做到这一点。为了嵌套:after伪选择器,我们使用after函数。为了嵌套.overlay选择器,我们使用selector函数。就像在 Sass 中一样,我们使用&符号来引用父元素,如下所示:

module Styles = {
  open Css;

  let app =
    style([
      minHeight(vh(100.)),

      after([
 contentRule(""),
 transitions([
 `transition("opacity 450ms cubic-bezier(0.23, 1, 0.32, 1)"),
 `transition("transform 0ms cubic-bezier(0.23, 1, 0.32, 1) 450ms"),
 ]),
        position(fixed),
        top(zero),
        right(zero),
        bottom(zero),
        left(zero),
        backgroundColor(rgba(0, 0, 0, 0.33)),
        transform(translateX(pct(-100.))),
        opacity(0.),
        zIndex(1),
      ]),

      selector(
        "&.overlay",
        [ 
          after([
            `transition("opacity 450ms cubic-bezier(0.23, 1, 0.32, 1)"),
            transform(translateX(zero))),
            opacity(1.),
          ]),
        ],
      )
    ]);
};

注意我们是如何使用多态变体transition来表示过渡字符串的。否则,过渡是不有效的。

你可以在 GitHub 仓库的Chapter06/app-end/src/App.re文件中找到其余的转换。现在我们只剩下将样式应用到<App />组件的className属性上了,如下所示:

/* App.re */
...
render: self =>
  <div
    className={"App " ++ Styles.app ++ (self.state.isOpen ? " overlay" : "")}
...

删除App.scss后,一切看起来几乎都一样。太棒了!例外的是nav > ul > li:after选择器。在之前的章节中,我们使用内容属性来渲染图像,如下所示:

content: url(./img/icon/chevron.svg);

根据 Css.reicontentRule 函数接受一个字符串。因此,使用 url 函数不会进行类型检查,如下面的代码所示:

contentRule(url("./img/icon/chevron.svg")) /* type error */

作为一条逃生路线,bs-css 提供了 unsafe 函数(如下面的代码所示),这将绕过这个问题:

unsafe("content", "url('./img/icon/chevron.svg')")

然而,尽管我们之前的 webpack 配置会将前面的图像作为依赖项拉入,但在使用 bs-css 时,它不再这样做。

权衡

在 Reason 中使用 CSS-in-JS 显然是一个权衡。一方面,我们可以获得类型安全的、局部作用域的 CSS,并且可以将我们的 CSS 与组件一起定位。另一方面,语法稍微有点冗长,可能会有一些奇怪的边缘情况。选择 Sass 而不是 CSS-in-JS 解决方案是完全合理的,因为在这里没有明显的胜者。

其他库

我鼓励您尝试其他 CSS-in-JS Reason 库。并且无论您在寻找 Reason 库时,您的第一个目的地应该是 Redex(Reason Package Index**)。

您可以在以下位置找到 Redex(Reason Package Index**):

redex.github.io/

另一个有用的资源是 Reason Discord 频道。这是一个询问各种 CSS-in-JS 解决方案及其权衡的好地方。

您可以在以下位置找到 Reason Discord 频道:

discord.gg/reasonml

摘要

CSS-in-JS 仍然相对较新,在 Reason 社区中未来将会有很多关于它的实验。在本章中,我们了解了一些 CSS-in-JS(在 Reason 中)的好处和挑战。您站在哪一边?

在 第七章,“Reason 中的 JSON”,我们将学习如何在 Reason 中处理 JSON,并看看 GraphQL 如何帮助减少样板代码同时实现一些相当吸引人的保证。

第七章:Reason 中的 JSON

在本章中,我们将通过构建一个简单的客户管理应用程序来学习如何使用 JSON。此应用程序位于我们现有应用程序的 /customers 路径中,可以创建、读取和更新客户。JSON 数据持久化到 localStorage。在本章中,我们将以两种不同的方式将外部 JSON 转换为 Reason 可以理解的类型化数据结构:

  • 使用纯粹的理由

  • 使用 bs-json

我们将在本章末尾比较和对比每种方法。我们还将讨论 GraphQL 如何帮助在静态类型语言(如 Reason)中处理 JSON 时提供愉快的开发者体验。

要跟随构建客户管理应用程序,请克隆本书的 GitHub 仓库并从 Chapter07/app-start 开始:

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

在本章中,我们将探讨以下主题:

  • 构建视图

  • 与 localStorage 集成

  • 使用 bs-json

  • 使用 GraphQL

构建视图

总共,我们将有三个视图:

  • 列表视图

  • 创建视图

  • 更新视图

每个视图都有自己的路由。创建和更新视图共享一个公共组件,因为它们非常相似。

文件结构

由于我们的 bsconfig.json 包含子目录,我们可以创建一个 src/customers 目录来存放相关组件,BuckleScript 将递归地在 src 的子目录中查找 Reason(和 OCaml)文件:

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

让我们继续并将 src/Page1.re 组件重命名为 src/customers/CustomerList.re。在同一个目录中,我们稍后创建 Customer.re,它将用于创建和更新单个客户。

更新路由和导航菜单

Router.re 中,我们将用以下内容替换 /page1 路由:

/* Router.re */
let routes = [
  ...
  {href: "/customers", title: "Customer List", component: <CustomerList />}
  ...
];

我们还将添加 /customers/create/customers/:id 的路由:

/* Router.re */
let routes = [
  ...
  {href: "/customers/create", title: "Create Customer", component: <Customer />,},
  {href: "/customers/:id", title: "Update Customer", component: <Customer />}
  ...
];

路由已更新,以便它可以处理路由变量(例如 /customers/:id)。此更改已在 Chapter07/app-start 中为您完成。

最后,务必更新 <App.re /> 中的导航菜单:

/* App.re */
render: self =>
  ...
  <ul>
    <li>
      <NavLink href="/customers">
        {ReasonReact.string("Customers")}
      </NavLink>
    </li>
  ...

CustomerType.re

此文件将包含 <CustomerList /><Customer /> 都使用的客户类型。这样做是为了避免任何循环依赖编译器错误:

/* CustomerType.re */
type address = {
  street: string,
  city: string,
  state: string,
  zip: string,
};

type t = {
  id: int,
  name: string,
  address,
  phone: string,
  email: string,
};

CustomerList.re

目前,我们将使用硬编码的客户数组。很快,我们将从 localStorage 中检索这些数据。以下组件渲染一个样式化的客户数组。每个客户都被 <Link /> 组件包裹。点击客户将导航到更新视图:

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

let customers: array(CustomerType.t) = [
  {
    id: 1,
    name: "Christina Langworth",
    address: {
      street: "81 Casey Stravenue",
      city: "Beattyview",
      state: "TX",
      zip: "57918",
    },
    phone: "877-549-1362",
    email: "Christina.Langworth@gmail.com",
  },
  ...
];

module Styles = {
  open Css;

  let list =
    style([
      ...
    ]);
};

let make = _children => {
  ...component,
  render: _self =>
    <div>
      <ul className=Styles.list>
        {
          ReasonReact.array(
            Belt.Array.map(customers, customer =>
              <li key={string_of_int(customer.id)}>
                <Link href={"/customers/" ++ string_of_int(customer.id)}>
                  <p> {ReasonReact.string(customer.name)} </p>
                  <p> {ReasonReact.string(customer.address.street)} </p>
                  <p> {ReasonReact.string(customer.phone)} </p>
                  <p> {ReasonReact.string(customer.email)} </p>
                </Link>
              </li>
            )
          )
        }
      </ul>
    </div>,
};

Customer.re

此还原组件渲染一个表单,其中每个客户字段都可以在输入元素内编辑。组件有两个模式——CreateUpdate——基于 window.location.pathname

我们首先绑定到 window.location.pathname,并定义组件的动作和状态:

/* Customer.re */
[@bs.val] external pathname: string = "window.location.pathname";

type mode =
  | Create
  | Update;

type state = {
  mode,
  customer: CustomerType.t,
};

type action =
  | Save(ReactEvent.Form.t);

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

接下来,我们使用 bs-css 添加我们的组件样式。要查看样式,请查看 Chapter07/app-end/src/customers/Customer.re

/* Customer.re */
module Styles = {
  open Css;

  let form =
    style([
      ...
    ]);
};
Chapter07/app-end/src/customers/Customer.re:
/* Customer.re */
let customers: array(CustomerType.t) = [|
  {
    id: 1,
    name: "Christina Langworth",
    address: {
      street: "81 Casey Stravenue",
      city: "Beattyview",
      state: "TX",
      zip: "57918",
    },
    phone: "877-549-1362",
    email: "Christina.Langworth@gmail.com",
  },
  ...
|];

我们还提供了辅助函数,以下是一些原因:

  • window.location.pathname 提取客户 ID

  • 要通过 ID 获取客户

  • 要生成默认客户:

let getId = pathname =>
  try (Js.String.replaceByRe([%bs.re "/\\D/g"], "", pathname)->int_of_string) {
  | _ => (-1)
  };

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

let getDefault = customers: CustomerType.t => {
  id: Belt.Array.length(customers) + 1,
  name: "",
  address: {
    street: "",
    city: "",
    state: "",
    zip: "",
  },
  phone: "",
  email: "",
};

当然,以下是我们组件的 make 函数:

let make = _children => {
  ...component,
  initialState: () => {
    let mode = Js.String.includes("create", pathname) ? Create : Update;
    {
      mode,
      customer:
        switch (mode) {
        | Create => getDefault(customers)
        | Update =>
          Belt.Option.getWithDefault(
            getCustomer(customers),
            getDefault(customers),
          )
        },
    };
  },
  reducer: (action, state) =>
    switch (action) {
    | Save(event) =>
      ReactEvent.Form.preventDefault(event);
      ReasonReact.Update(state);
    },
  render: self =>
    <form
      className=Styles.form
      onSubmit={
        event => {
          ReactEvent.Form.persist(event);
          self.send(Save(event));
        }
      }>
      <label>
        {ReasonReact.string("Name")}
        <input type_="text" defaultValue={self.state.customer.name} />
      </label>
      <label>
        {ReasonReact.string("Street Address")}
        <input
          type_="text"
          defaultValue={self.state.customer.address.street}
        />
      </label>
      <label>
        {ReasonReact.string("City")}
        <input type_="text" defaultValue={self.state.customer.address.city} />
      </label>
      <label>
        {ReasonReact.string("State")}
        <input type_="text" defaultValue={self.state.customer.address.state} />
      </label>
      <label>
        {ReasonReact.string("Zip")}
        <input type_="text" defaultValue={self.state.customer.address.zip} />
      </label>
      <label>
        {ReasonReact.string("Phone")}
        <input type_="text" defaultValue={self.state.customer.phone} />
      </label>
      <label>
        {ReasonReact.string("Email")}
        <input type_="text" defaultValue={self.state.customer.email} />
      </label>
      <input
        type_="submit"
        value={
          switch (self.state.mode) {
          | Create => "Create"
          | Update => "Update"
          }
        }
      />
    </form>,
};

Save 动作还没有保存到 localStorage。当导航到 /customers/create 时,表单是空的,当导航到例如 /customers/1 时,表单会被填充。

与 localStorage 集成

让我们创建一个单独的模块来与数据层交互,我们将它称为 DataPureReason.re。在这里,我们公开 localStorage.getItemlocalStorage.setItem 的绑定,以及一个解析函数,用于将 JSON 字符串解析为之前定义的 CustomerType.t 记录。

填充 localStorage

你可以在 Chapter07/app-end/src/customers/data.json 中找到一些初始数据。请在浏览器控制台中运行 localStorage.setItem("customers", JSON.stringify(/* paste JSON data here */)) 以填充 localStorage 中的这些初始数据。

DataPureReason.re

记得当 BuckleScript 绑定感觉有点神秘的时候吗?希望现在它们开始感觉稍微直接一些:

[@bs.val] [@bs.scope "localStorage"] external getItem: string => string = "";
[@bs.val] [@bs.scope "localStorage"]
external setItem: (string, string) => unit = "";

要解析 JSON,我们将使用 Js.Json 模块。

Js.Json 文档可以在以下 URL 找到:

bucklescript.github.io/bucklescript/api/Js_json.html

很快,你就会看到一种使用 Js.Json 模块解析 JSON 字符串的方法。不过有一个注意事项:这有点繁琐。但了解正在发生的事情以及为什么我们需要为像 Reason 这样的类型化语言做这件事是很重要的。从高层次上讲,我们将验证 JSON 字符串以确保它是有效的 JSON,如果是的话,使用 Js.Json.classify 函数将 JSON 字符串(Js.Json.t)转换为标签类型(Js.Json.tagged_t)。可用的标签如下:

type tagged_t =
  | JSONFalse
  | JSONTrue
  | JSONNull
  | JSONString(string)
  | JSONNumber(float)
  | JSONObject(Js_dict.t(t))
  | JSONArray(array(t));

这样,我们可以将 JSON 字符串转换为 Reason 数据结构。

验证 JSON 字符串

上一节中定义的 getItem 绑定将返回一个字符串:

let unvalidated = DataPureReason.getItem("customers");

我们可以这样验证 JSON 字符串:

let validated =
  try (Js.Json.parseExn(unvalidated)) {
  | _ => failwith("Error parsing JSON string")
  };

如果 JSON 无效,它将生成一个运行时错误。在本章的结尾,我们将学习 GraphQL 如何帮助改善这种情况。

使用 Js.Json.classify

假设我们已经验证了以下 JSON(它是一个对象数组):

[
  {
    "id": 1,
    "name": "Christina Langworth",
    "address": {
      "street": "81 Casey Stravenue",
      "city": "Beattyview",
      "state": "TX",
      "zip": "57918"
    },
    "phone": "877-549-1362",
    "email": "Christina.Langworth@gmail.com"
  },
  {
    "id": 2,
    "name": "Victor Tillman",
    "address": {
      "street": "2811 Toby Gardens",
      "city": "West Enrique",
      "state": "NV",
      "zip": "40465"
    },
    "phone": "(502) 091-2292",
    "email": "Victor.Tillman30@gmail.com"
  }
]

现在我们已经验证了 JSON,我们准备对其进行分类:

switch (Js.Json.classify(validated)) {
| Js.Json.JSONArray(array) =>
  Belt.Array.map(array, customer => ...)
| _ => failwith("Expected an array")
};

我们对 Js.Json.tagged_t 的可能标签进行模式匹配。如果它是一个数组,我们就会使用 Belt.Array.map(或 Js.Array.map)遍历它。否则,在我们的应用程序上下文中会得到一个运行时错误。

map 函数传递一个对数组中每个对象的引用。但 Reason 还不知道每个元素实际上是一个对象。在 map 内部,我们再次对数组中的每个元素进行分类。分类后,Reason 现在知道每个元素实际上是一个对象。我们将定义一个名为 parseCustomer 的自定义辅助函数,用于与 map 函数一起使用:

switch (Js.Json.classify(validated)) {
| Js.Json.JSONArray(array) =>
  Belt.Array.map(array, customer => parseCustomer(customer))
| _ => failwith("Expected an array")
};

let parseCustomer = json =>
  switch (Js.Json.classify(json)) {
  | Js.Json.JSONObject(json) => (
      ...
    )
  | _ => failwith("Expected an object")
  };

现在,如果数组中的每个元素都是一个对象,我们希望返回一个新的记录。这个记录将是 CustomerType.t 类型。否则,我们会得到一个运行时错误:

let parseCustomer = json =>
  switch (Js.Json.classify(json)) {
  | Js.Json.JSONObject(json) => (
      {
        id: ...,
        name: ...,
        address: ...,
        phone: ...,
        email: ...,
      }: CustomerType.t
    )
  | _ => failwith("Expected an object")
  };

现在,对于每个字段(即idnameaddress等),我们使用Js.Dict.get来获取和分类每个字段:

Js.Dict的文档可以在以下 URL 找到:

bucklescript.github.io/bucklescript/api/Js.Dict.html

let parseCustomer = json =>
  switch (Js.Json.classify(json)) {
  | Js.Json.JSONObject(json) => (
      {
        id:
          switch (Js.Dict.get(json, "id")) {
          | Some(id) =>
            switch (Js.Json.classify(id)) {
            | Js.Json.JSONNumber(id) => int_of_float(id)
            | _ => failwith("Field 'id' should be a number")
            }
          | None => failwith("Missing field: id")
          },
        name:
          switch (Js.Dict.get(json, "name")) {
          | Some(name) =>
            switch (Js.Json.classify(name)) {
            | Js.Json.JSONString(name) => name
            | _ => failwith("Field 'name' should be a string")
            }
          | None => failwith("Missing field: name")
          },
        address:
          switch (Js.Dict.get(json, "address")) {
          | Some(address) =>
            switch (Js.Json.classify(address)) {
            | Js.Json.JSONObject(address) => {
                street:
                  switch (Js.Dict.get(address, "street")) {
                  | Some(street) =>
                    switch (Js.Json.classify(street)) {
                    | Js.Json.JSONString(street) => street
                    | _ => failwith("Field 'street' should be a string")
                    }
                  | None => failwith("Missing field: street")
                  },
                city: ...,
                state: ...,
                zip: ...,
              }
            | _ => failwith("Field 'address' should be a object")
            }
          | None => failwith("Missing field: address")
          },
        phone: ...,
        email: ...,
      }: CustomerType.t
    )
  | _ => failwith("Expected an object")
  };

查看src/customers/DataPureReason.re以获取完整的实现。DataPureReason.rei隐藏了实现细节,只暴露了localStorage绑定和一个解析函数。

哎呀,这有点繁琐,不是吗?不过现在完成了,我们可以将CustomerList.reCustomer.re中的硬编码客户数组替换为以下内容:

let customers =
  DataBsJson.(parse(getItem("customers")));

目前一切顺利!JSON 数据正在动态地被拉取、解析,现在的工作方式与硬编码时相同。

写入 localStorage

现在,让我们添加创建和更新客户的功能。为此,我们需要将我们的 Reason 数据结构转换为 JSON。在接口文件DataPureReason.rei中,我们将暴露一个toJson函数:

/* DataPureReason.rei */
let parse: string => array(CustomerType.t);
let toJson: array(CustomerType.t) => string;

然后我们将实现它:

/* DataPureReason.re */
let customerToJson = (customer: CustomerType.t) => {
  let id = customer.id;
  let name = customer.name;
  let street = customer.address.street;
  let city = customer.address.city;
  let state = customer.address.state;
  let zip = customer.address.zip;
  let phone = customer.phone;
  let email = customer.email;

  {j|
    {
      "id": $id,
      "name": "$name",
      "address": {
        "street": "$street",
        "city": "$city",
        "state": "$state",
        "zip": "$zip"
      },
      "phone": "$phone",
      "email": "$email"
    }
  |j};
};

let toJson = (customers: array(CustomerType.t)) =>
  Belt.Array.map(customers, customer => customerToJson(customer))
  ->Belt.Array.reduce("[", (acc, customer) => acc ++ customer ++ ",")
  ->Js.String.replaceByRe([%bs.re "/,$/"], "", _)
  ++ "]"
     ->Js.String.split("/n", _)
     ->Js.Array.map(line => Js.String.trim(line), _)
     ->Js.Array.joinWith("", _);

然后我们将在Customer.re的 reducer 中使用toJson函数:

reducer: (action, state) =>
  switch (action) {
  | Save(event) =>
    let getInputValue: string => string = [%raw
      (selector => "return document.querySelector(selector).value")
    ];
    ReactEvent.Form.preventDefault(event);
    ReasonReact.UpdateWithSideEffects(
      {
        ...state,
        customer: {
          id: state.customer.id,
          name: getInputValue("input[name=name]"),
          address: {
            street: getInputValue("input[name=street]"),
            city: getInputValue("input[name=city]"),
            state: getInputValue("input[name=state]"),
            zip: getInputValue("input[name=zip]"),
          },
          phone: getInputValue("input[name=phone]"),
          email: getInputValue("input[name=email]"),
        },
      },
      (
        self => {
          let customers =
            switch (self.state.mode) {
            | Create =>
              Belt.Array.concat(customers, [|self.state.customer|])
            | Update =>
              Belt.Array.setExn(
                customers,
                Js.Array.findIndex(
                  customer =>
                    customer.CustomerType.id == self.state.customer.id,
                  customers,
                ),
                self.state.customer,
              );
              customers;
            };

          let json = customers->DataPureReason.toJson;
          DataPureReason.setItem("customers", json);
        }
      ),
    );
  },

在 reducer 中,我们使用 DOM 中的值更新self.state.customer,然后调用一个更新localStorage的函数。现在,我们能够通过创建或更新客户来写入localStorage。导航到/customers/create以创建新客户,然后导航回/customers以查看您新添加的客户。点击客户以导航到更新视图,更新客户,点击更新按钮,然后刷新页面。

使用 bs-json

现在我们确切地了解了如何将 JSON 字符串转换为类型化的 Reason 数据结构,我们注意到这个过程有点繁琐。与 JavaScript 这样的动态语言相比,代码行数更多。此外,还有相当多的重复代码。作为替代方案,Reason 社区中的许多人已经采用了bs-json作为编码和解码 JSON 的“官方”解决方案。

让我们创建一个新的模块DataBsJson.re和一个新的接口文件DataBsJson.rei。我们将复制与DataPureReason.rei中完全相同的接口,这样我们就可以知道,一旦我们完成,我们就可以用DataBsJson替换所有对DataPureReason的引用,一切应该都能正常工作。

暴露的接口如下:

/* DataBsJson.rei */
[@bs.val] [@bs.scope "localStorage"] external getItem: string => string = "";
[@bs.val] [@bs.scope "localStorage"]
external setItem: (string, string) => unit = "";

let parse: string => array(CustomerType.t);
let toJson: array(CustomerType.t) => string;

让我们关注parse函数:

let parse = json =>
  json |> Json.parseOrRaise |> Json.Decode.array(customerDecoder);

在这里,我们接受与之前相同的 JSON 字符串,验证它,将其转换为Js.Json.t(通过Json.parseOrRaise),然后将结果传递给这个新的Json.Decode.array(customerDecoder)函数。Json.Decode.array将尝试将 JSON 字符串解码为数组,并使用一个名为customerDecoder的自定义函数解码数组的每个元素——我们将在下面看到:

let customerDecoder = json =>
  Json.Decode.(
    (
      {
        id: json |> field("id", int),
        name: json |> field("name", string),
        address: json |> field("address", addressDecoder),
        phone: json |> field("phone", string),
        email: json |> field("email", string),
      }: CustomerType.t
    )
  );

customerDecoder函数接受与数组中每个元素关联的 JSON,并尝试将其解码为CustomerType.t类型的记录。这基本上与我们之前所做的是完全相同的,但它更加简洁,更容易阅读。正如你所看到的,我们还有一个名为addressDecoder的客户解码器,用于解码CustomerType.address类型:

let addressDecoder = json =>
  Json.Decode.(
    (
      {
        street: json |> field("street", string),
        city: json |> field("city", string),
        state: json |> field("state", string),
        zip: json |> field("zip", string),
      }: CustomerType.address
    )
  );

注意自定义解码器是如何轻松组合的。每个记录字段都是通过调用Json.Decode.field,传递字段名称(在 JSON 方面),并传递一个Json.Decode函数来解码的,该函数最终将 JSON 字段转换为 Reason 可以理解的数据类型。

编码的工作方式类似,但顺序相反:

let toJson = (customers: array(CustomerType.t)) =>
  customers->Belt.Array.map(customer =>
    Json.Encode.(
      object_([
        ("id", int(customer.id)),
        ("name", string(customer.name)),
        (
          "address",
          object_([
            ("street", string(customer.address.street)),
            ("city", string(customer.address.city)),
            ("state", string(customer.address.state)),
            ("zip", string(customer.address.zip)),
          ]),
        ),
        ("phone", string(customer.phone)),
        ("email", string(customer.email)),
      ])
    )
  )
  |> Json.Encode.jsonArray
  |> Json.stringify;

客户数组被映射,每个客户都被编码为一个 JSON 对象。结果是 JSON 对象的数组,然后被编码为 JSON,并转换为字符串。这比我们之前的实现要好得多。

在将DataPureReason.re中的相同localStorage绑定复制过来之后,我们的界面现在已经实现。在将所有对DataPureReason的引用替换为DataBsJson之后,我们看到我们的应用程序仍然在正常工作。

使用 GraphQL

在 2018 年 ReactiveConf 上,Sean Grove 关于 Reason 和 GraphQL 的演讲非常精彩,标题为*ReactiveMeetups w/ Sean Grove | ReasonML GraphQL.*以下是从这次演讲中摘录的内容,很好地总结了在 Reason 中使用 JSON 的问题和解决方案:

因此,我认为,在像 Reason 这样的类型语言中,当你想要与现实世界交互时,有三个真正非常大的问题。第一个是,将数据放入和从你的类型系统中取出所需的所有样板代码。

第二个是,即使你可以通过编程方式摆脱样板代码,你仍然会担心转换的准确性和安全性。

最后,即使你得到了所有这些,并且你绝对确信你已经捕捉到了所有的变化,有人仍然可以在你不知情的情况下从下面改变它。

每当服务器更改字段时,我们有多少次会得到一个变更日志?在一个理想的世界里,我们会。但大多数时候我们不会。我们只能反向工程我们的服务器发生了什么变化。

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

  1. 以编程方式访问 API 可以为我们提供的数据类型。

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

  3. 我们希望有一个合同。我们希望服务器保证,如果它说一个字段不可为空,那么它们永远不会给我们空值。如果它们更改字段名称,那么我们立即知道,并且它们也知道。

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

那就是 GraphQL。

-Sean Grove

你可以在以下 URL 找到ReactiveMeetups w/ Sean Grove | ReasonML GraphQL的视频:

youtu.be/t9a-_VnNilE

此外,这里是 ReactiveConf 的 YouTube 频道:

www.youtube.com/channel/UCBHdUnixTWymmXBIw12Y8Qg

本书的内容并不深入探讨 GraphQL,但鉴于我们在讨论在 Reason 中使用 JSON,一个高级介绍似乎很合适。

什么是 GraphQL?

如果你属于 ReactJS 社区,那么你很可能已经听说过 GraphQL。GraphQL 是一种查询语言和运行时,我们可以用它来满足这些查询,它也是由 Facebook 创建的。使用 GraphQL,ReactJS 组件可以包含 GraphQL 片段,用于组件所需的数据——这意味着组件可以将 HTML、CSS、JavaScript 和其外部数据全部放在一个文件中。

当使用 GraphQL 时,我需要创建 JSON 解码器吗?

由于 GraphQL 对你的应用程序的外部数据了如指掌,GraphQL 客户端(reason-apollo)将为你自动生成解码器。当然,解码器必须自动生成,这样我们才能确信它们反映了外部数据的当前形状。这又是考虑在需要处理外部数据时使用 GraphQL 与你的 Reason 应用程序的一个原因。

摘要

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

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