可取消的提取

杰克·阿奇博尔德
Jake Archibald

有关“中止提取”的原始 GitHub 问题已于 2015 年提出。现在,如果我将 2015 年与 2017 年(今年)相差 2015 年,这演示了数学中的错误,因为 2015 年实际上已经“永远”了。

2015 年是我们首次开始探索如何中止正在进行的提取。之后,在经过 780 条 GitHub 评论、几个错误启动和 5 个拉取请求之后,我们终于在浏览器上实现了可中止的提取,第 57 版是 Firefox 57。

更新:糟糕,我错了。Edge 16 首先推出了中止支持功能!恭喜 Edge 团队!

我们稍后会详细介绍相关历史记录,但首先要介绍的是 API:

控制器 + 信号操作

了解AbortControllerAbortSignal

const controller = new AbortController();
const signal = controller.signal;

该控制器只有一个方法:

controller.abort();

当您执行此操作时,它会通知信号:

signal.addEventListener('abort', () => {
    // Logs true:
    console.log(signal.aborted);
});

该 API 由 DOM 标准提供,这也是整个 API。它特意属于通用类型,因此可供其他网络标准和 JavaScript 库使用。

取消信号并提取

提取操作可能需要 AbortSignal。例如,以下代码展示了如何将提取超时设置为 5 秒后:

const controller = new AbortController();
const signal = controller.signal;

setTimeout(() => controller.abort(), 5000);

fetch(url, { signal }).then(response => {
    return response.text();
}).then(text => {
    console.log(text);
});

如果您取消提取操作,那么请求和响应也会取消,因此对响应正文(例如 response.text())的任何读取操作也会被取消。

点击此处查看演示 - 在撰写本文时,唯一支持此演示的浏览器是 Firefox 57。另外,没有人会参与创建演示,任何设计技能都不要参加。

或者,您也可以将信号提供给请求对象,稍后将其传递以提取:

const controller = new AbortController();
const signal = controller.signal;
const request = new Request(url, { signal });

fetch(request);

这之所以有效,是因为 request.signalAbortSignal

对中止的提取做出响应

如果您取消异步操作,promise 会拒绝,并返回名为 AbortErrorDOMException

fetch(url, { signal }).then(response => {
    return response.text();
}).then(text => {
    console.log(text);
}).catch(err => {
    if (err.name === 'AbortError') {
    console.log('Fetch aborted');
    } else {
    console.error('Uh oh, an error!', err);
    }
});

您通常不希望在用户中止操作时显示错误消息,因为如果您成功执行了用户要求的操作,那么这并不是“错误”。为避免出现这种情况,请使用诸如上述的 if 语句来专门处理中止错误。

以下示例为用户提供了一个用于加载内容的按钮和一个用于取消的按钮。如果提取错误,则会显示错误,但除非它是中止错误:

// This will allow us to abort the fetch.
let controller;

// Abort if the user clicks:
abortBtn.addEventListener('click', () => {
    if (controller) controller.abort();
});

// Load the content:
loadBtn.addEventListener('click', async () => {
    controller = new AbortController();
    const signal = controller.signal;

    // Prevent another click until this fetch is done
    loadBtn.disabled = true;
    abortBtn.disabled = false;

    try {
    // Fetch the content & use the signal for aborting
    const response = await fetch(contentUrl, { signal });
    // Add the content to the page
    output.innerHTML = await response.text();
    }
    catch (err) {
    // Avoid showing an error message if the fetch was aborted
    if (err.name !== 'AbortError') {
        output.textContent = "Oh no! Fetching failed.";
    }
    }

    // These actions happen no matter how the fetch ends
    loadBtn.disabled = false;
    abortBtn.disabled = true;
});

点击此处查看演示 - 在撰写本文时,仅支持 Edge 16 和 Firefox 57 的浏览器。

一个信号,多次提取

单个信号可用于一次取消多项提取:

async function fetchStory({ signal } = {}) {
    const storyResponse = await fetch('/story.json', { signal });
    const data = await storyResponse.json();

    const chapterFetches = data.chapterUrls.map(async url => {
    const response = await fetch(url, { signal });
    return response.text();
    });

    return Promise.all(chapterFetches);
}

在上面的示例中,初始提取和并行章节提取使用相同的信号。fetchStory 的使用方法如下:

