Promise

用 Promise 封装 WebHID 单通道通信

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

2026年6月13日19 分钟阅读
用 Promise 封装 WebHID 单通道通信

这篇笔记记录一下我在工作中封装前端和固件通信逻辑的过程。

当时做的是一个通过 WebHID 和固件通信的前端项目。

固件那边发数据有一个特点:只有一条通信通道,而且数据是一包一包依次传过来的。我发送一条获取数据的命令后,必须把这次响应完整接收完,才能继续发送下一条命令。

不能多,也不能少,更不能两条命令同时发出去。

旧项目里的处理方式比较直接:发送命令后,固定等待一段时间。这个时间留得足够长,理论上固件肯定已经把数据发完了,然后前端再继续执行后面的逻辑。

后来我用 Promise 把它重新封装了一遍,并且和固件约定:一条响应发完以后,再发一个结束标记。前端在 WebHID 回调里看到结束标记,就主动结束等待。

这样不用每次都硬等固定时间,通信完成得快就能立刻继续。

先说结论

我最后想要的使用体验是:

ts
const config = await transport.request(GET_CONFIG_COMMAND);
const version = await transport.request(GET_VERSION_COMMAND);

虽然 WebHID 接收数据使用的是事件回调:

ts
device.addEventListener("inputreport", handleInputReport);

但是业务层不需要到处处理回调。业务层只需要正常 await 一个请求函数。

中间的转换过程可以理解成:

text
发送命令
  -> 创建 Promise
  -> 保存当前请求的 resolve 和 reject
  -> 固件分包返回数据
  -> WebHID inputreport 回调持续收集数据
  -> 收到结束标记
  -> 调用 resolve
  -> await 继续往下执行

旧方案:固定等待一段时间

旧项目里的逻辑大概像这样:

ts
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 里,前端发送数据和接收数据的方向不太一样。

发送命令时,可以主动调用:

ts
await device.sendReport(reportId, data);

接收数据时,则是监听 inputreport 事件:

ts
device.addEventListener("inputreport", (event) => {
  const data = new Uint8Array(
    event.data.buffer,
    event.data.byteOffset,
    event.data.byteLength,
  );
  console.log(data);
});

固件什么时候返回数据,是固件决定的。前端不能直接写成:

ts
const response = await device.receive();

因为 WebHID 没有这样一个正好满足业务协议的 receive() 方法。

这时就需要自己把“事件回调”包装成“可以等待的 Promise”。

Promise 解决的是什么问题

Promise 很适合表达一件现在还没有完成,但是未来会完成的事情。

一个最小例子:

ts
const result = await new Promise<string>((resolve) => {
  setTimeout(() => {
    resolve("执行完成");
  }, 1000);
});

console.log(result);

创建 Promise 时,我们拿到了一个 resolve 函数。

只要以后在合适的时间调用:

ts
resolve("执行完成");

外面的 await 就会继续执行。

关键点在于:resolve 不一定要在创建 Promise 的函数内部立刻调用。它可以先保存起来,等 WebHID 回调收到完整数据后再调用。

先写一个最小版本

为了方便理解,先写一个只允许同时存在一个请求的简化版本:

ts
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 回调里处理接收数据:

ts
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

看到结束标记后:

  1. 把所有数据包合并起来。
  2. 调用 resolve(data)
  3. 清空当前请求状态。
  4. 原来等待 request() 的代码继续往下走。

如果一个 HID 设备会使用多个 report ID,回调里还要根据:

ts
event.reportId

过滤出当前协议需要处理的数据包。

和固件约定结束标记

这次改造里很重要的一点,是提前和固件沟通好:

一条命令的数据全部返回以后,固件再发送一个结束标记。

比如协议可以约定:

text
0xFF 0xFF 0x0D 0x0A

表示本次响应结束。

前端就可以写:

ts
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])
  );
}

真实协议不一定要用这个值。重点不是具体字节,而是双方要明确约定:

  • 结束标记长什么样。
  • 它是单独一包发送,还是可能出现在普通数据包末尾。
  • 普通业务数据里是否可能出现相同内容。
  • 空响应是不是只发结束标记。
  • 报错时固件会不会发送错误标记。

如果结束标记可能出现在普通数据中,就不能只靠简单比较判断结束。更稳妥的协议通常会带包类型、数据长度或者校验信息。

把全局变量收进一个类里

最小版本可以说明思路,但项目里最好不要把通信状态散落在全局变量中。

可以封装成一个类:

ts
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;
  }
}

使用时:

ts
const transport = new HidTransport(device, {
  reportId: 0,
  timeoutMs: 3000,
});

const config = await transport.request(GET_CONFIG_COMMAND);
console.log(config);

业务层不需要关心数据是分几包传过来的,也不需要自己维护回调状态。

为什么要先保存 Promise,再发送命令

这里的顺序很重要:

ts
const responsePromise = new Promise<Uint8Array>((resolve, reject) => {
  this.pendingRequest = {
    // ...
  };
});

await this.device.sendReport(this.options.reportId, command);

return responsePromise;

不能先发送,再设置 pendingRequest

ts
await this.device.sendReport(this.options.reportId, command);

const responsePromise = new Promise<Uint8Array>((resolve, reject) => {
  this.pendingRequest = {
    // ...
  };
});

因为固件可能响应得很快。

如果回调先收到数据,而 pendingRequest 还没有准备好,这一包数据就会丢失。

这种问题很难排查,因为设备响应慢时一切正常,设备响应快时反而偶尔出错。

单通道通信要保证请求串行执行

固件一次只能处理一个请求,所以前端不能同时发两条命令:

ts
await Promise.all([
  transport.request(GET_CONFIG_COMMAND),
  transport.request(GET_VERSION_COMMAND),
]);

