1
/
5

Babelプラグインの順序とallowDeclareFieldsの妙

Babelでdeclareクラスフィールドを使うためにallowDeclareFieldsというオプションがあります。一見すると単に設定でこれを有効化すればいいような気がしますが、実は正しくこの設定を有効化するには罠がありました。本記事ではBabelとTypeScriptのクラスフィールドサポートを紐解き、正しい理解にもとづいて適切な設定を紹介します。

クラスフィールドとは

クラスフィールドはJavaScriptのStage 3提案で、クラスの直下に変数宣言のような記法でインスタンス変数やクラス変数に相当する宣言を行えるというものです。

class C {
  x;
  y = 42;
  static a;
  static b = 42;
}

console.log(new C().y); // => 42
console.log(C.b); // => 42

TypeScriptでは型定義や修飾子とともに使うことができます。

class C {
  public x?: string;
  private y: number = 42;
  public static a?: string;
  private static b: number = 42;
}

定義 vs. 代入

実は歴史的経緯から、クラスフィールドには2種類の異なる意味論が用意されています。現在標準に入ろうとしているのは「定義」意味論と呼ばれるもので、ざっくり以下のようにトランスパイルされます。 (ブラウザやNodeの実装も同じような挙動になります)

class C {
  constructor() {
    Object.defineProperty(this, "x", {
      value: void 0,
      writable: true,
      configurable: true,
      enumerable: true,
    });
    Object.defineProperty(this, "y", {
      value: 42,
      writable: true,
      configurable: true,
      enumerable: true,
    });
  }
}
Object.defineProperty(C, "a", {
  value: void 0,
  writable: true,
  configurable: true,
  enumerable: true,
});
Object.defineProperty(C, "b", {
  value: 42,
  writable: true,
  configurable: true,
  enumerable: true,
});

一方、「代入」意味論では以下のようにトランスパイルされます。

class C {
  constructor() {
    this.x = void 0;
    this.y = 42;
  }
}
C.a = void 0;
C.b = 42;

この2つは、定義/代入しようとしたフィールドのsetterが既に存在していた場合の違いとして現れます。

class A {
  set x(_value) {
    // 「代入」意味論ではこちらが呼ばれる
    console.log("x is assigned to.");
  }
}
class B extends A {
  x = 42;
}
if (new B().x === 42) {
  // 「定義」意味論ではこちらが呼ばれる
  console.log("x is defined.");
}

主要なトランスパイラであるBabelとTypeScript (tsc) はどちらも、「定義」意味論と「代入」意味論を切り替えるオプションを持っています。

Babelの @babel/plugin-proposal-class-properties は標準の「定義」意味論がデフォルトで、 { loose: true } を渡すと非標準の「代入」意味論になります。

module.exports = {
  plugins: [
    // 非標準の「代入」意味論に切り替える
    ["@babel/plugin-proposal-class-properties", { loose: true }],
  ],
}

TypeScript (tsc) は非標準の「代入」意味論がデフォルトで、TypeScript 3.7で導入された --useDefineForClassFields オプションによって標準の「定義」意味論に切り替えることができます。

{
  "compilerOptions": {
    // 標準の「定義」意味論に切り替える
    "useDefineForClassFields": true
  }
}

初期化子がないときの挙動

「代入」意味論にはさらに、「初期化子があるときだけ代入する」という変種もあります。

class C {
  constructor() {
    this.y = 42;
  }
}
C.b = 42;

この場合、 xC のインスタンスの所有するプロパティとして存在しないので、 Object.entries などでプロパティを列挙する処理をしている場合などに振舞いの違いが現れます。

Babelのlooseモードは常にundefinedを代入し、TypeScriptのデフォルトモードでは初期化子のない代入を省略します。

declareフィールド

前節で説明したように、TypeScriptは元々、クラスフィールドが初期化子を持たないときはランタイムコードを生成しない挙動でした。そのため、初期化子のないクラスフィールドを書くことで型宣言として使うことができました。

class C {
  // TypeScript特有の構文を取り払うと x; になる
  public x?: string;
}

しかし標準準拠モードではこれによって undefined で初期化された x が作られてしまいます。これを回避しつつ型宣言のみを行う用途のために、同じくTypeScript 3.7で declare フィールドが新設されています。

class C {
  // declareがついているので、JavaScript化するときに取り除かれる
  public declare x?: string;
}

BabelのTypeScriptサポート

Babelは公式のTypeScriptサポートである @babel/plugin-transform-typescript を提供しています。実はこのプラグインも、TypeScript 3.7で起こったような挙動の変更に振り回されています。

現在 (Babel 7.x) のデフォルトでは、 @babel/plugin-transform-typescript は初期化子のないクラスフィールドを削除する、という挙動になります。 (非標準の意味論)

class C {
  // public declare x: string; // エラー
  public y: string;
  public z: string = "foo";
}
// ↓
class C {
  z = "foo";
}

一方、 Babel 7.7.0 で導入された allowDeclareFields を有効化すると、declareが使えるようになり、declareのあるフィールドだけが削除されるようになります。

class C {
  public declare x: string;
  public y: string;
  public z: string = "foo";
}
// ↓
class C {
  y;
  z = "foo";
}

