发布于 2026-01-06 0 阅读
0

使用 ReScript 实现更友好的 API,并支持外部资源

使用 ReScript 可以实现更友好的 API。

拥抱外部

在 JavaScript 的世界里,许多 API 的设计方式与函数式开发者的设计方式截然不同。尽管近年来得益于 React 等技术的出现,情况有所改善,但大多数浏览器 API 仍然偏向面向对象或底层设计。

ReScript(前身为 BuckleScript)是一个静态类型且可靠的语言、编译器和构建系统,能够生成可读性极高的 JavaScript 代码。因此,对于那些无法忍受类型漏洞的人来说,它是 TypeScript 的绝佳替代方案。

ReScript提供了各种各样的装饰器,使与 JavaScript API 的绑定更加容易。

最近,我需要使用浏览器的mediaDevicesAPI。其中一个最重要的方法是getUserMedia返回一个包含MediaStream对象的 Promise 对象。它还有一个必需参数,constraints即一个对象,用于确定 API 用户请求的是音频、视频还是两者都请求。例如,如果只想请求音频流,可以使用以下 JS 代码:

navigator.mediaDevices.getUserMedia({audio: true, video: false})
Enter fullscreen mode Exit fullscreen mode

仅当需要视频时才进行设置audio: false, video: true,并且只有当两者都需要时才进行设置audio: true, video: true

在惯用的ReScript中,这样的 API 可能会采用以下几种设计方式之一:

  • 可以通过以下constraints方式:
type constraints = Audio | Video | Both

let getUserMedia: constraints => Js.Promise.t<stream> = ...

/* Calling the function */
getUserMedia(Audio)
getUserMedia(Video)
getUserMedia(Both)
Enter fullscreen mode Exit fullscreen mode
  • 或者三个独立的功能
let getUserAudio: unit => Js.Promise.t<stream> = ...
let getUserVideo: unit => Js.Promise.t<stream> = ...
let getUserMedia: unit => Js.Promise.t<stream> = ...

/* Calling the function */
getUserAudio()
getUserVideo()
getUserMedia()
Enter fullscreen mode Exit fullscreen mode

在这种情况下,我更倾向于后一种选择。很快你就会明白为什么了。

拥抱外部

绑定到现有 JS 函数的默认方法是使用 external 关键字,并配合一系列不同的装饰器/注解,这些装饰器/注解总是以@.

注意:使用最新版本的 ReScript 平台(bs-platform 8.3),可以省略bs注解中的部分内容@bs.as。例如,将仅显示 `<script>` @as、`<script>` 等。请检查您的版本是否足够新,以便编写更简洁的代码。

绑定尝试 1

要绑定到全局值(例如navigator.mediaDevices.getUserMedia),需要两个注解。我们先来看看如何编写绑定,稍后再将其拆解分析。

/* Navigator.MediaDevices module */
type constraints = {
  audio: bool,
  video: bool,
}

@bs.val @bs.scope(("navigator", "mediaDevices"))
external _getUserMedia: constraints => Js.Promise.t<stream> =
  "getUserMedia"
Enter fullscreen mode Exit fullscreen mode
  1. type constraints = ...第一部分是对
    我们需要传递给函数的约束参数的类型定义getUserMedia。它是一个所谓的记录,看起来像一个对象,也能编译成相同形状的JS对象,但实际上是不同的东西

  2. @bs.val绑定到全局值。全局值是指始终在作用域内的值,例如 `int`navigator或 `int` window。当然,这取决于目标平台(浏览器、Node 等)。

  3. @bs.scope(("navigator", "mediaDevices"))当这些值嵌套时,您还需要使用 `.` 定义其作用域@bs.scope。这里,我们使用字符串元组来告诉编译器作用域是多层嵌套的。您可以通过双括号 `.`((和 ` .` 来判断这是一个元组))。外层括号来自作用域函数,内层括号来自元组。或者,您也可以像这样直接使用字符串:@bs.scope("navigator.mediaDevices")`.`

  4. external _getUserMedia上述注解只能与external关键字一起使用。通过关键字,您可以定义绑定的名称,该名称不一定需要与您要绑定的 JavaScript 方法的名称相同。

  5. : constraints => Js.Promise.t<stream>接下来是类型注解。根据 MDN 的说明,它会接收前面提到的约束对象,并返回一个 MediaStream 类型的 Promise,该 Promise 会转换为内置的ReScript类型Js.Promise.t<stream>遗憾的是,Promise 包装的类型并非内置类型,但可以相应地进行类型声明(留给读者作为练习 😉)。

  6. = "getUserMedia"最后,这里显示的是函数的实际名称。注意拼写错误!

