在TypeScript类中不使用new调用构造函数

41
在JavaScript中,我可以定义一个构造函数,这个构造函数可以使用或不使用new来调用:

在JavaScript中,我可以定义一个构造函数,这个构造函数可以使用或不使用new来调用:

function MyClass(val) {
    if (!(this instanceof MyClass)) {
        return new MyClass(val);
    }

    this.val = val;
}

我可以使用以下任意一种语句来构造MyClass对象:

var a = new MyClass(5);
var b = MyClass(5);

我尝试使用下面的TypeScript类来达到类似的结果:

class MyClass {
    val: number;

    constructor(val: number) {
        if (!(this instanceof MyClass)) {
            return new MyClass(val);
        }

        this.val = val;
    }
}

但是调用MyClass(5)会报错,错误为类型“typeof MyClass”的值不能调用。要使用“new”才能创建带有构造函数的值

在 TypeScript 中是否有办法让这种模式工作?

8个回答

37

这个怎么样?描述MyClass的期望形状和它的构造函数:

interface MyClass {
  val: number;
}

interface MyClassConstructor {
  new(val: number): MyClass;  // newable
  (val: number): MyClass; // callable
}

注意,MyClassConstructor被定义为既可作为函数调用,也可作为构造函数使用。然后实现它:

const MyClass: MyClassConstructor = function(this: MyClass | void, val: number) {
  if (!(this instanceof MyClass)) {
    return new MyClass(val);
  } else {
    this!.val = val;
  }
} as MyClassConstructor;

上述代码可行,但还有一些小问题。问题一:实现返回MyClass | undefined,编译器不知道MyClass返回值对应可调用函数,undefined值对应新构造函数,因此会报错。因此在结尾处需要加上as MyClassConstructor。问题二:this参数目前不能通过控制流分析缩小范围,因此当设置其val属性时必须断言this不是void,即使此时我们知道它不可能是void。因此,我们必须使用非空断言操作符!。无论如何,您可以验证这些是否可行:
var a = new MyClass(5); // MyClass
var b = MyClass(5); // also MyClass

希望这有所帮助;祝你好运!


更新

注意:如@Paleo的answer中所提到的,如果你的目标是ES2015或更高版本,则在源代码中使用class将在编译后的JavaScript中输出class,并且这些需要new()。根据规范,我曾经看到像TypeError: Class constructors cannot be invoked without 'new'这样的错误。很可能一些JavaScript引擎忽略规范,也会接受函数式调用。如果你不关心这些注意事项(例如,你的目标明确是ES5,或者你知道你将在其中一个非规范兼容的环境中运行),那么你肯定可以强制TypeScript遵循这个规则:

class _MyClass {
  val: number;

  constructor(val: number) {
    if (!(this instanceof MyClass)) {
      return new MyClass(val);
    }

    this.val = val;
  }
}
type MyClass = _MyClass;
const MyClass = _MyClass as typeof _MyClass & ((val: number) => MyClass)

var a = new MyClass(5); // MyClass
var b = MyClass(5); // also MyClass

在这种情况下,您已经将 MyClass 重命名为 _MyClass ,并定义了 MyClass ,使其既是类型(与 _MyClass 相同),又是值(与 _MyClass 构造函数相同,但其类型被断言为也可以像函数一样调用)。如上所示,这在编译时可以工作。无论您的运行时是否满意都取决于上述警告。个人而言,我会在我的原始答案中坚持函数样式,因为我知道它们在es2015及以后版本中都可调用和可newable。祝您好运!

更新 2

如果你只是想声明这个答案中的bindNew()函数的类型,该函数接受一个符合规范的class并生成一个既可实例化又可调用的函数,你可以像这样做:

function bindNew<C extends { new(): T }, T>(Class: C & {new (): T}): C & (() => T);
function bindNew<C extends { new(a: A): T }, A, T>(Class: C & { new(a: A): T }): C & ((a: A) => T);
function bindNew<C extends { new(a: A, b: B): T }, A, B, T>(Class: C & { new(a: A, b: B): T }): C & ((a: A, b: B) => T);
function bindNew<C extends { new(a: A, b: B, d: D): T }, A, B, D, T>(Class: C & {new (a: A, b: B, d: D): T}): C & ((a: A, b: B, d: D) => T);
function bindNew(Class: any) {
  // your implementation goes here
}

这将正确地对其进行类型设置:

class _MyClass {
  val: number;

  constructor(val: number) {    
    this.val = val;
  }
}
type MyClass = _MyClass;
const MyClass = bindNew(_MyClass); 
// MyClass's type is inferred as typeof _MyClass & ((a: number)=> _MyClass)

var a = new MyClass(5); // MyClass
var b = MyClass(5); // also MyClass

但是要注意,对于bindNew()的重载声明并不适用于每种可能的情况。具体来说,它适用于最多有三个必需参数的构造函数。具有可选参数或多个重载签名的构造函数可能无法正确推断。因此,您可能需要根据用例调整类型。

好的,希望这有所帮助。祝你第三次好运。


2018年8月更新3

TypeScript 3.0引入了元组在剩余和展开位置,使我们能够轻松处理任意数量和类型参数的函数,而不需要以上述重载和限制。这是bindNew()的新声明:

declare function bindNew<C extends { new(...args: A): T }, A extends any[], T>(
  Class: C & { new(...args: A): T }
): C & ((...args: A) => T);

谢谢您的回答!如果使用class语法和bindNew函数或类似我在此处回答中提到的classy-decorator,仍然有可能调和这个问题吗?https://dev59.com/_10a5IYBdhLWcg3wHlil#48326964 - kasbah
请参见上面的更新2(第一个更新是我误读了您的问题;后来的更新解决了如何使用您的bindNew()函数)。 - jcalz
谢谢您的详细解释。我已经给您颁发了奖励。那么,使用instanceof MyClass可能不可行吗?此外,难道没有更好的方法来输入变量数量的参数吗?总的来说,这似乎比它值得的麻烦多了 :) - kasbah
1
我认为你可以让instanceof MyClass工作,这取决于bindNew()的实现,但我没有尝试过。 截至v2.7,TypeScript缺少一些类型运算符,需要正确地处理可变数量的参数。 无法检查函数签名或new()签名以提取其参数类型(可能作为某种元组)和返回类型。 - jcalz

4
关键字new是ES6类所必需的:
然而,你只能通过使用new来调用一个类,不能通过函数调用(在规范的第9.2.2节中)。[来源]

我遇到了相同的问题,但是与上面描述的类似ES5模块的声明有关。我的理解是,声明应该能够描述ES5的行为,因此一定有方法可以做到这一点? - Andrew
1
@dan的前两个例子仍然是有效的ES5语法(包括和不包括new关键字),而且Typescript声明文件(https://www.typescriptlang.org/docs/handbook/declaration-files/introduction.html)应该能够描述已经用ES5编写的现有库,因此仍然必须有一些方法来实现这一点... - Andrew
@Andrew ES5代码可以与TS一起使用。点击链接。但我不知道如何在定义文件中声明该函数。您应该创建一个新的问题,然后在此处提供链接。;) - Paleo
好的...在这里:https://dev59.com/C57ha4cB1Zd3GeqPqu1m - Andrew

4

使用 instanceofextends 的解决方案

大多数我见过的使用 x = X() 替代 x = new X() 的解决方案存在以下问题:

  1. x instanceof X 不起作用
  2. class Y extends X { } 不起作用
  3. console.log(x) 打印出的类型与 X 不同
  4. 有时候 x = X() 起作用,而 x = new X() 没有起作用
  5. 有时候在针对现代平台(ES6)时根本不起作用

我的解决方案

TL;DR - 基本用法

使用以下代码(也可在 GitHub 上查看:ts-no-new),你可以写出:

interface A {
  x: number;
  a(): number;
}
const A = nn(
  class A implements A {
    x: number;
    constructor() {
      this.x = 0;
    }
    a() {
      return this.x += 1;
    }
  }
);

或者:

class $A {
  x: number;
  constructor() {
    this.x = 10;
  }
  a() {
    return this.x += 1;
  }
}
type A = $A;
const A = nn($A);

不再使用通常的方法:

class A {
  x: number;
  constructor() {
    this.x = 0;
  }
  a() {
    return this.x += 1;
  }
} 

