2020-05-23

package.jsonのmainとmodule

最近パッケージの更新屋をやっているのですが、moment.jsの2.25.3の変更

Remove package.json module property. It looks like webpack behaves differently for modules loaded via module vs jsnext:main.
(機械翻訳ぶっこんだ結果 => package.jsonモジュールプロパティを削除してください。モジュール vs jsnext:main を介してロードされたモジュールに対して webpack の挙動が異なるようです。)

とはどう言うことなのか?
よく意味がわからんかったので調べてみた

変更内容としては package.json から
"module": "./dist/moment.js",
の行が削除されたというもの

この変更の意図を理解するには 「モダンに使ってもらうために何をパッケージングして配布すべきか」というパッケージオーナーの考えを理解する必要がある
また、CommonJSとECMAScriptのモジュールの違いを理解する必要がある
なのでCommonJSとECMA modulesについて基本から確認していく

CommonJS

CommonJSモジュールをpackage.jsonの main フィールドに設定すると、 require("パッケージ名") で利用可能になる

// CommonJS形式で定数hogeをexport
// hoge.js
exports.hoge = "HOGE";
# package.jsonのmainフィールドに設定
{
  "name": "パッケージ名",
  "main": "hoge.js"
}

これで、Node.js環境なら以下のように使用することができる

const { hoge } = require("パッケージ名");

ECMAScript

webpackなどでバンドルして使われる想定の場合は、ECMAのモジュールとして実装したコードも含めるべきで
webpackのようなモダンなモジュールバンドラーでは、 package.jsonmodule フィールドに設定されているコードをECMA Modulesとして優先的に読み込むようになっている

// ES2015形式で定数hogeをexport
// hoge.js
export const hoge = "HOGE";
# package.jsonのmoduleフィールドに設定
{
  "name": "パッケージ名",
  "module": "hoge.js"
}

これでバンドル前のJavaScriptで以下のように使える

import { hoge } from "パッケージ名";

本題と状況の確認

mainmodule 両方書いてあった場合、Node.jsは標準パッケージ(ECMA)として認識する
moduleを削除したということは moment.js をwebpackはCommonJSパッケージとして認識するようになるということ

ルートに置いてある ./moment.js はCommonJSの形式で書かれている
削除された ./dist/moment.js はECMAScriptの形式で書かれている

ここまでの状況をみる限り、別にmodule指定を削除する必要はないように思えるが…?

どうやらwebpackで何らかの問題が発生したので消したっぽい
そしてその問題はこれとかこれっぽい

そしてこの "module": "./dist/moment.js", という行は2.25.0になったときに入れられたもの…
なんだか全体的によくわからないので時系列に問題点などを整理してみる

問題の発生と原因

  • moment.js のバージョンが2.25.0に上げられる
  • バージョン2.25.0に上がったときにこんなissueが立てられる

    • どうもパッケージを見つけられないらしい、依存解決に失敗するとも書いてある
    • これが今回の件について最も影響していると思われる問題
    • この問題はどのパッケージで発生したのかわからないが、このissueを見るとmoment-timezone0.5.23 で発生するようだ
  • 2.25.0のときに加えられた変更で、 package.json"module": "./src/moment.js", の行が追加されている

    • この修正が加えられたことで、Node.jsはパッケージを標準モジュールとして評価するようになる
    • 評価が CommonJS から ECMA modules に変化するということ
    • webpackで言うと、(特に指定がない限り)パッケージに含まれるファイルの中からECMAで書かれたモジュールを使うようになるということ
    • ./src/moment.jsimport などが使われていることからもECMA形式であることがわかる
  • この状態(2.25.0)で require("moment") とすると↑のモジュールが取得できる(Module {__esModule: true, Symbol(Symbol.toStringTag): "Module", default: ƒ} こんな感じの)

1

  • では2.25.0になる前に参照されていたモジュール(すなわちCommonJSのモジュール)はどうだったかを確認してみる

    • 2.25.0でもパッケージには含まれているので、 require("moment/moment") で参照可能
    • どうやら hooks という関数への参照となっているようだ

2

  • この2つは確かに等価ではない、 require("moment/moment") と等価なのは require("moment").default だからだ

    • どうもこれが原因っぽい
  • ここまでの内容を頭に入れたうえで、moment-timezone0.5.23のコードを見てみる

    • まず、環境はwebpackなのでここが実行されることで moment.js がロードされる
    • ちなみに factory 関数はこの第二引数で、第二引数の引数が↑でロードされた moment.js 本体(を想定している)
    • その後momentの存在確認を行っているが、ここでチェックを行なっている moment 変数は↑で確認した通りモジュール(Module {__esModule: true, Symbol(Symbol.toStringTag): "Module", default: ƒ} これ)なので、 moment.versionundefined である
    • したがって この問題の発生に繋がった
  • まとめると

    • moment.jsがexportするモジュールを変化させてしまったことにより、「moment.jsはCommonJSでexportされている」という前提でmoment.jsに依存している他のパッケージを破壊してしまった
    • ということになりそうだ

対策

これに対して各パッケージでは以下のような対策が行われた

正直、 moment.js はNode.jsでもブラウザでも使われるから、 mainmodule は両方存在しているのが正しい形な気がしている
ただ仕込むバージョンがヤバすぎただけ(明らかに破壊的変更なので、メジャーバージョンアップに含めるべきだった)

めでたしめでたし。 完全理解してすっきりした

参考