这里我们还定义了一个辅助记录。它看起来像一个对象,编译后也会生成一个JS对象,但实际上是不同的。

现在我们有了绑定getUserMedia,但是我们真的想Navigator.MediaDevices._getUserMedia({audio: true, video: true}在代码库的每个地方都调用这个丑陋的方法吗?还是我们可以实现一个更友好的 API?

让我们编写一些辅助函数:

/* Navigator.MediaDevices module continued */
let getUserAudio = () => _getUserMedia({audio: true, video: false})
let getUserVideo = () => _getUserMedia({audio: false, video: true})
let getUserMedia = () => _getUserMedia({audio: true, video: true})
Enter fullscreen mode Exit fullscreen mode

然后可以通过以下方式从代码库中的任何位置调用这些函数:

Navigator.MediaDevices.getUserAudio()
Navigator.MediaDevices.getUserVideo()
Navigator.MediaDevices.getUserMedia()
Enter fullscreen mode Exit fullscreen mode

注意:这是一个简化的示例,在实际应用中,您可以使用Js.Promise.then_类似方法来检索流本身。

太好了,看起来好多了。但现在我们需要生成一些额外的代码,
例如这个函数:

function getUserAudio(param) {
  return navigator.mediaDevices.getUserMedia({
              audio: true,
              video: false
            });
}
Enter fullscreen mode Exit fullscreen mode

绑定尝试 2

我们能否做得更好,同时仍然使用默认参数填充函数?我认为可以,使用这个巧妙的技巧就行了。

@bs.as此外,当需要为实体指定一个复杂的名称(而这个名称通常无法直接创建)时,注解也非常有用。例如,在记录字段中,通常-不允许使用某些特定名称,而此注解可以解决这个问题。

@bs.as它接受一个字符串作为参数。巧合的是,JSON 本质上就是一个复杂的字符串,因此我们也可以将一些复杂的对象注入到默认配置中。

只需写

@bs.as(json`{your-config-object}`)
Enter fullscreen mode Exit fullscreen mode

后面加上一个省略号_,以便在调用函数时省略该值。
此外,我们还添加了一个最终unit参数(该函数的唯一“参数”)。

/* Navigator.MediaDevices module */
@bs.val @bs.scope(("navigator", "mediaDevices"))
external getUserAudio: (
  @bs.as(json`{"audio": false, "video": true}`) _,
  unit,
) => Js.Promise.t<stream> = "getUserMedia"
Enter fullscreen mode Exit fullscreen mode

不过,我称之为“ as-JSON技巧”的方法也有其局限性。它仅适用于符合JSON规范的值,例如数组、对象、字符串、数字和布尔值。例如,函数就无法使用。

我们可以对另外两个可能的函数进行同样的操作:

/* Navigator.MediaDevices module continued */
@bs.val @bs.scope(("navigator", "mediaDevices"))
external getUserVideo: (
  @bs.as(json`{"audio": true, "video": false}`) _,
  unit,
) => Js.Promise.t<stream> = "getUserMedia"

@bs.val @bs.scope(("navigator", "mediaDevices"))
external getUserMedia: (
  @bs.as(json`{"audio": true, "video": true}`) _,
  unit,
) => Js.Promise.t<stream> = "getUserMedia"
Enter fullscreen mode Exit fullscreen mode

最终得到的 API 与第一次尝试相同。同样,我们可以像以前一样调用函数:

Navigator.MediaDevices.getUserAudio()
Navigator.MediaDevices.getUserVideo()
Navigator.MediaDevices.getUserMedia()
Enter fullscreen mode Exit fullscreen mode

但这一次没有构建产物,第二次尝试的成本为零🎉。

经验法则lets 会生成额外的 JS 代码,而externals 则不会。

感谢阅读,希望这个技巧对大家有所帮助。如需更简洁的示例,请查看本文所基于的ReScript Playground 示例。

文章来源:https://dev.to/fhammerschmidt/nicer-apis-with-rescript-361