面试官:简单写个 stringify 吧

180 阅读3分钟

记于:2022-02-18

前言

题目很简单,就是实现一个简单的 JSON.stringify() 甚至只需要考虑 number | string | array | object 这四种类型即可。

思路也很简单,和深克隆如出一辙,根据 value 不同,进行不同的处理

  • number | string:直接返回字符串
  • array:遍历
  • object:递归

然而我在面试过程中却没能写出来,究其原因,有两点:

  • 紧张:心态影响了思路,这很重要,要学会调整自己的情绪,尽量让自己冷静,放松,无论面试还是工作中,总会遇到高压的情况,这是很不错的经验
  • 平时看得多,写得少,而且很多时候记笔记,跟着别人的代码敲一遍,理解了,就以为自己也能写出来了:这也是很大的问题,学习学习,【学】了还需要【习】,需要实践,放到 coding 中,就是需要自己独立多写一写。

实现

v1 - 简化版

先准备一些函数:

const isNumber = tar => Object.prototype.toString.call(tar) === "[object Number]";
const isString = tar => Object.prototype.toString.call(tar) === "[object String]";
const isArray = tar => Object.prototype.toString.call(tar) === "[object Array]";
const isObject = tar => Object.prototype.toString.call(tar) === "[object Object]";
const isBase = tar => isNum(tar) || isStr(tar)

递归这种操作,硬要在脑子里模拟调用栈,是很难理解的。

我们应该抽象,以人的视角去思考,把视角局限在函数内部,比如在这里就应该是:

  • number | string:我要处理基本数据
  • array:我要处理数组
  • object:我要处理对象

那么把它翻译成代码就是:

function stringify(target) {
  let result = ""
  
  if(isBase(target)){
  	result = dealBase(target)
  }else if(isArray(target)) {
  	result = dealArray(target)
  }else if(isObject(target)) {
  	result = dealObject(target)
  }
  
  return result
}

这样,我们不关心实现细节,只关心策略骨架就搭起来了,接下来还是以相同的思维去思考,要把自己局限于函数之内:

  • dealBase:我拿到了一个基本类型,很简单,直接返回它的字符串,并且给它加上引号就可以了
  • dealArray:我拿到了一个数组,我应该遍历它,返回 [a, b, c] 的形式,至于每一个 item 怎么处理,关我啥事,问 stringify
  • dealObject:我拿到了一个对象,那我应该遍历它,返回 {key: value} 的形式,啥?你说 value 可能是数组,也可能是对象?那关我啥事儿,找 stringify

那么让我们带入角色吧:

function dealBase(target) {
	return `"${target}"`
}

function dealArray(target) {
	return `[${target.map(i=>stringify(i))}]`
}

function dealObject(target) {
  let result = [];
  Object.entries(target).forEach(([k, v]) => {
    result.push(`"${k}": ${stringify(v)}`);
  });
  return `{${result.join(",")}}`;
}

v2 - 基础版

首先来看看原生的 JSON.stringify 能处理哪些类型吧:

InOut
1"1"
"a""a"
true"true"
undefined"undefined"
null"null"
[1]"[1]"
{a: "a"}'{"a": "a"}'
new Set([1])"{}"
new WeakSet([[1]])"{}"
new Map([[1, 1]])"{}"
new WeakMap([[[1], 1]])"{}"
new RegExp(/1/)"{}"
new Date()"2022-02-18T14:34:54.057Z"
Symbol(1)"undefined"
BigInt(1)TypeError: Do not know how to serialize a BigInt

可以看到,stringify 其实要比深克隆简单,很多类型的处理方式都是一致的,这里只需要增加对应策略的处理就行了:

  • Set | WeakSet | Map | WeakMap | RegExp:这些是啥?我不认识,丢个 "{}" 出去吧
  • base:这些类型直接转成字符串返回就行了
  • Date:这个类型我需要返回格式化之后的字符串
  • Symbol:这啥呀?没见过,又不是引用类型,那还是返回 undefined
  • BigInt:mua 的,这东西完全触及到我的知识盲区了,得报个错才行

按照上面的思路,这里就直接上代码吧。

