不违背诺言的取消

不违背诺言的取消

原文:https://medium.com/hackernoon/considering-cancelation-a96e0f3c2298

反思可取消承诺的棘手之处,将功能纯粹作为一种解决方案

所以… “可取消的承诺”已经,嗯,取消了。因此, Javascript 社区再次想知道像fetch这样更新的、基于承诺的 API 如何可能向前发展。为什么这会成为这样的阻碍?我们能做什么?

在现在与未来一起工作

让我们先快速回顾一下问题空间。我们可以在这里讨论 Javascript 中的“事件循环”……但是为了我们的目的,让我们只考虑这样一个事实,即我们的应用程序中的所有代码都是按照特定的操作顺序执行的:从内向外,从第一行到最后一行。任何需要时间来完成的事情都会“阻塞”应用程序的其余部分,直到它完成。

显然,这使得激活和响应真正的异步动作有点棘手。在用户级代码中,第一个也是最自然的模式是回调:你传递一个函数和另一个函数,一旦其他接口声明结果可用,你就希望被调用。这种模式本身并没有什么异步性:只是一些基本的高阶函数在起作用:

All synchronous, but with the same layer of abstraction in place that asynchronous code would require

值得注意的是,实际的*非阻塞异步行为几乎不可能在 javascript 中创建或想象,除非使用特殊的语言级函数来提供支持。因此,让我们快速思考一下该语言(至少在浏览器中)提供的一个最简单的方法: setTimeout 。*

//setTimeout::Function->Int->a setTimeout(x =>console . log(x),700,5);****

在这种情况下,我们只需指定一个回调函数、调用它之前等待的时间(以毫秒为单位),以及可选的用于调用回调的值。考虑到所有这些,语言将等待适当的时间,然后用该值调用函数。

就是这样。基本的。据我所知,没有针对非阻塞 setTimeout 的 polyfill:要么该语言可以做到这一点(即:等待,而不阻塞任何后续代码行的执行),要么它不能。

*但是 setTimeout 的一个结构限制是其 API 的“死胡同”性质:副作用回调不是特别可扩展。也就是说,如果你想在回调的结果上扩展和链接进一步的计算,你不能在定义核心回调操作本身之后这样做。你必须首先准备好你想要发生的一切,并在将它交给 setTimeout 执行之前打包到回调中。现在,可以让回调触发已经在外部作用域中定义的其他函数…或者甚至使用另一个 setTimeout 来调度它们稍后运行。但是事实仍然是回调返回的实际返回值实际上并没有去任何地方:***

setTimeout(x= > x,700,5);

我们在那里的回调函数, x= > x 实际上返回 a 5 作为其结果,但这基本上是无意义的:回调是同步定义的(在现在),但它异步运行(在未来)。所以“5”没有地方可去:没有其他的东西被定义或者可以被定义来让它进入。在它未来的时间范围内没有“别的”存在来听它。

能否引入“未来倾听”这个概念?当然可以!我们就是这样得到承诺的。承诺就像一个的表示,即在未来存在……从而为你提供了一种可理解的方式来编码那些现在缺失的值。他们通过对未来事件进行某种“装箱”来做到这一点:包含最终的爆炸,并以全状态存储其结果。

因此,当您构造一个承诺时,您定义了一个函数,该函数在内部执行类似于 setTimeout 操作的东西,但是还指定了如何通过调用两个专门的函数处理程序之一来捕获结果:resolve 或 reject。像这样:

希望我不需要过多地进入承诺的 API。相反,让我们列出几个与我们关于取消的讨论很快相关的关键因素:

  1. 构造函数(传递了控制承诺内部状态的特殊 resolve/reject 参数的函数)实际上没有返回任何东西(因此,它隐式地返回了 undefined )。这对于承诺来说很正常,就像*一样。即使它确实返回了一些东西,这也不重要。 new Promise 返回,嗯,一个承诺:不是构造函数可能返回的任意结果。*
  2. 正如我们所说,构造函数被立即执行。**
  3. *如果你马上链一个 。然后() 方法推演出一个许诺,结果是一个新的,派生出许诺,其最终状态取决于第一个的结果。*
  4. 第二,派生承诺没有(实际上,不应该有*任何)挂钩回“进入”,或者控制我们使用构造函数创建的原始承诺!它只是从前一个的结果中获得自己的“解决”执行。这个通信通道只能容纳一件事,而且是单向的:传递给最初的 resolve 回调函数的值,然后使用 将该值外包给附加在承诺上的任何函数。然后() 。*