const controller = new AbortController();
const signal = controller.signal;

fetchStory({ signal }).then(chapters => {
    console.log(chapters);
});

在这种情况下,调用 controller.abort() 将会中止正在进行的提取。

未来展望

其他浏览器

Edge 在推出这项功能方面做得非常不错,而 Firefox 也在持续尝试。他们的工程师在编写规范时通过测试套件实现。对于其他浏览器,请遵循以下工单:

在 Service Worker 中

我需要完成有关 Service Worker 部分的规范,但计划如下:

如前所述,每个 Request 对象都有一个 signal 属性。在 Service Worker 中,如果页面不再对响应感兴趣,fetchEvent.request.signal 将发出中止信号。因此,类似如下的代码可以正常运行:

addEventListener('fetch', event => {
    event.respondWith(fetch(event.request));
});

如果页面取消提取,fetchEvent.request.signal 会发出中止信号,因此 Service Worker 中的提取也会取消。

如果您要提取 event.request 以外的项,则需要将信号传递给自定义提取。

addEventListener('fetch', event => {
    const url = new URL(event.request.url);

    if (event.request.method == 'GET' && url.pathname == '/about/') {
    // Modify the URL
    url.searchParams.set('from-service-worker', 'true');
    // Fetch, but pass the signal through
    event.respondWith(
        fetch(url, { signal: event.request.signal })
    );
    }
});

按照规范进行跟踪:在准备好实现后,我会添加浏览器工单的链接。

发展历程

是的,这个相对简单的 API 需要很长时间才能整合起来。原因如下:

API 不一致

如您所见,GitHub 上的讨论非常长。 该线程中有许多细微差别(并且没有细微差别),但关键的不一致是,一个群体希望在 fetch() 返回的对象上存在 abort 方法,而另一个群体希望将获取响应和影响响应分开。

这两个要求并不兼容,因此有一个群组无法获得他们想要的东西。如果是您,我们很抱歉!如果让你感觉好一些,我也在那个组中。但是,如果 AbortSignal 符合其他 API 的要求,这看起来似乎是正确的选择。此外,允许链式 promise 变为可中止也会变得非常复杂,即使并非不可能也是如此。

如果您想返回一个提供响应但也可以取消的对象,您可以创建一个简单的封装容器:

function abortableFetch(request, opts) {
    const controller = new AbortController();
    const signal = controller.signal;

    return {
    abort: () => controller.abort(),
    ready: fetch(request, { ...opts, signal })
    };
}

TC39 中的 False 开始

已尝试将已取消的操作与错误区分开来。其中包括第三个表示“已取消”的 promise 状态,以及用于在同步和异步代码中处理取消的一些新语法:

错误做法

不是真实代码 - 提案已撤消

    try {
      // Start spinner, then:
      await someAction();
    }
    catch cancel (reason) {
      // Maybe do nothing?
    }
    catch (err) {
      // Show error message
    }
    finally {
      // Stop spinner
    }

操作被取消时,最常见的做法是不执行任何操作。上述方案将取消与错误分开,因此您无需专门处理中止错误。catch cancel 可让您了解已取消的操作,但大多数情况下您不需要这样做。

此问题进入了 TC39 的第 1 阶段,但因未达成共识,提案已被撤消

我们的替代方案 AbortController 不需要任何新的语法,因此在 TC39 中进行规范没有意义。我们所需的 JavaScript 一切都已准备就绪,因此我们在网络平台内定义了接口,具体来说就是定义 DOM 标准。在我们做出该决定后 其余各方面的团队很快就会整合在一起

规范大幅变更

多年来 XMLHttpRequest 一直可以中止,但规范不明确。不清楚底层网络活动可以在什么时间点避免或终止,也不清楚在调用 abort() 和完成提取之间出现竞态条件时会发生什么情况。

我们希望这次能够顺利进行,但最终导致了一项大的规范变更,需要进行大量的审核(这是我的错,并感谢 Anne van KesterenDomenic Denicola 拖我完成相关工作)以及一套很不错的测试

而我们现在在这里!我们有一个用于取消异步操作的新 Web 基元,并且您可以一次控制多个提取!接下来,我们将介绍如何在提取的整个生命周期内启用优先级更改,以及如何观察提取进度的更高级别的 API。