为了能够使用a = new A()或者a = A(),并支持instanceofextends、适当的继承以及现代编译目标(某些解决方案只能在转换为ES5或更早版本时工作,因为它们依赖于将class转换为具有不同调用语义的function)。

完整示例

#1

type cA = () => A;

function nonew<X extends Function>(c: X): AI {
  return (new Proxy(c, {
    apply: (t, _, a) => new (<any>t)(...a)
  }) as any as AI);
}

interface A {
  x: number;
  a(): number;
}

const A = nonew(
  class A implements A {
    x: number;
    constructor() {
      this.x = 0;
    }
    a() {
      return this.x += 1;
    }
  }
);

interface AI {
  new (): A;
  (): A;
}

const B = nonew(
  class B extends A {
    a() {
      return this.x += 2;
    }
  }
);

#2

type NC<X> = { new (): X };
type FC<X> = { (): X };
type MC<X> = NC<X> & FC<X>;
function nn<X>(C: NC<X>): MC<X> {
  return new Proxy(C, {
    apply: (t, _, a) => new (<any>t)(...a)
  }) as MC<X>;
}

class $A {
  x: number;
  constructor() {
    this.x = 0;
  }
  a() {
    return this.x += 1;
  }
}
type A = $A;
const A: MC<A> = nn($A);
Object.defineProperty(A, 'name', { value: 'A' });

class $B extends $A {
  a() {
    return this.x += 2;
  }
}
type B = $B;
const B: MC<B> = nn($B);
Object.defineProperty(B, 'name', { value: 'B' });

#3

type NC<X> = { new (): X };
type FC<X> = { (): X };
type MC<X> = NC<X> & FC<X>;
function nn<X>(C: NC<X>): MC<X> {
  return new Proxy(C, {
    apply: (t, _, a) => new (<any>t)(...a)
  }) as MC<X>;
}

type $c = { $c: Function };

class $A {
  static $c = A;
  x: number;
  constructor() {
    this.x = 10;
    Object.defineProperty(this, 'constructor', { value: (this.constructor as any as $c).$c || this.constructor });
  }
  a() {
    return this.x += 1;
  }
}
type A = $A;
var A: MC<A> = nn($A);
$A.$c = A;
Object.defineProperty(A, 'name', { value: 'A' });

class $B extends $A {
  static $c = B;
  a() {
    return this.x += 2;
  }
}
type B = $B;
var B: MC<B> = nn($B);
$B.$c = B;
Object.defineProperty(B, 'name', { value: 'B' });

#2 简化版

type NC<X> = { new (): X };
type FC<X> = { (): X };
type MC<X> = NC<X> & FC<X>;
function nn<X>(C: NC<X>): MC<X> {
  return new Proxy(C, {
    apply: (t, _, a) => new (<any>t)(...a)
  }) as MC<X>;
}

class $A {
  x: number;
  constructor() {
    this.x = 0;
  }
  a() {
    return this.x += 1;
  }
}
type A = $A;
const A: MC<A> = nn($A);

class $B extends $A {
  a() {
    return this.x += 2;
  }
}
type B = $B;
const B: MC<B> = nn($B);

第三部分 简化版

type NC<X> = { new (): X };
type FC<X> = { (): X };
type MC<X> = NC<X> & FC<X>;
function nn<X>(C: NC<X>): MC<X> {
  return new Proxy(C, {
    apply: (t, _, a) => new (<any>t)(...a)
  }) as MC<X>;
}

class $A {
  x: number;
  constructor() {
    this.x = 10;
  }
  a() {
    return this.x += 1;
  }
}
type A = $A;
var A: MC<A> = nn($A);

class $B extends $A {
  a() {
    return this.x += 2;
  }
}
type B = $B;
var B: MC<B> = nn($B);

#1#2中:
  • instanceof有效
  • extends有效
  • console.log正确打印
  • 实例的constructor属性指向真正的构造函数
#3中:
  • instanceof有效
  • extends有效
  • console.log正确打印
  • 实例的constructor属性指向公开的包装器(这可能是优点或缺点,具体取决于情况)
简化版不提供所有元数据以进行内省,如果您不需要它。

另请参阅


1
我的解决方法是使用一个类型和一个函数:

class _Point {
    public readonly x: number;
    public readonly y: number;

