半熟前端

軟體工程師 / 台灣人 / 在前端的路上一邊探索其他領域的可能性

前端

從無到有寫一個 JSON 解析器(2)

part1 當中我們提到了如何撰寫一個 JSON 解析器,並實作解析字串的功能,接下來我們把其他函數補上。(事實上只要知道基本原理,剩下的函數實作都是照本宣科而已)

Number

json-grammer

number 實作上也不難,容易忽略的地方在於小數點、負數、浮點數的部分,還有指數表達(1e6)。(話說剛剛才發現 E 也可以)

function number(parser) {
  let str = "";
  if (parser.current() === "-") {
    str += "-";
    parser.index += 1;
  }

  let curr = "";
  while (((curr = parser.current()), curr >= "0" && curr <= "9")) {
    str += curr;
    parser.index += 1;
  }

  let isFloat = false;
  // float number
  if (parser.next(".")) {
    str += ".";
    isFloat = true;
    while (((curr = parser.current()), curr >= "0" && curr <= "9")) {
      str += curr;
      parser.index += 1;
    }
  }

  // exponential expression
  let expo = "";
  if (parser.next("e")) {
    curr = "";
    if (parser.next("-")) {
      expo += "-";
    }

    while (((curr = parser.current()), curr >= "0" && curr <= "9")) {
      expo += curr;
      parser.index += 1;
    }
  }

  if (expo) {
    return isFloat
      ? parseFloat(str, 10) * Math.pow(10, +expo)
      : parseInt(str, 10) * Math.pow(10, +expo);
  }

  return isFloat ? parseFloat(str, 10) : parseInt(str, 10);
}
  • 第一個部分,我們先看看它是不是負數

  • 第二部分,跑 while 迴圈把數字部分放入

  • 第三部分,判斷是否有小數點

    • 如果有的話就再爬一次數字
  • 第四部分,判斷是否有指數表達(大寫或小寫 e)

    • 如果有的話也再爬一次數字
  • 第五部分:把字串變成數字(使用 parseInt 或是 parseFloat)

關鍵字(true, false, null)

function keyword(parser) {
  if (parser.next("true")) {
    return true;
  } else if (parser.next("false")) {
    return false;
  } else if (parser.next("null")) {
    return null;
  }
}

這部分很簡單,就看看是不是值有匹配。

陣列(Array)

json-grammer

function array(parser) {
  const arr = [];

  if (parser.current() === "[") {
    parser.next("[");
    parser.skip();

    if (parser.next("]")) {
      return arr;
    }
    let i = 0;
    while (parser.current()) {
      const val = value(parser);
      arr.push(val);

      parser.skip();

      if (parser.current() === "]") {
        parser.next("]");
        return arr;
      }
      parser.next(",");
      parser.skip();
    }
  }

  return arr;
}
  • 第一個部分,先看看是否是 [ 開頭

    • 如果遇到 ] 代表他是一個空陣列
  • 第二部分,跑 while 迴圈執行 value 函數,並放到 array 當中

  • 遇到 ] 代表陣列結尾,回傳 array。

  • 遇到逗號代表還有下一個元素,繼續執行

這樣一來就算是差不多了,可以看一下 Repository 上的程式碼實作,可以發現測試當中有一個 specical-character 是失敗的,因為字串中可能會有一些跳脫字元,我們試著實作看看。

const escape = {
  '"': '"',
  t: "\t",
  r: "\r",
  "\\": "\\",
};

while (((curr = parser.current()), curr)) {
    if (parser.next('"')) {
      return str;
    } else if (curr === "\\") {
      parser.index += 1;
      const escapeCh = parser.current();
      if (escape[escapeCh]) {
        str += escape[escapeCh];
      }
    } else {
      str += curr;
    }
    parser.index += 1;
  }

我們建立一個跳脫字元表,查表後取代成對應的實作,這裡只有實作 \t\r。這樣子就算是通過基本的 JSON 測試了🍻。不過跳脫字元除了以上提到的之外,還要實作 \u ,代表 unicode,這個功能蠻重要的。

客製功能:templatetemplate

既然解析部分是自己寫,我們當然可以自己加入新的語法!假設今天要實作一個模板功能,任何用 \$\$ 包起來的變數,都會用傳入的物件取代,例如:

{
  "name": $name$
}

會變成:

new Parser(string, { name: 'kalan' }).parse();
// { name: "kalan" }

實作

function template(parser) {
  parser.skip();
  if (parser.next("$")) {
    parser.skip();

    if (parser.next("$")) {
      throw new Error("template can not be empty");
    }
    let curr = "";
    let key = "";
    while (((curr = parser.current()), curr)) {
      if (parser.next("$")) {
        return parser.variables[key];
      }
      key += curr;
      parser.index += 1;
    }
  }
}
  • 首先先匹配 $
  • 開始讀取內容,直到看到 $
  • 遇到 $ 停止 while 迴圈並用變數將模板變數取代,回傳結果

詳細的實作可以到 template 這個 branch 上查看,可以瞧瞧測試結果(在 test/template 資料夾當中)

結尾

透過自己撰寫解析器,我們可以將比較複雜的實作用比較容易表達語法表現。甚至也可以在既有的文法中(如這次的 JSON)做一層擴充,加入自己想要的功能。雖然不是那麼實用,但目的是為了展示透過解析能夠做到的事情有哪些。

雖然解析本身很重要也很有趣,但解析語言本身只能算是第一步而已,就像如果只是把 JSX 純粹轉成 JavaScript 的程式碼,沒有 React 的幫助也沒有用;把 SQL 變成抽象語法樹但沒有資料庫的實作,也是有點雞肋。解析語言的目的是為了方便後續的處理(執行 Query、渲染到 DOM)。

事實上現在也已經有很多的函式庫,幫助你直接省略解析器這一塊,像是著名的 Bison 或是 PEG.js,都是讓你用類似 BNF 的語法自動幫你產生一個穩定的解析器,省下自己解析的時間,直接專注在語言實作上。

我們這次的 JSON 解析器並沒有做轉換成抽象語法樹再來產生最後結果,所以我們下一個階段,會試著解析簡單的 HTML,並且轉換成語法樹後用 JavaScript 的 DOM API 渲染出來。