怎样手写 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 都用它。
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
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 也是这样做的,原来的值会被覆盖。