什么时候在 JavaScript 的构造函数和 class 中创建新对象?

When are new objects created in JavaScript's constructor function vs in class?

构造函数

通过旧的 ES5 构造函数创建新对象时:新对象何时创建?

一个猜测:是不是在JS引擎遇到new关键字的时候立即创建,直接在构造函数执行之前?


Class

与上面类似,但对于类:新对象何时创建?

猜测:由于我们可以使用 class 语法对内置对象进行子类化,我认为引擎必须知道什么类型(exotic vs ordinary) 它的父对象是。因此,我在想也许新对象是在引擎遇到 extends 关键字时创建的,并且可以读取父对象的类型。


最后

在这两种情况下,什么时候设置原型属性?是在执行构造函数之前还是之后/ClassBody?


备注

注 1:如果答案可以包含指向 ECMAScript specification 两个创作中每个作品发生位置的链接,那就太好了。我一直在四处搜索,一直无法找到正确的算法步骤。

注 2:"created" 我的意思是 space 分配在内存和类型集(异国情调与普通)中,至少。

new will call Construct, which in turn will call the related function's internal [[Construct]]。我只会在这里讨论普通的 [[Construct]],而不关心例如。具有自定义行为的代理,恕我直言,与主题无关。


在标准场景中(没有 extends),在步骤 5.a 中,[[Construct]] 调用 OrdinaryCreateFromConstructor, and the return of that will be used as this (see OrdinaryCallBindThis,用作参数)。请注意,OrdinaryCallEvaluateBody 在稍后的步骤中出现 - 在评估构造函数之前创建对象。对于new f,基本上就是Object.create(f.prototype)。一般是Object.create(newTarget.prototype)。这与 class 和 ES5 方式相同。原型显然也放在那里。


混淆可能源于使用 extends 的情况。在那种情况下,[[ConstructorKind]] 不是 "base"(参见 ClassDefinitionEvaluation), so in [[Construct]], step 5.a does not apply anymore, nor is OrdinaryCallBindThis called. The important part here happens in the super call 的第 15 步)。长话短说,它使用 SuperConstructor 和当前的 newTarget 调用 Construct,并将结果绑定为 this。因此,如您所知,在 super 调用之前对 this 的任何访问都会导致错误。因此,"new object" 是在 super 调用中创建的(请注意,所讨论的再次适用于调用 Construct - 如果 SuperConstructor 不扩展任何东西,非派生情况,否则这个 - 唯一的区别是 newTarget)。

为了详细说明 newTarget 转发,以下是其行为方式的示例:

class A { constructor() { console.log(`newTarget: ${new.target.name}`); } }
class B extends A { constructor(){ super(); } }
console.log(
  `B.prototype's prototype: ${Object.getPrototypeOf(B.prototype).constructor.name}.prototype`
);
console.log("Performing `new A();`:");
new A();
console.log("Performing `new B();`:");
new B();

由于 [[Construct]] 以 newTarget 作为参数调用 OrdinaryCreateFromConstructor,它总是被转发,最后使用的原型将是正确的(在上面的例子中,B.prototype,并注意这在turn 以 A.prototype 作为原型,又名 Object.getPrototypeOf(B.prototype) === A.prototype)。最好查看所有相关部分(超级调用、Construct、[[Construct]] 和 OrdinaryCreateFromConstructor),并观察它们如何 get/set 或传递 newTarget。此处还要注意,对 PrepareForOrdinaryCall 的调用也获得了新目标,并将其设置在相关 SuperConstructor 调用的 FunctionEnvironment 中,以便其他链接的超级调用也将获得正确的目标(对于从某些东西扩展的情况,而这些东西又从东西)。


最后但最不重要的一点是,构造函数可以使用 return 生成他们想要的任何对象。这通常会导致在前面描述的步骤中创建的对象被简单地丢弃。但是,您可以执行以下操作:

const obj = {};
class T extends Number {
  constructor() {
    return obj;
  }
}
let awkward = new T();

在这种非常尴尬的情况下,没有调用 super,但这也没有错误,因为构造函数只是 returns 一些先前创建的对象。在这里,至少从我所见,使用 new T().

时根本不会创建任何对象

还有一个副作用。如果你从一个构造函数扩展,returns 一些自制对象,newTarget 的转发和所有这些都没有效果,扩展 class 的原型就会丢失:

class A {
  constructor() {
    // The created object still has the function here.
    // Note that in all normal cases, this should not
    // be in the constructor of A, it's just to show
    // what is happening.
    this.someFunc();
    //rip someFunc, welcome someNewFunc
    return {
      someNewFunc() { console.log("I'm new!"); }
    }; 
  }
}
class B extends A {
  constructor() {
    super();
    //We get the new function here, after the call to super
    this.someNewFunc();
  }
  someFunc() { console.log("something"); }
}
console.log("Performing `new B();`:");
let obj = new B();
console.log("Attempting to call `someFunc` on the created obj:");
obj.someFunc(); // This will throw an error.


PS: 我自己也是第一次在规范中阅读很多内容,所以可能会有一些错误。我自己的兴趣是找出扩展内置插件的工作方式(源于前一段时间的不同辩论)。要理解这一点,在上述之后,只需要最后一件事:我们注意到例如对于 Number constructor,它检查 "If NewTarget is undefined [...]",否则使用 NewTarget 正确调用 OrdinaryCreateFromConstructor,同时添加内部 [[NumberValue]] 插槽,然后在下一步中设置它。


编辑以尝试回答评论中的问题:

我认为您仍然将 class 和 ES5 方式视为两个独立的事物。 class 几乎完全是语法糖,正如在对该问题的评论中已经提到的那样。一个 class 只不过是一个函数,类似于 "old ES5 way".


关于你的第一个问题,你提到的 "method" 是函数,它将以 ES5 方式使用(以及变量将保存什么,class A extends Number {}; console.log(typeof A === "function" && Object.getPrototypeOf(A) === Number);)。原型已设置,以实现您之前记为 "inheriting static properties" 的内容。静态属性只不过是构造函数上的属性(如果你曾经使用过 ES5 方式)。

[[HomeObject]] 用于访问 super,如 table 27. If you look at what the related calls do (see table 27, GetSuperBase 中所述),您会注意到它,本质上只是执行“[[HomeObject]].[ [GetPrototypeOf]]()”。这将是 superclass 原型,因为它应该是这样的,因此 super.someProtoMethod 可以在 superClass 的原型上工作。


对于第二个问题,我觉得还是举个例子比较好:

class A { constructor() { this.aProp = "aProp"; } }
class B extends A { constructor() { super(); this.bProp = "bProp"; }
new B();

我将尝试列出在评估 new B(); 时按顺序执行的有趣步骤:

  • new 调用 Construct,由于没有当前的 newTarget,它调用 B 的 [[Construct]],newTarget 现在设置为 B

  • [[Construct]] 遇到一个不是 "base" 的种类,因此不会创建任何对象

  • PrepareForOrdinaryCall,为构造函数的执行,生成一个新的执行上下文,以及一个新的FunctionEnvironment(其中[[NewTarget]]将被设置为newTarget!),并使其成为运行执行上下文。

  • OrdinaryCallBind这个也不执行,this保持未初始化状态

  • OrdinaryCallEvaluateBody 现在将开始执行 B

  • 的构造函数
  • 遇到超级调用并执行:

    • GetNewTarget() 从 FunctionEnvironment 中检索 [[NewTarget]],这是之前设置的

    • Construct 在 SuperConstructor 上调用,检索到的 newTarget

    • 调用SuperConstructor的[[Construct]],newTarget

    • SuperConstructor 具有种类 "base",因此它执行 OrdinaryCreateFromConstructor,但设置了 newTarget。现在本质上是 Object.create(B.prototype),再次注意,Object.getPrototypeOf(B.prototype) === A.prototype 已经在函数 B 上设置,来自 class 构造。

    • 和上面类似,正在制作一个新的执行上下文,这一次,OrdinaryCallBind也完成了。 SuperConstructor 将执行,产生一些对象,再次弹出执行上下文。请注意,如果 A 反过来再次扩展其他内容,newTarget 又会在所有地方正确设置,所以它会越来越深。

    • super 获取 Construct 的结果(SuperConstructor 产生的对象,它确实以 B.prototype 作为原型,如果没有任何异常发生 - 正如所讨论的,例如构造函数 returns 其他值,或者原型被手动更改),并在当前环境中将其设置为 this ,即用于执行 B 的构造函数的那个​​(另一个已被弹出已经)。

  • B 的构造函数的执行继续,this 现在已初始化。它是一个对象,以 B.prototype 作为原型,又以 A.prototype 作为原型,并且已经在其上调用了 A 构造函数(同样,如果没有发生任何异常情况),所以 this.aProp 已经存在。 B 的构造函数然后将添加 bProp,并且该对象是 new B();.

