這周的 Cassidoo 的每週簡訊有這麼一個面試題::javascript
寫一個函數,這個函數接收一個正確的 JSON 字符串並將其轉化爲一個對象(或字典,映射等,這取決於你選擇的語言)。示例輸入:html
fakeParseJSON('{ "data": { "fish": "cake", "array": [1,2,3], "children": [ { "something": "else" }, { "candy": "cane" }, { "sponge": "bob" } ] } } ')
當時,我想這麼寫:java
const fakeParseJSON = JSON.parse;
可是,我想起以前寫了一些關於AST的文章,git
其中涵蓋了編譯器管道的概述以及如何操做AST,可是我沒有過多介紹如何實現解析器。由於實現JavaScript編譯器對我來講是一項艱鉅的任務。github
那就不必擔憂。 JSON也是一種語言,有本身的語法,能夠參考規範。 根據編寫JSON解析器所需的知識和技術轉移到編寫JS解析器中。面試
好了,那就開始編寫一個JSON解析器吧。json
查看規範文檔頁面,能夠看到如下兩個圖。babel
json element value object array string number "true" "false" "null" object '{' ws '}' '{' members '}'
兩個圖實際上是等價的。框架
一個基於視覺,一個基於文本。基於文本語法的語法 —— 巴科斯-諾爾範式,一般被提供給另外一個解析這種語法併爲其生成解析器的解析器,終於說到解析器了!🤯ide
在這篇文章中,咱們重點關注鐵路圖上,由於它是可視化的,看起來更友好。
先來看下第一張的鐵路圖:
因此這就是JSON中「object」的語法。
從左側開始,沿着箭頭的方向走,一直到右側爲止。
圓圈裏面是一個字符,例如 {
,,
,:
,}
,矩形裏面是其它語法的佔位符,例如 whitespace(空格)
、string
和 value
。所以要解析"whitespace",咱們須要查閱"whitepsace"語法。
所以,對於一個對象而言,從左邊開始,第一個字符必須是一個左花括號 {
,而後往下走會有兩種狀況:
whitespace
→ }
→ 結束whitespace
→ string
→ whitespace
→ :
→ value
→ }
→ 結束固然當抵達value的時候,你能夠選擇繼續下去:
}
→ 結束,或者,
→ whitespace
→ … → value}
→ 結束。下面咱們開始編寫代碼,代碼結構以下:
function fakeParseJSON(str) { let i = 0; // TODO }
初始化 i 將其做爲當前字符的索引值,只要 i 值到達 str 的長度,咱們就會結束函數。
後面咱們來實現「object」語法:
function fakeParseJSON(str) { let i = 0; function parseObject() { if (str[i] === '{') { i++; skipWhitespace(); // 若是不是 '}', // 咱們接收 string -> whitespace -> ':' -> value -> ... 這樣的路徑字符串 while (str[i] !== '}') { const key = parseString(); skipWhitespace(); eatColon(); const value = parseValue(); } } } }
咱們能夠調用 parseObject 來解析相似string和whitespace之類的語法,只要咱們實現這些功能,一切都解決了🤞。
還有就是我我忘記加逗號,
了。逗號,
只會出如今開始第二次whitespace
→ string
→ whitespace
→ :
→ … 循環以前。
在這個基礎上,咱們加上了一下幾行:
function fakeParseJSON(str) { let i = 0; function parseObject() { if (str[i] === '{') { i++; skipWhitespace(); let initial = true; // 若是不是 '}', // 就按照這樣的路徑執行: 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
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對象:
function fakeParseJSON(str) { let i = 0; function parseObject() { if (str[i] === '{') { i++; skipWhitespace(); const result = {}; let initial = true; // 若是不是 '}', // 就按照這樣的路徑執行: string -> whitespace -> ':' -> value -> ... while (str[i] !== '}') { if (!initial) { eatComma(); skipWhitespace(); } const key = parseString(); skipWhitespace(); eatColon(); const value = parseValue(); result[key] = value; initial = false; } // 移動到下一個字符 '}' i++; return result; } } }
如今你已經看到我怎麼去實現「object「語法,如今是時候讓你嘗試一下」array「語法了:
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; } // 移動到下一個字符 ']' i++; return result; } } }
如今,咱們來看一個更有趣的語法,「value」:
如上圖的路徑:
一個值是以「whitespace」開始,而後接着是如下類型的一種:「string」,「number」,「object」,「array」,「true」,「false」 或者null,最後以一個「whitespace」結束。
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。能夠看個例子:
const foo = null ?? 'default string'; console.log(foo); // 輸出: "default string"
parseKeyword 將檢查當前 str.slice(i) 是否與關鍵字字符串匹配,若是匹配,將返回關鍵字值:
function fakeParseJSON(str) { // ... function parseKeyword(name, value) { if (str.slice(i, i + name.length) === name) { i += name.length; return value; } } }
這個就是parseKeyword
的實現。
咱們還有 3 個以上的語法要實現,但我爲了控制文章篇幅,在下面的 CodeSandbox 中實現這些語法。
CodeSandbox
完成全部語法實現以後,而後返回由parseValue返回的json值:
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"
例如以上的錯誤,只會讓用戶很困惑地盯着屏幕,而不知道錯誤在哪裏。
相比去吐槽,其實有不少更好的方式去改善這些錯誤提示,下面有幾點建議能夠考慮加到解析器裏面:
標準關鍵字對用戶谷歌尋求幫助頗有用
// 很差的提示 Unexpected token "a" Unexpected end of input // 好的提示 JSON_ERROR_001 Unexpected token "a" JSON_ERROR_002 Unexpected end of input
像 Babel 這樣的解析器,會向你顯示一個代碼框架,它是一個帶有下劃線、箭頭或突出顯示錯誤的代碼片斷
// 很差的提示 Unexpected token "a" at position 5 // 好的提示 { "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); } }
能夠的話,能夠說明是哪裏出問題以及給出修復建議。
// 很差的提示 Unexpected token "a" at position 5 // 好的提示 { "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
推薦閱讀Evan Czaplicki的關於如何提升編譯器用戶體驗的一篇文章「編譯器錯誤建議」
function fakeParseJSON(str) { let i = 0; const value = parseValue(); expectEndOfInput(); return value; 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 (i < str.length && str[i] !== "}") { if (!initial) { eatComma(); skipWhitespace(); } const key = parseString(); if (key === undefined) { expectObjectKey(); } skipWhitespace(); eatColon(); const value = parseValue(); result[key] = value; initial = false; } expectNotEndOfInput("}"); // move to the next character of '}' i++; return result; } } function parseArray() { if (str[i] === "[") { i++; skipWhitespace(); const result = []; let initial = true; while (i < str.length && str[i] !== "]") { if (!initial) { eatComma(); } const value = parseValue(); result.push(value); initial = false; } expectNotEndOfInput("]"); // move to the next character of ']' i++; return result; } } function parseValue() { skipWhitespace(); const value = parseString() ?? parseNumber() ?? parseObject() ?? parseArray() ?? parseKeyword("true", true) ?? parseKeyword("false", false) ?? parseKeyword("null", null); skipWhitespace(); return value; } function parseKeyword(name, value) { if (str.slice(i, i + name.length) === name) { i += name.length; return value; } } function skipWhitespace() { while ( str[i] === " " || str[i] === "\n" || str[i] === "\t" || str[i] === "\r" ) { i++; } } function parseString() { if (str[i] === '"') { i++; let result = ""; while (i < str.length && str[i] !== '"') { if (str[i] === "\\") { const char = str[i + 1]; if ( char === '"' || char === "\\" || char === "/" || char === "b" || char === "f" || char === "n" || char === "r" || char === "t" ) { result += char; i++; } else if (char === "u") { if ( isHexadecimal(str[i + 2]) && isHexadecimal(str[i + 3]) && isHexadecimal(str[i + 4]) && isHexadecimal(str[i + 5]) ) { result += String.fromCharCode( parseInt(str.slice(i + 2, i + 6), 16) ); i += 5; } else { i += 2; expectEscapeUnicode(result); } } else { expectEscapeCharacter(result); } } else { result += str[i]; } i++; } expectNotEndOfInput('"'); i++; return result; } } function isHexadecimal(char) { return ( (char >= "0" && char <= "9") || (char.toLowerCase() >= "a" && char.toLowerCase() <= "f") ); } function parseNumber() { let start = i; if (str[i] === "-") { i++; expectDigit(str.slice(start, i)); } if (str[i] === "0") { i++; } else if (str[i] >= "1" && str[i] <= "9") { i++; while (str[i] >= "0" && str[i] <= "9") { i++; } } if (str[i] === ".") { i++; expectDigit(str.slice(start, i)); while (str[i] >= "0" && str[i] <= "9") { i++; } } if (str[i] === "e" || str[i] === "E") { i++; if (str[i] === "-" || str[i] === "+") { i++; } expectDigit(str.slice(start, i)); while (str[i] >= "0" && str[i] <= "9") { i++; } } if (i > start) { return Number(str.slice(start, i)); } } function eatComma() { expectCharacter(","); i++; } function eatColon() { expectCharacter(":"); i++; } // error handling function expectNotEndOfInput(expected) { if (i === str.length) { printCodeSnippet(`Expecting a \`${expected}\` here`); throw new Error("JSON_ERROR_0001 Unexpected End of Input"); } } function expectEndOfInput() { if (i < str.length) { printCodeSnippet("Expecting to end here"); throw new Error("JSON_ERROR_0002 Expected End of Input"); } } function expectObjectKey() { printCodeSnippet(`Expecting object key here For example: { "foo": "bar" } ^^^^^`); throw new Error("JSON_ERROR_0003 Expecting JSON Key"); } function expectCharacter(expected) { if (str[i] !== expected) { printCodeSnippet(`Expecting a \`${expected}\` here`); throw new Error("JSON_ERROR_0004 Unexpected token"); } } function expectDigit(numSoFar) { if (!(str[i] >= "0" && str[i] <= "9")) { printCodeSnippet(`JSON_ERROR_0005 Expecting a digit here For example: ${numSoFar}5 ${" ".repeat(numSoFar.length)}^`); throw new Error("JSON_ERROR_0006 Expecting a digit"); } } function expectEscapeCharacter(strSoFar) { printCodeSnippet(`JSON_ERROR_0007 Expecting escape character For example: "${strSoFar}\\n" ${" ".repeat(strSoFar.length + 1)}^^ List of escape characters are: \\", \\\\, \\/, \\b, \\f, \\n, \\r, \\t, \\u`); throw new Error("JSON_ERROR_0008 Expecting an escape character"); } function expectEscapeUnicode(strSoFar) { printCodeSnippet(`Expect escape unicode For example: "${strSoFar}\\u0123 ${" ".repeat(strSoFar.length + 1)}^^^^^^`); throw new Error("JSON_ERROR_0009 Expecting an escape unicode"); } function printCodeSnippet(message) { const from = Math.max(0, i - 10); const trimmed = from > 0; const padding = (trimmed ? 4 : 0) + (i - from); const snippet = [ (trimmed ? "... " : "") + str.slice(from, i + 1), " ".repeat(padding) + "^", " ".repeat(padding) + message ].join("\n"); console.log(snippet); } } // console.log("Try uncommenting the fail cases and see their error message"); // console.log("↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓"); // Fail cases: printFailCase("-"); printFailCase("-1."); printFailCase("1e"); printFailCase("-1e-2.2"); printFailCase("{"); printFailCase("{}{"); printFailCase('{"a"'); printFailCase('{"a": "b",'); printFailCase('{"a":"b""c"'); printFailCase('{"a":"foo\\}'); printFailCase('{"a":"foo\\u"}'); printFailCase("["); printFailCase("[]["); printFailCase("[[]"); printFailCase('["]'); function printFailCase(json) { try { console.log(`fakeParseJSON('${json}')`); fakeParseJSON(json); } catch (error) { console.error(error); } }
要實現解析器,你須要從語法開始。
你能夠用鐵路圖或巴科斯-諾爾範式來使語法正式化。設計語法是最困難的一步。
一旦你解決了語法問題,就能夠開始基於語法實現解析器。
錯誤處理很重要,更重要的是要有有意義的錯誤消息,以便用戶知道如何修復它。
如今,你已經瞭解瞭如何實現簡單的解析器,如今應該關注更復雜的解析器了:
最後,請關注 @cassidoo,她的每週簡訊棒極了。
(完)
以上譯文僅用於學習交流,水平有限,不免有錯誤之處,敬請指正。若是以爲文章對你有幫助,請點個贊吧。