我在#3 中撒了一个微小的位当然:还有另外一个沟通渠道:拒绝* / 。catch() 捕捉()操作。和决心一样。和 resolve 一样,它是一个只使用单个值调用的函数。管道中没有太多空间容纳额外的信号!这实际上是一件非常好的事情,因为我们已经在处理一个复杂的结构,我们试图让简单的&具体化。*

抢先一步

但是现在我们进入了问题的实质:取消。如果我们在某个时候决定不关心最初的操作,而我们仍在操作中,该怎么办?我们预计会发生两件重要的事情:

  1. 任何异步生成结果的操作都应该停止,释放它正在消耗的资源
  2. 依赖于这一结果的副作用(或者至少是等到它到来的时候)都不应该出现

这个看起来看似简单,对吗?

而仅仅用一个光秃秃的,它就是其实相当容易。虽然它可能是一个创建异步效果的接口,但是*setTimeout***一被调用就立即返回一个东西:浏览器会话唯一 id。如果你曾经使用过 setTimeout ,你知道你可以简单地使用那个 id 在操作完成之前取消整个操作:

但是让我们在这里注意一些关于作用域的非常重要的事情:在外部作用域中存在取消。也就是说:它存在于当下。另一方面,**x =>console . log(x)函数,则是某种未来的存在。现在可访问的是可能将来仍可访问,因此有可能通过使用可变外部变量将取消 id 隐藏到回调函数中,如下所示:**

但是希望你能明白这样做是没有意义的:一旦内部回调运行,取消超时就太晚了。已经跑了!因此,您访问任何取消控件的理想时间和地点实际上与您选择执行可取消操作的时间完全相同……并且在完全相同的范围内。 **setTimeout 说对了。**

现在,进行 **setTimeout 调用所涉及的资源相对较少,因此节省 CPU 周期并不像停止任何副作用那样重要。但是其他的异步 API可能非常耗费资源。对大文件的网络请求。或者甚至是异步文件读取操作。如果我们的程序或用户决定我们不需要完成那些操作,那么可选的取消提供了一个可能的巨大的性能优势。**

在混合的所有异步 API 中,有两种风格:已经使用(即返回)承诺的,以及特殊的雪花,如 **setTimeout 。在前一组中,我们有像预期的新 获取 API 这样的东西,它被阻塞主要是因为没有人非常确定如何将取消硬塞到它的基于承诺的 API 中。Promises 和它们的语法 sugar-y friendsasync/await看起来是一个非常有前途的干净、同步定义的非阻塞异步代码的抽象。**

但是让我们快速看一下后者,据说是一个老派团体,其中我们也有 XMLHttpRequest 和 FileReader 之类的东西。对于大多数这样的接口,您首先创建一个可以调度异步动作的对象,在其上定义(或被给予)一些处理程序和挂钩(包括用于取消的处理程序和挂钩),然后最终执行带有所有回调功能的动作:

关键是,您使用原始控件对象来启动这个执行步骤:您有一个作用域内的引用。这就是我们说我们想要的!太好了。

嗯……这正是你在承诺的情况下所没有的!当然,您可以将这些特殊的 API 包装在承诺中。但是有了那些特殊的雪花 API, **setTimeout 或者 WebWorker 事件通道或者其他什么:如果你用它们来创建一个新的承诺,那么那些控件引用存在于承诺构造函数的范围内并且只*连接到承诺的内部表面。但是正如我们所看到的,除了已解析或已拒绝的值之外,其他任何值都不会再次出现!你没有办法回到你正在运行的操作中,因此没有真正的控制权。***

现在,我们可以*玩和我们玩 setTimeout 一样的把戏:我们可以首先在外部作用域中定义一个变量,然后在构造函数中重新分配它,保留以后对它的访问。这基本上是许多简单的“可取消”承诺的(非本地)实现所做的。事实上,这种方法是……的起源。)取消令牌建议。*

*这有点一次性,所以大多数实现倾向于将整个结构包装在更多的层中,这样它们就可以在 Promise 对象上返回一个唯一的 cancel 方法,同时确保 extension-y 的不可思议性通过连续的 得以实现。然后()。catch()等。锁链。或者他们将令牌建模为承诺本身,并带有可选的取消原因。*

但是,如果对于一个相当罕见的操作来说,这听起来已经是一个令人头痛的巨大开销,那么你没有错。我们甚至还没有进入令人难以置信的令人困惑的问题,即派生承诺的继承状态会发生什么。

派生和从属状态

