Monads 和 Applicative Functors 在函数式编程中被广泛使用。它们与 React Suspense for Data Fetching 和 React Hooks API 之间存在关系。这是对 Monads 和 Applicatives 的快速简单的介绍,并描述了它们的相似之处。

这篇文章是关于实验性的React Suspense for Data Fetching,而不是关于最近发布的 React Suspense for Code Splitting(React.SuspenseReact.lazy)。

阅读本文不需要事先了解 Monad、Applicatives 和实验 Suspense。它解释了它们的本质。

Monad do-notation

React 框架方法鼓励开发人员使用函数式编程技术。至少组件渲染函数不应该有可观察到的副作用。 JavaScript 无法确保这一点,但有一些编程语言可以。例如,Haskell 根本不接受副作用。

纯函数使代码模块化、可预测且更易于验证。但它们也显着增加了冗长。这是来自Phil Walder的Monads for 函数式编程(1995) 教程的声明:

就模块化而言,显式数据流既是福也是祸。一方面,它是模块化的极致。所有输入和输出的数据都呈现为清单并可访问,提供了最大的灵活性。另一方面,它是模块化的最低点。算法的本质可能隐藏在将数据从创建点传输到使用点所需的管道之下。

Monads 为 Haskell 解决了这个问题。 Suspense/Hooks 在 React 中解决了同样的问题。

那么什么是Monad?它是一个简单的抽象接口,有两个函数,我们称它们为 of 和 chain。

  • of— 接受任何值并返回一些单子(有效)值

  • chain— 将一个有效值和一个函数从任何值变为有效值并返回另一个有效值

那里的有效值可以封装任何具体的特定于实现的信息。没有要求它到底应该是什么,它是一些不透明的数据。接口的具体实现应该遵循一套规律,就是这样。

monad 是抽象的,没有什么好说的了。他们不一定存储任何东西,包装或打开任何东西,甚至链接任何东西。

但是,如果它是如此抽象并且几乎没有定义,为什么我们需要它呢?该接口提供了一种抽象方法来组合具有副作用的计算。

如果您使用 JavaScript 编写代码,您现在可能想知道。你已经编写了很多带有副作用的计算,但没有看到任何 Monad。但实际上,您可以认为您已经在那里使用过它们。

在计算机科学中,Monads 首次出现是为了研究命令式语言的副作用。它们是将命令式世界嵌入纯数学世界以供进一步研究的工具。

这样,如果您想将命令式程序转换为表示它的数学公式,使用 Monad 表达式执行此操作将是最简单和最直接的方法。您甚至不需要手动操作,这非常简单,有一些工具可以为您完成。

Haskell 有一个语法糖,称为 do-notation 正是为此。这使得在 Haskell 中编写命令式程序成为可能。它的编译器中有一个特殊的工具。它将这些命令式程序转换为 Monadic 纯 Haskell 表达式。这些表达式与您在教科书中看到的数学很接近。

JavaScript 是一种命令式语言。我们已经可以将任何命令式代码视为 do-notation。但与 Haskell 中的不同,它不是抽象的。它仅适用于内置的副作用。除了扩展语言之外,没有办法添加任何新的支持。

有这样的扩展,即生成器、异步和异步生成器函数。 JavaScript JIT 编译器将异步和生成器函数转换为具体的内置 API 调用。 Haskell 不需要这样的扩展。它的编译器将 do-notation 转换为抽象的 Monads 接口函数调用。

对于这篇文章,我们只需要两个 JavaScript 内置效果。我们称它们为突变和异常。它们有明确的含义。突变允许更改某些引用的值。 JavaScript 使用throw/try-catch语句嵌入了异常效果。

我们可以将一些效果转换为其他效果。这样我们就可以使用生成器编写异步代码。

这种转换技巧也可以应用于其他效果。显然,仅 Mutation 和 Exception 就足以产生任何其他效果。这意味着我们已经可以将任何普通函数转换为抽象的 do-notation。这正是 Suspense 所做的。

当代码遇到一些有效的操作并需要暂停时,它会抛出异常。它包含一些细节(例如一个 Promise 对象)。它的一个调用者捕获了异常,等待参数中的承诺被解决,将结果值存储在缓存中,并从头开始重新运行有效的函数。

解决 Promise 后,引擎再次调用该函数。执行从一开始就开始,当它遇到相同的操作时,它会从缓存中返回它的值。它不会抛出异常并继续执行,直到下一个暂停请求或函数退出。如果函数没有任何其他副作用,则它的执行应该走相同的路径,并且所有纯表达式都被重新计算,产生相同的值。

