JavaScript實現JSON解析器

原文地址: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個圖。

  • 左側的語法圖(或者鐵路圖):

圖1

圖片來源:www.json.org/img/object.…

json
  element

value
  object
  array
  string
  number
  "true"
  "false"
  "null"

object
  '{' ws '}'
  '{' members '}'
複製代碼

這兩個圖是等效的。

一個是可視化的,另外一個是基於文本的。基於文本的語法( Backus-Naur 形式)一般被提供給另外一個解析器,該解析器解析該語法併爲其生成一個解析器。🤯

在本文中,咱們將重點關注鐵路圖,由於它是可視化的,並且彷佛對我更友好。

讓咱們看看第一張鐵路圖:

圖1

圖片來源: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

讓咱們來實現eatCommaeatColon

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;
    }
  }
}
複製代碼

既然您已經看到我實現了「對象」語法,如今輪到您嘗試實現一下「數組」語法了:

圖1

圖片來源: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;
    }
  }
}
複製代碼

如今進入一個更有趣的語法「值」。

圖1

圖片來源: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。然而只有當foonull或者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&amp;fontsize=14&amp;hidenavigation=1&amp;theme=dark&amp;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&amp;fontsize=14&amp;hidenavigation=1&amp;theme=dark&amp;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),每週都有優質文章推送:

WecTeam
相關文章
相關標籤/搜索