  • 的结果

When creating a new object via a good old ES5 constructor function: When is the new object created?

对象构造行为的规范级定义由 [[Construct]] 函数定义。对于标准的JS函数(function Foo(){},这个函数的定义在9.2.3 FunctionAllocate where functionKind will "normal". Then you can see on step 9.a, the [[Construct]] slot is declared to point at section 9.2.2中初始化,[[ConstructorKind]]设置为"base"

当用户代码调用new Foo();构造这个函数的实例时,它会从12.3.3 The new operator to 12.3.3.1.1 EvaluateNew to 7.3.13 Construct调用到[[Construct]],调用上面初始化的slot,传递参数,并且Foo 的功能相当于 newTarget.

深入研究 9.2.2 [[Construct]],我们可以看到步骤 5.a 执行:

  1. a. Let thisArgument be ? OrdinaryCreateFromConstructor(newTarget, "%ObjectPrototype%").

这回答了您 何时 的问题。 this 对象实际上是通过 Object.create(Foo.prototype) 创建的(其中有一些额外的可忽略逻辑)。然后该函数将继续并在步骤 8 它将执行

  1. If kind is "base", perform OrdinaryCallBindThis(F, calleeContext, thisArgument).

你可以把它想象成 this = thisArgument,它会在函数中设置 this 的值,然后再实际调用 Foo 函数的逻辑步骤 11.

ES6 classes 与 ES5 风格的构造函数的主要区别在于 [[Construct]] 方法仅在 first 级别使用一次建设。例如,如果我们有

function Parent(){}
function Child(){
  Base.apply(this, arguments);
}
Object.setPrototype(Child.prototype, Parent.prototype);

new Child();

new 将对 Child 使用 [[Construct]],但对 Parent 的调用使用 .apply,这意味着它实际上并未构建parent,它只是像普通函数一样调用它并传递适当的 this 值。

这就是事情变得复杂的地方,正如您所注意到的,因为这意味着 Parent 实际上对 this 的创建没有任何影响,只是希望它被赋予了一个可接受的值。

Similarly to above, but for classes: When is the new object created?

与 ES6 class 语法的主要区别在于,因为父函数是用 super() 而不是 Parent.call/Parent.apply 调用的,所以 [[Construct]]调用父函数的函数而不是 [[Call]]。因此,实际上可以将 [[ConstructorKind]] 设置为 "base" 以外的值,从而进入 9.2.2 [[Construct]]。正是这种行为变化影响了对象的构造时间。

如果我们现在重新审视上面的示例,使用 ES6 classes

class Parent {
  constructor() {
  }
}
class Child extends Parent {
  constructor() {
    super();
  }
}

Child 不是 "base",因此当 Child 构造函数最初运行时,this 值未初始化。你可以把 super() 想成 const this = super();,就像

console.log(value);
const value = 4;

会抛出异常,因为value还没有初始化,是调用父[[Construct]]调用super(),然后初始化this ] 在 Child 构造函数体内。如果 function Parent(){},父 [[Construct]] 的行为就像在 ES5 中一样,因为 [[ConstructorKind]]"base"

这种行为也是允许 ES6 class 语法扩展 Array 等原生类型的原因。对 super() 的调用是实际创建实例的调用,并且由于 Array 函数知道创建真正的函数数组所需知道的所有信息,因此它可以这样做,然后 return那个对象。

In both cases, when is the prototype property set? Is it before or after executing the constructor function / ClassBody?

我在上面掩盖的另一个关键部分是上面规范片段中提到的 newTarget 的确切性质。在 ES6 中,有一个新概念是 "new target",它是传递给 new 的实际构造函数。因此,如果您执行 new Foo,您实际上是在以两种不同的方式使用 Foo。一个是您将该函数用作构造函数,另一个是您将该值用作 "new target"。这对于 class 构造函数的嵌套至关重要,因为当您调用 [[Construct]] 函数链时,被调用的实际构造函数将在链的上游运行,但 newTarget 值将保持不变。这很重要,因为 newTarget.prototype 用于实际设置最终构造对象的原型。例如,当你做

class Parent extends Array {
  constructor() {
    console.log(new.target); // Child
    super();
  }
}
class Child extends Parent {
  constructor() {
    console.log(new.target); // Child
    super();
  }
}
new Child();

new Child的调用将调用Child构造函数,同时将其作为newTarget值设置为Child。然后当 super() 被调用时,我们使用 Parent 中的 [[Construct]],但也将 Child 作为 newTarget 值传递。这对 Parent 重复并且意味着即使 Array 负责创建数组奇异对象,它仍然可以使用 newTarget.prototype (Child.prototype) 来确保数组具有正确的原型链。