让我们重新实现 Suspense。与 React 不同,它使用抽象的 Monads 接口。为简单起见,我的实现还隐藏了资源缓存。相反,运行器函数计算调用的效果并使用当前计数器值作为内部缓存的键。这是抽象接口的运行器:

/** effectful expression throws this object if it requires suspension */
const token = {};

/** Pointer to mutable data used to record effectful computations */
let context;

/** Runs `thunk()` as an effectful expression with `of` and `chain` as Monad's definition */
const run = (of, chain) => thunk => {
  /** here it caches effects requests */
  const trace = [];
  const ctx = {trace};
  return step();
  function step() {
    const savedContext = context;
    ctx.pos = 0;
    try {
      context = ctx;
      return of(thunk());
    } catch(e) {
      /** re-throwing other exceptions */
      if (e !== token)
        throw e;
      const {pos} = ctx;
      return chain(ctx.effect,
                   (value) => {
                     trace.length = pos;
                     /* recording the resolved value */
                     trace[pos] = value;
                     ctx.pos = pos + 1;
                     /** replay */
                     return step(value);
                   })
    } finally {
      context = savedContext;
    }
  }
}

/** marks effectful expression */
const M = eff => {
  /* if the execution is in a replay stage the value will be cached */
  if (context.pos < context.trace.length)
    return context.trace[context.pos++];
  /* saving the expression to resolve in `run` */
  context.effect = eff;
  throw token;
}

现在让我们添加一个具体的异步效果实现。不幸的是,Promise 并不完全是 monad,因为一条 Monad 定律并不适用于它们,并且它是微妙问题的根源,但它们仍然可以让我们的 do-notation 工作。

这是具体的异步效果实现:

const runPromise = run(
  v => Promise.resolve(v), 
  (arg, f) => arg.then(f));

这是一个简单的例子,它在渲染继续之前等待延迟值:

沙盒还包含Component包装器。它将一个有效的功能组件变成了一个 React 组件。它只是添加链回调并相应地更新状态。这个版本还没有回退阈值功能,但这里的最后一个示例确实有它。

runner 是抽象的,所以我们可以将它应用到别的东西上。让我们试试这个useState钩子。它是一个 Continuation monad,而不是顾名思义的 State monad。

这里的有效值是将回调作为参数的函数。当 runner 有一些值可以进一步传递时,将调用此回调。例如,当调用从useState返回的回调时。

在这里,为简单起见,我使用单个回调延续。 Promise 有一个更多的失败传播的延续。

const runCont = run(
  value => cont => cont(value),
  (arg, next) => cont => arg(value => next(value)(cont)));

const useState = initial =>
  M(cont => 
    cont([initial, function next(value) { cont([value,next]); }]));

这是一个有效的用法示例,除了 monad 的定义之外,大部分“kit.js”都是复制粘贴的。

不幸的是,这还不是 React 的useState钩子,下一节将说明原因。

应用do-notation

Haskell 中还有另一个 do-notation 扩展。它不仅针对 Monad 抽象接口调用,还针对 Applicative Functors 抽象接口的调用。

Applicative 接口与 Monads 共享of函数,还有另一个函数,我们称之为join。它接受一个有效值数组并返回一个解析为数组的有效值。结果数组包含参数数组的每个元素解析为的所有值。

我使用了与 Haskell 界面不同的界面。尽管如此,两者都是相同的——将 Haskell 的接口转换为此处使用的接口很简单。我这样做是因为这个基础在 JavaScript 中使用起来要简单得多,它不需要任何高阶函数,并且在标准运行时中已经有了它的实例。

在 Haskell 和 JavaScript 中,任何 Monad 都会立即成为 Applicative Functor。这意味着我们不需要编写 Applicative 接口的具体实现,我们可以自动生成它。

如果有默认实现,为什么我们需要 Applicative Functors?有两个原因。第一个不是所有的 Applicative Functor 都是 Monad,所以没有chain方法可以从中生成join。另一个原因是,即使有chain,自定义join实现也可以用不同的方式做同样的事情,可能更有效.例如,并行而不是顺序地获取资源。

在标准运行时中有一个 Promises 接口的实例。它是Promise.all(为了简单起见,这里再次忽略一些细节)。

现在让我们回到状态示例。如果我们在组件中添加另一个计数器怎么办?

当第一个计数器增加时,第二个计数器现在重置其值。这不是 Hooks 应该如何工作的。两个计数器都应保持其值并并行工作。

发生这种情况是因为每次继续调用都会删除代码中它之后的所有内容。当第一个计数器改变它的值时,整个下一个延续从头开始重新开始。在那里,第二个计数器值再次为 0。

在运行函数实现中,失效发生在第 26 行 -trace.length = pos- 这将删除当前值之后的所有记忆值(在pos)。相反,我们可以尝试对跟踪进行差异/修补。这将是用于增量计算的 Adaptive Monad 的一个实例。 MobX 和类似的库与此非常相似。

如果我们只从函数的顶层调用有效的操作,就没有分支或循环。一切都会很好地合并,覆盖相应位置的值,这正是 Hooks 所做的。尝试删除上面两个计数器的代码沙箱中的行。

转译器替代品

使用 Hooks 已经使程序更加简洁、可重用和可读。想象一下,如果没有限制(钩子规则),你可以做什么。限制是由于仅运行时嵌入。我们可以通过转译器消除这些限制。

Effectful.JS是一个用于将有效嵌入到 JavaScipt 中的转译器。它支持 Monadic 和 Applicative 目标。它极大地简化了设计、实施、测试和维护阶段的程序。

与 React Hooks 和 Suspense 不同,转译器不需要遵循任何规则。它适用于任何 JavaScript 语句(分支、循环、异常等)。它从不从一开始就重新播放功能。这更快。此外,这些函数可以使用任何 JavaScript 内置的副作用。

Effectful.JS 不完全是一个转译器,而是一个创建转译器的工具。还有一些预定义的和许多用于调整的选项。它支持双层语法,带有用于有效值的特殊标记(如异步函数中的await表达式,或 Haskell 的 do)。它还支持隐含信息的单级语法(如 Suspense、Hooks 或具有代数效应的语言)。

我很快就为演示目的构建了一个类似于 Hooks 的转译器——@effectful/react-do。调用名称以“use”开头的函数被认为是有效的。仅当函数名称以“use”开头或具有“组件”或“有效”块指令(函数开头的字符串)时,函数才会被转译。

还有“par”和“seq”块级指令在应用和单子目标之间切换。启用“par”模式后,编译器会分析变量依赖关系并尽可能注入join而不是chain

这是带有两个计数器的示例,但现在已使用转译器进行了调整:

出于演示目的,它还实现了代码拆分的 Suspense。整个函数有六行长。在运行时实现中检查它@effectful/react-do/main.js。在下一个示例中,为了演示目的,我添加了另一个人为延迟渲染的计数器。

代数效应

代数效应经常与悬念和钩子一起被提及。这些可能是内部细节或建模工具,但 React 无论如何都不会将代数效果传送到其用户空间。

通过访问代数效果,用户可以使用自己的效果处理程序覆盖操作行为。这就像异常,能够在throw之后恢复计算。比如说,如果某个文件不存在,某些库函数会抛出异常。任何调用者函数都可以覆盖它如何处理它,忽略或退出进程等。

EffectfulJS 没有内置的代数效果。但它们的实现是一个位于延续或免费 monad 之上的小型运行时库。

调用延续也会删除相应的throw之后的所有内容。还有特殊的语法和键入规则来获取 Applicative(和箭头)API —Algebraic Effects and Effect Handlers for Idioms and Arrows。 Unline Applicative - 这样做禁止使用任何需要 Monad 操作的东西。

发电机

对于一元符号,我们可以使用生成器,但有一些限制。Using Generators as syntax sugar for side effects文章中有更多详细信息。

结束

转译器是一种负担,它有自己的使用成本。与任何其他工具一样,只有在此成本小于您获得的价值时才使用它。

您可以使用 EffectfulJS 实现很多目标。这是一种编写 JavaScript 程序的新方法。它对于具有复杂业务逻辑的项目很有用。任何复杂的工作流程都可以是一个简单的可维护脚本。

例如,Effectful.JS 可以用小函数替换 Suspense、Hooks、Context 和 Components State。错误边界是通常的try-catch语句。异步渲染是一个异步调度器。但是我们可以将它用于任何计算,而不仅仅是渲染。

还有很多其他很棒的特定于应用程序的用途,我很快就会写更多关于它们的内容。敬请关注!

Logo

React社区为您提供最前沿的新闻资讯和知识内容

更多推荐