Promise
用 Promise 封装 WebHID 单通道通信
这篇笔记记录一下我在工作中封装前端和固件通信逻辑的过程。 当时做的是一个通过 WebHID 和固件通信的前端项目。 固件那边发数据有一个特点:只有一条通信通道,而且数据是一包一包依次传过来的。我发送一条获取数据的命令后,必须把这次响应完整接收完,才能继续发送下一条命令。 不能多,也不能少,更不能两条命令同时发出去。 旧

这篇笔记记录一下我在工作中封装前端和固件通信逻辑的过程。
当时做的是一个通过 WebHID 和固件通信的前端项目。
固件那边发数据有一个特点:只有一条通信通道,而且数据是一包一包依次传过来的。我发送一条获取数据的命令后,必须把这次响应完整接收完,才能继续发送下一条命令。
不能多,也不能少,更不能两条命令同时发出去。
旧项目里的处理方式比较直接:发送命令后,固定等待一段时间。这个时间留得足够长,理论上固件肯定已经把数据发完了,然后前端再继续执行后面的逻辑。
后来我用 Promise 把它重新封装了一遍,并且和固件约定:一条响应发完以后,再发一个结束标记。前端在 WebHID 回调里看到结束标记,就主动结束等待。
这样不用每次都硬等固定时间,通信完成得快就能立刻继续。
先说结论
我最后想要的使用体验是:
const config = await transport.request(GET_CONFIG_COMMAND);
const version = await transport.request(GET_VERSION_COMMAND);虽然 WebHID 接收数据使用的是事件回调:
device.addEventListener("inputreport", handleInputReport);但是业务层不需要到处处理回调。业务层只需要正常 await 一个请求函数。
中间的转换过程可以理解成:
发送命令
-> 创建 Promise
-> 保存当前请求的 resolve 和 reject
-> 固件分包返回数据
-> WebHID inputreport 回调持续收集数据
-> 收到结束标记
-> 调用 resolve
-> await 继续往下执行旧方案:固定等待一段时间
旧项目里的逻辑大概像这样:
function sleep(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function getConfig() {
await sendCommand(GET_CONFIG_COMMAND);
await sleep(1000);
return readCachedConfig();
}这种写法能跑,是因为固件一般会在一秒以内把数据传完。
但是它有几个问题:
- 固件如果
100ms就传完了,前端还是要硬等一秒。 - 不同命令的数据量不同,很难选一个合适的等待时间。
- 固件偶尔比较慢时,固定时间可能又不够。
- 业务代码不知道通信到底有没有真的完成,只是“估计应该完成了”。
- 连续发送多个命令时,很容易把不同请求的数据混在一起。
固定等待不是完全不能用。刚开始联调时,它可以作为一个临时方案。
但是长期维护时,最好还是让协议明确告诉前端:这次响应已经发送完成。
WebHID 接收数据是回调形式
WebHID 里,前端发送数据和接收数据的方向不太一样。
发送命令时,可以主动调用:
await device.sendReport(reportId, data);接收数据时,则是监听 inputreport 事件:
device.addEventListener("inputreport", (event) => {
const data = new Uint8Array(
event.data.buffer,
event.data.byteOffset,
event.data.byteLength,
);
console.log(data);
});固件什么时候返回数据,是固件决定的。前端不能直接写成:
const response = await device.receive();因为 WebHID 没有这样一个正好满足业务协议的 receive() 方法。
这时就需要自己把“事件回调”包装成“可以等待的 Promise”。
Promise 解决的是什么问题
Promise 很适合表达一件现在还没有完成,但是未来会完成的事情。
一个最小例子:
const result = await new Promise<string>((resolve) => {
setTimeout(() => {
resolve("执行完成");
}, 1000);
});
console.log(result);创建 Promise 时,我们拿到了一个 resolve 函数。
只要以后在合适的时间调用:
resolve("执行完成");外面的 await 就会继续执行。
关键点在于:resolve 不一定要在创建 Promise 的函数内部立刻调用。它可以先保存起来,等 WebHID 回调收到完整数据后再调用。
先写一个最小版本
为了方便理解,先写一个只允许同时存在一个请求的简化版本:
type PendingRequest = {
chunks: Uint8Array[];
resolve: (data: Uint8Array) => void;
reject: (error: Error) => void;
};
let pendingRequest: PendingRequest | undefined;
async function request(command: Uint8Array): Promise<Uint8Array> {
if (pendingRequest) {
throw new Error("上一条命令还没有处理完成");
}
const responsePromise = new Promise<Uint8Array>((resolve, reject) => {
pendingRequest = {
chunks: [],
resolve,
reject,
};
});
await sendCommand(command);
return responsePromise;
}这里的 pendingRequest 就是我当时放在全局对象里的状态。
它记录了:
- 当前是否有请求正在等待响应。
- 已经收到哪些数据包。
- 数据收完后,要调用哪个
resolve。 - 通信失败时,要调用哪个
reject。
然后在 WebHID 回调里处理接收数据:
function handleInputReport(event: HIDInputReportEvent) {
if (!pendingRequest) {
console.warn("当前没有等待中的请求");
return;
}
const chunk = new Uint8Array(
event.data.buffer.slice(
event.data.byteOffset,
event.data.byteOffset + event.data.byteLength,
),
);
if (isEndMarker(chunk)) {
const data = mergeChunks(pendingRequest.chunks);
pendingRequest.resolve(data);
pendingRequest = undefined;
return;
}
pendingRequest.chunks.push(chunk);
}固件每发来一包数据,就先放进 chunks。
看到结束标记后:
- 把所有数据包合并起来。
- 调用
resolve(data)。 - 清空当前请求状态。
- 原来等待
request()的代码继续往下走。
如果一个 HID 设备会使用多个 report ID,回调里还要根据:
event.reportId过滤出当前协议需要处理的数据包。
和固件约定结束标记
这次改造里很重要的一点,是提前和固件沟通好:
一条命令的数据全部返回以后,固件再发送一个结束标记。
比如协议可以约定:
0xFF 0xFF 0x0D 0x0A表示本次响应结束。
前端就可以写:
const END_MARKER = new Uint8Array([0xff, 0xff, 0x0d, 0x0a]);
function isEndMarker(data: Uint8Array) {
return (
data.length === END_MARKER.length &&
data.every((value, index) => value === END_MARKER[index])
);
}真实协议不一定要用这个值。重点不是具体字节,而是双方要明确约定:
- 结束标记长什么样。
- 它是单独一包发送,还是可能出现在普通数据包末尾。
- 普通业务数据里是否可能出现相同内容。
- 空响应是不是只发结束标记。
- 报错时固件会不会发送错误标记。
如果结束标记可能出现在普通数据中,就不能只靠简单比较判断结束。更稳妥的协议通常会带包类型、数据长度或者校验信息。
把全局变量收进一个类里
最小版本可以说明思路,但项目里最好不要把通信状态散落在全局变量中。
可以封装成一个类:
type PendingRequest = {
chunks: Uint8Array[];
resolve: (data: Uint8Array) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
};
type HidTransportOptions = {
reportId: number;
timeoutMs?: number;
};
class HidTransport {
private pendingRequest?: PendingRequest;
private readonly timeoutMs: number;
constructor(
private readonly device: HIDDevice,
private readonly options: HidTransportOptions,
) {
this.timeoutMs = options.timeoutMs ?? 3000;
this.device.addEventListener("inputreport", this.handleInputReport);
}
async request(command: Uint8Array): Promise<Uint8Array> {
if (this.pendingRequest) {
throw new Error("上一条命令还没有处理完成");
}
const responsePromise = new Promise<Uint8Array>((resolve, reject) => {
const timer = setTimeout(() => {
this.failPendingRequest(new Error("等待固件响应超时"));
}, this.timeoutMs);
this.pendingRequest = {
chunks: [],
resolve,
reject,
timer,
};
});
try {
await this.device.sendReport(this.options.reportId, command);
} catch (error) {
this.failPendingRequest(
error instanceof Error ? error : new Error(String(error)),
);
}
return responsePromise;
}
dispose() {
this.device.removeEventListener("inputreport", this.handleInputReport);
this.failPendingRequest(new Error("通信连接已关闭"));
}
private readonly handleInputReport = (event: HIDInputReportEvent) => {
const request = this.pendingRequest;
if (!request) {
console.warn("收到数据,但当前没有等待中的请求");
return;
}
const chunk = new Uint8Array(
event.data.buffer.slice(
event.data.byteOffset,
event.data.byteOffset + event.data.byteLength,
),
);
if (this.isEndMarker(chunk)) {
const data = this.mergeChunks(request.chunks);
clearTimeout(request.timer);
this.pendingRequest = undefined;
request.resolve(data);
return;
}
request.chunks.push(chunk);
};
private failPendingRequest(error: Error) {
const request = this.pendingRequest;
if (!request) {
return;
}
clearTimeout(request.timer);
this.pendingRequest = undefined;
request.reject(error);
}
private isEndMarker(data: Uint8Array) {
const marker = [0xff, 0xff, 0x0d, 0x0a];
return (
data.length === marker.length &&
marker.every((value, index) => value === data[index])
);
}
private mergeChunks(chunks: Uint8Array[]) {
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
}使用时:
const transport = new HidTransport(device, {
reportId: 0,
timeoutMs: 3000,
});
const config = await transport.request(GET_CONFIG_COMMAND);
console.log(config);业务层不需要关心数据是分几包传过来的,也不需要自己维护回调状态。
为什么要先保存 Promise,再发送命令
这里的顺序很重要:
const responsePromise = new Promise<Uint8Array>((resolve, reject) => {
this.pendingRequest = {
// ...
};
});
await this.device.sendReport(this.options.reportId, command);
return responsePromise;不能先发送,再设置 pendingRequest:
await this.device.sendReport(this.options.reportId, command);
const responsePromise = new Promise<Uint8Array>((resolve, reject) => {
this.pendingRequest = {
// ...
};
});因为固件可能响应得很快。
如果回调先收到数据,而 pendingRequest 还没有准备好,这一包数据就会丢失。
这种问题很难排查,因为设备响应慢时一切正常,设备响应快时反而偶尔出错。
单通道通信要保证请求串行执行
固件一次只能处理一个请求,所以前端不能同时发两条命令:
await Promise.all([
transport.request(GET_CONFIG_COMMAND),
transport.request(GET_VERSION_COMMAND),
]);上面的类会直接拒绝第二条请求:
上一条命令还没有处理完成这样至少不会悄悄把数据混在一起。
但是业务层如果经常需要连续调用多个地方,直接报错不一定是最好的体验。还可以在外面加一个请求队列,自动排队执行:
class SerialTaskQueue {
private tail = Promise.resolve();
run<T>(task: () => Promise<T>): Promise<T> {
const result = this.tail.then(task);
this.tail = result.then(
() => undefined,
() => undefined,
);
return result;
}
}把它接进通信类:
class QueuedHidTransport {
private readonly queue = new SerialTaskQueue();
constructor(private readonly transport: HidTransport) {}
request(command: Uint8Array) {
return this.queue.run(() => {
return this.transport.request(command);
});
}
}使用时,即使业务代码同时发起请求:
const [config, version] = await Promise.all([
transport.request(GET_CONFIG_COMMAND),
transport.request(GET_VERSION_COMMAND),
]);底层仍然会按照顺序一条一条发送:
发送 GET_CONFIG_COMMAND
-> 等待完整响应
-> 收到结束标记
-> 发送 GET_VERSION_COMMAND
-> 等待完整响应
-> 收到结束标记这个队列解决的是调用方不小心并发的问题。
超时处理
只等待结束标记还不够。
如果固件异常、连接断开,或者某一包数据丢了,前端可能永远等不到结束标记。
所以每次请求都应该有超时:
const timer = setTimeout(() => {
reject(new Error("等待固件响应超时"));
}, timeoutMs);请求成功、失败或者连接关闭时,都要记得:
clearTimeout(timer);而且还要清空当前请求:
this.pendingRequest = undefined;不然下一条命令会一直以为上一条命令还没有结束。
超时重试不是无条件安全
我一开始想到超时后自动重发,感觉这样就更稳了。
但是后来再想一下,单通道协议里不能无条件重试。
假设发生了下面的情况:
前端发送请求 A
-> 固件响应比较慢
-> 前端认为超时
-> 前端重新发送请求 A
-> 第一条请求 A 的迟到响应到达
-> 第二条请求 A 的响应也开始到达如果协议里没有请求编号,前端很难判断某一包数据到底属于第一次请求,还是第二次请求。
这时重试反而可能把响应拼错。
更稳妥的协议可以加入:
- 请求编号。
- 命令编号。
- 包序号。
- 总包数或者总长度。
- 错误码。
- 校验码。
- 取消命令或者重置命令。
例如:
请求:
requestId = 12
command = GET_CONFIG
响应:
requestId = 12
packetIndex = 0
packetCount = 3
payload = ...前端只接收当前 requestId 对应的数据包。
如果暂时改不了固件协议,超时后不要立刻无脑重发。可以先断开并重新初始化连接,或者进入一个明确的恢复流程。
结束标记也可以继续升级
结束标记比固定等待已经好很多,但它仍然是一种比较简单的协议。
如果通信越来越复杂,可以考虑让每一包数据都带上结构信息:
type Packet = {
requestId: number;
command: number;
packetIndex: number;
packetCount: number;
payload: Uint8Array;
checksum: number;
};这样前端可以检查:
- 是不是当前请求的数据。
- 有没有少包。
- 包顺序是否正确。
- 是否已经接收完整。
- 数据有没有损坏。
收到全部数据包后,前端就能主动完成 Promise,不一定还要额外依赖结束标记。
如果协议规模不大,结束标记已经够用。协议要不要升级,还是看项目复杂度。
错误处理要统一收口
通信代码最容易出现的问题,是成功路径写得很完整,异常路径却散落在各处。
可以让所有错误最终都走一个方法:
private failPendingRequest(error: Error) {
const request = this.pendingRequest;
if (!request) {
return;
}
clearTimeout(request.timer);
this.pendingRequest = undefined;
request.reject(error);
}这些情况都可以统一处理:
- 发送失败。
- 等待超时。
- HID 设备断开。
- 协议解析失败。
- 包顺序异常。
- 校验失败。
- 用户主动取消。
业务层只需要正常捕获:
try {
const config = await transport.request(GET_CONFIG_COMMAND);
console.log(config);
} catch (error) {
console.error("读取配置失败", error);
}Promise 封装带来的变化
旧方案关注的是时间:
发完命令以后等一秒,差不多应该收完了新方案关注的是事件:
发完命令以后等待,直到真的收到完整响应变化看起来不大,但代码维护体验会好很多:
- 固件响应快时,不再浪费固定等待时间。
- 固件响应慢时,可以明确触发超时错误。
- 业务代码可以正常使用
await。 - 数据收集逻辑统一放在通信层。
- 单通道串行限制可以统一处理。
- 后面加重试、断线恢复和协议校验更容易。
我最后想通的地方
WebHID 给我的原始能力是:
我可以主动发送一包数据
固件返回数据时,浏览器会触发一个回调但是业务层真正想要的是:
我发送一条命令
等固件完整返回结果
拿到结果后继续执行Promise 正好能把这两种形式接起来。
事件回调
-> 收集数据
-> 判断完成条件
-> resolve
-> await 返回结果我当时做的事情,本质上就是把一段“基于事件的底层通信”,封装成一个“基于 Promise 的请求响应接口”。
封装以后,调用方不用知道 WebHID 的回调细节,也不用自己猜固件什么时候返回完成。底层协议仍然是单通道依次传输,但上层代码已经可以像调用普通异步函数一样使用它。