1. 别急着上原生 App:为什么我盯上了 Web NFC
先交代一下背景。我手头有个很实际的需求:仓库里贴了一批 NFC 标签,每个标签对应一台设备的唯一编号,巡检人员需要经常读取这些编号,然后把它转成二维码贴到交接单上。以前的做法是拿一台预装了原生 App 的安卓机去刷,App 解析出来之后还得手动抄号、再找个二维码工具生成。整个过程非常繁琐,而且那台专用安卓机一旦坏了或者系统被更新,整个流程就停摆。
后来我试着用 GLM-5 帮我搭了个原型——直接用 HTML5 + JavaScript 调 Web NFC API,把“读卡”和“转二维码”这两件事全部塞进浏览器里。试完之后我发现这事远比预期靠谱:不需要装任何 App,手机上打开一个网页就能读 NFC 标签,读完自动跳转到包含设备编号的二维码,再拿另一台设备扫这个码就能打开设备详情页。整套流程 30 秒内完成,而且跨平台,只要有支持 Web NFC 的浏览器就能跑。
这篇文章把我从零到上线这条路上踩过的坑、做过的取舍、以及最终稳定运行的实现方式完整记录下来。适合谁看?两类人:一类是跟我一样需要在浏览器里接 NFC 硬件的开发者,另一类是考虑“用 Web 技术做业务工具,而不是又去开发一个 Android/iOS App”的产品和技术决策者。我先把结论放在这里:如果你的使用场景是安卓 Chrome 系浏览器为主,Web NFC 完全够用,而且从立项到能跑通 Demo,比原生开发至少快一倍以上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. GLM-5 生成的初版代码,以及我改掉的三个问题
2.1 提示词怎么写,才能让 AI 给出可运行的原型
既然标题里提到了 GLM-5,我得说说这段经历。很多人用大模型写代码,上来就一句“帮我写个 NFC 读卡页面”,结果出来的代码要么是调用了不存在的 API,要么是拿二维码库拼凑但完全没理解需求。我的做法是分两步给提示词:第一步描述业务场景,第二步提具体技术约束。
我实际使用的提示词大概长这样:
你是一个熟悉 Web NFC API 的前端工程师。我要实现一个 H5 页面,运行在安卓 Chrome 浏览器上,功能是:用户点击页面上的按钮后,触发 NFC 标签读取;读取成功后,在页面上显示标签里的设备编号,并自动生成对应二维码。要求:使用 navigator.nfc.read(),只读一次,不持续扫描;页面需要处理用户拒绝权限、NFC 不支持、读取超时三种异常;界面用简洁的移动端布局,中文提示。
这样做的效果是:GLM-5 给出了一个约 80 行的 HTML 页面,包含一个按钮、一个展示区域、一段基于 qrcodejs 的二维码生成逻辑。当然,初版代码并不能直接上线,它只是把我的思路变成了一个可跑通的骨架。关键意义在于——大模型把“从零开始写”变成了“在已有骨架上改”,省掉的是最枯燥的框架搭建时间。
2.2 初版代码里最典型的三处毛病
我这里不是黑 GLM-5,实际上这三类问题在我过去用各种代码生成工具时都遇到过,属于 AI 生成前端代码时的通病。
第一,权限状态管理缺失。 初版代码只在页面加载时判断一次 "NFC" in navigator,然后就绑定了按钮事件。但实际场景中,用户可能第一次点了“允许”,之后在系统设置里手动关掉了 NFC 权限,这时浏览器不会重新弹授权框,读取会静默失败。我改成了在每次点击按钮时都检查 navigator.permissions.query({ name: "nfc" }),根据权限状态给出对应的 UI 提示。
第二,NDEFMessage 的解析逻辑太天真。 初版代码假设标签里存的是一条纯文本记录,直接 record.data 转字符串,这在大多数简单标签上没问题。但实际仓库里的标签有的是 URL 格式开头,有的是空记录开头,还有的是厂商写入的 TNF 类型标识。后来我改成遍历 message.records,逐个判断 record.type 是 "text" 还是 "url",再按需解析,这样兼容性大幅提升。
第三,二维码生成时机没把控。 初版代码是在 reading 事件的回调里同步生成二维码,看起来没毛病。但 Web NFC 的读取回调里如果做大量 DOM 操作,某些国产浏览器内核的 WebView 会把 NFC 事务的响应延迟拉高,导致同一个标签被读到两三次,二维码闪烁。解决办法是把二维码生成放到 requestAnimationFrame 或 setTimeout(0) 里,先更新状态,再生成码图,最后统一渲染。
3. Web NFC 核心 API 拆解:从 NDEF 数据格式到读取流程
3.1 NDEF 到底是什么,为什么读取要看 record.type
如果你之前没接触过 NFC 的底层数据格式,直接看 Web NFC 的文档会有点懵——它所有读取操作都围绕“NDEF 消息”展开。NDEF 全称 NFC Data Exchange Format,是 NFC Forum 定义的通用数据封装格式,你可以把它理解成一个“信封”,里面装着一条或多条“记录”,每条记录由头部(含类型、长度等元信息)和载荷(实际数据)组成。
举个例子:你用手机上的某个工具往标签里写入一段文本“DEVICE-0842”,这个数据在底层会被封装成一条 NDEF 记录,type 字段是 "text",data 字段是 "DEVICE-0842" 的字节序列。如果你写的是一个网址,比如 https://example.com/dev/0842,type 字段会是 "url",按照 NFC Forum 的规定,URL 往往还会有一个前缀缩写表,读取时解析器要把缩写还原成完整协议头。
Web NFC 的 NDEFRecord 对象就是这些记录在浏览器侧的映射。它的结构大致如下:
javascript复制{
recordType: "text", // 或 "url"、"mime"、"empty" 等
mediaType: "text/plain", // 部分记录才有
data: DataView, // 原始字节数据
encoding: "utf-8", // 文本编码
lang: "en" // 语言标签
}
没处理过这块的开发者最容易踩的坑:看着 data 是一个 DataView,直接 String.fromCharCode 转字符串,结果中文全乱码。 正确做法是先判断 encoding,再用 TextDecoder 解码:
javascript复制const decoder = new TextDecoder(record.encoding || "utf-8");
const text = decoder.decode(record.data);
3.2 一次完整的 NFC 读取流程,代码长这样
下面这段是我最终在线上跑的读取逻辑,去掉了业务噪音后的核心部分。注意 nfc.read() 和 nfc.write() 都是“一次性”操作——调用之后,浏览器会等待用户把 NFC 标签贴到手机背面,完成一次读取/写入后立即结束,不会持续监听。这种设计跟我想象中“像扫码枪一样一直开着扫描”完全不同,但它更省电,也更符合 NFC 短距离通信的特性。
javascript复制let reader = null;
async function startRead() {
if (!("NFC" in window)) {
showToast("当前浏览器不支持 Web NFC,请使用安卓 Chrome 89+");
return;
}
try {
// 每次触发前检查权限状态
const permStatus = await navigator.permissions.query({ name: "nfc" });
if (permStatus.state === "denied") {
showToast("NFC 权限已被拒绝,请到系统设置中开启");
return;
}
reader = new NDEFReader();
reader.addEventListener("readingerror", handleReadError);
reader.addEventListener("reading", handleReading);
// 调用后等待贴卡
await reader.scan();
setStatus("scanning");
// 10 秒后自动关闭扫描状态,避免用户以为卡住了
setTimeout(() => {
if (reader) {
reader.stop();
setStatus("idle");
}
}, 10000);
} catch (error) {
// error.code 可能是 NotAllowedError、NotReadableError、AbortError 等
handleScanException(error);
}
}
function handleReading(event) {
const message = event.message;
for (const record of message.records) {
if (record.recordType === "text") {
const deviceId = new TextDecoder(record.encoding || "utf-8")
.decode(record.data);
renderResult(deviceId);
break;
}
}
if (reader) reader.stop();
}
有几个细节我需要单独拎出来说明:
一是 scan() 之后并不能立即读到数据, 它只是告诉浏览器“我准备好读 NFC 了”,然后把控制权交给系统 NFC 服务。用户必须把手机靠近标签,才会触发对应的 reading 事件。所以 UI 上一定要给足提示,比如“请将手机背面上方贴近标签”。
二是 10 秒超时是必须的。 因为用户可能一直没贴卡,或者没贴准,如果没有超时清理,reader 对象会一直持有 NFC 的事务,再次点击“读取”时可能报 AbortError。我最初没加超时,结果一次误触后整页所有按钮都像卡死了一样,检查半天才发现是上一个 NDEFReader 实例还活着。
三是 reader.stop() 之后的清理。 有的场景下你可能想连续读多个标签,那就不要 stop,而是在 handleReading 里做一个防抖:记录 lastReadTime 和 lastTagId,如果 500ms 内读到相同 ID 就忽略。我一开始没做这个,读同一个标签会连续弹两次二维码。
3.3 为什么我最终选择了“只读一次,手动触发”模式
这里有个设计取舍想展开说说。Web NFC 从标准层面其实可以支持“读一次”和“持续读”两种模式,你只要在 scan() 之后不调用 stop(),并且一直保持页面处于前台,就可以连续读取多个标签。
但我在实际业务里选择的是“点一次按钮,读一次卡”。原因有三:第一,巡检场景往往要配合填写备注,如果持续读卡,用户刚把标签放到设备上,结果在表单里打字的时候手机又碰到了口袋里的另一张标签,会莫名其妙触发下一次读取;第二,NDEFReader 持续占用 NFC 模块时,手机刷公交卡、碰其他 NFC 设备都会被这个页面拦截,这种体验极其糟糕;第三,手动触发模式在异常处理上更简单——每 10 秒重置一次,用户可控性更高,心智负担更小。
如果你的场景是“批量点检,一个个扫过去”,我建议还是用持续模式,但一定要加上标签 ID 去重和三次读取失败的容错机制。
4. 兼容性实测:Chrome、Safari、微信内置浏览器到底谁能用
4.1 一张表看清各端支持情况
说句实话,Web NFC 是“标准很久,落地很慢”的典型代表。虽然 W3C 的 Web NFC 规范早在 2015 年就开始起草,但直到 Chrome 89(2021 年)才在 Android 上默认开启。iOS 的 Safari 至今没有实现 Web NFC 的迹象——苹果的思路是你去用 Core NFC 或者快捷指令,浏览器里就别想了。
我在开发前先列了一个自己的测试矩阵,免得部署到生产环境才发现关键用户用不了:
| 环境 | Web NFC 支持状态 | 我的替代方案 |
|---|---|---|
| 安卓 Chrome 89+ | 支持 | 主路径 |
| 安卓原生 WebView | 部分支持(需系统 Chrome 版本够新) | 引导用户用 Chrome 打开 |
| 安卓微信内置浏览器 | 不支持 | 右上角用浏览器打开 |
| iOS Safari / 微信 iOS | 不支持 | 手动输入编号 + 摄像头扫二维码 |
| 桌面 Chrome(无 NFC 硬件) | API 存在但无法读取 | 提供输入框兜底 |
这张表是我在开发中途补上的,因为最初我以为“谷歌浏览器能用”就等于“所有安卓手机都能用”。结果测试时发现一台较老的荣耀手机,系统自带浏览器用的是旧版 WebView,navigator.nfc 直接是 undefined。再一查,这台机器因为厂商不再更新系统 WebView,Chrome 被冻结在一个很旧的版本,Web NFC 永远用不了。
4.2 降级方案设计:没有 Web NFC 时怎么保住核心流程
兼容性不能靠“告诉用户换浏览器”解决,至少不应该只靠这个。我在页面里做了一个很朴素但好用的降级逻辑:
javascript复制function isWebNFCSupported() {
return "NFC" in window;
}
function isNFCScanEnabled() {
// 如果用户手机上根本没开启 NFC 硬件,读取会一直超时,这里可以做前置检测
// 但浏览器没有直接 API,只能靠读取时的报错判断
return true;
}
当 isWebNFCSupported() 返回 false 时,页面会进入“手动输入模式”:页面展示一个输入框,用户可以手动键入设备编号,然后走同样的二维码生成流程。这个模式不解决“读卡”的效率问题,但保证在没有 Web NFC 支持的设备上,整个业务流程不会中断。
另外我针对 iOS 用户还做了一个小优化:如果检测到是 iOS Safari,页面会显示一个“打开摄像头扫码”的按钮,用户可以用系统相机直接扫 NFC 标签上(如果标签表面印了二维码)的二维码,或者扫别人生成的交接单二维码,效果一样。这套“Web NFC 主路径 + 手动录入兜底 + 扫码路径兼容”的三层结构,是我这次上线流程里最不显眼但最重要的设计之一。
4.3 实测过程中的意外发现:有的浏览器“支持”但不好用
再补充两个我在真机上发现的坑。
第一个坑:部分国产浏览器对 navigator.permissions.query({ name: "nfc" }) 支持不完整。 在 Chrome 里这个调用会正常返回 { state: "granted" | "prompt" | "denied" },但在某些基于 Chromium 内核的国产浏览器里,navigator.permissions.query 会因为不认识 "nfc" 这个 name 直接抛 TypeError。我原本想用权限状态做 UI 控制,结果反而被这个 API 坑了。最后改成 try-catch 包住整个权限查询,查询失败就默认走扫描流程,由真正的读取回调去暴露问题。
第二个坑:Android 12 之后的系统 NFC 设置页反馈。 如果用户系统 NFC 开关没开,reader.scan() 会抛 NotReadableError,但不同的 ROM 对错误码的处理还不一样。有的返回 NotReadableError,有的返回 NotAllowedError,还有的直接静默失败,连回调都不触发。我最终在页面上加了“如果 5 秒内没有读到标签,请检查 NFC 是否开启”的引导文案,并附带一个打开系统 NFC 设置页的按钮(用 intent: 协议做不到,只能提示用户去设置),这比在代码里穷举错误码靠谱多了。
5. 二维码生成与扫码闭环:库选择和控制台细节
5.1 为什么我选了 qrcodejs,而没有用更重的方案
二维码生成的库不少,我列几个比较常见的:
qrcodejs:轻量,无依赖,直接生成 canvas 或 img,适合简单场景qrcode(npm 上的 node-qrcode):功能全,支持 SVG、数据流,适合服务端或工程化项目zxing-js/library:纯前端条码解析库,能生成也能识别,但体积偏大- 在线 API(如 qrserver):最省事,但依赖外网,数据要过第三方服务,不适合企业内部工具
我最终选了 qrcodejs,因为它只需要一个 script 标签引入,没有 npm 构建链,页面本身就是个纯静态文件,扔到任何静态服务器上就能跑。生成二维码的核心代码:
javascript复制const qrContainer = document.getElementById("qr-result");
qrContainer.innerHTML = "";
const deviceId = "DEVICE-0842";
const payload = `https://your-domain.com/device/${deviceId}`;
new QRCode(qrContainer, {
text: payload,
width: 200,
height: 200,
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.M
});
这里有一个值得注意的点:我生成的二维码内容是“设备详情页的 URL”,而不是“设备的原始编号”。为什么?因为如果二维码里直接放编号,扫码的人拿到后就不知道下一步干嘛,他还得自己去系统里查编号对应哪台设备,这就把“扫码”和“业务系统”切断了。而生成一个 URL,扫码后直接打开设备详情页,从扫码到看到设备信息之间没有任何中间环节。整个流程对用户来说是一个闭环——贴卡读编号,编号生成二维码,扫码打开详情页。
5.2 关于二维码容错率的取舍
二维码有一个容易被人忽略的属性叫“容错率”(Error Correction Level),它分为 L、M、Q、H 四档,L 最低,H 最高。容错率越高,二维码能承受的污损、遮挡面积越大,但码图本身会变得越密集、越小。
我在这个项目里选择 M 档,也就是中等容错(约 15% 的码字可被恢复)。为什么没用 H?因为 H 档的二维码在 200x200 的尺寸下,模块颗粒会变得非常小,打印到纸质交接单上,如果打印机墨不够或者纸张受潮,扫码反而更容易失败。M 档在可靠性和码图清晰度之间取了平衡。如果你的二维码是要发到微信里让别人在手机屏幕上扫,L 挡就够了;如果要打印到贴纸上并可能被磨损,建议直接 M 或 H。
另外我强烈建议在生成二维码后做一次“自检”——用另一台手机扫一下生成的码,确认内容正确。我自己就在开发时栽过一次:localStorage 里存的设备编号带了一个看不见的 \u200b 零宽字符,首页显示编号完全正常,但生成的 URL 里多了一个非法字符,扫码后手机浏览器提示“无法访问”。这个 bug 排查了很久,最后还是把二维码内容直接输出到控制台对比才发现的。
5.3 控制台里的隐藏细节:二维码“重复生成”和“缓存”问题
还有一个和前端工程相关的细节。qrcodejs 这个库在同一个容器元素上重复创建时,如果不清空容器,会在 canvas 后面追加而不是覆盖。另外,扫码页面如果使用了 Service Worker 做离线缓存,二维码生成的 JS 文件如果更新了版本但没有更新缓存版本号,用户那边可能一直加载旧代码,导致二维码内容始终是旧的。
我的处理方式是:每次进入“生成结果页”时,强制清空容器 innerHTML = "",并且给静态资源配置版本查询串,比如 qrcode.min.js?v=20260115。这些细节在开发环境完全看不出来,因为本地浏览器每次都是新加载,但到了生产环境、用户手机上有缓存时,就会莫名其妙出现“明明改好了,用户还是说没变”的经典问题。
6. 从“能跑”到“能上线”:HTTPS、权限和异常处理的硬要求
6.1 HTTPS 不是可选项,是硬前提
Web NFC 是一个“强势” API,浏览器安全模型要求它只能在安全上下文中使用。所谓安全上下文,说白了就是 HTTPS 或者 localhost。如果在 http 协议下打开页面,"NFC" in navigator 虽然可能返回 true(因为浏览器把 API 暴露出来了),但调用 scan() 会直接抛 SecurityError。
这一点我在开发初期没太注意,因为本机调试用的是 localhost,一切正常。部署到内网一台 Windows Server 上,用了 IP 访问,结果在同事手机上打开就是报错。后来查了 MDN 才知道,IP 地址访问不属于安全上下文,哪怕你走了 HTTPS 但证书无效也不行——除非用户在浏览器设置里手动信任该证书。 所以最稳妥的方案是:要么用正式的域名 + Let's Encrypt 证书,要么在内网环境搭建私有 CA,把根证书装到每台工作手机上。
我给内部小团队用的方案是:申请了一个子域名,比如 nfc.yourcompany.com,用 Caddy 自动申请证书,反向代理到内网服务器。整个过程半小时搞定,但省掉了跟手机厂商各种安全策略作斗争的时间。
6.2 用户手势要求:为什么按钮不能“自动触发”读取
Web NFC 规范明确要求 scan() 必须由用户主动手势触发,不能页面加载后就自动调用。这意味着你不能做成“打开页面就开始读”,必须让用户点一下按钮。这个限制其实也是好事:它强制开发者把交互流程变成“用户知情且主动”,避免有些网站在后台偷偷读取用户身上的 NFC 卡。
我当时做过一个尝试:页面加载后通过 setTimeout 延迟 2 秒钟自动调用 scan(),结果在 Chrome 里直接抛 NotAllowedError。在本地查文档确认了原因——浏览器已经把用户手势的上下文信息附加到了调用栈里,异步的定时器里没有这个上下文,所以不允许。这不是 Web NFC 独有的,很多敏感 API(比如 getUserMedia、Notification.requestPermission)都有类似的限制。所以最终我把“读取按钮”设计成全页面最大的那个按钮,从交互上引导用户主动点击。
6.3 异常处理:把用户可能遇到的每种失败都变成一句人话
一个生产可用的 NFC 页面,异常处理至少要考虑以下 6 种场景,而且不能只用 console.log 打日志,要给用户一个看得懂的提示:
| 异常场景 | 错误类型 | 页面提示 |
|---|---|---|
| 浏览器不支持 Web NFC | 功能检测失败 | 提示不支持,引导用手动输入 |
| 页面不是 HTTPS | SecurityError | 提示需使用 https 访问 |
| 用户拒绝了 NFC 权限 | NotAllowedError | 引导去系统设置打开权限 |
| 系统 NFC 开关没开 | NotReadableError | 检查系统 NFC 开关 |
| 手机没有贴近标签 | 无事件触发 | 提示“请将手机贴近标签” |
| 读取到标签但格式不对 | 解析异常 | 提示标签格式不支持 |
每个错误都应归一到同一个 UI 组件:在按钮下方显示一行状态文案,并用不同的颜色区分(黄色是等待、红色是失败、绿色是成功)。我还把所有异常做了全局 window.onerror 上报,虽然不是完整的错误监控平台,但能帮我在前一周快速定位几台特殊手机上特有的问题。
6.4 上线后一周内出现的问题和补救
这里再分享两个上线后才发现的问题。
第一个问题:某些手机上 NFC 功能开着,但系统会弹一个“NFC 标签已损坏”的提示。 这个提示来自于系统级 NFC 服务,不是网页弹出来的,Web 页面完全无法拦截。原因是仓库里有一批标签写入时用的不是 NDEF 格式,而是厂商私有的格式。对于这种标签,浏览器侧的 NDEFReader 只能抛 readingerror 事件,没法定制提示。我的解决方式是:在标签上贴一个小的圆形贴纸,标上“不兼容,请更换”,并把这类标签从库存中逐步淘汰。程序员能写代码,但有些物理世界的问题,还是要靠重新贴标解决。
第二个问题:手机在扫描 NFC 时如果屏幕刚好锁屏,流程会中断。 有的安卓机在息屏状态下会关闭 NFC 天线轮询,用户按了按钮、提示贴卡后,手机息屏了,此时贴上标签什么也不会发生。这个没法在网页内优雅处理,我最后在按钮旁边的提示文案里加了一句“请保持屏幕常亮,建议将屏幕超时时间设置为 5 分钟以上”。这是最“土”但最有效的办法。
7. 我个人这段时间沉淀下来的一些体会
写完这套东西,我最想对准备做类似事情的人说的一句话是:Web NFC 没有你想象中那么“玩具”,它真正的问题是兼容性边界,而不是技术上限。
从具体技术细节上讲,Web NFC 的 API 设计其实相当简洁——核心就是 NDEFReader、scan()、reading 事件这三个概念,比蓝牙、USB 那些 Web API 好上手得多。它的成熟度在安卓 Chrome 生态里已经有不错的保障,很多企业内部工具的场景(巡检、资产标签、出入库、访客登记)都完全够用。真正需要花心思的是兜底链路:你要接受“有一部分用户就是不能用 Web NFC”这个现实,然后为它们准备一个同样顺畅的替代方案。
从工具链的角度说,GLM-5 这类大模型在这一类标准明确、边界清晰的项目里发挥的作用很明显。一来它能把 W3C 规范里的复杂术语翻译成能运行的代码,二来它能帮助你快速搭建交互框架,让你把精力集中在真机测试和异常处理上。但你一定要带着“审代码”的脑子去用它——AI 生成的代码不会替你考虑权限时序、不会替你处理各种 ROM 的奇怪行为,这些只能靠真实设备和用户的反馈来完善。
最后再分享一个小技巧:如果你跟我一样需要频繁测试 NFC 页面,建议在开发机上装一个浏览器远程调试工具,把手机通过 USB 连上电脑,直接在 DevTools 里模拟 reading 事件。比如在 Chrome DevTools 的 Console 里手动执行:
javascript复制const fakeEvent = new CustomEvent("reading", { detail: { message: { records: [{ recordType: "text", data: new TextEncoder().encode("DEVICE-0842").buffer }] } } });
reader.dispatchEvent(fakeEvent);
这样可以绕开真机贴卡的重复操作,千行代码的调试效率能提升一大截。等业务逻辑都验证完了,再回到真机上做最后的实战验收。这套“模拟事件驱动开发 + 真机回归”的节奏,是我这次项目里最值回票价的工作方式。
