可取消的提取

Jake Archibald
Jake Archibald

关于“中止提取”的原始 GitHub 问题是在 2015 年提出的。现在,如果我从 2017 年(当前年份)减去 2015 年,我会得到 2。这说明了数学中存在 bug,因为 2015 年实际上已经过去很久了。

我们于 2015 年首次开始探索如何中止正在进行的提取操作。在收到 780 条 GitHub 评论、经历几次失败的尝试以及提交 5 项拉取请求后,我们终于在浏览器中实现了可中止的提取功能,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。它是故意设计为通用的,以便其他 Web 标准和 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 中

我需要完成服务工作器部分的规范,但计划如下:

如前所述,每个 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 来监控提取进度