# Web NFC API

> NDEFReader.scan() 以 reading 事件送达 NFC 标签上的 NDEF 消息，write() 把记录写入标签。本页列出记录成员、每个异常，以及带回退分支的示例。

`NDEFReader` 与贴近手机的标签交换 NFC Data Exchange Format（NDEF）消息：`scan()` 开始监听，每当标签进入范围就触发带标签序列号和记录的 `reading` 事件；`write()` 把一条消息写入下一个靠近的标签；`makeReadOnly()` 把标签永久锁定。PWA 可以用它做库存标签、活动签到，或与贴有 NFC 贴纸的设备配对；NDEF 之外的底层标签协议不对外暴露。

Android 版 Chrome 89 是唯一的实现：它提供 `NDEFReader`、`scan()`、`write()` 以及 `reading` 与 `readingerror` 事件，Android 版 Chrome 100 加入 `makeReadOnly()`（BCD `api.NDEFReader`）。跟随 Chrome 的 Android Chromium 浏览器（如 Samsung Internet）继承该支持；桌面 Chrome、Firefox 与 Safari 均无支持，WebKit 已提交「反对」立场（[standards-positions #584](https://github.com/WebKit/standards-positions/issues/584)）。

## 语法

```js
const reader = new NDEFReader()

reader.scan()
reader.scan(options)
reader.write(message)
reader.write(message, options)
reader.makeReadOnly()
reader.makeReadOnly(options)
```

三个方法都返回 `Promise<undefined>`。`scan()` 在监听开始时即兑现，而不是在读到标签时；结果通过 `reading`（`NDEFReadingEvent`，带 `serialNumber` 与 `message`）和 `readingerror`（标签在范围内但无法读取时触发）送达。`write()` 与 `makeReadOnly()` 在标签写入或锁定完成后兑现，没有标签靠近时可能要等上数秒。接口标注为 `[SecureContext, Exposed=Window]`，只能在顶层浏览上下文中使用，页面不可见时会被挂起。

## 参数

`scan()` 与 `makeReadOnly()` 各接受一个可选字典；`write()` 接受一条消息和一个可选字典。

| 方法 | 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `scan()` | `options` | `NDEFScanOptions` `{ signal }` | 否 | `signal`（`AbortSignal`）停止监听，并把该 reader 从活动集合中移除。 |
| `write()` | `message` | `NDEFMessageSource`：`DOMString`、`BufferSource` 或 `NDEFMessageInit` | 是 | 字符串变成一条 `"text"` 记录，缓冲区变成一条类型为 `application/octet-stream` 的 `"mime"` 记录，`NDEFMessageInit` 则显式给出记录。 |
| `write()` | `options` | `NDEFWriteOptions` `{ overwrite = true, signal }` | 否 | `overwrite: false` 时，标签上已有 NDEF 记录则拒绝写入。`signal` 中止挂起的写入。 |
| `makeReadOnly()` | `options` | `NDEFMakeReadOnlyOptions` `{ signal }` | 否 | `signal` 中止挂起的锁定。 |

`NDEFMessageInit` 只有一个必填成员 `records`，类型为 `sequence<NDEFRecordInit>`。

| `NDEFRecordInit` 成员 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `recordType` | `USVString` | 是 | `"empty"`、`"text"`、`"url"`、`"smart-poster"`、`"mime"`、`"absolute-url"`、`"unknown"`，形如 `"example.com:shelf"` 的外部类型，或智能海报内部形如 `":act"` 的本地类型。 |
| `mediaType` | `USVString` | 仅 `"mime"` | 载荷的 MIME 类型；其他记录类型都不得携带。 |
| `id` | `USVString` | 否 | 记录标识符；`"empty"` 记录不得携带。 |
| `encoding` | `USVString` | 否 | 仅用于 `"text"`：`"utf-8"`（默认）、`"utf-16"`、`"utf-16be"` 或 `"utf-16le"`。 |
| `lang` | `USVString` | 否 | `"text"` 记录的 BCP 47 语言标签，如 `"zh"`；默认取文档语言。 |
| `data` | `any` | 视类型而定 | `"text"`、`"url"`、`"absolute-url"` 用 `DOMString`；`"mime"`、`"unknown"` 用 `BufferSource`；外部类型与 `"smart-poster"` 用 `BufferSource` 或 `NDEFMessageInit`；`"empty"` 省略。 |

`NDEFMessageInit` 的嵌套最多 32 层，外部类型名不得超过 255 字节。

## 异常

在返回 Promise 之前执行的检查会立即拒绝；其余检查发生在用户授予 `nfc` 权限之后。

| 异常 | 方法 | 条件 |
|---|---|---|
| `InvalidStateError` | 全部 | 调用方不是活动的顶层浏览上下文（iframe 被拒），或对 `scan()` 而言该 reader 已在扫描中。 |
| 信号的 abort reason（默认 `AbortError`） | 全部 | 调用时 `options.signal` 已中止，或操作挂起期间被中止。 |
| `TypeError` | `write()` | 消息无效：`records` 为空；嵌套超过 32 层；禁止携带 `mediaType` 或 `id` 的记录类型带了它们；`data` 类型与 `recordType` 不匹配；`"text"` 的 `encoding` 不在允许的四种之内；`"url"` 无法解析；外部类型名超过 255 字节；或 `"smart-poster"` 没有恰好一条 URL 记录。 |
| `NotAllowedError` | 全部 | 用户拒绝了 `nfc` 权限，或（`overwrite: false` 的 `write()`）标签上已有 NDEF 记录。 |
| `NotSupportedError` | 全部 | 设备没有 NFC 适配器或无法连接适配器，适配器不支持推送数据，或标签不暴露 NDEF 技术且无法格式化。 |
| `NotReadableError` | 全部 | 浏览器不被允许使用适配器，例如系统设置中已关闭 NFC。 |
| `NetworkError` | `write()`、`makeReadOnly()` | 向标签传输或锁定操作失败，例如标签在写入途中被移开。 |

`scan()` 本身不会报告无法读取的标签：那会以不带载荷的 `readingerror` 事件到达 reader，处理函数应提示用户稳住标签再试一次。

:::observed
Android 版 Chrome 对从跨源 iframe 调用的 `new NDEFReader().scan()` 以 `InvalidStateError: Web NFC can only be accessed in a top-level browsing context.` 拒绝，对同一个 reader 的第二次 `scan()` 以 `InvalidStateError: A scan() operation is ongoing.` 拒绝，对用户拒绝授权的调用以 `NotAllowedError: NFC permission request denied.` 拒绝。这些字符串定义在 Chromium 的 [`ndef_reader.cc`](https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/modules/nfc/ndef_reader.cc)（chromium.googlesource.com）。
:::

## 示例

每个示例都先检测 `window` 中是否有 `NDEFReader`；在桌面浏览器和 iOS 上会走回退分支，通常是携带同一标识符的二维码或手动输入。

### 扫描标签，缺少支持时回退到二维码

从一次点击开始扫描，让权限提示与手势绑定；解码 `"text"` 与 `"url"` 记录，没有 Web NFC 时显示二维码扫描器。`scan()` 在监听开始时兑现，真正的读取稍后通过 `reading` 事件到达。

```js
async function startTagScan(onTag, showQrFallback) {
  if (!('NDEFReader' in window)) {
    showQrFallback();
    return;
  }

  const reader = new NDEFReader();
  reader.addEventListener('reading', ({ serialNumber, message }) => {
    const decoder = new TextDecoder();
    for (const record of message.records) {
      if (record.recordType === 'text') onTag({ serialNumber, text: decoder.decode(record.data) });
      if (record.recordType === 'url') onTag({ serialNumber, url: decoder.decode(record.data) });
    }
  });
  reader.addEventListener('readingerror', () => onTag({ error: '标签无法读取，请稳住标签再试一次。' }));

  try {
    await reader.scan();
  } catch (err) {
    if (err.name === 'NotAllowedError') showQrFallback();
    else throw err;
  }
}
```

`record.data` 是 `DataView`；`"text"` 记录在 `record.encoding` 中携带编码、在 `record.lang` 中携带语言，所以 UTF-16 标签要用 `new TextDecoder(record.encoding)` 而不是默认值。

### 只向空白标签写入 URL，并设置超时

`overwrite: false` 保护已有数据的标签，`AbortSignal` 在十秒内没有标签靠近时停止挂起的写入。没有 Web NFC 时函数返回 `false`，界面可以改为提供打印二维码标签的选项。

```js
async function writeLabel(url) {
  if (!('NDEFReader' in window)) return false;

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 10_000);
  try {
    await new NDEFReader().write(
      { records: [{ recordType: 'url', data: url }] },
      { overwrite: false, signal: controller.signal },
    );
    return true;
  } catch (err) {
    if (err.name === 'AbortError') return false; // 10 秒内没有标签
    if (err.name === 'NotAllowedError') throw new Error('标签已有内容，或 NFC 权限被拒绝');
    throw err;
  } finally {
    clearTimeout(timer);
  }
}
```

标签在传输途中移开会以 `NetworkError` 拒绝；此时标签上的记录可能只写了一部分，重试应使用 `overwrite: true`。

### 写入厂商记录并锁定标签

外部记录类型让应用在自己的命名空间下存放结构化数据。写入后 `makeReadOnly()` 锁定标签，访客的手机就无法改动它；锁定不可逆，所以示例先请求确认。

```js
async function provisionShelfTag(shelfId, confirmLock) {
  if (!('NDEFReader' in window)) return 'unsupported';

  const reader = new NDEFReader();
  const payload = new TextEncoder().encode(JSON.stringify({ shelfId }));
  await reader.write({ records: [{ recordType: 'example.com:shelf', data: payload }] });

  if (!(await confirmLock())) return 'written';
  try {
    await reader.makeReadOnly();
    return 'locked';
  } catch (err) {
    if (err.name === 'NotSupportedError') return 'written-not-lockable';
    throw err;
  }
}
```

外部类型 `example.com:shelf` 必须全小写、使用你控制的域名并保持在 255 字节以内；读回时 `recordType === 'example.com:shelf'`，`record.data` 中是同样的字节。

## 另请参阅

- [Web Bluetooth API](/zh/reference/capabilities/web-bluetooth/)，用于通过无线广播而非携带标签的设备
- [WebHID API](/zh/reference/capabilities/web-hid/)
- [WebUSB API](/zh/reference/capabilities/web-usb/)
- [Web NFC: NDEFReader scan() method](https://w3c-cg.github.io/web-nfc/#dom-ndefreader-scan)（w3c-cg.github.io）
- [Web NFC: security policies](https://w3c-cg.github.io/web-nfc/#security-policies)（w3c-cg.github.io）
- [WebKit standards position: Web NFC](https://github.com/WebKit/standards-positions/issues/584)（github.com）
- [Interact with NFC devices on Chrome for Android](https://developer.chrome.com/docs/capabilities/nfc)（developer.chrome.com）