1 引言
JSON.parse
是瀏覽器內建的 API,但如果面試官讓你實現一個怎麼辦?好在有人已經幫忙做了這件事,本週我們一起精讀這篇 JSON Parser with Javascript 文章吧,再溫習一遍大學時編譯原理相關知識。
2 概述 & 精讀
要解析 JSON 首先要理解語法概念,之前的 精讀《手寫 SQL 編譯器 - 語法分析》 系列也有介紹過,不過本文介紹的更形象,看下面這個語法圖:
這是關於 Object 型別的語法描述圖,從左向右看,根據箭頭指向只要能走出這個迷宮就屬於正確語法。
比如第一行 {
→ whitespace
→ }
表示 { }
屬於合法的 JSON 語法。
再比如觀察向下的一條最長路線:{
→ whitespace
→ string
→ whitespace
→ :
→ value
→ }
表示 { string : value }
屬於合法的 JSON 語法。
你可能會問,雙引號去哪兒了?這就是語法樹最核心的概念了,這張圖是關於 Object 型別的 產生式,同理還有 string、value 的產生式,產生式中可以巢狀其他產生式,甚至形成環路,以此擁有描述紛繁多變語法的能力。
最後我們再看一個環路,即 {
→ whitespace
→ string
... ,
→ whitespace
→ string
... ,
... }
,我們發現,只要不走回頭路,這條路是可以一直 “繞圈” 下去的,因此 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 的語法圖:
我們只需要吃逗號和 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 與對應字元相等時,返回第二個傳入引數即可。
處理異常輸入
一個完整的語法解析功能需要包含錯誤處理,錯誤的情況主要分兩種:
- 非法字元。
- 非正常結尾。
原文提到的 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 總結
這篇文章通過一個具體的例子解釋如何做語法分析,對於詞法解析入門非常直觀,如果你想更深入理解語法解析,或者寫一個通用語法解析器,可以閱讀語法解析系列入門文章,筆者通過實際例子帶你一步一步做一個完備的詞法解析工具!
語法解析入門系列文章,建議閱讀順序:
- 精讀《手寫 SQL 編譯器 - 詞法分析》
- 精讀《手寫 SQL 編譯器 - 文法介紹》
- 精讀《手寫 SQL 編譯器 - 語法分析》
- 精讀《手寫 SQL 編譯器 - 回溯》
- 精讀《手寫 SQL 編譯器 - 語法樹》
- 精讀《手寫 SQL 編譯器 - 錯誤提示》
- 精讀《手寫 SQL 編譯器 - 效能優化之快取》
- 精讀《手寫 SQL 編譯器 - 智慧提示》
syntax-parser 這個零依賴的通用語法解析庫就是根據上述文章一步一步完成的,看完了上面文章,就徹底理解了這個庫的原始碼。
如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。
關注 前端精讀微信公眾號
版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證)