    constructor(x: number, y: number) {
        this.x = x;
        this.y = y;
    }
}
export type Point = _Point;
export function Point(x: number, y: number): Point {
    return new _Point(x, y);
}

或者使用界面:
export interface Point {
    readonly x: number;
    readonly y: number;
}

class _PointImpl implements Point {
    public readonly x: number;
    public readonly y: number;

    constructor(x: number, y: number) {
        this.x = x;
        this.y = y;
    }
}

export function Point(x: number, y: number): Point {
    return new _PointImpl(x, y);
}


1
使用这种方法,您不会失去调用 new Point(0, 0) 的能力吗? - jordanbtucker

1

我喜欢 @N. Kudryavtsev 的创建智能实例工厂的解决方案(使用 CreateCallableConstructor 包装构造函数)。但是如果只需要使用任意类型的数组参数,简单的 Reflect.construct(type, args) 就可以完美地工作。

以下是使用 mobx (v5) 的示例,它显示了原型和装饰器没有问题:


import { observable, reaction } from "mobx";
class TestClass { @observable stringProp: string;
numProp: number;
constructor(data: Partial) { if (data) { Object.assign(this, data); } } }
var obj = Reflect.construct(TestClass, [{numProp: 123, stringProp: "foo"}]) as TestClass; // var obj = new TestClass({numProp: 123, stringProp: "foo"});
console.log(JSON.stringify(obj));
reaction(() => obj.stringProp, v => { console.log(v); } );
obj.stringProp = "bar";

甚至这个简单的包装函数也可以工作:


type Constructor = new (...args: any[]) => any;
const createInstance = (c: Constructor, ...args) => new c(...args);
var obj = createInstance(TestClass, {numProp: 123, stringProp: "foo"});
// or const createInstance1 = (c: Constructor) => (...args) => new c(...args); var obj1 = createInstance(TestClass)({numProp: 123, stringProp: "foo"}, 'bla');

1

简而言之

如果你的目标是ES6,并且你真的想使用class存储你的数据,而不是一个function

  • 创建一个简单地调用类构造函数的function,并将其参数传递给它;
  • 将该functionprototype设置为你的类的prototype

现在你可以使用带有或不带有new关键字的方式来调用该function,以生成新的类实例。

Typescript playground

在 HTML 中,这是一个带有粗体文本并包含链接文本“Typescript playground”的段落元素。
Typescript提供了一种强类型的方式来创建这样一个函数(我们称之为“可调用的构造函数”)。在中间类型定义中需要使用any类型(将其替换为unknown会导致错误),但这个事实不会影响你的体验。
首先,我们需要定义基本类型来描述我们正在处理的实体。
// Let's assume "class X {}". X itself (it has type "typeof X") can be called with "new" keyword,
// thus "typeof X" extends this type
type Constructor = new(...args: Array<any>) => any;

// Extracts argument types from class constructor
type ConstructorArgs<TConstructor extends Constructor> =
    TConstructor extends new(...args: infer TArgs) => any ? TArgs : never;

// Extracts class instance type from class constructor
type ConstructorClass<TConstructor extends Constructor> =
    TConstructor extends new(...args: Array<any>) => infer TClass ? TClass : never;

// This is what we want: to be able to create new class instances
// either with or without "new" keyword
type CallableConstructor<TConstructor extends Constructor> =
  TConstructor & ((...args: ConstructorArgs<TConstructor>) => ConstructorClass<TConstructor>);

下一步是编写一个函数,该函数接受常规类构造函数并创建相应的“可调用构造函数”。
function CreateCallableConstructor<TConstructor extends Constructor>(
    type: TConstructor
): CallableConstructor<TConstructor> {
    function createInstance(
        ...args: ConstructorArgs<TConstructor>
    ): ConstructorClass<TConstructor> {
        return new type(...args);
    }

    createInstance.prototype = type.prototype;
    return createInstance as CallableConstructor<TConstructor>;
}

现在我们所要做的就是创建我们的“可调用构造函数”,并检查它是否真正起作用。
class TestClass {
  constructor(readonly property: number) { }
}

const CallableTestConstructor = CreateCallableConstructor(TestClass);

