浏览器扫码听起来像是个「有手就行」的需求——直到你在生产环境看到 React 报错Node.removeChild: The node to be removed is not a child of this node。这不是你的代码问题,是两个运行时争夺 DOM 控制权的经典翻车现场。

最近用html5-qrcode给 Next.js 项目做扫码功能,踩了三个深坑。这篇把完整的事件线还原一遍,包括那个让我调试到凌晨两点的竞态条件。

选型:为什么不是 zxing-js 或 jsQR

选型:为什么不是 zxing-js 或 jsQR

浏览器扫码有三条技术路线:zxing-js功能全但 API 繁琐,jsQR轻量却得自己处理摄像头权限和帧循环,html5-qrcode把这两层都包了。对于不想维护getUserMedia兼容层的产品团队,这是唯一理性的选择。

安装命令一行:

npm install html5-qrcode

架构设计很简单:同一个Html5Qrcode实例,图片上传走scanFile(),实时摄像头start()。代码层面区分调用方式即可,核心逻辑复用。

但纸面架构和真实 DOM 是两回事。

第一崩:React 与第三方库的 DOM 战争

第一崩:React 与第三方库的 DOM 战争

最初的实现很直觉:用 React 状态控制摄像头容器的显隐。

{cameraActive && (

问题在html5-qrcode的工作机制。调用start()后,库会往#qr-camera-feed里注入、和若干内部

。这些节点不在 React 的虚拟 DOM 里。

当用户关闭摄像头,cameraActive置为false,React 尝试卸载它记忆中的那个#qr-camera-feed。但真实 DOM 已经被库改得面目全非——React 找不到它以为存在的子节点,直接抛异常。

打开网易新闻 查看精彩图片

修复方案是彻底放弃 React 对容器内部的控制:

{/* 提示层和摄像头层完全分离 */}

{!cameraActive && (

点击启动摄像头

关键点:容器永远挂载,只用 CSS 控制可见性。库对 DOM 的修改被限制在一个 React 不会触碰的隔离区内。

第二崩:扫码成功时的竞态条件

第二崩:扫码成功时的竞态条件

第一个坑修好后,功能跑通了——直到测试发现偶发的崩溃。

复现路径:二维码进入画面 → 成功回调触发 → 回调里调用stopCamera()scanner.stop()执行 → 库内部还在处理刚才那一帧 → 状态不一致。

根本原因是html5-qrcodestart()是异步的,但很多人以为它返回时扫描就已经就绪。实际上,内部引擎还在预热,帧处理循环和回调注册存在时间窗口。

解决方案是用 ref 做运行状态守卫:

const scannerRef = useRef(null);

const scannerRunningRef = useRef(false);

启动流程:

打开网易新闻 查看精彩图片

await scanner.start(...);

scannerRunningRef.current = true; // 必须在 start() resolve 之后

停止流程:

if (scannerRunningRef.current) {

scannerRunningRef.current = false;

await scanner.stop();

}

这个 flag 的作用不是性能优化,是防止重复停止和状态错乱。扫码成功回调里第一件事就是检查并置 false,再执行业务逻辑。

第三坑:图片扫描的内存泄漏

第三坑:图片扫描的内存泄漏

图片上传模式看起来更安全——没有摄像头流,没有实时回调。但scanFile()内部会创建临时的Image对象和 canvas 上下文,大文件或批量扫描时内存曲线会缓慢爬升。

库文档没提的是,scanFile()的第二个参数showImage如果设为true,会在 DOM 里留一个预览图节点。如果你用 React 管理预览 UI,这个内部节点就成了孤儿。

统一的做法:第二个参数永远传false,预览层自己用URL.createObjectURL()实现,扫描完手动revokeObjectURL()

三个坑的共同主题:第三方库和 React 的边界管理。

浏览器扫码这个需求,表面是 CV 代码,实际是运行时协调。html5-qrcode 把底层复杂度包得很好,但包不住它和 React 在 DOM 所有权上的根本冲突。