現在可以讀取及寫入 NFC 標記。
什麼是 Web NFC?
NFC 代表近距離無線通訊,這是一種短距離無線技術,頻率為 13.56 MHz,可讓裝置在距離不到 10 公分時進行通訊,傳輸速率最高可達 424 kbit/s。
Web NFC 可讓網站在 NFC 感應標籤靠近使用者裝置 (通常為 5 到 10 公分,即 2 到 4 英寸) 時讀取及寫入 NFC 感應標籤。目前的範圍僅限於 NFC 資料交換格式 (NDEF),這是一種輕量二進位訊息格式,可跨不同標記格式運作。
建議用途
Web NFC 受限於 NDEF,因為讀取和寫入 NDEF 資料的安全性屬性較容易量化。不支援低階 I/O 作業 (例如 ISO-DEP、NFC-A/B、NFC-F)、對等端通訊模式和主機式卡片模擬 (HCE)。
以下列舉一些可能會使用 Web NFC 的網站:
- 當使用者將裝置輕觸展覽品附近的 NFC 卡時,博物館和藝廊就能顯示相關資訊。
- 庫存管理網站可以讀取或寫入容器上的 NFC 標記資料,以更新其內容的資訊。
- 會議網站可在活動期間使用這項功能掃描 NFC 徽章,並確保徽章已上鎖,以免日後變更其上寫的資訊。
- 網站可以使用這項功能,分享裝置或服務佈建情境所需的初始密鑰,並在運作模式中部署設定資料。
目前狀態
步驟 | 狀態 |
---|---|
1. 建立說明 | 完成 |
2. 建立規格初稿 | 完成 |
3. 收集意見回饋並反覆改進設計 | 完成 |
4. 來源試用 | 完成 |
5. 啟動 | 完成 |
使用 Web NFC
特徵偵測
硬體功能偵測與您可能習慣的方式不同。如果出現 NDEFReader
,就表示瀏覽器支援網路 NFC,但不知道該硬體是否存在必要硬體。特別是,如果缺少硬體,則某些呼叫傳回的承諾會遭到拒絕。我會在說明 NDEFReader
時提供詳細資訊。
if ('NDEFReader' in window) { /* Scan and write NFC tags */ }
術語
NFC 標記是被動式 NFC 裝置,也就是說,如果有效的 NFC 裝置 (例如手機) 就在附近,就能使用磁吸式感應功能。NFC 標籤有多種形式和款式,例如貼紙、信用卡、手腕等。
NDEFReader
物件是 Web NFC 中的進入點,會在 NDEF 標記鄰近時,提供用於準備讀取和/或寫入動作的功能。NDEFReader
中的 NDEF
代表 NFC 資料交換格式,這種輕量的二進位訊息格式是由 NFC 論壇標準化。
NDEFReader
物件可處理來自 NFC 標記的傳入 NDEF 訊息,並將 NDEF 訊息寫入範圍內的 NFC 標記。
支援 NDEF 的 NFC 標籤就像便利貼,任何人都可以讀取,而且除非是唯讀,否則任何人都可以寫入。其中包含一個 NDEF 訊息,封裝一或多個 NDEF 記錄。每個 NDEF 記錄都是二進位結構,其中包含資料酬載和相關類型資訊。Web NFC 支援下列 NFC Forum 標準化記錄類型:空白、文字、網址、智慧海報、MIME 類型、絕對網址、外部類型、不明和本機類型。
掃描 NFC 標記
如要掃描 NFC 標籤,請先將新的 NDEFReader
物件例項化。呼叫 scan()
會傳回承諾。如果先前未授予存取權,系統可能會提示使用者。如果符合下列所有條件,承諾就會解析:
- 只有在回應使用者手勢 (例如觸控手勢或滑鼠點選) 時,才會呼叫此方法。
- 使用者已允許網站與 NFC 裝置互動。
- 使用者的手機支援 NFC。
- 使用者已在手機上啟用 NFC。
承諾解決後,您可以透過事件監聽器訂閱 reading
事件,接收傳入的 NDEF 訊息。您也應訂閱 readingerror
事件,以便在相容的 NFC 標記在附近時收到通知。
const ndef = new NDEFReader();
ndef.scan().then(() => {
console.log("Scan started successfully.");
ndef.onreadingerror = () => {
console.log("Cannot read data from the NFC tag. Try another one?");
};
ndef.onreading = event => {
console.log("NDEF message read.");
};
}).catch(error => {
console.log(`Error! Scan failed to start: ${error}.`);
});
當 NFC 標籤在附近時,系統會觸發 NDEFReadingEvent
事件。其中包含兩個專屬的屬性:
serialNumber
代表裝置的序號 (例如 00-11-22-33-44-55-66),如果沒有序號,則為空字串。message
代表儲存在 NFC 標記中的 NDEF 訊息。
如要讀取 NDEF 訊息的內容,請循環 message.records
,並根據 recordType
適當處理其 data
成員。data
成員會以 DataView
的形式公開,因為它可處理資料以 UTF-16 編碼的情況。
ndef.onreading = event => {
const message = event.message;
for (const record of message.records) {
console.log("Record type: " + record.recordType);
console.log("MIME type: " + record.mediaType);
console.log("Record id: " + record.id);
switch (record.recordType) {
case "text":
// TODO: Read text record with record data, lang, and encoding.
break;
case "url":
// TODO: Read URL record with record data.
break;
default:
// TODO: Handle other records with record data.
}
}
};
寫入 NFC 標記
如要寫入 NFC 標記,請先將新的 NDEFReader
物件例項化。呼叫 write()
會傳回承諾值。如果先前未授予存取權,系統可能會提示使用者。此時,NDEF 訊息便已「準備」,且若符合下列所有條件,就能解決問題:
- 只有在回應使用者手勢 (例如觸控手勢或滑鼠點擊) 時才會呼叫。
- 使用者已允許網站與 NFC 裝置互動。
- 使用者的手機支援 NFC。
- 使用者已在手機上啟用 NFC。
- 使用者已輕觸 NFC 標籤,且已成功寫入 NDEF 訊息。
如要將文字寫入 NFC 標記,請將字串傳送至 write()
方法。
const ndef = new NDEFReader();
ndef.write(
"Hello World"
).then(() => {
console.log("Message written.");
}).catch(error => {
console.log(`Write failed :-( try again: ${error}.`);
});
如要將網址記錄寫入 NFC 標記,請將代表 NDEF 訊息的字典傳送至 write()
。在下列範例中,NDEF 訊息是包含 records
鍵的字典。其值為記錄陣列,在本例中,網址記錄定義為物件,其中 recordType
鍵設為 "url"
,而 data
鍵設為網址字串。
const ndef = new NDEFReader();
ndef.write({
records: [{ recordType: "url", data: "https://w3c.github.io/web-nfc/" }]
}).then(() => {
console.log("Message written.");
}).catch(error => {
console.log(`Write failed :-( try again: ${error}.`);
});
您也可以將多筆記錄寫入 NFC 標記。
const ndef = new NDEFReader();
ndef.write({ records: [
{ recordType: "url", data: "https://w3c.github.io/web-nfc/" },
{ recordType: "url", data: "https://web.dev/nfc/" }
]}).then(() => {
console.log("Message written.");
}).catch(error => {
console.log(`Write failed :-( try again: ${error}.`);
});
如果 NFC 標記包含不應覆寫的 NDEF 訊息,請在傳遞至 write()
方法的選項中,將 overwrite
屬性設為 false
。在這種情況下,如果 NFC 標籤中已儲存 NDEF 訊息,系統會傳回拒絕的承諾。
const ndef = new NDEFReader();
ndef.write("Writing data on an empty NFC tag is fun!", { overwrite: false })
.then(() => {
console.log("Message written.");
}).catch(error => {
console.log(`Write failed :-( try again: ${error}.`);
});
將 NFC 標記設為唯讀
為避免惡意使用者覆寫 NFC 標記的內容,您可以將 NFC 標記設為永久唯讀。這項操作是單向程序,無法復原。NFC 標籤一旦設為唯讀,就無法再寫入。
如要將 NFC 標籤設為唯讀,請先將新的 NDEFReader
物件例項化。呼叫 makeReadOnly()
會傳回承諾。如果先前未授予存取權,系統可能會提示使用者。如果滿足下列所有條件,承諾就會解析:
- 只有在回應使用者手勢 (例如觸控手勢或滑鼠點選) 時,才會呼叫此方法。
- 使用者已允許網站與 NFC 裝置互動。
- 使用者的手機支援 NFC。
- 使用者在手機上啟用 NFC。
- 使用者已輕觸 NFC 標記,且 NFC 標記已成功設為唯讀。
const ndef = new NDEFReader();
ndef.makeReadOnly()
.then(() => {
console.log("NFC tag has been made permanently read-only.");
}).catch(error => {
console.log(`Operation failed: ${error}`);
});
以下說明如何在寫入 NFC 標記後永久設為唯讀。
const ndef = new NDEFReader();
try {
await ndef.write("Hello world");
console.log("Message written.");
await ndef.makeReadOnly();
console.log("NFC tag has been made permanently read-only after writing to it.");
} catch (error) {
console.log(`Operation failed: ${error}`);
}
makeReadOnly()
可在 Android 版 Chrome 100 以上版本中使用,請確認是否支援以下功能:
if ("NDEFReader" in window && "makeReadOnly" in NDEFReader.prototype) {
// makeReadOnly() is supported.
}
安全性和權限
Chrome 團隊根據「控制強大網路平台功能的存取權」一文中定義的核心原則,設計並實作 Web NFC,包括使用者控管、資訊公開和人體工學等原則。
由於 NFC 會擴大可能出現在惡意網站的資訊領域,因此 NFC 的可用性會受到限制,以提高使用者對 NFC 使用方式的瞭解與控制。
網路 NFC 僅適用於頂層頁框和安全的瀏覽環境 (僅限 HTTPS)。來源必須先在處理使用者手勢 (例如按鈕點擊) 時要求 "nfc"
權限。如果先前未授予存取權,NDEFReader
scan()
、write()
和 makeReadOnly()
方法會觸發使用者提示。
document.querySelector("#scanButton").onclick = async () => {
const ndef = new NDEFReader();
// Prompt user to allow website to interact with NFC devices.
await ndef.scan();
ndef.onreading = event => {
// TODO: Handle incoming NDEF messages.
};
};
使用者啟動的權限提示以及在目標 NFC 標記上實際移動裝置時,兩者的結合,會反映其他檔案和裝置存取 API 中的選擇工具模式。
如要執行掃描或寫入作業,使用者必須用裝置觸碰 NFC 標籤,網頁才會顯示。瀏覽器會使用觸覺回饋來表示輕觸動作。如果螢幕關閉或裝置處於鎖定狀態,NFC 無線電將無法存取。針對未顯示的網頁,系統會暫時停用接收及推送 NFC 內容,並在網頁再次進入時恢復運作。
有了 Page Visibility API,您就能追蹤文件瀏覽權限變更的時間。
document.onvisibilitychange = event => {
if (document.hidden) {
// All NFC operations are automatically suspended when document is hidden.
} else {
// All NFC operations are resumed, if needed.
}
};
食譜集
以下提供一些程式碼範例,協助您快速上手。
檢查權限
Permissions API 可讓您檢查是否已授予 "nfc"
權限。此範例說明如何在使用者先前授予存取權的情況下,不經使用者互動即可掃描 NFC 標籤,或在其他情況下顯示按鈕。請注意,相同的機制可用於寫入 NFC 標記,因為該標記實際上使用的權限相同。
const ndef = new NDEFReader();
async function startScanning() {
await ndef.scan();
ndef.onreading = event => {
/* handle NDEF messages */
};
}
const nfcPermissionStatus = await navigator.permissions.query({ name: "nfc" });
if (nfcPermissionStatus.state === "granted") {
// NFC access was previously granted, so we can start NFC scanning now.
startScanning();
} else {
// Show a "scan" button.
document.querySelector("#scanButton").style.display = "block";
document.querySelector("#scanButton").onclick = event => {
// Prompt user to allow UA to send and receive info when they tap NFC devices.
startScanning();
};
}
取消 NFC 作業
使用 AbortController
原始類別可輕鬆終止 NFC 作業。以下範例說明如何透過 NDEFReader scan()
、makeReadOnly()
、write()
方法的選項,傳遞 AbortController
的 signal
,並同時中止兩個 NFC 作業。
const abortController = new AbortController();
abortController.signal.onabort = event => {
// All NFC operations have been aborted.
};
const ndef = new NDEFReader();
await ndef.scan({ signal: abortController.signal });
await ndef.write("Hello world", { signal: abortController.signal });
await ndef.makeReadOnly({ signal: abortController.signal });
document.querySelector("#abortButton").onclick = event => {
abortController.abort();
};
寫入後讀取
使用 write()
和 scan()
與 AbortController
原始碼,即可在寫入訊息後讀取 NFC 標記。以下範例說明如何將文字訊息寫入 NFC 標記,並讀取 NFC 標記中的新訊息。會在三秒後停止掃描。
// Waiting for user to tap NFC tag to write to it...
const ndef = new NDEFReader();
await ndef.write("Hello world");
// Success! Message has been written.
// Now scanning for 3 seconds...
const abortController = new AbortController();
await ndef.scan({ signal: abortController.signal });
const message = await new Promise((resolve) => {
ndef.onreading = (event) => resolve(event.message);
});
// Success! Message has been read.
await new Promise((r) => setTimeout(r, 3000));
abortController.abort();
// Scanning is now stopped.
讀取及寫入文字記錄
文字記錄 data
可透過使用記錄 encoding
屬性實例化的 TextDecoder
解碼。請注意,文字記錄的語言可透過其 lang
屬性取得。
function readTextRecord(record) {
console.assert(record.recordType === "text");
const textDecoder = new TextDecoder(record.encoding);
console.log(`Text: ${textDecoder.decode(record.data)} (${record.lang})`);
}
如要寫入簡單的文字記錄,請將字串傳遞至 NDEFReader write()
方法。
const ndef = new NDEFReader();
await ndef.write("Hello World");
文字記錄預設為 UTF-8,並假設目前文件的語言,但兩個屬性 (encoding
和 lang
) 皆可使用完整語法指定,以便建立自訂 NDEF 記錄。
function a2utf16(string) {
let result = new Uint16Array(string.length);
for (let i = 0; i < string.length; i++) {
result[i] = string.codePointAt(i);
}
return result;
}
const textRecord = {
recordType: "text",
lang: "fr",
encoding: "utf-16",
data: a2utf16("Bonjour, François !")
};
const ndef = new NDEFReader();
await ndef.write({ records: [textRecord] });
讀取及寫入網址記錄
使用 TextDecoder
解碼記錄的 data
。
function readUrlRecord(record) {
console.assert(record.recordType === "url");
const textDecoder = new TextDecoder();
console.log(`URL: ${textDecoder.decode(record.data)}`);
}
如要寫入網址記錄,請將 NDEF 訊息字典傳送至 NDEFReader write()
方法。NDEF 訊息中包含的網址記錄定義為物件,其中 recordType
鍵設為 "url"
,data
鍵則設為網址字串。
const urlRecord = {
recordType: "url",
data:"https://w3c.github.io/web-nfc/"
};
const ndef = new NDEFReader();
await ndef.write({ records: [urlRecord] });
讀取及寫入 MIME 類型記錄
MIME 類型記錄的 mediaType
屬性代表 NDEF 記錄酬載的 MIME 類型,以便正確解碼 data
。舉例來說,使用 JSON.parse
可將 JSON 文字解碼,並使用 Image 元素解碼圖片資料。
function readMimeRecord(record) {
console.assert(record.recordType === "mime");
if (record.mediaType === "application/json") {
const textDecoder = new TextDecoder();
console.log(`JSON: ${JSON.parse(decoder.decode(record.data))}`);
}
else if (record.mediaType.startsWith('image/')) {
const blob = new Blob([record.data], { type: record.mediaType });
const img = new Image();
img.src = URL.createObjectURL(blob);
document.body.appendChild(img);
}
else {
// TODO: Handle other MIME types.
}
}
如要寫入 MIME 類型記錄,請將 NDEF 訊息字典傳遞至 NDEFReader write()
方法。NDEF 訊息中包含的 MIME 類型記錄會定義為物件,其中 recordType
鍵設為 "mime"
、mediaType
鍵設為內容的實際 MIME 類型,而 data
鍵設為物件,該物件可以是 ArrayBuffer
或提供 ArrayBuffer
的檢視畫面 (例如 Uint8Array
、DataView
)。
const encoder = new TextEncoder();
const data = {
firstname: "François",
lastname: "Beaufort"
};
const jsonRecord = {
recordType: "mime",
mediaType: "application/json",
data: encoder.encode(JSON.stringify(data))
};
const imageRecord = {
recordType: "mime",
mediaType: "image/png",
data: await (await fetch("icon1.png")).arrayBuffer()
};
const ndef = new NDEFReader();
await ndef.write({ records: [jsonRecord, imageRecord] });
讀取及寫入絕對網址記錄
絕對網址記錄 data
可透過簡單的 TextDecoder
解碼。
function readAbsoluteUrlRecord(record) {
console.assert(record.recordType === "absolute-url");
const textDecoder = new TextDecoder();
console.log(`Absolute URL: ${textDecoder.decode(record.data)}`);
}
如要寫入絕對網址記錄,請將 NDEF 訊息字典傳遞至 NDEFReader write()
方法。NDEF 訊息中包含的絕對網址記錄會定義為物件,其中 recordType
鍵設為 "absolute-url"
,而 data
鍵則設為網址字串。
const absoluteUrlRecord = {
recordType: "absolute-url",
data:"https://w3c.github.io/web-nfc/"
};
const ndef = new NDEFReader();
await ndef.write({ records: [absoluteUrlRecord] });
讀取及寫入智慧海報記錄
智慧型海報記錄 (用於雜誌廣告、傳單、看板廣告等) 會將某些網路內容描述為 NDEF 記錄,其中包含 NDEF 訊息做為酬載。呼叫 record.toRecords()
即可將 data
轉換為智慧型海報記錄中包含的記錄清單。其中應包含網址記錄、標題文字記錄、圖片的 MIME 類型記錄,以及智慧型海報記錄的類型、動作和大小等部分自訂本機類型記錄,例如 ":t"
、":act"
和 ":s"
。
本地類型記錄僅在包含 NDEF 記錄的本地內容中才會是唯一的。當所含記錄的本機結構定義之外,以及儲存空間用量是硬性限制時,請使用這些類型代表的意義。在 Web NFC 中,本機類型記錄名稱一律以 :
開頭 (例如 ":t"
、":s"
、":act"
)。這可區分文字記錄和本機類型文字記錄。
function readSmartPosterRecord(smartPosterRecord) {
console.assert(record.recordType === "smart-poster");
let action, text, url;
for (const record of smartPosterRecord.toRecords()) {
if (record.recordType == "text") {
const decoder = new TextDecoder(record.encoding);
text = decoder.decode(record.data);
} else if (record.recordType == "url") {
const decoder = new TextDecoder();
url = decoder.decode(record.data);
} else if (record.recordType == ":act") {
action = record.data.getUint8(0);
} else {
// TODO: Handle other type of records such as `:t`, `:s`.
}
}
switch (action) {
case 0:
// Do the action
break;
case 1:
// Save for later
break;
case 2:
// Open for editing
break;
}
}
如要寫入智慧海報記錄,請將 NDEF 訊息傳遞至 NDEFReader write()
方法。NDEF 訊息中包含的智慧型海報記錄定義為一個物件,該物件的 recordType
鍵設為 "smart-poster"
,並將 data
鍵設為物件,代表智慧海報記錄中包含的 NDEF 訊息 (再次一次)。
const encoder = new TextEncoder();
const smartPosterRecord = {
recordType: "smart-poster",
data: {
records: [
{
recordType: "url", // URL record for smart poster content
data: "https://my.org/content/19911"
},
{
recordType: "text", // title record for smart poster content
data: "Funny dance"
},
{
recordType: ":t", // type record, a local type to smart poster
data: encoder.encode("image/gif") // MIME type of smart poster content
},
{
recordType: ":s", // size record, a local type to smart poster
data: new Uint32Array([4096]) // byte size of smart poster content
},
{
recordType: ":act", // action record, a local type to smart poster
// do the action, in this case open in the browser
data: new Uint8Array([0])
},
{
recordType: "mime", // icon record, a MIME type record
mediaType: "image/png",
data: await (await fetch("icon1.png")).arrayBuffer()
},
{
recordType: "mime", // another icon record
mediaType: "image/jpg",
data: await (await fetch("icon2.jpg")).arrayBuffer()
}
]
}
};
const ndef = new NDEFReader();
await ndef.write({ records: [smartPosterRecord] });
讀取及寫入外部類型記錄
如要建立應用程式定義的記錄,請使用外部類型記錄。這些檔案可能會包含 NDEF 訊息,做為可透過 toRecords()
存取的酬載。其名稱包含發布機構的網域名稱、冒號和至少一個字元的型別名稱,例如 "example.com:foo"
。
function readExternalTypeRecord(externalTypeRecord) {
for (const record of externalTypeRecord.toRecords()) {
if (record.recordType == "text") {
const decoder = new TextDecoder(record.encoding);
console.log(`Text: ${textDecoder.decode(record.data)} (${record.lang})`);
} else if (record.recordType == "url") {
const decoder = new TextDecoder();
console.log(`URL: ${decoder.decode(record.data)}`);
} else {
// TODO: Handle other type of records.
}
}
}
如要寫入外部類型記錄,請將 NDEF 訊息字典傳遞至 NDEFReader write()
方法。NDEF 訊息中包含的外部類型記錄定義為一個物件,該物件的 recordType
金鑰設定為外部類型名稱,並將 data
金鑰設為物件,代表外部類型記錄內含的 NDEF 訊息。請注意,data
鍵也可以是 ArrayBuffer
,或提供 ArrayBuffer
的檢視畫面 (例如 Uint8Array
、DataView
)。
const externalTypeRecord = {
recordType: "example.game:a",
data: {
records: [
{
recordType: "url",
data: "https://example.game/42"
},
{
recordType: "text",
data: "Game context given here"
},
{
recordType: "mime",
mediaType: "image/png",
data: await (await fetch("image.png")).arrayBuffer()
}
]
}
};
const ndef = new NDEFReader();
ndef.write({ records: [externalTypeRecord] });
讀取及寫入空白記錄
空白記錄沒有酬載。
如要寫入空白記錄,請將 NDEF 訊息字典傳遞至 NDEFReader write()
方法。NDEF 訊息中包含的空白記錄定義為 recordType
鍵設為 "empty"
的物件。
const emptyRecord = {
recordType: "empty"
};
const ndef = new NDEFReader();
await ndef.write({ records: [emptyRecord] });
瀏覽器支援
在 Android 上,Chrome 89 支援 Web NFC。
開發人員提示
以下是開始使用 Web NFC 時,我希望自己知道的事項:
- 在 Web NFC 功能開始之前,Android 會在作業系統層級處理 NFC 標記。
- 您可以在 material.io 上找到 NFC 圖示。
- 需要時,可以使用 NDEF 記錄
id
輕鬆識別記錄。 - 未格式化的 NFC 標記 (支援 NDEF) 包含空值類型的單一記錄。
- 編寫 Android 應用程式記錄很簡單,如下所示。
const encoder = new TextEncoder();
const aarRecord = {
recordType: "android.com:pkg",
data: encoder.encode("com.example.myapp")
};
const ndef = new NDEFReader();
await ndef.write({ records: [aarRecord] });
示範
請試用官方範例,並查看一些精彩的 Web NFC 示範:
意見回饋
Web NFC 社群群組和 Chrome 團隊很樂意聽取你對 Web NFC 的想法和體驗。
請說明 API 設計
API 是否有任何功能無法正常運作?或者,您是否缺少實作想法所需的方法或屬性?
您可以在 Web NFC GitHub 存放區上提出規格問題,或是將您的想法新增至現有問題。
回報導入問題
您發現 Chrome 實作錯誤嗎?還是實作方式與規格不同?
請前往 https://new.crbug.com 提交錯誤。請務必盡可能提供詳細資訊,提供重現錯誤的簡單操作說明,並將「元件」設為 Blink>NFC
。Glitch 非常適合用來快速輕鬆地提出重新提案。
顯示支援
您是否打算使用 Web NFC?公開支援服務可協助 Chrome 團隊優先開發特定功能,並讓其他瀏覽器廠商瞭解這些功能對這些功能的重要性。
使用主題標記 #WebNFC
發送推文給 @ChromiumDev,告訴我們你在何處使用這項功能,以及使用方式。
實用連結
- 規格
- 網路 NFC 示範 | 網路 NFC 示範來源
- 追蹤錯誤
- ChromeStatus.com 項目
- Blink 元件:
Blink>NFC
特別銘謝
在此要向Intel 團隊致上萬分謝,感謝他們實作了 Web NFC。Google Chrome 需要有共同合作的社群成員,才能推動 Chromium 專案。並非每位 Chromium 提交者都是 Google 員工,這些貢獻者值得特別表揚!