大家好,我是梦兽。一个 WEB 全栈开发和 Rust 爱好者。如果你对 Rust 非常感兴趣,可以关注梦兽编程公众号获取群,进入和梦兽一起交流。
JS 发起的请求可以暂停吗?这个问题需要澄清两个概念:什么样的状态可以被视为“暂停”?以及 JS 发起的请求具体是指什么?
如何定义暂停?
暂停指的是暂时停止一个已经开始但尚未完成的过程。该过程可以在某个时间点被中断,并在另一个时间点恢复。
请求应该是什么样的?
首先,我们来介绍一下 TCP/IP 网络模型。该模型从上到下分为应用层、传输层、网络层和网络接口层。
每次网络传输时,应用数据都需要通过网络模型层层打包后才能发送到目的地,就像寄送包裹一样。要发送的物品首先会被打包并登记包裹的大小,然后放入箱子并且登记发送目的地,最后装载到车配送到目的地。
请求的概念可以理解为客户端通过多次数据网络传输向服务器发送完整数据的行为,服务器针对特定请求发送给客户端的回复数据可以称为响应。
理论上,应用层协议可以通过标记数据包序列号等方式实现暂停机制。但是 TCP 协议并不支持这一点。TCP 协议的数据传输是面向流的,数据被视为连续的字节流。客户端发送的数据会被分成多个 TCP 段,这些段独立地在网络上传输。无法直接控制每个 TCP 段的传输,因此无法实现暂停请求或响应等功能。
如果请求指的是网络模型中的传输,那么自然无法暂停。
再来看一下使用场景——JS 发起的请求。可以认为这里提到的请求是指 JS 运行时发起的 XMLHttpRequest 或 fetch 请求。既然请求已经发出,自然的问题就是响应是否可以暂停。
我们都知道,像分块上传大文件和下载文件这样的功能,本质上就是定义块的顺序、按顺序请求,并且能够通过中断和记录中断点来实现暂停和恢复。单个请求并不具备这样的环境。
使用 JS 实现“假暂停”机制
虽然我们无法真正实现暂停请求,但我们可以模拟一个假的暂停功能。在前端业务场景中,数据在收到后并不会立即显示在客户端界面上。前端开发者需要执行渲染界面之前处理这些数据。
如果我们在发起请求之前添加一个控制器,并且在请求返回时控制器处于暂停状态,那么就不处理数据,而是等待控制器恢复后再进行处理。这样不就能达到我们的目的了吗?让我们尝试实现一下。
如果我们使用 fetch 进行请求,可以设计一个控制器 Promise,并使用 Promise.all 包装它与请求结合。当 fetch 完成时,检查这个控制器是否处于暂停状态;如果没有暂停,直接解析控制器,同时解析并抛出 Promise.all。
function _request () {
return new Promise<number>((res) => setTimeout(() => {
res(123)
}, 3000))
}
// 原本想用“class extends Promise”来实现。
// 但问题一直出现在 https://github.com/nodejs/node/issues/13678。
function createPauseControllerPromise () {
const result = {
isPause: false,
// 表示在恢复时是否需要解析Promise。
resolveWhenResume: false,
// 用于解析控制器的Promise。
resolve (value?: any) {},
// 暂停方法
pause () {
this.isPause = true
},
// 恢复方法
resume () {
if (!this.isPause) return
this.isPause = false
if (this.resolveWhenResume) {
this.resolve()
}
},
// 控制器的Promise对象。
promise: Promise.resolve()
}
const promise = new Promise<void>((res) => {
result.resolve = res
})
result.promise = promise
return result
}
function requestWithPauseControl <T extends () => Promise<any>>(request: T) {
// 创建的控制器对象
const controller = createPauseControllerPromise()
// 请求的Promise,并在请求完成后检查控制器的状态。如果控制器没有暂停,则解析控制器的Promise。
const controlRequest = request().then((data) => {
if (!controller.isPause) controller.resolve()
controller.resolveWhenResume = controller.isPause
return data
})
const result = Promise.all([controlRequest, controller.promise])
.then(data => data[0])
result.finally(() => controller.resolve())
(result as any).pause = controller.pause.bind(controller);
(result as any).resume = controller.resume.bind(controller);
return result as ReturnType<T> & { pause: () => void, resume: () => void }
}
使用方法
我们可以通过调用 requestWithPauseControl(_request)来替代直接调用_request,并通过返回的 pause 和 resume 方法来控制暂停和恢复。
const result = requestWithPauseControl(_request).then((data) => {
console.log(data)
})
if (Math.random() > 0.5) { result.pause() }
setTimeout(() => {
result.resume()
}, 4000)
执行原理
在流程设计上,如下所示:设计一个控制器,发起请求,并在接收到响应后检查控制器的状态。如果控制器不在“暂停”状态,则正常返回数据;当控制器处于“暂停”状态时,设置控制器在调用 resume 方法后返回数据的状态。
在代码中,使用 Promise.all 来绑定一个控制器 Promise。如果控制器处于暂停状态,Promise.all 将不会被释放。然后,暴露相应的 pause 方法和 resume 方法供外部使用。
最后
有些同学误以为网络请求和响应根本无法暂停。我在文章开头特意提到了与数据传输相关的内容,并加了一句“理论上,应用层协议可以通过标记数据包序列号等方式实现暂停机制。”这意味着如果你修改 HTTP 或设计自己的应用层协议(如 socket、vmess 等协议),只要两端都支持这个协议,就有可能实现请求或响应的暂停。这不会影响 TCP 连接,但实现暂停机制需要综合考虑场景和 TCP 策略,以确保更好的可靠性。
例如,提供一种用于控制传输暂停的控制消息,需要对所有数据包的序列号进行标记。当需要暂停时,发送一个带有此序列号的暂停消息给接收端。接收端在接收到暂停消息后,会将已接收的数据包块标记返回给发送端(类似于分块上传机制)。
注意
感谢阅读!感谢您的时间,并希望您觉得这篇文章有价值。
创建和维护这个博客以及相关的库带来了十分庞大的工作量,即便我十分热爱它们,仍然需要你们的支持。或者转发文章。通过赞助我,可以让我有能投入更多时间与精力在创造新内容,开发新功能上。赞助我最好的办法是微信公众号看看广告。