正如我们所说,承诺在某种意义上是一流的值*,你可以四处传递并附加额外的 。然后() 随时处理人来。但是当一个原始的效果被取消时,我们如何通过一个有状态依赖链对它进行建模?我们不能让任何成功处理程序被调用(导致不必要的副作用,基于我们现在没有也永远不会有的值)。但是,如果同样的承诺被使用了几次,产生了不同的效果,那该怎么办呢:我们会把取消当作一个错误(到处传播)吗?*

我的意思是,取消并不是真正的错误,也不应该每个受影响的函数都需要知道如何捕捉它:它们只需要不在运行所有的。那么…我们就永远不要让这些衍生的承诺解决… 永远不要*?我们是否建立了某种方式来告诉他们,他们永远不会得到一个值或一个错误(这样我们至少可以链上一些特殊的清理方法,比如 )。最后() 去处理那个案子?*

有很多可能的方法来回答这些问题…而这就是实际上的问题!没有一个是特别自然或直观的:你只能选择一个,并忍受它的缺点。像蓝鸟这样的库处理“多重依赖”的问题,例如,通过基本上有状态地跟踪每个消费者,直到最初的承诺被取消。只有当所有附加的处理程序都取消时,原始效果才会被取消,并为取消发生后附加的任何处理程序引发一个错误。

它可以工作,但它仍然是一个相当丑陋的,种族主义的,有点令人费解的系统。

但是,如果你甚至稍微同意我的观点,取消承诺变得相当严重,那么让我们首先考虑一下为什么这是一个问题:因为有状态本身。也就是说,承诺不仅仅是对未来事件的描述:它们是我们对待甚至有时认为是价值的小状态机,尽管它们实际上是某种你可以映射的价值容器。大多数时候,它对作品进行排序。这看似令人兴奋:我们甚至可以在值存在之前存储和传递对它们的引用!

但是当它让我们谈论一个取消的值时,它就没有那么有意义了。就像一个时间旅行者谋杀了自己的祖父母,一个被取消的价值,按理说,*从一开始就不应该存在。事实上,我们不应该有一个对它的引用,因此我们不可能/应该首先把所有这些派生状态附加到它上面!毕竟,如果你回到过去,阻止你的祖父母生孩子,不仅仅是你不应该存在,你的孙子也不应该存在。***

然而,当使用承诺作为异步操作的抽象时,这正是我们所坚持的那种奇怪的构造!

所以,开枪吧。当 fetch 和其他基于 Promise 的 API 推出时,我们会陷入这种混乱吗?大概吧!但是事情一定要这样吗?

*嗯,不。还记得我们如何哀叹 setTimeout 中的回调本质上是不可组合的吗(或者至少,一旦你描述了 setTimeout,你就不能向回调添加任何进一步的连锁行为了)?用承诺来解决这个问题是一个可能的解决方案。但它也引入了这种“未来价值”的抽象,正如我们所看到的,这是非常麻烦的。*

不过,还有另一种可能的解决方案:一种链能力,不是通过使用链接状态,而是通过实现“懒惰”操作。当然,我们谈论的是不同的功能类型,如未来任务

有很多很棒的任务 / 未来库,但是因为核心概念从根本上来说非常简单,所以现在就让我们在这里编写一个任务:

Pretty generic pattern, just with a funny name. The magic is almost entirely in how it’s used.

是的,这是承诺的功能替代的核心,全部在几个字符的代码中。然而,它的构造函数的用法与承诺惊人地相似!

No “new” keyword needed in this simple implementation (or even in the more evolved/prototypical versions of the type).

现在,实际上不会做*任何事情,当然:我们必须运行 。fork() 通过给它传递两个与我们在构造函数中定义的处理器相匹配的处理器。*

If we don’t want to model the possibility of error, then we don’t even have to have two handlers in the constructor or fork: it could just be a pure continuation instead, with just resolve. But to avoid confusion, it’d probably be best to call something that’s used with a different type signature like that something other than a “Task.”

请注意,取消接口是从盒子中暴露出来的,这里没有任何额外的工作:调用。 fork 非常自然地*返回**setTimeout**id 令牌,我们可以调用clear time out*on 如果我们曾经想:

很整洁,不是吗?

如果我们想更通用,我们可以只要求所有的任务构造函数直接返回一个通用的取消函数,这意味着我们只需定义并从构造函数返回它,标准化取消用法,而不管如何取消一些浏览器级异步操作的具体细节:

We could also just shim this into the constructor logic, as Bluebird does with onCancel.

