精讀《手寫 JSON Parser》

黃子毅發表於2020-02-17

1 引言

JSON.parse 是瀏覽器內建的 API,但如果面試官讓你實現一個怎麼辦?好在有人已經幫忙做了這件事,本週我們一起精讀這篇 JSON Parser with Javascript 文章吧,再溫習一遍大學時編譯原理相關知識。

2 概述 & 精讀

要解析 JSON 首先要理解語法概念,之前的 精讀《手寫 SQL 編譯器 - 語法分析》 系列也有介紹過,不過本文介紹的更形象,看下面這個語法圖:

精讀《手寫 JSON Parser》

這是關於 Object 型別的語法描述圖,從左向右看,根據箭頭指向只要能走出這個迷宮就屬於正確語法。

比如第一行 {whitespace} 表示 { } 屬於合法的 JSON 語法。

再比如觀察向下的一條最長路線:{whitespacestringwhitespace:value} 表示 { string : value } 屬於合法的 JSON 語法。

你可能會問,雙引號去哪兒了?這就是語法樹最核心的概念了,這張圖是關於 Object 型別的 產生式,同理還有 string、value 的產生式,產生式中可以巢狀其他產生式,甚至形成環路,以此擁有描述紛繁多變語法的能力。

最後我們再看一個環路,即 {whitespacestring ... ,whitespacestring ... , ... },我們發現,只要不走回頭路,這條路是可以一直 “繞圈” 下去的,因此 Object 型別擁有了任意數量子欄位的能力,只是每形成一個子欄位,必須經過 , 號分割。

實現 Parser

首先實現一個基本結構:

function fakeParseJSON(str) {
  let i = 0;
  // TODO
}
複製程式碼

i 表示訪問字元的下標,當 i 走到字串結尾表示遍歷結束。

然後是下一步,用幾個函式描述解析語法的過程:

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();
      }
    }
  }
}
複製程式碼

其中 skipWhitespace 表示匹配並跳過空格,所謂匹配意味著匹配成功,此時 i 下標可以繼續後移,否則匹配失敗。下一步則判斷如果 i 不是結束標誌 },則按照 parseString 匹配字串 → skipWhitespace 跳過空格 → eatColon 吃掉逗號 → parseValue 匹配值,這個鏈路迴圈。其中吃掉逗號表示 “匹配逗號但不會產生任何結果,所以就像吃掉了一樣”,吃這個動作還可以用在其他場景,比如吃掉尾分號。

對於看到這兒的小夥伴,筆者要友情提示一下,原文的思路是一種定製語法解析思路,無論是 eatColon 還是 parseValue 都僅具備解析 JSON 的通用性,但不具備解析任意語法的通用性。如果你想做一個具備解析任何通用語法的解析器,讀入的內容應該是語法描述,處理方式必須更加通用,如果感興趣可以閱讀 精讀《手寫 SQL 編譯器 - 語法分析》 系列文章瞭解更多。

由於 Object 第一個元素前面不允許加逗號,因此可以利用 initial 做一個初始化判定,在初始時機不會吃掉逗號:

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++;
    }
  }
}
複製程式碼

那麼當第一個子元素前面存在逗號時,由於沒有 “吃掉逗號” 這個功能,所以讀到逗號會報錯,語法解析提前結束。

吃逗號和吃冒號的程式碼都非常簡單,即判斷當前字串必須是 “要吃的那個元素”,並且在吃掉後將 i 下標自增 1:

function fakeParseJSON(str) {
  // ...
  function eatComma() {
    if (str[i] !== ',') {
      throw new Error('Expected ",".');
    }
    i++;
  }

  function eatColon() {
    if (str[i] !== ':') {
      throw new Error('Expected ":".');
    }
    i++;
  }
}
複製程式碼

在有了基本判定功能後,fakeParseJSON 需要返回 Object,因此我們只需在每個迴圈中對 Object 賦值,最後一併 return 即可:

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

解析 Object 的程式碼就完成了。

接著試著解析 Array,下面是 Array 的語法圖:

精讀《手寫 JSON Parser》

我們只需要吃逗號和 parseValue 即可:

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

接下來到了有趣的 value 語法圖,可以看到 value 是許多種基礎型別的 “或” 關係組成的:

精讀《手寫 JSON Parser》

我們只需要繼續拆解分析即可:

function fakeParseJSON(str) {
  // ...
  function parseValue() {
    skipWhitespace();
    const value =
      parseString() ??
      parseNumber() ??
      parseObject() ??
      parseArray() ??
      parseKeyword('true', true) ??
      parseKeyword('false', false) ??
      parseKeyword('null', null);
    skipWhitespace();
    return value;
  }
}
複製程式碼

其中 parseKeyword 函式用來解析一些保留關鍵字,比如將 "true" 解析成布林型別 true

function fakeParseJSON(str) {
  // ...
  function parseKeyword(name, value) {
    if (str.slice(i, i + name.length) === name) {
      i += name.length;
      return value;
    }
  }
}
複製程式碼

如上所示,只要在 name 與對應字元相等時,返回第二個傳入引數即可。

處理異常輸入

一個完整的語法解析功能需要包含錯誤處理,錯誤的情況主要分兩種:

  1. 非法字元。
  2. 非正常結尾。

原文提到的 JSON 錯誤提示優化非常棒,想想你在開發中突然看到下面的提示,是不是很蒙圈:

Unexpected token "a"
複製程式碼

既然我們是自己寫的 JSON 解析器,就可以進行更友好的異常提示,比如:

// 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
複製程式碼

更多 Demo 可以檢視 原文

3 總結

這篇文章通過一個具體的例子解釋如何做語法分析,對於詞法解析入門非常直觀,如果你想更深入理解語法解析,或者寫一個通用語法解析器,可以閱讀語法解析系列入門文章,筆者通過實際例子帶你一步一步做一個完備的詞法解析工具!

語法解析入門系列文章,建議閱讀順序:

syntax-parser 這個零依賴的通用語法解析庫就是根據上述文章一步一步完成的,看完了上面文章,就徹底理解了這個庫的原始碼。

討論地址是:精讀《手寫 JSON Parser》 · Issue #233 · dt-fe/weekly

如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公眾號

精讀《手寫 JSON Parser》

版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證

相關文章