从 Jest 继承对象测试结果不符合预期发现的 ts 与 es2022 之间的冲突

今天把我的请求封装库重构了一遍,因为用到了 typescript 的面向对象类继承的方式,然后发现了一个在 jest ts-jest中 比较少见且不符合预期的测试用例。

案例

该案例简化后如下所示

class Super {
  a!: number;
  constructor() {
    this.init();
  }
  init() {
    this.a = 1;
  }
}

it('right', () => {
  class Sub extends Super {
    b!: number;
    constructor() {
      super();
      this.b = 2;
    }
  }
  const s = new Sub();
  expect(s).toEqual({ a: 1, b: 2 }); // {a:1, b:2}
});
it('error', () => {
  class Sub extends Super {
    public b!: number;
    init() {
      super.init();
      this.b = 2; // expect 2 but got undefined
    }
  }
  const s = new Sub();
  expect(s).toEqual({ a: 1, b: 2 }); // expect {a:1, b:2} but got {a:1, b:undefined}
});

error 这个测试用例里面期待得到{a:1, b:2},不过测试用例并没有通过,获得的是{a:1, b:undefined}

这个问题就很奇怪了。

猜测

然后我推断它可能是 ts 编译导致的问题。

ts 类编译后是这样的

'use strict';
class Super {
  constructor() {
    this.init();
  }
  init() {
    this.a = 1;
  }
}
class Sub extends Super {
  init() {
    super.init();
    this.b = 2; // expect 2 but got undefined
  }
}

console.log(new Sub()); // {a:1, b:2}

运行后的结果是{a:1, b:2}

就算设置 tsconfigtarget 设置为 es5

'use strict';
var __extends =
  (this && this.__extends) ||
  (function () {
    var extendStatics = function (d, b) {
      extendStatics =
        Object.setPrototypeOf ||
        ({ __proto__: [] } instanceof Array &&
          function (d, b) {
            d.__proto__ = b;
          }) ||
        function (d, b) {
          for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p];
        };
      return extendStatics(d, b);
    };
    return function (d, b) {
      if (typeof b !== 'function' && b !== null)
        throw new TypeError('Class extends value ' + String(b) + ' is not a constructor or null');
      extendStatics(d, b);
      function __() {
        this.constructor = d;
      }
      d.prototype = b === null ? Object.create(b) : ((__.prototype = b.prototype), new __());
    };
  })();
var Super = /** @class */ (function () {
  function Super() {
    this.init();
  }
  Super.prototype.init = function () {
    this.a = 1;
  };
  return Super;
})();
var Sub = /** @class */ (function (_super) {
  __extends(Sub, _super);
  function Sub() {
    return (_super !== null && _super.apply(this, arguments)) || this;
  }
  Sub.prototype.init = function () {
    _super.prototype.init.call(this);
    this.b = 2; // expect 2 but got undefined
  };
  return Sub;
})(Super);

console.log(new Sub()); // {a:1, b:2}

运行后的结果也是{a:1, b:2}

然后猜测是 babel 的问题,使用 babel 编译后虽然代码不尽相同,

但是运行后的结果也是{a:1, b:2}

这就很没脾气了。

搜索答案

在以上尝试未果后,果断尝试了询问群友,以及在搜索引擎搜索问题。

然而万能的群友不会,搜索引擎也不行,搜索出来的问题都不对。

然后在 ts-jestgithub 仓库里的 issueDiscussions一番搜索,也没找到答案。

查找文档

在各种地方都搜索不到答案后去看了 ts-jestgithub 源码,然后看到里面有一个 example,我就有点想对比一下官方的配置与我的有何不同。

打开examples/ts-only/jest-esm.config.js文件,发现对方是这么写的:

/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
  preset: 'ts-jest/presets/default-esm',
  transform: {
    '^.+\\.tsx?$': [
      'ts-jest',
      {
        tsconfig: 'tsconfig-esm.json',
        useESM: true,
      },
    ],
  },
};

而我的jest.config.js是这样的:

module.exports = {
-  preset: 'ts-jest/presets/default-esm',
-  transform: {
-    '^.+\\.tsx?$': [
-      'ts-jest',
-      {
-        tsconfig: 'tsconfig-esm.json',
-        useESM: true,
-      },
-    ],
-  },
+  transform: {
+    '^.+\\.tsx?$': 'ts-jest',
+  },
}

我的tsconfig是用的默认的也就是tsconfig.json,而ts-jest官方的是指定的某个配置文件。

我的 tsconfig 配置如下

{
  "compilerOptions": {
    "baseUrl": ".",
    "lib": ["esnext", "dom"],
    "target": "esnext",
    "sourceMap": true,
    "allowJs": true,
    "moduleResolution": "node",
    "declaration": true,
    "forceConsistentCasingInFileNames": false,
    "noImplicitReturns": true,
    "noImplicitThis": false,
    "noImplicitAny": false,
    "importHelpers": true,
    "strictNullChecks": true,
    "suppressImplicitAnyIndexErrors": true,
    "noUnusedLocals": true,
    "experimentalDecorators": true,
    "esModuleInterop": true,
    "strict": true
  },
  "include": ["src", "__test__"]
}

然后我把官方的配置复制到我的配置文件,把 tsconfig 改为tsconfig.build.json(我的项目上的打包配置)

jest.config.js:

module.exports = {
  transform: {
    '^.+\\.tsx?$': [
      'ts-jest',
      {
        tsconfig: 'tsconfig.build.json',
        useESM: true,
      },
    ],
  },
};