const isNumber = tar => Object.prototype.toString.call(tar) === "[object Number]";
const isString = tar => Object.prototype.toString.call(tar) === "[object String]";
const isBoolean = tar => Object.prototype.toString.call(tar) === "[object Boolean]";
const isArray = tar => Object.prototype.toString.call(tar) === "[object Array]";
const isObject = tar => Object.prototype.toString.call(tar) === "[object Object]";
const isUndefined = tar => Object.prototype.toString.call(tar) === "[object Undefined]";
const isNull = tar => Object.prototype.toString.call(tar) === "[object Null]";
const isDate = tar => Object.prototype.toString.call(tar) === "[object Date]";
const isSymbol = tar => Object.prototype.toString.call(tar) === "[object Symbol]";
const isSet = tar => Object.prototype.toString.call(tar) === "[object Set]";
const isWeakSet = tar => Object.prototype.toString.call(tar) === "[object WeakSet]";
const isMap = tar => Object.prototype.toString.call(tar) === "[object Map]";
const isWeakMap = tar => Object.prototype.toString.call(tar) === "[object WeakMap]";
const isRegExp = tar => Object.prototype.toString.call(tar) === "[object RegExp]";
const isBigInt = tar => Object.prototype.toString.call(tar) === "[object BigInt]";

const isBase = tar =>
  isNumber(tar) || isString(tar) || isBoolean(tar) || isUndefined(tar) || isNull(tar);
const isUnknowRef = tar =>
  isSet(tar) || isMap(tar) || isWeakSet(tar) || isWeakMap(tar) || isRegExp(tar);

function dealBase(target) {
  return `"${target}"`;
}

function dealArray(target) {
  return `[${target.map(i => stringify(i))}]`;
}

function dealObject(target) {
  let result = [];
  Object.entries(target).forEach(([k, v]) => {
    result.push(`"${k}": ${stringify(v)}`);
  });
  return `{${result.join(",")}}`;
}

function dealUnknownRef(_target) {
  return `"{}"`;
}

function dealDate(target) {
  return `${target}`;
}

function dealSymbol(_target) {
  return `"undefined"`;
}

function dealBigInt(_target) {
  throw new Error("TypeError: Do not know how to serialize a BigInt");
}

function stringify(target) {
  let result = "";

  if (isBase(target)) {
    result = dealBase(target);
  } else if (isObject(target)) {
    result = dealObject(target);
  } else if (isArray(target)) {
    result = dealArray(target);
  } else if (isUnknowRef(target)) {
    result = dealUnknownRef(target);
  } else if (isDate(target)) {
    result = dealDate(target);
  } else if (isSymbol(target)) {
    result = dealSymbol(target);
  } else if (isBigInt(target)) {
    return dealBigInt(target);
  }

  return result;
}

const data = {
  a: 1,
  b: "2",
  c: {
    c1: { c11: "c11" }
  },
  d: [
    1,
    2,
    3,
    {
      d1: {
        d11: "d11"
      }
    }
  ],
  e: undefined,
  f: null,
  h: new Date(),
  i: Symbol(1),
  j: new Set([1]),
  k: new WeakSet([[1]]),
  l: new Map([[1, 1]]),
  m: new WeakMap([[[1], 1]]),
  n: new RegExp(/1/)
  // j: BigInt(1)
};

v3 - 增强版

从 v2 中的表格可以看到,其实很多类型 JSON.stringify 都是处理不了的,并且还有两个问题:

  • 如果出现循环引用是会报错的
  • 如果 objectkey 是一个 Symbol,会被直接忽略掉

类型的问题很好处理,顺着递归的思维,把思维限制在函数之内,不要模拟调用栈,增加不同的策略就可以了,这里我们先解决特殊类型处理和 keySymbol 的问题:

  • keySymbol,在遍历 object 的时候,使用 Reflect.ownKeys(target) 即可,它会遍历对象上所有属性,无论是否为 Symbol,是否可以迭代
const isNumber = tar => Object.prototype.toString.call(tar) === "[object Number]";
const isString = tar => Object.prototype.toString.call(tar) === "[object String]";
const isBoolean = tar => Object.prototype.toString.call(tar) === "[object Boolean]";
const isArray = tar => Object.prototype.toString.call(tar) === "[object Array]";
const isObject = tar => Object.prototype.toString.call(tar) === "[object Object]";
const isUndefined = tar => Object.prototype.toString.call(tar) === "[object Undefined]";
const isNull = tar => Object.prototype.toString.call(tar) === "[object Null]";
const isDate = tar => Object.prototype.toString.call(tar) === "[object Date]";
const isSymbol = tar => Object.prototype.toString.call(tar) === "[object Symbol]";
const isSet = tar => Object.prototype.toString.call(tar) === "[object Set]";
const isWeakSet = tar => Object.prototype.toString.call(tar) === "[object WeakSet]";
const isMap = tar => Object.prototype.toString.call(tar) === "[object Map]";
const isWeakMap = tar => Object.prototype.toString.call(tar) === "[object WeakMap]";
const isRegExp = tar => Object.prototype.toString.call(tar) === "[object RegExp]";
const isBigInt = tar => Object.prototype.toString.call(tar) === "[object BigInt]";

