JSON is a subset of Javascript, and although you can use eval to convert JSON strings into Javascript objects, this presents security issues
If we want to implement a Json.stringify, we first need to understand the structure of JSON. Through a simple lookup, we can see that a JSON can look like the following
- Literal, including
true
.false
.null
- digital
- string
- Object whose key is a string and whose value can be a JSON value
- The contents of an array can contain any JSON value
Therefore, in Typescript, JSON can be defined as follows
type LiteralValue = boolean | null;
type PrimitiveValue = number | LiteralValue | string;
type JSONArray = (PrimitiveValue | JSONArray | JSONObject)[];
type JSONObject = { [key: string]: PrimitiveValue | JSONArray | JSONObject };
type JSONValue = PrimitiveValue | JSONArray | JSONObject;
Copy the code
JSONValue represents all the possible types that we can present through Json.parse
So, quite naturally, for the three basic types, we need to provide these three methods
The name of the | |
---|---|
parseLiteral | conversiontrue .false .null |
parseNumber | Convert digital |
parseString | Conversion string |
Then, for arrays and objects that form nesting structures, we need two other methods, parseObject and parseArray.
Obviously, they indirectly recursively call themselves and methods that convert numbers, strings, and literals. We define a parseJSONValue method that guesses the type of the next value to call the appropriate Parse XXX method.
Also, we need a skipWhitespace method to skip null characters. They have no effect on the result of the JSON transformation
This gives us a general framework for our code
class JSONParser {
private input: string;
constructor(input: string) {
this.input = input;
}
privateskipWhitespace(cur? :number) :number {}
privateguessNextValueType(cur? :number): MaybeJSONValue
privateparseLiteral(cur? :number): ParseResult<LiteralValue>
privateparseNumber(cur? :number): ParseResult<number>
privateparseString(cur? :number): ParseResult<string>
privateparseObject(cur? :number): ParseResult<JSONObject>
privateparseArray(cur? :number): ParseResult<JSONArray>
privateparseJSON(cur? :number): ParseResult<JSONValue>
// This is a method for external calls
parse(): string;
}
Copy the code
You also need some type and enumeration definitions
type ParseResult<T extends JSONValue> = {
success: boolean;
// If the conversion succeeds, its value represents the position of the last bit of the value in the entire JSON string
// If it fails, it represents the location of the failure
position: number; value? : T; };enum MaybeJSONValue {
LITERAL,
NUMBER,
STRING,
ARRAY,
OBJECT,
UNKNOWN,
}
Copy the code
implementation
We have agreed that the cur parameter in all parse methods represents where the input string begins for subsequent conversion operations. The position field in the return value of these functions represents the position at which the current resolution succeeded or failed. As we progress through the conversion, the value of cur increases until the conversion is complete or the conversion fails.
parseLiteral
Literals have fixed values. The literal can be converted simply by identifying the literal string with an equal sign.
private parseLiteral(cur = 0): ParseResult<LiteralValue> {
if (this.input[cur] === "t") {
if (this.input.substring(cur, cur + 4) = = ="true") {
return {
success: true.position: cur + 3.value: true}; }}else if (this.input[cur] === "f") {
if (this.input.substring(cur, cur + 5) = = ="false") {
return {
success: true.position: cur + 4.value: false}; }}else if (this.input[cur] === "n") {
if (this.input.substring(cur, cur + 4) = = ="null") {
return {
success: true.position: cur + 3.value: null}; }}return {
success: false.position: cur,
};
}
Copy the code
parseString
There is one detail to note about string length, ‘\n’. Length === 1, the result of executing json.stringify (‘\n’) is ‘\\n’. Obviously, we need a dictionary to convert multiple characters beginning with ‘\\’ before conversion into a single character such as \n.
const ESCAPE_CHAR_MAP = {
"\ \ \ \": "\ \".'\ \ "': '"'."\\b": "\b"."\\f": "\f"."\\n": "\n"."\\r": "\r"};private parseString(cur = 0): ParseResult<string> {
if (this.input[cur] ! = ='"') {
return {
success: false.position: cur,
};
}
let value = "";
cur++;
while (this.input[cur] ! = ='"') {
if (this.input[cur] === "\ \") {
const maybeEscapeChar = this.input.slice(cur, cur + 2);
const ch = ESCAPE_CHAR_MAP[maybeEscapeChar];
if (ch) {
value += ch;
cur += 2;
continue;
} else {
return {
success: false.position: cur,
};
}
}
value += this.input[cur];
cur++;
}
return {
success: true.position: cur,
value,
};
}
Copy the code
parseNumber
Digital transformation is actually more complicated than imagination, the number of the JSON, integer, ordinary floating point number, you can also use like 2 e – 6 scientific notation to represent a floating point number But the official document will have a variety of circumstances are represented using images, although its code to achieve slightly longer, I drew a flow chart to help you understand.
Graph of TD is called [parseNumber] -- > B} {if there is A minus B - > | | C () reads in minus B - > whether | | D {whether to begin with 0} C -- -- -- -- > > D D is | | E read in [0] D - > whether | | F [read non-zero Numbers] F - > G {} if there is a decimal point E > G G -- - > | | H is [read in decimal point] H -- -- > I [read in decimal point Numbers] I -- -- -- -- > > J G whether | | J {if there is a character E or E} J - > K/read E K - > {have a plus or minus sign} L L - > is | | M] [read index of plus or minus K - > whether | | O M - > O [read in scientific notation index] J - > whether | | P O - > P [call parseInt or parseFloat strings that will be read into digital]
private parseNumber(cur = 0): ParseResult<number> {
const parseDigit = (cur: number, allowLeadingZero: boolean) = > {
let dights = "";
if(! allowLeadingZero &&this.input[cur] === "0") {
return ["", cur] as const;
}
let allowZero = allowLeadingZero;
while (
(allowZero ? "0" : "1") < =this.input[cur] &&
this.input[cur] <= "9"
) {
dights += this.input[cur];
cur++;
allowZero = true;
}
return [dights, cur - 1] as const;
};
let value = "";
let isFloat = false;
/ / minus
if (this.input[cur] === "-") {
value += "-";
cur++;
}
// The number before the decimal point
if (this.input[cur] === "0") {
value += "0";
} else {
const [dights, endCur] = parseDigit(cur, false);
// Invalid case 1, which begins with a non-digit or multiple zeros
if (dights.length === 0) {
return {
success: false.position: cur,
};
}
value += dights;
cur = endCur;
}
/ / the decimal point
if (this.input[cur + 1= = =".") {
isFloat = true;
value += ".";
cur++;
// Input [cur] is the decimal
// Move to the position after the decimal point
const [dights, endCur] = parseDigit(cur + 1.true);
// Invalid case 2, there are no numbers after the decimal point
if (dights.length === 0) {
return {
success: false.position: cur,
};
}
value += dights;
cur = endCur;
}
// The index of scientific notation
if (this.input[cur + 1= = ="e" || this.input[cur + 1= = ="E") {
isFloat = true;
value += "e";
cur++;
// This. Input [cur] is e or e
if (this.input[cur + 1= = ="+" || this.input[cur + 1= = ="-") {
cur++;
value += this.input[cur];
// This. Input [cur] is the symbol
}
const [dights, endCur] = parseDigit(cur + 1.false);
// Illegal case 3, E has no exponent
if (dights.length === 0) {
return {
success: false.position: cur,
};
}
value += dights;
cur = endCur;
}
return {
success: true.value: isFloat ? parseFloat(value) : parseInt(value, 10),
position: cur,
};
}
Copy the code
parseJSON
andguessNextValueType
We need to use guessNextValueType to make a guess at the exact type of the JSONValue that follows. The logic of this guess is very simple: if it starts with a ‘[‘ it is an array, and if it starts with a ‘” it is a string. When we fail to guess, the incoming JSON string is invalid.
private guessNextValueType(cur = 0): MaybeJSONValue {
const leadingChar = this.input[cur];
if (/ [0-9] /.test(leadingChar)) {
return MaybeJSONValue.NUMBER;
}
switch (leadingChar) {
case "[":
return MaybeJSONValue.ARRAY;
case "{":
return MaybeJSONValue.OBJECT;
case '"':
return MaybeJSONValue.STRING;
case "n":
return MaybeJSONValue.LITERAL;
case "t":
return MaybeJSONValue.LITERAL;
case "f":
return MaybeJSONValue.LITERAL;
default:
returnMaybeJSONValue.UNKNOWN; }}private parseJSON(cur = 0): ParseResult<JSONValue> {
const valueType = this.guessNextValueType(cur);
switch (valueType) {
case MaybeJSONValue.NUMBER:
return this.parseNumber(cur);
case MaybeJSONValue.ARRAY:
return this.parseArray(cur);
case MaybeJSONValue.OBJECT:
return this.parseObject(cur);
case MaybeJSONValue.STRING:
return this.parseString(cur);
case MaybeJSONValue.LITERAL:
return this.parseLiteral(cur);
case MaybeJSONValue.UNKNOWN:
return {
success: false.position: cur, }; }}Copy the code
parseArray
Once we have parseJSON above, the implementation of parseArray becomes simpler. All we need to do is read the left square bracket and keep calling parseJSON and handling the comma separating elements, putting the converted elements into an array.
private parseArray(cur = 0): ParseResult<JSONArray> {
if (this.input[cur] ! = ="[") {
return {
success: false.position: cur,
};
}
const result: JSONArray = [];
cur++;
let isFirstItem = true;
while (this.input[cur] ! = ="]") {
cur = this.skipWhitespace(cur);
if(! isFirstItem) {if (this.input[cur] ! = =",") {
return {
success: false.position: cur,
};
}
cur++;
}
const itemResult = this.parseJSON(cur);
if(! itemResult.success) {return itemResult as ParseResult<JSONArray>;
}
cur = itemResult.position + 1; result.push(itemResult.value!) ; isFirstItem =false;
}
return {
success: true.position: cur,
value: result,
};
}
Copy the code
parseObject
Again, we need to do much the same thing as parseArray, except that object has a key, and we need to call parseString to get the key of the object, then call parseJSON to get the value, and finally set the key and its value to the result.
private parseObject(cur = 0): ParseResult<JSONObject> {
if (this.input[cur] ! = ="{") {
return {
success: false.position: cur,
};
}
const result: JSONObject = {};
let isFirstItem = true;
cur++;
cur = this.skipWhitespace(cur);
while (this.input[cur] ! = ="}") {
cur = this.skipWhitespace(cur);
if(! isFirstItem) {if (this.input[cur] ! = =",") {
return {
success: false.position: cur,
};
}
cur++;
}
const keyResult = this.parseString(cur);
if(! keyResult.success) {return keyResult as unknown as ParseResult<JSONObject>;
}
cur = keyResult.position;
cur = this.skipWhitespace(cur);
cur++;
if (this.input[cur] ! = =":") {
return {
success: false.position: cur,
};
}
const valueResult = this.parseJSON(cur + 1); result[keyResult.value!] = valueResult.value; isFirstItem =false;
cur = valueResult.position + 1;
}
return {
success: true.value: result,
position: cur,
};
}
Copy the code
parse
Methods and test methodstest
methods
public parse() {
const result = this.parseJSON();
if (result.success) {
returnresult.value! ; }else {
throw new Error(`parse error at ${result.position}`); }}Copy the code
function test(input: JSONValue) {
const parser = new JSONParser(JSON.stringify(input));
const result = parser.parse();
if (JSON.stringify(result) ! = =JSON.stringify(input)) {
throw new Error(`The ${JSON.stringify(result)}! = =The ${JSON.stringify(input)}`); }}Copy the code
Finally, let’s do a simple test
/ / digital
test(0.1);
test(1.1);
test(0);
test(-1);
test(+2);
test(+1e2);
test(+1e-2);
test(123456);
test(1.23456 e2);
/ / string
test("");
test("Hello, world");
test("\n");
test("\b");
test("\f");
test("\r");
test("\ \ \ \ \ \");
test('"');
test('\ \ \ \ "');
/ / literal
test(null);
test(true);
test(false);
/ / array
test([]);
test([0.null.undefined.true.false."", [], [[], [], {}, {value: {}}]);/ / object
test({
number: 1.string: "".array: [].object: {},
null: null.boolean: true.nested: {
number: 1.string: "".array: [123].object: {},
null: null.boolean: true,}});Copy the code
I used Visual Studio CodeQuokkaExtension that responds to code changes in real time and marks correctly running code to the left. Our tests were cleanThe complete code
type LiteralValue = boolean | null;
type PrimitiveValue = number | LiteralValue | string;
type JSONArray = (PrimitiveValue | JSONArray | JSONObject)[];
type JSONObject = { [key: string]: PrimitiveValue | JSONArray | JSONObject };
type JSONValue = PrimitiveValue | JSONArray | JSONObject;
type ParseResult<T extends JSONValue> = {
success: boolean;
// If the conversion succeeds, its value represents the position of the last bit of the value in the entire JSON string
// If it fails, it represents the location of the failure
position: number; value? : T; };enum MaybeJSONValue {
LITERAL,
NUMBER,
STRING,
ARRAY,
OBJECT,
UNKNOWN,
}
const ESCAPE_CHAR_MAP = {
"\ \ \ \": "\ \".'\ \ "': '"'."\\b": "\b"."\\f": "\f"."\\n": "\n"."\\r": "\r"};class JSONParser {
private input: string;
constructor(input: string) {
this.input = input;
}
private parseLiteral(cur = 0): ParseResult<LiteralValue> {
if (this.input[cur] === "t") {
if (this.input.substring(cur, cur + 4) = = ="true") {
return {
success: true.position: cur + 3.value: true}; }}else if (this.input[cur] === "f") {
if (this.input.substring(cur, cur + 5) = = ="false") {
return {
success: true.position: cur + 4.value: false}; }}else if (this.input[cur] === "n") {
if (this.input.substring(cur, cur + 4) = = ="null") {
return {
success: true.position: cur + 3.value: null}; }}return {
success: false.position: cur,
};
}
private parseNumber(cur = 0): ParseResult<number> {
const parseDigit = (cur: number, allowLeadingZero: boolean) = > {
let dights = "";
if(! allowLeadingZero &&this.input[cur] === "0") {
return ["", cur] as const;
}
let allowZero = allowLeadingZero;
while (
(allowZero ? "0" : "1") < =this.input[cur] &&
this.input[cur] <= "9"
) {
dights += this.input[cur];
cur++;
allowZero = true;
}
return [dights, cur - 1] as const;
};
let value = "";
let isFloat = false;
/ / minus
if (this.input[cur] === "-") {
value += "-";
cur++;
}
// The number before the decimal point
if (this.input[cur] === "0") {
value += "0";
} else {
const [dights, endCur] = parseDigit(cur, false);
// Invalid case 1, which begins with a non-digit or multiple zeros
if (dights.length === 0) {
return {
success: false.position: cur,
};
}
value += dights;
cur = endCur;
}
/ / the decimal point
if (this.input[cur + 1= = =".") {
isFloat = true;
value += ".";
cur++;
// Input [cur] is the decimal
// Move to the position after the decimal point
const [dights, endCur] = parseDigit(cur + 1.true);
// Invalid case 2, there are no numbers after the decimal point
if (dights.length === 0) {
return {
success: false.position: cur,
};
}
value += dights;
cur = endCur;
}
// The index of scientific notation
if (this.input[cur + 1= = ="e" || this.input[cur + 1= = ="E") {
isFloat = true;
value += "e";
cur++;
// This. Input [cur] is e or e
if (this.input[cur + 1= = ="+" || this.input[cur + 1= = ="-") {
cur++;
value += this.input[cur];
// This. Input [cur] is the symbol
}
const [dights, endCur] = parseDigit(cur + 1.false);
// Illegal case 3, E has no exponent
if (dights.length === 0) {
return {
success: false.position: cur,
};
}
value += dights;
cur = endCur;
}
return {
success: true.value: isFloat ? parseFloat(value) : parseInt(value, 10),
position: cur,
};
}
private parseString(cur = 0): ParseResult<string> {
if (this.input[cur] ! = ='"') {
return {
success: false.position: cur,
};
}
let value = "";
cur++;
while (this.input[cur] ! = ='"') {
if (this.input[cur] === "\ \") {
const maybeEscapeChar = this.input.slice(cur, cur + 2);
const ch = ESCAPE_CHAR_MAP[maybeEscapeChar];
if (ch) {
value += ch;
cur += 2;
continue;
} else {
return {
success: false.position: cur,
};
}
}
value += this.input[cur];
cur++;
}
return {
success: true.position: cur,
value,
};
}
private skipWhitespace(cur = 0) :number {
const isWhitespace = (cur: string) = > {
return (
cur === "\u0009" ||
cur === "\u000A" ||
cur === "\u000D" ||
cur === "\u0020"
);
};
while (isWhitespace(this.input[cur])) {
cur++;
}
return cur;
}
private parseArray(cur = 0): ParseResult<JSONArray> {
if (this.input[cur] ! = ="[") {
return {
success: false.position: cur,
};
}
const result: JSONArray = [];
cur++;
let isFirstItem = true;
while (this.input[cur] ! = ="]") {
cur = this.skipWhitespace(cur);
if(! isFirstItem) {if (this.input[cur] ! = =",") {
return {
success: false.position: cur,
};
}
cur++;
}
const itemResult = this.parseJSON(cur);
if(! itemResult.success) {return itemResult as ParseResult<JSONArray>;
}
cur = itemResult.position + 1; result.push(itemResult.value!) ; isFirstItem =false;
}
return {
success: true.position: cur,
value: result,
};
}
private parseObject(cur = 0): ParseResult<JSONObject> {
if (this.input[cur] ! = ="{") {
return {
success: false.position: cur,
};
}
const result: JSONObject = {};
let isFirstItem = true;
cur++;
cur = this.skipWhitespace(cur);
while (this.input[cur] ! = ="}") {
cur = this.skipWhitespace(cur);
if(! isFirstItem) {if (this.input[cur] ! = =",") {
return {
success: false.position: cur,
};
}
cur++;
}
const keyResult = this.parseString(cur);
if(! keyResult.success) {return keyResult as unknown as ParseResult<JSONObject>;
}
cur = keyResult.position;
cur = this.skipWhitespace(cur);
cur++;
if (this.input[cur] ! = =":") {
return {
success: false.position: cur,
};
}
const valueResult = this.parseJSON(cur + 1); result[keyResult.value!] = valueResult.value; isFirstItem =false;
cur = valueResult.position + 1;
}
return {
success: true.value: result,
position: cur,
};
}
private guessNextValueType(cur = 0): MaybeJSONValue {
const leadingChar = this.input[cur];
if (/ [0-9] /.test(leadingChar)) {
return MaybeJSONValue.NUMBER;
}
switch (leadingChar) {
case "[":
return MaybeJSONValue.ARRAY;
case "{":
return MaybeJSONValue.OBJECT;
case '"':
return MaybeJSONValue.STRING;
case "n":
return MaybeJSONValue.LITERAL;
case "t":
return MaybeJSONValue.LITERAL;
case "f":
return MaybeJSONValue.LITERAL;
default:
returnMaybeJSONValue.UNKNOWN; }}private parseJSON(cur = 0): ParseResult<JSONValue> {
const valueType = this.guessNextValueType(cur);
switch (valueType) {
case MaybeJSONValue.NUMBER:
return this.parseNumber(cur);
case MaybeJSONValue.ARRAY:
return this.parseArray(cur);
case MaybeJSONValue.OBJECT:
return this.parseObject(cur);
case MaybeJSONValue.STRING:
return this.parseString(cur);
case MaybeJSONValue.LITERAL:
return this.parseLiteral(cur);
case MaybeJSONValue.UNKNOWN:
return {
success: false.position: cur, }; }}public parse() {
const result = this.parseJSON();
if (result.success) {
returnresult.value! ; }else {
throw new Error(`parse error at ${result.position}`); }}}function test(input: JSONValue) {
const parser = new JSONParser(JSON.stringify(input));
const result = parser.parse();
if (JSON.stringify(result) ! = =JSON.stringify(input)) {
throw new Error(`The ${JSON.stringify(result)}! = =The ${JSON.stringify(input)}`); }}/ / digital
test(0.1);
test(1.1);
test(0);
test(-1);
test(+2);
test(+1e2);
test(+1e-2);
test(123456);
test(1.23456 e2);
/ / string
test("");
test("Hello, world");
test("\n");
test("\b");
test("\f");
test("\r");
test("\ \ \ \ \ \");
test('"');
test('\ \ \ \ "');
/ / literal
test(null);
test(true);
test(false);
/ / array
test([]);
test([0.null.undefined.true.false."", [], [[], [], {}, {value: {}}]);/ / object
test({
number: 1.string: "".array: [].object: {},
null: null.boolean: true.nested: {
number: 1.string: "".array: [123].object: {},
null: null.boolean: true,}});Copy the code