原文地址:lihautan.com/json-parser…
原文做者:Tan Li Hau
譯者:龔亮
聲明:本翻譯僅作學習交流使用,轉載請註明來源。javascript
本週 Cassidoo 每週時事通信的面試問題是:編寫一個函數,該函數接受一個有效的JSON字符串並將其轉換爲一個對象。編程語言不限,數據結構不限。輸入示例:html
fakeParseJSON('{ "data": { "fish": "cake", "array": [1,2,3], "children": [ { "something": "else" }, { "candy": "cane" }, { "sponge": "bob" } ] } } ') 複製代碼
有一次,我忍不住想寫:java
const fakeParseJSON = JSON.parse;
複製代碼
可是,我想,我已經寫了很多關於 AST 的文章:面試
其中包括編譯器管道的概述,以及如何操做 AST,可是我尚未詳細介紹如何實現解析器。bash
這是由於在一篇文章中實現JavaScript編譯器對我來講是一項艱鉅的任務。微信
好吧,不用擔憂。JSON 也是一種語言。它具備本身的語法,您能夠從規範中參考。編寫 JSON 解析器所需的知識和技術能夠轉移到編寫 JS 解析器中。babel
所以,讓咱們開始編寫 JSON 解析器!
若是您查看了規範頁面,會發現有2個圖。
圖片來源:www.json.org/img/object.…
json element value object array string number "true" "false" "null" object '{' ws '}' '{' members '}' 複製代碼
這兩個圖是等效的。
一個是可視化的,另外一個是基於文本的。基於文本的語法( Backus-Naur 形式)一般被提供給另外一個解析器,該解析器解析該語法併爲其生成一個解析器。🤯
在本文中,咱們將重點關注鐵路圖,由於它是可視化的,並且彷佛對我更友好。
讓咱們看看第一張鐵路圖:
圖片來源:www.json.org/img/object.…
這是 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; } } } 複製代碼
既然您已經看到我實現了「對象」語法,如今輪到您嘗試實現一下「數組」語法了:
圖片來源:www.json.org/img/array.p…
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; } } } 複製代碼
如今進入一個更有趣的語法「值」。
圖片來源:www.json.org/img/value.p…
值是以「空格」開始,而後是如下任意一種:「字符串」,「數字」,「對象」,「數組」,「真」,「假」或「空」,而後以「空格」結尾:
function fakeParseJSON(str) { // ... function parseValue() { skipWhitespace(); const value = parseString() ?? parseNumber() ?? parseObject() ?? parseArray() ?? parseKeyword('true', true) ?? parseKeyword('false', false) ?? parseKeyword('null', null); skipWhitespace(); return value; } } 複製代碼
??
是 空值合併操做符,它就像||
,咱們一般使用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; } } 複製代碼
您還記得您仍是一名初級開發人員的時候,每當您遇到帶有加密消息的語法錯誤時,您徹底不知道出了什麼問題嗎? 如今您有了更多經驗,該中止這個良性循環並中止大喊大叫了。
Unexpected token "a" 複製代碼
並讓用戶呆呆地盯着屏幕。
有不少比大喊大叫來處理錯誤消息的更好的方法,您能夠考慮將如下幾點添加到解析器中:
這對於用戶向 Google 尋求幫助做爲標準關鍵字頗有用。
// instead of Unexpected token "a" Unexpected end of input // show JSON_ERROR_001 Unexpected token "a" JSON_ERROR_002 Unexpected end of input 複製代碼
像 Babel 這樣的解析器,將向您顯示一個代碼框架,一個帶有下劃線、箭頭或突出顯示錯誤的代碼片斷:
// instead of Unexpected 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 of Unexpected 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 of Expecting a `"` over here, eg: "Foo Bar" ^ // show Expecting 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
若是你以爲這篇內容對你有價值,請點贊,並關注咱們的官網和咱們的微信公衆號(WecTeam),每週都有優質文章推送: