怎样手写 Object.create、Object.assign 和 Array.from?
Object.create 新建一个以指定对象为原型的空对象,可以用临时构造函数模拟;Object.assign 把源对象自己的可枚举属性(含 Symbol 键)用赋值的方式拷到目标上,是浅拷贝,会触发 getter 和 setter;Array.from 先看参数能不能迭代,能就逐个取值,否则按 length 当类数组处理,空位会变成 undefined。
简答
想让
dog直接用上animal的方法,又不想写 class,可以用Object.create(animal)造一个原型是animal的空对象。手写时借一个空的构造函数,把它的prototype设成animal,再new一次就行;参数不是对象也不是null时要抛TypeError。
Object.assign(target, ...sources)把每个源对象自己的、可枚举的属性拷到目标上,字符串键和 Symbol 键都拷。它用的是读取和赋值,所以会触发源对象的 getter 和目标对象的 setter;拷的只是第一层,嵌套对象还是同一个引用。目标是null或undefined会报错,源是null或undefined会被跳过。
Array.from先判断参数能不能被for...of遍历,比如字符串、Set、Map,能就按迭代顺序取值;不能就当成类数组,读length,从 0 到length - 1逐个取,缺的下标得到undefined。第二个参数mapFn会拿到值和下标。手写版只覆盖常见行为,真实实现还处理了描述符细节、子类构造等边界,面试时讲清这些差别比代码长短更重要。
Object.create:借一个空构造函数
new F() 生成的对象,原型就是 F.prototype。所以只要把 F.prototype 换成想要的原型,就能得到一个“继承”它的空对象。第二个参数是属性描述符,直接交给 Object.defineProperties。
function myCreate(proto, props) {
if (typeof proto !== 'object' && typeof proto !== 'function') {
throw new TypeError('Object prototype may only be an Object or null');
}
function F() {}
F.prototype = proto;
const obj = new F();
if (proto === null) Object.setPrototypeOf(obj, null);
if (props !== undefined) Object.defineProperties(obj, props);
return obj;
}
const animal = { speak() { return 'hi from ' + this.name; } };
const dog = myCreate(animal, { name: { value: 'Dog', enumerable: true } });
console.log(dog.speak()); // hi from Dog
console.log(Object.getPrototypeOf(dog) === animal); // true
console.log(Object.getPrototypeOf(myCreate(null))); // null
try { myCreate(1); } catch (e) { console.log(e instanceof TypeError); } // true
为什么 null 要单独处理?因为 F.prototype 不是对象时,new F() 会退回用 Object.prototype 做原型,得不到“没有原型”的对象,所以这里再用 Object.setPrototypeOf 改成 null。typeof null 是 'object',正好能通过前面的检查;MDN 写明 proto 既不是 null 也不是对象时抛 TypeError。
第二个参数里的属性,没写 writable、enumerable、configurable 时默认都是 false。上面给 name 写了 enumerable: true,但它仍然不能被改写。
Object.assign:逐个源对象赋值
MDN 对 Object.assign 的描述是:只拷贝源对象自己的可枚举属性,在源上用 [[Get]] 读、在目标上用 [[Set]] 写,所以会调用 getter 和 setter。手写时用 Reflect.ownKeys 拿到字符串键和 Symbol 键,再筛掉不可枚举的。
function myAssign(target, ...sources) {
if (target == null) throw new TypeError('Cannot convert undefined or null to object');
const to = Object(target);
for (const source of sources) {
if (source == null) continue;
const from = Object(source);
for (const key of Reflect.ownKeys(from)) {
const desc = Object.getOwnPropertyDescriptor(from, key);
if (desc && desc.enumerable) to[key] = from[key];
}
}
return to;
}
const s = Symbol('id');
const src = { a: 1, [s]: 2 };
Object.defineProperty(src, 'hidden', { value: 3, enumerable: false });
const out = myAssign({ a: 0 }, src, null, { b: { deep: true } });
console.log(out.a, out[s], 'hidden' in out); // 1 2 false
const nested = { b: { deep: true } };
const copy = myAssign({}, nested);
console.log(copy.b === nested.b); // true
const withSetter = { set x(v) { this.log = 'set ' + v; } };
myAssign(withSetter, { x: 1 });
console.log(withSetter.log); // set 1
console.log(myAssign('ab', { c: 1 }) instanceof String); // true
copy.b === nested.b 是 true,说明 assign 只拷第一层,要独立的嵌套数据得用深拷贝,见《怎样手写深拷贝?和 structuredClone 有什么区别?》。withSetter 那一行说明它是“赋值”而不是“定义属性”:目标上的 setter 被调用了,x 并没有变成普通数据属性。想原样复制 getter 和 setter,要改用 Object.defineProperties(target, Object.getOwnPropertyDescriptors(source))。
目标是原始值时会先被包装成对象,所以字符串 'ab' 返回的是 String 包装对象。源是 null 或 undefined 时直接跳过,MDN 写明 assign 不会因为这种源报错。
Array.from:先看可迭代,再看类数组
“可迭代”指对象有 Symbol.iterator 方法,for...of 能遍历它;“类数组”指有 length 和数字下标、但不是真数组的对象,比如 arguments、{ length: 3 }。Array.from 优先按可迭代处理。
function myFrom(items, mapFn, thisArg) {
if (mapFn !== undefined && typeof mapFn !== 'function') {
throw new TypeError('mapFn is not a function');
}
const result = [];
const map = (v, i) => (mapFn ? mapFn.call(thisArg, v, i) : v);
if (items != null && typeof items[Symbol.iterator] === 'function') {
let i = 0;
for (const v of items) { result.push(map(v, i)); i++; }
return result;
}
const obj = Object(items);
const len = Math.min(Math.max(Math.trunc(Number(obj.length)) || 0, 0), Number.MAX_SAFE_INTEGER);
for (let i = 0; i < len; i++) result.push(map(obj[i], i));
return result;
}
console.log(myFrom('ab')); // [ 'a', 'b' ]
console.log(myFrom(new Set([1, 1, 2]))); // [ 1, 2 ]
console.log(myFrom({ length: 3 }, (_, i) => i * 2)); // [ 0, 2, 4 ]
console.log(myFrom({ 0: 'x', 2: 'z', length: 3 })); // [ 'x', undefined, 'z' ]
console.log(Array.from({ 0: 'x', 2: 'z', length: 3 })); // [ 'x', undefined, 'z' ]
最后两行对比了手写版和原生版:下标 1 不存在,结果是真正的 undefined,不是空位,所以之后 map、forEach 都会访问到它。{ length: 3 } 配合 mapFn 是生成序列的常用写法。
length 那一行做的是“转成整数、不小于 0、不超过最大安全整数”,对应规范里的 ToLength。MDN 写明 Array.from 返回浅拷贝的新数组,元素是对象时仍是原来的引用。真实实现还支持被子类调用(this 是构造函数时用它创建结果),手写版省略了这一点。
常见追问
Object.assign 和对象展开 { ...obj } 有什么区别?
两者都只拷自己的可枚举属性。区别在写入方式:
assign对目标做赋值,会触发目标上的 setter;展开语法是在新对象上定义属性,不会触发 setter。另外assign会修改并返回第一个参数,展开语法总是生成新对象。
Object.create(null) 有什么用?
它造出的对象没有原型,不会从
Object.prototype继承toString、hasOwnProperty等属性,适合当纯粹的字典,键名就算叫toString也不会和继承来的方法混淆。代价是不能直接调用obj.hasOwnProperty,要用Object.hasOwn(obj, key)。