无论如何,这里的要点是任务并不像承诺那样是花哨的“未来值”:它们只是对未来计算的描述。因此,他们还有一个承诺中缺少的关键特征:功能的纯粹性。也就是说,定义一个任务不会像定义一个承诺那样产生副作用。每个任务构造函数返回相同的东西:一个持有相同构造函数的任务,仍然没有执行。任务也不会“跟踪”它是“未决、已解决还是已拒绝”任务从来都不是那些东西,因为它们只是描述。

*因此,我们首先要担心的是,从来没有“取消的”任务。根据定义,您不能取消一个任务,因为它是一个还没有运行的操作!一旦你通过调用 fork 运行,你实际上不再真正处理一个任务(已定义的任务**本身是不可变的/可重用的/可扩展的):剩下的就是操作,它最终的副作用,以及你在此期间留给自己去改变它的任何控制。一旦函数类型达到了它们有效的目的,它们就会像这样“离开你的方式”。***

*所有这一切的额外伟大之处在于,在我最初关于任务任务任务任务的文章中,我试图赞美。fork 方法并不是什么特殊的魔法,而拒绝/解析参数给任务的构造函数也不是什么奇怪的内部接口,它们有承诺。 *fork 实际上只是构造函数定义本身的入口:使用 附加的on failure/on success回调。fork() 实际上是传递给任务的构造函数的reject/resolve参数!

但是如何将后续操作组合到构造函数的结果中呢?简单:

将操作添加到任务构造函数的拒绝或解析分支上同样没有副作用:它只是描述了从一个值到另一个值的引用透明转换。我们甚至不需要做任何特殊的事情来允许原来的 setTimeout id 在结束时仍然返回!

几乎所有你可以用承诺做的事情,你也可以用任务、来做,实际上还有更多(尽管大多数其他操作,比如从一个值到一个新任务的一元转换,我们已经习惯了 )。then() 隐式处理,需要更多的代码,并且必须在它们的内部实现中处理更多的边缘情况,而不值得在这里深入讨论)。但重要的是,取消接口可以在我们需要的地方公开。我们不得不放弃的是这种一阶“未来”值的怪异概念,这种概念可能比它的价值更麻烦。

在任何情况下,我们最终为基于承诺的 API(如 fetch )提供的任何可取消的接口都可能会很好地工作,并让我们基本上做我们需要做的事情……但不可避免的是,使用它可能会有问题。

就我个人而言,知道存在另一种模式(并且我将能够包装一个任务来处理它),这是一种解脱!

当然,还有总是可以观察到的!

附录:zalgo 注意事项

任务简单明了的一个结果是,要求 ES6 强制区分同步/异步承诺中的“zalgo”危险在我们的 任务 中不存在。没有强制性的set immediate/next tick要求确保任务效果总是异步“运行”以避免混淆事物的顺序。我们在这里的全部目的似乎是弄清楚如何进行异步操作,但是 任务 完全消除了这种区别。他们再一次做到了这一点,将效果的描述和任何变换从它被执行的实际时刻中分离出来,并告诉如何处理自身。

因此,任务可以非常自然地用于建模同步回调(功能依赖)异步回调(基于时间的依赖)。那是因为是最后的 。fork 明确控制执行时间的操作(执行这个产生副作用的操作从右边开始…现在!).

任务与我们最初的回调方法一样,无缝地跨同步或异步效果工作。如果你甚至不关心取消,你可以直接从构造函数中返回并使用一个同步值。

但是这确实意味着应该小心任何产生价值的副作用,甚至可能同步或异步运行。只是要警惕副作用(比如带有同步缓存或内存化的网络请求),如果是这样的话,在执行它们时要非常小心(因为在它们之后的任何其他同步代码都可能在没有 nextTick 保护的情况下运行之前或之后),或者只是自己对效果施加一个 setImmediate

你在这里有更多的权力…但也有更多的责任。“zalgo”问题发生在副作用不总是正确排序的时候(因此可能以意想不到的顺序运行)。

这真的不是一件坏事:一旦你对所有副作用的排序有了更统一的控制,你就可以在一个特定的控制流中显式地将它们链接起来,而不是仅仅将它们写成一串行,这些行可能会也可能不会按照“偶然事件”的顺序运行。

黑客中午是黑客如何开始他们的下午。我们是 @AMI 家庭的一员。我们现在接受投稿,并乐意讨论广告&赞助机会。

要了解更多信息,请阅读我们的“关于”页面在脸书上点赞/给我们发消息,或者简单地说, tweet/DM @HackerNoon。

如果你喜欢这个故事,我们推荐你阅读我们的最新科技故事趋势科技故事。直到下一次,不要把世界的现实想当然!


本站为非盈利网站,作品由网友提供上传,如无意中有侵犯您的版权,请联系删除