在开始阅读这篇文章之前,你可以对比下面这两段代码的输出结果是否一致(假设 myNew 是你自己实现的 new 操作):

function F(){}
F.prototype = null
const obj1 = new F()
const obj2 = myNew(F)

console.log(Object.getPrototypeOf(obj1))
console.log(Object.getPrototypeOf(obj2))

如果不一样,那么可能说明你的 myNew 方法和标准的 new 操作之间存在着些许出入,这篇文章也许能够让你的方法更加完善 / 严谨。

Object.create 说起

最近在刷一些手写实现原生方法的面试题,偶然看到了有一个 Object.create() 方法的实现是这么写的:

Object.myCreate = function (proto, propertyObject = undefined) {
  if (propertyObject === null) {
    // 这里没有判断propertyObject是否是原始包装对象
    throw 'TypeError'
  } else {
    function Fn () {}
    Fn.prototype = proto
    const obj = new Fn()
    if (propertyObject !== undefined) {
      Object.defineProperties(obj, propertyObject)
    }
    if (proto === null) {
      // 创建一个没有原型对象的对象,Object.create(null)
      obj.__proto__ = null
    }
    return obj
  }
}

而这个方法在规范里的实现是这样的:

简单地说,它会接受两个参数,第一个参数作为调用后返回对象的 __proto__,第二个参数负责配置该对象的相关属性。而这里的第一个参数,可以是对象也可以是 null

基本上,上面代码的实现没有什么问题,但是我突然产生了一个疑问:当第一个参数是 null 的时候,Fn.prototype = proto 已经把构造函数的原型对象设置为 null了,为什么后面还要在判断第一个参数为 null 之后设置 obj.__proto__ = null 呢?这两个语句的作用难道不是一样的吗?毕竟 Fn.prototypeobj.__proto__ 都是指向同一个原型对象呀!

于是我将代码中的 if (proto === null) 判断去掉,并分别测试了 Object.create() 方法和 Object.myCreate() 方法:

可以看到,第二个打印是符合预期的,返回对象的 __proto__确实指向传入的参数 null;但第一个打印却和预想的不一样,展开打印对象后会发现,它其实是 Object.prototype。这是怎么回事呢?难道说代码中执行 Fn.prototype = proto 的时候,实际上实例的 __proto__ 并没有跟着改变?

于是继续测试:

这里可以看到:用 null 重写构造函数的原型后,通过 new 构造函数创建的实例的 __proto__ 并没有跟着变成 null,而是指向了 Object.prototype

调用构造函数的时候做了什么?

这时候,我们可能会想到,通过 new 调用构造函数的时候,内部可能做了一些处理,导致最终返回的实例对象的 __proto__ 和我们预期的不一致。既然如此,我们通过规范看一下调用构造函数的时候,具体做了什么事:

这里我们只需要关注第六步和第七步。这两步会检查构造函数的原型对象的类型,如果是一个对象,则会将其作为实例的 __proto__;如果不是对象,则会将 Object.prototype 作为实例的 __proto__。这就能解释为什么用 null 重写构造函数的原型后,实例的 __proto__ 没有跟着改变了,因为在调用构造函数的过程中,它链接上了 Object.prototype,可以说,这里实例的原型链并没有断开。

实现一个更严谨的 new

在大部分的手写 new 实现中,通常都没有去检查构造函数的原型是否是一个对象。有的实现中甚至直接使用了 Object.create() 方法以快速地建立原型关系,就像这样:

function myNew(Fn,...args){
    if(typeof Fn != 'function'){
        throw new TypeError(Fn + 'is not a constructor')
    }
    const instance = Object.create(Fn.prototype)    
    const returnValue = Fn.call(instance,...args)
    return returnValue instanceof Object ? returnValue : instance
}

这里直接使用Object.create() 方法,是有问题的。

在前面阅读规范的时候我们已经知道了,即使传给 Object.create 的参数是 null,也会将其作为创建的对象的 __proto__,所以这里如果使用了 Object.create,并且构造函数的原型 Fn.prototype 还恰好就是 null 的话,就会导致实例的 __proto__ 也是 null,这和 new 的实际实现是有出入的。

所以,如果想实现一个更加严谨的 new,那么就不应该在内部去调用 Object.create 方法,而应该选择手动创建一个对象并和构造函数建立原型关系,同时,我们还应该加入对构造函数原型的类型判断,看它到底是不是一个对象。

因此,上面的代码可以修改如下:

function myNew(Fn,...args){
    if(typeof Fn != 'function'){
        throw new TypeError(Fn + 'is not a constructor')
    }
    const instance = {}
    // 检测构造函数原型是不是对象
    instance.__proto__ = Fn.prototype instanceof Object ? Fn.prototype : Object.prototype 
    const returnValue = Fn.call(instance,...args)
    return returnValue instanceof Object ? returnValue : instance
}

现在,我们再用这个改进之后的 new 去测试文章开头的代码:

可以看到,加入了对构造函数原型可能为 null 的处理之后,返回的实例的 __proto__ 明确指向了 Object.prototype。现在我们实现的 new 就更加严谨了,而且也更接近原生的 new 操作。

本文到这里就结束了。不过,从语言设计的角度来说,为什么不将实例的 __proto__ 也跟着设置为 null 呢?这里不断开实例的原型链,而是将其链接到 Object.prototype 有什么好处?大家可以在评论区留言讨论一下。另外,不排除本文存在原理性的错误或者说法上的偏颇,如果你发现了,也欢迎在评论区指正。

参考

https://262.ecma-international.org/5.1/#sec-13.2.2

http://es5.github.io/#x15.2.3.5

https://stackoverflow.com/questions/18198178/null-prototype-object-prototype-and-object-create