原文地址:https://lihautan.com/json-parser-with-javascript/
原文作者:Tan Li Hau
譯者:龔亮
聲明:本翻譯僅做學習交流使用,轉載請註明來源。
本周 Cassidoo 每周時事通訊[1]的面試問題是:編寫一個函數,該函數接受一個有效的JSON字符串並將其轉換為一個對象。程式語言不限,數據結構不限。輸入示例:
fakeParseJSON('{ "data": { "fish": "cake", "array": [1,2,3], "children": [ { "something": "else" }, { "candy": "cane" }, { "sponge": "bob" } ] } } ')有一次,我忍不住想寫:
const fakeParseJSON = JSON.parse;但是,我想,我已經寫了不少關於 AST 的文章:
•使用Babel創建自定義JavaScript語法[2]•編寫自定義babel轉換的逐步指南[3]•用JavaScript操作AST[4]
其中包括編譯器管道的概述,以及如何操作 AST,但是我還沒有詳細介紹如何實現解析器。
這是因為在一篇文章中實現JavaScript編譯器對我來說是一項艱巨的任務。
好吧,不用擔心。JSON 也是一種語言。它具有自己的語法,您可以從規範[5]中參考。編寫 JSON 解析器所需的知識和技術可以轉移到編寫 JS 解析器中。
因此,讓我們開始編寫 JSON 解析器!
理解語法如果您查看了規範頁面,會發現有2個圖。
•左側的語法圖(或者鐵路圖):
圖片來源:https://www.json.org/img/object.png
•右側的 McKeeman形式[6] ,是 Backus-Naur形式(BNF)[7] 的變體。
json element
value object array string number "true" "false" "null"
object '{' ws '}' '{' members '}'這兩個圖是等效的。
一個是可視化的,另一個是基於文本的。基於文本的語法( Backus-Naur 形式)通常被提供給另一個解析器,該解析器解析該語法並為其生成一個解析器。🤯
在本文中,我們將重點關注鐵路圖,因為它是可視化的,而且似乎對我更友好。
讓我們看看第一張鐵路圖:
圖片來源:https://www.json.org/img/object.png
這是 JSON 中「對象」的語法。
我們從左邊開始,沿著箭頭走,然後在右邊結束。
圓圈(例如:左花括號({),英文逗號(,),英文冒號(:),右花括號(}))是字符,方框(例如:空格(whitespace)、字符串(string)和值(value))是另一種語法的佔位符。如果要解析「空格」,我們需要查看空格的語法。
因此,對於一個對象,從左邊開始第一個字符必須是一個左花括號。然後我們有兩個選擇:
•空格 -> 右花括號 -> 結束, 或者•空格 -> 字符串 -> 空格 -> 英文冒號 -> 值 -> 右花括號 -> 結束
當然,當您到達「值」時,您可以選擇:
•-> 右花括號 -> 結束,或者•-> 英文逗號 -> 空格 -> ... -> 值
您可以繼續保持循環,直到您決定執行以下操作:
•-> 右花括號 -> 結束。
我想我們現在已經熟悉鐵路圖,讓我們繼續下一節。
實現解析器讓我們從以下結構開始:
function fakeParseJSON(str) { let i = 0; // TODO}我們初始化i作為當前字符的索引,當i到達str結束時,我們將立即結束。
讓我們實現「對象」的語法:
function fakeParseJSON(str) { let i = 0; function parseObject() { if (str[i] === '{') { i++; skipWhitespace();
// if it is not '}', // we take the path of string -> whitespace -> ':' -> value -> ... while (str[i] !== '}') { const key = parseString(); skipWhitespace(); eatColon(); const value = parseValue(); } } }}在parseObject中,我們將調用其他語法的解析,例如「字符串」和」空格」,當我們實現它們時,一切都會起作用🤞。
我忘了加上一個英文逗號,,,只出現在我們開始第二次循環空格 -> 字符串 -> 空格 -> : -> ...之前。
基於此,我們添加了以下行,注意第8,12~15,20行(譯者加):
function fakeParseJSON(str) { let i = 0; function parseObject() { if (str[i] === '{') { i++; skipWhitespace();
let initial = true; // if it is not '}', // we take the path of string -> whitespace -> ':' -> value -> ... while (str[i] !== '}') { if (!initial) { eatComma(); skipWhitespace(); } const key = parseString(); skipWhitespace(); eatColon(); const value = parseValue(); initial = false; } // move to the next character of '}' i++; } }}一些命名約定:
•當我們基於語法解析代碼並使用返回值時,我們調用parseSomething•當我們期望字符在那裡,但我們沒有使用字符時,我們調用eatSomething•字符不在那裡,但我們的程序是ok的,我們調用skipSomething
讓我們來實現eatComma和eatColon:
function fakeParseJSON(str) { // ... function eatComma() { if (str[i] !== ',') { throw new Error('Expected ",".'); } i++; }
function eatColon() { if (str[i] !== ':') { throw new Error('Expected ":".'); } i++; }}我們已經完成了parseObject語法的實現,但是這個解析函數的返回值是什麼呢?
我們需要返回一個 JavaScript 對象,注意第8,22,28行(譯者加)。
function fakeParseJSON(str) { let i = 0; function parseObject() { if (str[i] === '{') { i++; skipWhitespace();
const result = {};
let initial = true; // if it is not '}', // we take the path of string -> whitespace -> ':' -> value -> ... while (str[i] !== '}') { if (!initial) { eatComma(); skipWhitespace(); } const key = parseString(); skipWhitespace(); eatColon(); const value = parseValue(); result[key] = value; initial = false; } // move to the next character of '}' i++;
return result; } }}既然您已經看到我實現了「對象」語法,現在輪到您嘗試實現一下「數組」語法了:
圖片來源:https://www.json.org/img/array.png
function fakeParseJSON(str) { // ... function parseArray() { if (str[i] === '[') { i++; skipWhitespace();
const result = []; let initial = true; while (str[i] !== ']') { if (!initial) { eatComma(); } const value = parseValue(); result.push(value); initial = false; } // move to the next character of ']' i++; return result; } }}現在進入一個更有趣的語法「值」。
圖片來源:https://www.json.org/img/value.png
值是以「空格」開始,然後是以下任意一種:「字符串」,「數字」,「對象」,「數組」,「真」,「假」或「空」,然後以「空格」結尾:
function fakeParseJSON(str) { // ... function parseValue() { skipWhitespace(); const value = parseString() ?? parseNumber() ?? parseObject() ?? parseArray() ?? parseKeyword('true', true) ?? parseKeyword('false', false) ?? parseKeyword('null', null); skipWhitespace(); return value; }}??是 空值合併操作符[8],它就像||,我們通常使用foo || default設置默認值。我們期望當foo是假值時||返回default。然而只有當foo是null或者undefined時空值合併操作符返回default。
parseKeyword 將檢查當前的str.slice(i)是否與關鍵字字符串匹配,如果匹配,將返回關鍵字值:
function fakeParseJSON(str) { // ... function parseKeyword(name, value) { if (str.slice(i, i + name.length) === name) { i += name.length; return value; } }}parseValue就是這樣!
我們還有3種語法,但是我將節省本文的篇幅,並在下面的 CodeSandbox 中實現它們:
<iframe src="https://codesandbox.io/embed/json-parser-k4c3w?expanddevtools=1&fontsize=14&hidenavigation=1&theme=dark&view=editor" style="width:100%; height:500px; border:0; border-radius: 4px; overflow:hidden;" title="JSON解析器" allow="geolocation; microphone; camera; midi; vr; accelerometer; gyroscope; payment; ambient-light-sensor; encrypted-media; usb" sandbox="allow-modals allow-forms allow-popups allow-scripts allow-same-origin"></iframe>在我們完成所有語法實現之後,現在讓我們返回json的值,它是由parseValue返回的:
function fakeParseJSON(str) { let i = 0; return parseValue();
// ...}就是這樣!
好吧,別急,我的朋友,我們剛剛完成了理想的情況,那異常的情況呢?
處理意外的輸入作為一名優秀的開發人員,我們還需要優雅地處理異常情況。對於解析器,這意味著使用適當的錯誤消息對開發人員進行提醒。
讓我們處理兩種最常見的錯誤情況:
•意外的標記•字符串意外結束
意外的標記字符串意外結束在所有的while循環中,比如parseObject中while循環:
function fakeParseJSON(str) { // ... function parseObject() { // ... while(str[i] !== '}') {我們需要確保訪問的字符不會超過字符串的長度。在這個例子中,這發生在字符串意外結束時,而我們仍然在等待一個結束字符「}」。
function fakeParseJSON(str) { // ... function parseObject() { // ... while (i < str.length && str[i] !== '}') { // ... } checkUnexpectedEndOfInput();
// move to the next character of '}' i++;
return result; }}加倍努力您還記得您還是一名初級開發人員的時候,每當您遇到帶有加密消息的語法錯誤時,您完全不知道出了什麼問題嗎?現在您有了更多經驗,該停止這個良性循環並停止大喊大叫了。
並讓用戶呆呆地盯著屏幕。
有很多比大喊大叫來處理錯誤消息的更好的方法,您可以考慮將以下幾點添加到解析器中:
錯誤代碼和標準錯誤消息這對於用戶向 Google 尋求幫助作為標準關鍵字很有用。
// instead ofUnexpected token "a"Unexpected end of input
// showJSON_ERROR_001 Unexpected token "a"JSON_ERROR_002 Unexpected end of input更好地了解出了什麼問題像 Babel 這樣的解析器,將向您顯示一個代碼框架,一個帶有下劃線、箭頭或突出顯示錯誤的代碼片段:
// instead ofUnexpected token "a" at position 5
// show{ "b"a ^JSON_ERROR_001 Unexpected token "a"有關如何列印代碼段的示例:
function fakeParseJSON(str) { // ... function printCodeSnippet() { const from = Math.max(0, i - 10); const trimmed = from > 0; const padding = (trimmed ? 3 : 0) + (i - from); const snippet = [ (trimmed ? '...' : '') + str.slice(from, i + 1), ' '.repeat(padding) + '^', ' '.repeat(padding) + message, ].join('\n'); console.log(snippet); }}錯誤恢復建議如果可能,請解釋出了什麼問題,並提供有關如何解決它們的建議:
// instead ofUnexpected token "a" at position 5
// show{ "b"a ^JSON_ERROR_001 Unexpected token "a".Expecting a ":" over here, eg:{ "b": "bar" } ^You can learn more about valid JSON string in http://goo.gl/xxxxx如果可能,請根據解析器到目前為止收集的上下文提供建議:
fakeParseJSON('"Lorem ipsum');
// instead ofExpecting a `"` over here, eg:"Foo Bar" ^
// showExpecting a `"` over here, eg:"Lorem ipsum" ^基於上下文的建議會讓人感覺更有共鳴和可操作。
記住所有的建議,檢查更新的 CodeSandbox。
•有意義的錯誤消息•帶有錯誤指向失敗點的代碼段•提供錯誤恢復建議
<iframe src="https://codesandbox.io/embed/json-parser-hjwxk?expanddevtools=1&fontsize=14&hidenavigation=1&theme=dark&view=editor" style="width:100%; height:500px; border:0; border-radius: 4px; overflow:hidden;" title="JSON解析器(帶有錯誤處理)" allow="geolocation; microphone; camera; midi; vr; accelerometer; gyroscope; payment; ambient-light-sensor; encrypted-media; usb" sandbox="allow-modals allow-forms allow-popups allow-scripts allow-same-origin"></iframe>總結要實現解析器,您需要從語法開始。
您可以使用鐵路圖或 Backus-Naur 形式語法。設計語法是最難的一步。
一旦掌握了語法,就可以開始基於語法來實現解析器。
錯誤處理很重要,更重要的是擁有有意義的錯誤消息,以便用戶知道如何解決它。
現在您知道了如何實現簡單的解析器,是時候著眼於更複雜的解析器了。
•Babel parser•Svelte parser
最後,請關注 @cassidoo[9] ,她的每周時事通訊棒極了!
感謝您花時間閱讀本文。這對我意義重大。
如果你喜歡你剛剛讀到的,請在 Tweet 轉發[10]並評論它,我會寫更多相關的文章;
如果你不同意或對這篇文章有意見,也請在 Tweet 轉發[11]並評論它,我可以採納你的建議並改進它。
References[1] Cassidoo 每周時事通訊: https://cassidoo.co/newsletter/confirmed.html
[2] 使用Babel創建自定義JavaScript語法: https://lihautan.com/creating-custom-javascript-syntax-with-babel/
[3] 編寫自定義babel轉換的逐步指南: https://lihautan.com/step-by-step-guide-for-writing-a-babel-transformation/
[4] 用JavaScript操作AST: https://lihautan.com/manipulating-ast-with-javascript/
[5] 規範: https://www.json.org/json-en.html
[6] McKeeman形式: https://www.crockford.com/mckeeman.html
[7] Backus-Naur形式(BNF): https://en.wikipedia.org/wiki/Backus%E2%80%93Naur_form
[8] 空值合併操作符: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Nullish_coalescing_operator
[9] @cassidoo: https://twitter.com/cassidoo
[10] 轉發: https://twitter.com/intent/tweet?text=I%20disgree%20with%20%40lihautan's%20article&url=https://lihautan.com/json-parser-with-javascript/#unexpected-token"
[11] 轉發: https://twitter.com/intent/tweet?text=I%20disgree%20with%20%40lihautan's%20article&url=https://lihautan.com/json-parser-with-javascript/#unexpected-token"