const isBase = tar =>
  isNumber(tar) || isString(tar) || isBoolean(tar) || isUndefined(tar) || isNull(tar);
const isUnknowRef = tar =>
  isSet(tar) || isMap(tar) || isWeakSet(tar) || isWeakMap(tar) || isRegExp(tar);
const isArrayLike = tar => isSet(tar) || isMap(tar);
const isWeak = tar => isWeakSet(tar) || isWeakMap(tar);

function dealBase(target) {
  return `"${target}"`;
}

function dealArrayLike(target) {
  const prefix = isSet(target) ? "<Set>" : isMap(target) ? "<Map>" : "";
  return `${prefix}[${[...target].map(i => stringify(i))}]`;
}

function dealWeak(target) {
  const prefix = isWeakSet(target) ? "<WeakSet>" : isWeakMap(target) ? "<WeakMap>" : "";
  return `${prefix}{ <items unknown> }`;
}

function dealObject(target) {
  let result = [];

  // 遍历一个对象的所有属性,不管是否是 Symbol,是否可以枚举
  Reflect.ownKeys(target).forEach(k => {
    const item = isSymbol(k)
      ? `"${k.toString()}": ${stringify(target[k])}`
      : `"${k}": ${stringify(target[k])}`;
    result.push(item);
  });

  return `{${result.join(",")}}`;
}

function dealDate(target) {
  return `"${target.toDateString()}"`;
}

function dealRegExp(target) {
  return `"${target.toString()}"`;
}

function dealSymbol(target) {
  return `"${target.toString()}"`;
}

function dealBigInt(target) {
  return `${target.toString()}n`;
}

function stringify(target) {
  let result = "";

  if (isBase(target)) {
    result = dealBase(target);
  } else if (isObject(target)) {
    result = dealObject(target);
  } else if (isArray(target) || isArrayLike(target)) {
    result = dealArrayLike(target);
  } else if (isWeak(target)) {
    result = dealWeak(target);
  } else if (isDate(target)) {
    result = dealDate(target);
  } else if (isRegExp(target)) {
    result = dealRegExp(target);
  } else if (isSymbol(target)) {
    result = dealSymbol(target);
  } else if (isBigInt(target)) {
    return dealBigInt(target);
  }

  return result;
}

那么最后一个问题,循环引用怎么解决呢?

要判断是否存在循环引用,就需要知道 key 是否与 object 指向了同一块内存,而能够用引用类型作为 key 的有 MapWeakMap,考虑到垃圾回收,这里选择使用 WeakMap,那么带入函数的视角,应该怎么看呢:

  • stringify:我要准备一个 WeakMap,方便它们到时查引用,其他的我可不知道,就交给 doStringify 吧,它可能干了
  • doStringify:我要判断 target 的类型,具体的处理就交给 deal 方法吧,对了,如果 target 类型是 Object,还要把 WeakMap 给它,方便它查一下有没有哪个小子偷偷来过一次了
  • dealObject:我得看看有没有谁已经来过了,如果被我逮到,它就完了。怎么逮?简单呀,我把它的指针所指向的内存地址记在 WeakMap 里呀,至于其他的,那我可不知道,问问 doStringify

根据上述思路 coding 即可:

const isObject = tar => Object.prototype.toString.call(tar) === "[object Object]";

const dealObject = (target, weakMap) => {
  const queue = [];
  const keys = Reflect.ownKeys(target);

  for (let key of keys) {
    const item = target[key];

    if (weakMap.has(item)) {
      return `circular reference`;
    }

    if (isObject(item)) {
      weakMap.set(item, key);
    }

    queue.push(`"${key}":${doStringify(item, weakMap)}`);
  }

  return `{${queue.join(",")}}`;
};

const doStringify = (target, weakMap) => {
  let result = "";

  if (isObject(target)) {
    result = dealObject(target, weakMap);
  } else {
    result = `"${target}"`;
  }

  return result;
};

const stringify = target => {
  const weakMap = new WeakMap();
  return doStringify(target, weakMap);
};