两个js手写题

7 阅读6分钟

在 JavaScript 的开发日常中,newinstanceof 是我们最熟悉的两个关键字。它们看起来像是面向对象编程(OOP)的标准配置,但在 JavaScript 这个基于原型的语言中,它们的底层逻辑与 Java 或 C++ 有着本质的区别。

很多开发者在使用它们时往往“知其然,不知其所以然”。一旦遇到原型链污染、跨 iframe 判断失效或者需要模拟类行为时,就容易束手无策。今天,我们将通过手写这两个操作符,深入理解 JavaScript 的原型机制,并探讨在工程实践中更优雅的解决方案。

一、instanceof:原型链上的寻根问祖

1. 核心逻辑解析

instanceof 运算符用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上。

简单来说,A instanceof B 的本质是:在 A 的原型链中,能否找到 B.prototype?

我们来看一个基础的原型继承结构:

function Animal() {}
function Person() {}
// 将 Person 的原型指向 Animal 的实例
Person.prototype = new Animal();
const p = new Person();

在这个结构中,p 的原型链如下: p -> Person.prototype (即 Animal 实例) -> Animal.prototype -> Object.prototype -> null

当执行 p instanceof Animal 时,引擎会沿着这条链向上查找,直到找到 Animal.prototype 或者链的尽头。

2. 手写实现与细节剖析

基于上述逻辑,我们可以尝试实现一个简易版的 instanceof

function isInstanceOf(instance, constructor) {
    // 获取实例的原型
    let proto = Object.getPrototypeOf(instance); 
    // 获取构造函数的原型对象
    const prototype = constructor.prototype;

    // 沿着原型链向上查找
    while (proto) {
        if (proto === prototype) {
            return true;
        }
        proto = Object.getPrototypeOf(proto);
    }
    return false;
}

代码解读与优化思考:

  1. __proto__ vs Object.getPrototypeOf: 在早期的代码示例中,常看到使用 obj.__proto__ 来访问原型。虽然它在大多数浏览器中有效,但它本质上是一个非标准的访问器属性(尽管已被纳入附录标准)。在严谨的工程代码中,推荐使用 Object.getPrototypeOf(),语义更清晰且符合规范。

  2. 边界条件处理: 原生的 instanceof 对左侧操作数有要求。如果左侧不是对象(例如基本类型 string, number),会直接抛出 TypeError 或返回 false。手写版本通常需要增加类型检查,确保 instance 是对象或函数。

  3. 深层原理:Symbol.hasInstance: 在 ES6 之后,instanceof 的行为可以通过 Symbol.hasInstance 被自定义。例如,你可以让一个普通对象在 instanceof 判断中表现得像一个数组。这是手写版本难以覆盖的高级特性,但在理解语言灵活性时非常重要。

3. 工程意义

为什么在大型项目中我们需要关注 instanceof

在多人协作的场景下,类型判断往往比 typeof 更精确。typeof null'object'typeof [] 也是 'object'。而通过原型链判断,我们可以准确区分引用类型的具体构造函数。但需要注意,instanceof 在多全局环境(如 iframe)下会失效,因为不同窗口的构造函数原型不同。此时,Array.isArray()Object.prototype.toString.call() 是更稳妥的选择。

二、new:对象实例化的四部曲

1. new 做了什么?

当我们写下 const p = new Person('张三', 18) 时,JavaScript 引擎在后台执行了四个关键步骤:

  1. 创建:创建一个全新的空对象。
  2. 链接:将该对象的原型(__proto__)指向构造函数的 prototype
  3. 绑定:将构造函数内部的 this 绑定到这个新对象,并执行构造函数。
  4. 返回:如果构造函数没有返回对象,则返回新创建的对象;否则返回构造函数返回的对象。

2. 手写实现与参数处理

模拟 new 的过程,核心难点在于参数的透传和原型的正确链接。