const viaCall = CallableTestConstructor(56) // inferred type is TestClass
console.log(viaCall instanceof TestClass) // true
console.log(viaCall.property) // 56

const viaNew = new CallableTestConstructor(123) // inferred type is TestClass
console.log(viaNew instanceof TestClass) // true
console.log(viaNew.property) // 123

CallableTestConstructor('wrong_arg'); // error
new CallableTestConstructor('wrong_arg'); // error

0

您可以使用 const obj = Object.create(MyClass.prototype),然后使用 Object.assign(obj, { foo: 'bar' }) 分配所需的值。

这将创建一个类实例,而无需使用 new 关键字或构造函数。


0

以下是我在使用 jest 进行不可变模型组测试时的解决方案。 makeHash 函数没有做任何特殊的事情,只是一个实用程序,从 uuid() 创建短的随机字符串。

对我来说,“魔法”就是将 type 声明为 new (...args: any[]) => any,使其可以作为 let model = new set.type(...Object.values(set.args)); 进行“new”。因此,与其绕过 new,不如在“可新建”形式中工作。

// models/oauth.ts
export class OAuthEntity<T = string> {
  constructor(public readonly id: T) {}
  [key: string]: any;
}

export class OAuthClient extends OAuthEntity {
  /**
   * An OAuth Client
   * @param id A unique string identifying the client.
   * @param redirectUris Redirect URIs allowed for the client. Required for the authorization_code grant.
   * @param grants Grant types allowed for the client.
   * @param accessTokenLifetime Client-specific lifetime of generated access tokens in seconds.
   * @param refreshTokenLifetime Client-specific lifetime of generated refresh tokens in seconds
   * @param userId The user ID for client credential grants
   */
  constructor(
    public readonly id: string = '',
    public readonly redirectUris: string[] = [],
    public readonly grants: string[] = [],
    public readonly accessTokenLifetime: number = 0,
    public readonly refreshTokenLifetime: number = 0,
    public readonly userId?: string,
    public readonly privateKey?: string
  ) {
    super(id);
  }
}

// models/oauth.test.ts
import { makeHash, makePin } from '@vespucci/utils';
import { OAuthEntity, OAuthClient } from '@vespucci/admin/server/models/oauth';

type ModelData = { type: new (...args: any[]) => any; args: { [key: string]: any }; defs?: { [key: string]: any } };

describe('Model Tests', () => {
  const dataSet: ModelData[] = [
    { type: OAuthEntity, args: { id: makeHash() } },
    {
      type: OAuthClient,
      args: {
        id: makeHash(),
        redirectUris: [makeHash()],
        grants: [makeHash()],
        accessTokenLifetime: makePin(2),
        refreshTokenLifetime: makePin(2),
        userId: makeHash(),
        privateKey: makeHash(),
      },
    },
    {
      type: OAuthClient,
      args: {},
      defs: {
        id: '',
        redirectUris: [],
        grants: [],
        accessTokenLifetime: 0,
        refreshTokenLifetime: 0,
      },
    },
  ];
  dataSet.forEach((set) => {
    it(`Creates ${set.type.name} With ${Object.keys(set.args).length} Args As Expected`, () => {
      let model!: any;
      const checkKeys = Object.keys(set.args).concat(Object.keys(set.defs || {}).filter((k) => !(k in set.args)));
      const checkValues: any = checkKeys
        .map((key) => ({ [key]: set.args[key] || set.defs?.[key] }))
        .reduce((p, c) => ({ ...p, ...c }), {});
      expect(() => {
        model = new set.type(...Object.values(set.args));
      }).not.toThrow();
      expect(model).toBeDefined();
      checkKeys.forEach((key) => expect(model[key]).toEqual(checkValues[key]));
    });
  });
});

对我来说,最终的结果是:

enter image description here


哪里会在没有使用 new 关键字的情况下调用类构造函数呢? - jordanbtucker
@jordanbtucker,正如我在我的回答中所分享的:“不是为了绕过新的问题,而是更多地关注于使用可更新的形式。”OP没有澄清他们为什么要避免使用new关键字,因此我提供了一种替代方法,即显式地创建一个类型,并且展示了如何通过方法动态地创建一个类型,而不需要知道该类型的实际构造函数。 - Gio

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接