精讀《手寫 JSON Parser》

1 引言

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

2 概述 & 精讀

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

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

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

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

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

最後咱們再看一個環路,即 {whitespacestring ... ,whitespacestring ... , ... },咱們發現,只要不走回頭路,這條路是能夠一直 「繞圈」 下去的,所以 Object 類型擁有了任意數量子字段的能力,只是每造成一個子字段,必須通過 , 號分割。json

實現 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 的語法圖:

咱們只須要吃逗號和 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 是許多種基礎類型的 「或」 關係組成的:

咱們只須要繼續拆解分析便可:

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

若是你想參與討論,請 點擊這裏,每週都有新的主題,週末或週一發佈。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公衆號

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

相關文章
相關標籤/搜索