若要將巢狀括號文字轉換為陣列樹,請由左至右掃描;每遇到一個左括號就建立一個陣列,每遇到一個右括號就返回其父陣列。本指南提供完整的 JavaScript 剖析器,可在 Mac 上的 Safari 中本機執行。
此處的任務是將一份由作者撰寫、包含標籤的小型大綱轉換成巢狀陣列,同時保留順序、引號中的文字及空群組。所有範例都使用虛構字串。剖析器包含明確的文法規則和檢查,讓你能確認產生的結構是否符合原意。
判定括號和引號中的標籤代表什麼
在開始轉換之前,請先區分結構用括號和屬於標籤內容的括號。在這個範例中,外層括號會建立群組,而雙引號標籤內的括號則保留為文字:
(desk ("blue (round)" "say \"hello\"") () cards)
預期的陣列結構如下:
[
[
"desk",
["blue (round)", "say \"hello\""],
[],
"cards"
]
]
多出來的外層陣列是刻意設計的:它代表整份文件。每個括號群組都會成為其中一個陣列。如此一來,不論輸入包含一個群組、多個群組,或是不帶外層括號的標籤,回傳型別都一致。
以下規則定義了本文的輸入格式。這些是此剖析器採用的設計選擇,並非 JavaScript 內建的括號文字格式。
| 輸入特徵 | 在結果樹中的意義 |
|---|---|
雙引號外的 ( 和 ) |
開始和結束巢狀陣列 |
| 雙引號外的空白 | 分隔標籤;不保留分隔符號 |
未加引號的詞元,例如 cards |
產生字串 |
雙引號字串,例如 "blue tile" |
產生一個字串,包含其中的空格 |
() |
產生空陣列 |
"" |
產生空字串 |
引號外的 12、true 或 null |
保留原樣作為字串 |
| 逗號和單引號 | 視為未加引號詞元中的一般字元 |
括號也會分隔詞元,因此 a(b)c 會變成 ["a", ["b"], "c"]。相鄰的兩個標籤之間需要空白:若寫成 "a"b,就會被拒絕。如果標籤含有空格、雙引號或字面上的括號,請使用雙引號格式。
雙引號詞元遵循 JSON 字串語法。剖析器會將每個完整的引號詞元傳給 JSON.parse();此函式接受 JSON 文字,並會在語法無效時擲回 SyntaxError。如此一來,跳脫字元驗證便交由文件明載的剖析器處理。ECMAScript 規格:JSON.parse
在 Safari 中開啟本機 JavaScript 工作區
Safari 的 Web Inspector 提供此桌面轉換所需的 JavaScript 提示字元。你不需要安裝套件,也不必另建專案。
- 在 Safari 中,選擇
Safari→設定⋯→進階。 - 啟用
顯示網頁開發者功能。 - 開啟分頁,並在網址欄輸入
about:blank。 - 選擇
開發→顯示網頁檢閱器,或按下Option–Command–I。 - 選取
主控台分頁,並使用其輸入提示字元。
WebKit 在啟用 Web Inspector中說明了開發者設定和檢閱器快捷鍵。Web Inspector 介面參考文件則說明,主控台提示字元會執行 JavaScript 並顯示結果。
將下方完整程式貼到提示字元中並執行。程式包含剖析器、一個撰寫的範例、六項有效輸入檢查,以及七項格式錯誤輸入檢查。不需要匯入任何內容。此程式已在 Node.js 20.19.2 中成功執行;Safari 介面操作步驟有文件可查,但本文未另行實際測試。
執行完整的括號文字剖析器
剖析器會維護一個開啟陣列的堆疊。遇到左括號時會建立子陣列並將它放入堆疊;遇到右括號時會將目前陣列從堆疊移除。標籤會附加到目前開啟的陣列中。
function parenthesizedToTree(text) {
if (typeof text !== "string") {
throw new TypeError("Expected a string");
}
const root = [];
const stack = [{ items: root, start: -1 }];
const separator = c => /\s|[()]/u.test(c);
let i = 0;
function fail(message, at = i) {
throw new SyntaxError(message + " at UTF-16 offset " + at);
}
while (i < text.length) {
const c = text[i];
const current = stack[stack.length - 1].items;
if (/\s/u.test(c)) {
i++;
} else if (c === "(") {
const child = [];
current.push(child);
stack.push({ items: child, start: i });
i++;
} else if (c === ")") {
if (stack.length === 1) fail("Unexpected closing parenthesis");
stack.pop();
i++;
} else if (c === '"') {
const start = i++;
let closed = false;
while (i < text.length) {
if (text[i] === "\\") {
i += 2;
} else if (text[i] === '"') {
i++;
closed = true;
break;
} else {
i++;
}
}
if (!closed) fail("Unclosed quoted string", start);
let value;
try {
value = JSON.parse(text.slice(start, i));
} catch {
fail("Invalid JSON string", start);
}
if (i < text.length && !separator(text[i])) {
fail("Expected whitespace or a parenthesis");
}
current.push(value);
} else {
const start = i;
while (i < text.length && !separator(text[i])) {
if (text[i] === '"') fail("Quote inside an unquoted token");
i++;
}
current.push(text.slice(start, i));
}
}
if (stack.length !== 1) {
fail("Unclosed opening parenthesis", stack[stack.length - 1].start);
}
return root;
}
const input = String.raw`(desk ("blue (round)" "say \"hello\"") () cards)`;
console.log(JSON.stringify(parenthesizedToTree(input), null, 2));
const validCases = [
["", []],
["()", [[]]],
["a(b)c", ["a", ["b"], "c"]],
['("a b" "")', [["a b", ""]]],
["(12 true)", [["12", "true"]]],
[String.raw`("a\\b" "\u0061" "$&")`, [["a\\b", "a", "$&"]]]
];
for (const [source, expected] of validCases) {
const actual = parenthesizedToTree(source);
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error("Unexpected tree for " + source);
}
}
const invalidCases = [
"(a", "a)", '("a)', String.raw`("\q")`,
'"a"b', 'a"b"', '("line\nbreak")'
];
for (const source of invalidCases) {
let rejected = false;
try {
parenthesizedToTree(source);
} catch (error) {
if (!(error instanceof SyntaxError)) throw error;
rejected = true;
}
if (!rejected) throw new Error("Expected rejection for " + source);
}
console.log("All 13 checks passed.");
第一個列印結果就是前面所示的巢狀結構,並以多行格式呈現。最後一則訊息應為 All 13 checks passed.。函式會回傳真正的 JavaScript 陣列;JSON.stringify() 會將該值轉成可列印的文字,而第三個引數則指定縮排。ECMAScript 規格:JSON.stringify
若要轉換另一份虛構大綱,只要變更指派給 input 的範例,然後重新執行程式。請保持檢查項目不變,讓它們繼續驗證剖析器的預期行為。需要文字表示時,將列印出的陣列文字複製到工作文件中。
保留引號和跳脫語意
此處有兩個層次需要考量:存放範例的 JavaScript 原始碼,以及要剖析的括號文字。範例使用 String.raw 標記範本,讓反斜線能在 JavaScript 原始碼層保留原樣傳給剖析器,不會經過一般字串跳脫解碼。本文所述的操作會讀取範本的原始片段。ECMAScript 規格:String.raw
請保留提供範例外側的反引號。若採用這種簡單的編輯方式,撰寫範例文字時請避免使用反引號或範本插值起始符 ${,因為它們在 JavaScript 範本語法中具有特殊意義。
在雙引號輸入詞元中,\" 代表一個字面上的雙引號,\\ 代表一個反斜線,而 \u0061 代表 a。這些是解碼後的字串值:樹會保留標籤的意義,但原始的跳脫寫法和外圍引號會被捨棄。在雙引號外,自訂剖析器會將反斜線視為一般詞元字元。
例如,最後一項有效輸入檢查會確認輸入詞元 "a\\b"、"\u0061" 和 "$&" 會分別轉成含有一個反斜線的標籤、標籤 a,以及字面標籤 $&。若要精確還原原始空格和跳脫寫法,解碼後字串的陣列並不足夠;這種表示法也必須保留來源文字或詞元位置。
為什麼全面取代會改變結果
將每個左括號都換成方括號左括號、每個右括號都換成方括號右括號,並不能實作此文法。這樣還會改變 "blue (round)" 中的括號,也無法處理未加引號的標籤和缺少項目分隔符的問題。這項結論可直接從撰寫的範例推得。
字串取代還有一個相關細節:取代字串引數會解讀 $& 等特殊序列,並將符合的文字插入其中。取代函式則會直接提供它回傳的文字,不會經過這項替換程序。ECMAScript 規格:replaceAll 與取代替換
以下獨立範例展示兩者的差異:
const label = "tile";
console.log(label.replaceAll("tile", "$&")); // tile
console.log(label.replaceAll("tile", () => "$&")); // $&
兩項結果都已在準備期間驗證。樹剖析器完全避開了取代語意:它會將解碼後的字串直接附加到陣列中。若之後要加入標籤重新命名步驟,請在剖析後的標籤上進行取代,並明確決定取代文字要解讀後套用,還是原樣插入。
重用樹之前診斷非預期的輸出
隨附的檢查項目是一份精簡的判斷指南,涵蓋空輸入、空群組、相鄰群組與標籤、含空格的引號文字、純字串值、跳脫字元及格式錯誤的語法。請依照下列症狀找出你的預期大綱與定義文法之間的落差。
| 症狀 | 說明與下一步 |
|---|---|
| 結果多出一個外層陣列 | 根節點代表文件。除非後續步驟明確要求恰好一個頂層群組,否則請保留它。 |
| 一個詞組變成多個字串 | 空白會分隔未加引號的標籤。請將詞組放在雙引號內。 |
| 逗號留在標籤中 | 逗號在此處是一般字元。請用空白分隔不同標籤。 |
| 括號意外變成巢狀陣列 | 請將字面上的括號放進雙引號標籤中。 |
Unexpected closing parenthesis |
右括號找不到相符的開啟群組。請檢查回報的位置。 |
Unclosed opening parenthesis |
輸入結尾仍有群組未關閉。該位置指出最內層未關閉的左括號。 |
Invalid JSON string |
請檢查引號詞元是否含有無效跳脫序列或未跳脫的控制字元。 |
| 與引號相關的分隔符錯誤 | 請以空白分隔相鄰標籤,或將整個預期標籤括在一組引號中。 |
此實作中的偏移量從零開始,並以 UTF-16 程式碼單位計算,與 JavaScript 字串索引方式一致。它們不是行號,也不是可見字元的數量。若引號詞元驗證發生錯誤,回報的位置是該詞元的起始處,因此請檢查整個引號標籤。
格式錯誤的輸入會擲回例外,函式也不會回傳結果。雖然陣列會隨掃描過程在內部逐步建立,呼叫端不會收到部分樹。此剖析器使用明確的堆疊,而不是遞迴函式呼叫,但範例並未設定文件大小或巢狀深度的支援上限。
對於小型桌面大綱,實際流程是先確認文法、執行未修改的範例和檢查項目,再換成你撰寫的文字。重用結果前,請確認引號標籤、空群組和文件根節點;這三項細節會決定陣列樹是否保留了你預期的結構。
來源
- 啟用 Web Inspector — WebKit。
- Web Inspector 介面 — WebKit。
- ECMAScript® 2027 語言規格:結構化資料 — Ecma International / TC39,持續更新的草案。
- ECMAScript® 2027 語言規格:文字處理 — Ecma International / TC39,持續更新的草案。