tsconfig.build.json:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "module": "esnext",
    "target": "es2017",
    "outDir": "dist",
    "declaration": true,
    "declarationDir": "types",
    "sourceMap": false
  },
  "include": ["src"]
}

这样一番改动后,发现测试果然就通过了。

定位问题

使用排除法后发现**问题是tsconfig导致的**,与presetuseESM没什么关系。

然后再在tsconfig.build.json里一番二分注释查找,

最终把**问题定位到了 tsconfig配置文件上 的 target 选项上**。

原来 ts-jest 需要的 target 不能是 esnextes2022

而刚好我的tsconfig.json里设置的就是esnext

ts-jest 文档ESM Support | ts-jest (kulshekhar.github.io)

Starting from v28.0.0, ts-jest will gradually switch to esbuild/swc to transform ts to js. To make the transition smoothly, we introduce legacy presets as a fallback when the new codes don't work yet.

可以看出,它的 ts 转 js 工具默认用的是esbuild/swc

然后使用这两种打包工具打包排查,发现最终是 esbuild 的锅,esbuild 会把 ts 代码编译成以下 js 代码

var Super = class {
  a;
  constructor() {
    this.init();
  }
  init() {
    this.a = 1;
  }
};
var Sub = class extends Super {
  b;
  init() {
    super.init();
    this.b = 2;
  }
};
console.log(new Sub()); // {a: 1, b: undefined}

从默认的 tsconfig 中的target: "exnext"可以发现这是 es2022 的特性:类字段可以在类的顶层被定义和初始化

es2022

  1. 声明类的字段:类字段可以在类的顶层被定义和初始化
  2. ...

es2015-es2022 参考

在此之前 js 中是不能像下面这样写的(es2022)

class Super {
  a = 1;
  b = 2;
}

必须写成这样(es2015 - es2021)

class Super {
  constructor() {
    this.a = 1;
    this.b = 2;
  }
}

但是在 ts 中,这种写法(类字段可以在类的顶层被定义和初始化)早就用上了。

所以就导致了两种编译结果:

在 ts es2022 以前的 target 中,会编译成 es2015-es2021 那种声明方式;

在 ts target es2022 中,会编译成 es2022 那种声明方式。

这就导致了问题的出现。

swcesbuild 两家编译后的代码也不完全一样,

swc 在成员变量初始值为 undefined 的时候并不会把成员变量移动到顶层:

初始变量为 undefined

ts

class Super {
  a!: number;
  constructor() {
    this.init();
  }
  init() {
    this.a = 1;
  }
}
class Sub extends Super {
  public b!: number;
  init() {
    super.init();
    this.b = 2; // expect 2 but got undefined
  }
}

console.log(new Sub());

js

class Super {
  constructor() {
    this.init();
  }
  init() {
    this.a = 1;
  }
}
class Sub extends Super {
  init() {
    super.init();
    this.b = 2;
  }
}
console.log(new Sub()); // {a:1, b:2}

初始变量为非 undefined

ts

class Super {
  a!: number = 3;
  constructor() {
    this.init();
  }
  init() {
    this.a = 1;
  }
}
class Sub extends Super {
  public b!: number = 5;
  init() {
    super.init();
    this.b = 2; // expect 2 but got undefined
  }
}

console.log(new Sub());

js

class Super {
  a = 3;
  constructor() {
    this.init();
  }
  init() {
    this.a = 1;
  }
}
class Sub extends Super {
  b = 5;
  init() {
    super.init();
    this.b = 2;
  }
}

const s = new Sub();
console.log(s); // {a: 1, b: 5}
s.init();
console.log(s); // {a: 1, b: 2}

该例子也是不符合预期的,你可能会认为实例是{a: 1, b: 2}然而实际是{a: 1, b: 5}

从中可以看出:你只能在 constructor 里设置成员变量,无法在 constructor 里调用其他方法更新成员的变量,
constructor 调用其他方法更新了成员变量后会再次被覆盖,变为默认的成员变量;
不过如果你在外面再次调用方法更新还是能更新成功的。

总之在 class 初始化时你只能在 constructor 里设置成员变量,实例化以后你怎么改都行。

总结

原因:

es2022 以前是不能在 class 顶层声明成员变量的,
所以那时候的 ts class 的成员变量编译后也只是在 constructor 里设置值,
es2022 有了顶层成员变量后,ts class 成员变量编译后是直接设置了 es2022 的成员变量。
由于 js es2022的 class 成员变量 class 初始化时只能在 constructor 里设置成员变量 ,导致的问题.


最后是 es2022 这种方式的执行顺序导致问题的出现,例子

class Super {
  a = (function () {
    console.log('a');
    return 3;
  })();
  constructor() {
    console.log('constructor');
    this.init();
  }
  init() {
    console.log('super init');
    this.a = 1;
  }
}
class Sub extends Super {
  b = (function () {
    console.log('b');
    return 5;
  })();
  init() {
    super.init();
    console.log('sub init');
    this.b = 2;
  }
}
const s = new Sub();

// --- outputs ---
// a
// constructor
// super init
// sub init
// b

从上面代码的输出结果可以看出:成员变量 b 是最后执行的,覆盖了 Subinit 方法所设置的值;这就导致了问题的出现。

解决方案:

target 设置为 es2022 以下就不会有这种问题了。

用最新的功能还是需要谨慎一点。

评论

0 / 800
全部评论()