allowDeclareFieldsはBabel 8でデフォルトになる予定で、Babel 8のマイルストーンでもリストの最初に来る程度には大きな変更として扱われています。

allowDeclareFieldsを有効化する

ここまで説明して、allowDeclareFieldsを有効化したほうがいいことは多くの人が納得するでしょう。ところがここで一つ問題があります。プラグインの順番です。

ここまでで説明したBabelの挙動は、 @babel/plugin-transform-typescript のあとに @babel/plugin-proposal-class-properties が実行されることを想定していました。

allowDeclareFields がfalseのときは以下のように変換されます。

一方 allowDeclareFields がtrueのときは以下のようになります。

これらはどちらも、ここまでで説明した通りの挙動です。

ところが、プラグインの適用順序が逆の場合は以下のようになってしまいます。 (@babel/plugin-proposal-class-properties はdeclareのあるフィールドを見つけたらエラーになるようになっています)

これでは allowDeclareFields を有効化しようがしまいが結果は同じで、declareフィールドは使えないことになってしまいます。

実はこのような順序の逆転は稀な設定ミスというわけではありません。BabelがTypeScriptをサポートしたときに書かれたTypeScript側のブログでは以下のような設定例が紹介されています (一部改変)。

module.exports = {
  presets: [
    "@babel/preset-env", // (4)
    "@babel/preset-typescript", // (3)
  ],
  plugins: [
    "@babel/plugin-proposal-class-properties", // (1)
    // ※object-rest-spreadは現在は標準化済みなのでpreset-envでハンドルできる
    "@babel/plugin-proposal-object-rest-spread", // (2)
  ],
};

@babel/preset-typescript@babel/plugin-transform-typescript を拡張子に応じて呼び出すプリセットです。ほとんどの場合はTypeScriptプラグインを直接呼び出すことはないでしょう。

プラグインの呼び出し順序はBabelのドキュメントに記述があります。ざっくり以下のようなルールになっています。

  • pluginsの内容が先に実行され、presetsの内容が後に実行される。
  • pluginsは上から下に実行される。
  • presetsは下から上に実行される。
  • デフォルトでは単一のビジターに全てのプラグインがフックする。つまり、あるノードに対して全てのプラグインを適用してから、その子ノードに対して再帰的にプラグインを適用していく。

そのため先ほどの設定例では @babel/proposal-class-properties が先に適用されてしまうことになります。

適切な順序でプラグインを適用するにはいくつかの方法があります。ひとつは @babel/preset-typescript の利用を止めることで、そうすればplugins内に適切な順番でプラグインを並べるだけでよくなります。

module.exports = {
  presets: [
    "@babel/preset-env", // (4)
  ],
  plugins: [
    ["@babel/plugin-transform-typescript", { allowDeclareFields: true }], // (1)
    "@babel/plugin-proposal-class-properties", // (2)
    "@babel/plugin-proposal-object-rest-spread", // (3)
  ],
}

ただしこの方法では @babel/preset-typescript の恩恵を受けることができません。

もしBabel configをJavaScriptで書いている場合、ダミーのプリセットを用意するという方法があります。

const lateAppliedProposals = () => ({
  plugins: [
    "@babel/plugin-proposal-class-properties", // (3)
  ],
});

module.exports = {
  presets: [
    "@babel/preset-env", // (4)
    lateAppliedProposals, // (3)
    ["@babel/preset-typescript", { allowDeclareFields: true }], // (2)
  ],
  plugins: [
    "@babel/proposal-object-rest-spread", // (1)
  ],
}

もう1つの方法として @babel/preset-env の機能を活用する方法があります。 @babel/preset-env はデフォルトでは標準化が完了した機能までを入力として受け付けるようになっていますが、 shippedProposals を有効にすると「ブラウザによる実装が提供開始された機能」までを入力として受け付けるようになります。Babel 7.10以降ではshippedProposalsにclass-propertiesが含まれるので、これで適切な順番でクラスフィールドをトランスパイルすることができます。

module.exports = {
  presets: [
    // preset-envにclass-propertiesが含まれる (Babel 7.10以降)
    ["@babel/preset-env", { shippedProposals: true }], // (3)
    "@babel/preset-typescript", // (2)
  ],
  plugins: [
    "@babel/proposal-object-rest-spread", // (1)
  ],
}

将来的にはクラスフィールドが正式に標準化されることで shippedProposals がなくてもトランスパイルできるようになるのではないかと思います。

まとめ

  • クラスフィールドは歴史的経緯から、トランスパイル後の挙動が何種類かある。Babel, TypeScriptともにオプションで制御可能。
  • allowDeclareFieldsはBabel 8でデフォルトになるオプションで、クラスフィールドに対するdeclareをBabelで使うためには必要。
  • 有効化するには @babel/plugin-proposal-class-properties を @babel/preset-typescript よりも後に実行する必要がある。
  • presetの後にはpresetしか実行されないので、plugins設定の順番をいくら入れ替えても意味がない。 @babel/preset-env の shippedProposals を有効にするのが楽。
Wantedly, Inc.からお誘い
この話題に共感したら、メンバーと話してみませんか?
Wantedly, Inc.では一緒に働く仲間を募集しています
10 いいね!
10 いいね!

今週のランキング

原 将己さんにいいねを伝えよう
原 将己さんや会社があなたに興味を持つかも