前端知库

怎样手写 lodash 的 get 和 set?

get 把 'a[0].b' 这样的路径拆成 ['a', '0', 'b'],一层层往下取,中途遇到 null 或 undefined 就返回默认值;只有结果是 undefined 时才用默认值,null 会原样返回。set 沿路径往下走,缺少的层级按下一段是不是数字下标补成数组或对象,并且要拒绝 __proto__ 这类键,防止原型污染。

参考答案

简答

接口返回的数据层级很深,res.data.list[0].name 中间任何一层是 undefined 就会报错。get(res, 'data.list[0].name', '无') 就是安全地按路径取值,取不到时返回默认值。

写法分两步。先把路径字符串拆成数组,'a[0].b' 变成 ['a', '0', 'b'];再从对象开始一层层往下取,中途遇到 null 或 undefined 就停下。lodash 文档写明,只有最后结果是 undefined 时才返回默认值,所以取到 null 会原样返回 null。

set 是反过来往里写。沿着路径往下走,某一层不存在或不是对象,就新建一个:下一段是数字下标就建数组,否则建对象,所以 set({}, 'x[0].y', 5) 得到的是 { x: [{ y: 5 }] }。

要注意原型污染。如果路径来自用户输入,set(obj, '__proto__.isAdmin', true) 会改到所有对象共享的原型上。lodash 4.17.21 的 set 遇到 __proto__、constructor、prototype 这几个键会直接放弃写入,手写时也要这样拦住。

手写 get 和 set

下面先写一个把路径拆成数组的函数,get 和 set 都用它。

JavaScript
function toPath(path) {
  if (Array.isArray(path)) return path.map(String);
  // 把 a[0].b 统一成 a.0.b 再按点号拆开,空段丢掉。
  return String(path)
    .replace(/\[(\w+)\]/g, '.$1')
    .split('.')
    .filter(Boolean);
}

function get(object, path, defaultValue) {
  let current = object;
  for (const key of toPath(path)) {
    if (current == null) return defaultValue;
    current = current[key];
  }
  return current === undefined ? defaultValue : current;
}

const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);

function set(object, path, value) {
  if (object == null || typeof object !== 'object') return object;
  const keys = toPath(path);
  if (keys.some((key) => UNSAFE_KEYS.has(key))) return object;

  let current = object;
  keys.forEach((key, index) => {
    if (index === keys.length - 1) {
      current[key] = value;
      return;
    }
    const next = current[key];
    if (next === null || typeof next !== 'object') {
      // 下一段是纯数字就建数组,否则建对象,与 lodash 的行为一致。
      current[key] = /^\d+$/.test(keys[index + 1]) ? [] : {};
    }
    current = current[key];
  });
  return object;
}

const data = { a: [{ b: { c: 3 } }], empty: null };

console.log(get(data, 'a[0].b.c')); // 3
console.log(get(data, 'a.0.b.c')); // 3
console.log(get(data, 'a.1.b', '默认')); // 默认
console.log(get(data, 'empty', '默认')); // null

const target = {};
set(target, 'x[0].y', 5);
console.log(JSON.stringify(target)); // {"x":[{"y":5}]}

set({}, '__proto__.polluted', true);
console.log({}.polluted); // undefined

get(data, 'empty', '默认') 为什么返回 null 而不是“默认”?因为循环走完时 current 是 null,不是 undefined。lodash 文档的原话是“如果解析出的值是 undefined,就用默认值代替”,null 是一个确实存在的值,所以原样返回。如果路径还没走完就碰到 null,后面已经取不下去了,这时才返回默认值。

set 里为什么要看下一段是不是数字?因为 'x[0].y' 的意思是“x 是数组,第 0 项有个 y”。如果一律建对象,得到的是 { x: { 0: { y: 5 } } },形状就不对了。

为什么要拦住 proto

JavaScript
const obj = {};
obj['__proto__']['isAdmin'] = true;
console.log({}.isAdmin); // true

obj['__proto__'] 拿到的不是 obj 自己的属性,而是 Object.prototype。往它上面写属性,所有普通对象都会“继承”到这个值,这就是原型污染。没有拦截的 set 会一步步走到这里。所以路径里只要出现 __proto__、constructor、prototype,就不写。lodash 4.17.21 的 baseSet 也是遇到这三个键就直接返回原对象。

和可选链怎么选

如果路径在写代码时就确定,data.a?.[0]?.b?.c ?? '默认' 更直接,不需要字符串解析。可是两者有一点不同:?? 遇到 null 和 undefined 都会用默认值,而 get 只在 undefined 时用。路径是运行时才知道的,比如表单配置里写着 'address.city',才需要 get。

常见追问

键名里本身带点号怎么办?

用字符串路径就没法区分了,'a.b' 会被拆成两段。这时传数组路径,get(obj, ['a.b'])。lodash 还会先检查整个字符串能不能直接作为对象上的键(用 in 判断),_.get({ 'a.b': 1 }, 'a.b') 能取到 1,手写版本没做这一步。

set 遇到中间层是数字或字符串会怎样?

原始值上不能挂属性,所以要把它换成新的对象或数组再往下写,上面代码里 typeof next !== 'object' 就是在处理这种情况。lodash 也是这样做的,原来的值会被覆盖。