前端知库

怎样手写 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。

JavaScript
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 键,再筛掉不可枚举的。

JavaScript
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 优先按可迭代处理。

JavaScript
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)。