function objectFactory() {
    // 1. 取出构造函数(第一个参数)
    const Constructor = [].shift.call(arguments);
    
    // 2. 创建新对象,并链接原型
    // 使用 Object.create 比直接赋值 __proto__ 更规范
    const obj = Object.create(Constructor.prototype);
    
    // 3. 绑定 this 并执行构造函数
    const result = Constructor.apply(obj, arguments);
    
    // 4. 处理返回值
    // 只有当构造函数返回的是对象时,new 表达式才返回该对象
    return (typeof result === 'object' && result !== null) ? result : obj;
}

深度剖析:

  1. 类数组对象 Arguments: 在早期的 JavaScript 代码中(如你提供的示例),处理不定参数常使用 arguments 对象。它是一个“类数组”,有长度和索引,但没有 slicemap 等数组方法。

    • 旧写法[].shift.call(arguments)。利用数组的方法借用,从 arguments 中移除并返回第一个元素(构造函数)。
    • 新写法:使用 ES6 的剩余参数 function factory(Constructor, ...args)。这样 args 就是真正的数组,可以直接使用 applyspread 语法,代码可读性大幅提升。
  2. 原型链接的方式: 示例代码中使用了 obj.__proto__ = Constructor.prototype。虽然直观,但 Object.create(Constructor.prototype) 是更标准的做法。它不仅设置了原型,还确保了创建的对象是一个纯净的空对象,避免了手动设置可能带来的副作用。

  3. 构造函数的返回值陷阱: 这是手写 new 最容易忽略的一点。如果构造函数内部显式 return { name: 'test' },那么 new 表达式的结果将是这个返回的对象,而不是最初创建的 obj。如果返回的是基本类型,则被忽略。完整的 new 模拟必须包含这个判断逻辑。

3. 现代替代方案

在 ES6 之后,我们有了 Reflect.construct。它是 new 操作符的函数式等价物,能够更准确地处理原型链和 new.target

// 等价于 new Person('张三', 18)
const p = Reflect.construct(Person, ['张三', 18]);

在元编程或高阶函数场景下,Reflect.construct 比手写 objectFactory 更可靠且性能更好。

三、原型链的真相:proto 与 prototype

在分析代码时,我们经常看到 arr.__proto__arr.constructor。理清它们的关系是理解上述两个手写函数的关键。

  1. prototype:这是函数特有的属性。当一个函数被用作构造函数时,它的 prototype 属性将成为实例的原型。
  2. __proto__:这是对象的内部属性(访问器),指向创建该对象的构造函数的 prototype
  3. constructor:这是原型对象上的一个属性,默认指回构造函数本身。

以数组为例:

const arr = [];
// arr 是由 Array 构造函数创建的
arr.__proto__ === Array.prototype // true
// Array.prototype 本身也是一个对象,由 Object 创建
Array.prototype.__proto__ === Object.prototype // true
// 最终指向 null
Object.prototype.__proto__ // null

理解这条链条,就能明白为什么 arr instanceof Object 也是 true。因为沿着 arr 的原型链向上找,最终会碰到 Object.prototype

四、总结与工程建议

通过手写 instanceofnew,我们不仅复现了语言特性,更窥见了 JavaScript 对象模型的基石。

  1. 理解优于记忆:不要死记硬背原型链的指向,理解“对象通过 __proto__ 寻找属性,函数通过 prototype 提供属性”这一模型更为重要。
  2. 生产环境慎用重写:虽然手写这些功能有助于学习,但在实际业务代码中,优先使用原生操作符或 Object.createReflect.construct 等标准 API。原生实现经过引擎深度优化,性能和兼容性远优于 JS 层面的模拟。
  3. 注意边界情况:无论是类型判断还是对象创建,都要考虑 null、基本类型、跨域上下文等边界条件。
  4. 拥抱 ES6+class 语法糖让原型继承看起来更像传统 OOP,但底层的原型机制并未改变。了解底层机制,能帮助我们在使用 classextends 时避免踩坑。

JavaScript 的灵活性源于其动态的原型系统。掌握这些底层原理,能让我们在编写复杂应用时,对对象的生命周期和类型关系有更清晰的掌控,从而写出更健壮、更易维护的代码。