上面的类会直接拒绝第二条请求:

text
上一条命令还没有处理完成

这样至少不会悄悄把数据混在一起。

但是业务层如果经常需要连续调用多个地方,直接报错不一定是最好的体验。还可以在外面加一个请求队列,自动排队执行:

ts
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;
  }
}

把它接进通信类:

ts
class QueuedHidTransport {
  private readonly queue = new SerialTaskQueue();

  constructor(private readonly transport: HidTransport) {}

  request(command: Uint8Array) {
    return this.queue.run(() => {
      return this.transport.request(command);
    });
  }
}

使用时,即使业务代码同时发起请求:

ts
const [config, version] = await Promise.all([
  transport.request(GET_CONFIG_COMMAND),
  transport.request(GET_VERSION_COMMAND),
]);

底层仍然会按照顺序一条一条发送:

text
发送 GET_CONFIG_COMMAND
  -> 等待完整响应
  -> 收到结束标记
  -> 发送 GET_VERSION_COMMAND
  -> 等待完整响应
  -> 收到结束标记

这个队列解决的是调用方不小心并发的问题。

超时处理

只等待结束标记还不够。

如果固件异常、连接断开,或者某一包数据丢了,前端可能永远等不到结束标记。

所以每次请求都应该有超时:

ts
const timer = setTimeout(() => {
  reject(new Error("等待固件响应超时"));
}, timeoutMs);

请求成功、失败或者连接关闭时,都要记得:

ts
clearTimeout(timer);

而且还要清空当前请求:

ts
this.pendingRequest = undefined;

不然下一条命令会一直以为上一条命令还没有结束。

超时重试不是无条件安全

我一开始想到超时后自动重发,感觉这样就更稳了。

但是后来再想一下,单通道协议里不能无条件重试。

假设发生了下面的情况:

text
前端发送请求 A
  -> 固件响应比较慢
  -> 前端认为超时
  -> 前端重新发送请求 A
  -> 第一条请求 A 的迟到响应到达
  -> 第二条请求 A 的响应也开始到达

如果协议里没有请求编号,前端很难判断某一包数据到底属于第一次请求,还是第二次请求。

这时重试反而可能把响应拼错。

更稳妥的协议可以加入:

  • 请求编号。
  • 命令编号。
  • 包序号。
  • 总包数或者总长度。
  • 错误码。
  • 校验码。
  • 取消命令或者重置命令。

例如:

text
请求:
  requestId = 12
  command = GET_CONFIG

响应:
  requestId = 12
  packetIndex = 0
  packetCount = 3
  payload = ...

前端只接收当前 requestId 对应的数据包。

如果暂时改不了固件协议,超时后不要立刻无脑重发。可以先断开并重新初始化连接,或者进入一个明确的恢复流程。

结束标记也可以继续升级

结束标记比固定等待已经好很多,但它仍然是一种比较简单的协议。

如果通信越来越复杂,可以考虑让每一包数据都带上结构信息:

ts
type Packet = {
  requestId: number;
  command: number;
  packetIndex: number;
  packetCount: number;
  payload: Uint8Array;
  checksum: number;
};

这样前端可以检查:

  • 是不是当前请求的数据。
  • 有没有少包。
  • 包顺序是否正确。
  • 是否已经接收完整。
  • 数据有没有损坏。

收到全部数据包后,前端就能主动完成 Promise,不一定还要额外依赖结束标记。

如果协议规模不大,结束标记已经够用。协议要不要升级,还是看项目复杂度。

错误处理要统一收口

通信代码最容易出现的问题,是成功路径写得很完整,异常路径却散落在各处。

可以让所有错误最终都走一个方法:

ts
private failPendingRequest(error: Error) {
  const request = this.pendingRequest;

  if (!request) {
    return;
  }

  clearTimeout(request.timer);
  this.pendingRequest = undefined;
  request.reject(error);
}

这些情况都可以统一处理:

  • 发送失败。
  • 等待超时。
  • HID 设备断开。
  • 协议解析失败。
  • 包顺序异常。
  • 校验失败。
  • 用户主动取消。

业务层只需要正常捕获:

ts
try {
  const config = await transport.request(GET_CONFIG_COMMAND);
  console.log(config);
} catch (error) {
  console.error("读取配置失败", error);
}

Promise 封装带来的变化

旧方案关注的是时间:

text
发完命令以后等一秒,差不多应该收完了

新方案关注的是事件:

text
发完命令以后等待,直到真的收到完整响应

变化看起来不大,但代码维护体验会好很多:

  • 固件响应快时,不再浪费固定等待时间。
  • 固件响应慢时,可以明确触发超时错误。
  • 业务代码可以正常使用 await
  • 数据收集逻辑统一放在通信层。
  • 单通道串行限制可以统一处理。
  • 后面加重试、断线恢复和协议校验更容易。

我最后想通的地方

WebHID 给我的原始能力是:

text
我可以主动发送一包数据
固件返回数据时,浏览器会触发一个回调

但是业务层真正想要的是:

text
我发送一条命令
等固件完整返回结果
拿到结果后继续执行

Promise 正好能把这两种形式接起来。

text
事件回调
  -> 收集数据
  -> 判断完成条件
  -> resolve
  -> await 返回结果

我当时做的事情,本质上就是把一段“基于事件的底层通信”,封装成一个“基于 Promise 的请求响应接口”。

封装以后,调用方不用知道 WebHID 的回调细节,也不用自己猜固件什么时候返回完成。底层协议仍然是单通道依次传输,但上层代码已经可以像调用普通异步函数一样使用它。

用 Promise 封装 WebHID 单通道通信 | Code & Craft