👬

ES Modulesで複数バージョン重複問題

2022/04/05に公開

私はBeako.jsというライブラリを開発しており、さっさと作ってしまおうとしていたのですが、3つの大きな問題を抱えていて、まだ、正式リリースできていません。
この3つの問題はBeako.jsというより、JavaScriptを扱ううえで必ず遭遇する問題だと考えましたので、一つずつ記事にしたいと思います。まず一つ目です。

二つ目と三つ目はこちら

https://zenn.dev/itte/articles/b9f5d4616caa3d

https://zenn.dev/itte/articles/71ba4005f5fb40

複数バージョン重複問題

ライブラリA@v1.0.0と、それに依存したライブラリB@v1.0.0があったとします。ライブラリA@v1.0.0)にパッチが当てられ、バージョンアップしたライブラリA@v1.0.1が誕生しました。ライブラリA@v1.0.1とライブラリB@v1.0.0をJavaScriptモジュール(ES Modules)でインポートすると、バージョン違いのライブラリA(A@v1.0.0、A@v1.0.1)が2つインポートされてしまいます。

何が起きたのか

JavaScriptモジュールでライブラリを使う場合は、基本的にはCDNからjsをインポートすることになるのではないでしょうか。なぜならライブラリBが参照しているライブラリAの場所はライブラリBのimport文に組み込まれることになるため、ライブラリBが利用される環境によって変化しない場所を参照しておく必要があるからです。
そのため、ライブラリAをバージョンアップしても、ライブラリBに書かれているimport文を書き換えることができないので、ライブラリAが2つ読み込まれます。

具体的には次のように、同じ関数aを実行しているはずなのに結果が変わります。

A@v1.0.0
export function a() {
  console.log('v1.0.0')
}
A@v1.0.1
export function a() {
  console.log('v1.0.1')
}
B@v1.0.0
export { a } from 'https://CDN/a@1.0.0/a.js'
アプリケーション
import { a } from 'https://CDN/a@1.0.1/a.js'
import { a as b } from 'https://CDN/b@1.0.0/b.js'

a() // v1.0.1
b() // v1.0.0

同一バージョンでも問題が起きる

この問題は、ライブラリAにバージョンアップが発生していなくても問題が起きます。例えばライブラリAがjsDelivrとcdnjsで配信されていたとします。アプリケーションがjsDelivrからライブラリAを参照したとしても、ライブラリBがcdnjsのライブラリAを参照したら、まったく同じファイルを2か所から読み込むことになります。

JavaScriptモジュール以前は起きなかった

この問題は、JavaScriptモジュール以前は発生していませんでした。なぜなら、アプリケーションがライブラリBを利用するときに、ライブラリAは別途読み込む必要があったからです。
例えば、jQueryに依存するBootstrap 4を使うときは次のようにjQueryも読み込む必要がありました。

<script src="/jquery-3.3.1.js"></script>
<script src="/bootstrap.js"></script>

Bootstrap 4はjQueryを含んでいませんのでjQueryだけバージョンアップ可能です。もちろん、バージョンアップすることでBootstrap側に不具合が出る可能性はあります。

Node.jsではどうなのか

Node.jsの場合は、依存関係の解決をnpmが担当していました。ライブラリの配布元はnpmに絞られるため、同一バージョンではまず問題が起きません。
異なるバージョンについては、package.jsonの書き方によって同一とみなすバージョンを指定できました。同一とみなせないバージョンについてはやむを得ずnode_modulesディレクトリに複数バージョンインストールされていました。
複数バージョンで問題が起きないように可能な限り効率化が図られていたということですね。

Import mapsで解決

この問題を解決する方法として真っ先に思いつくのはImport mapsを用いることです。次のようにライブラリAの参照先は固定されます。

A@v1.0.1
export function a() {
  console.log('v1.0.1')
}
B@v1.0.0
export { a } from 'a'
アプリケーション
<script type="importmap">
{
  "imports": {
    "a": "https://CDN/a@1.0.1/a.js"
  }
}
</script>
<script type="module">
import { a } from 'a'
import { a as b } from 'https://CDN/b@1.0.0/b.js'

a() // v1.0.1
b() // v1.0.1
</script>

ここで重要なのはライブラリBがライブラリAの参照先をフルパスではなく「a」というプレースホルダーにしていることです。

「問題は完全に解決しました」

・・・とは言えません。 残念ながらImport mapsのブラウザ対応状況は悪いです。
また、ブラウザ対応が進んだとしても、ライブラリAの開発者、ライブラリBの開発者、ライブラリの利用者の3者間でImport mapsを用いるという共通認識が必要となります。
Import mapsはいくつかの実行環境で実装されているとはいえ、世界標準に至っていないと思われます。

Import mapsが標準になるには高い壁があります。
ライブラリBの開発者としては、なるべく簡単にライブラリBを使ってほしいはずです。Import mapsを使っていなければライブラリ利用者はimport文1行で済むのに、Import mapsを使ったために<script type="importmap">~</script>の7行をライブラリ利用者に強いることになります。

また、Import mapsが標準になったとしても、Import mapsを使わないことができる以上、ライブラリBの開発者が100%Import mapsを使うとは断言できないので、ライブラリAの開発者はImport mapsが使われないライブラリBが存在する可能性を意識しておく必要があるのではないでしょうか。

問題ではなくメリット

そもそも、複数バージョンがインポートされることは問題なのでしょうか。ライブラリBが依存するのは、ライブラリA@v1.0.0であって、ライブラリA@v1.0.1ではありません。ライブラリBはライブラリA@v1.0.0を使っていると思い込んでいるのに、それがライブラリA@v1.0.1だったら、未知の不具合が生じる危険性があります。ライブラリBの開発者は自身のタイミングでライブラリAをバージョンアップしたいはずです。利用者から「動きませんでした」と言われても、「ライブラリAのバージョンを下げてください」としかすぐには言えません。
ライブラリのバージョンが固定されるのは、ライブラリBの開発者にとってはメリットなのです。
ライブラリを使ったアプリケーション開発者にとっても複数バージョンを棲み分けて使うことができるというメリットがありますし、そのためのJavaScriptモジュールです。

では被害を被るのは誰かというとエンドユーザーです。複数バージョン読み込まれるため、ページ表示が遅くなります。とはいえ、JavaScriptモジュールは動的インポートを活用して必要になったときに読み込まれるので、よほど巨大なモジュールが何個も読み込まれない限り問題ないのではないでしょうか。そのような状況に陥っているとすれば、アプリケーション開発者の計画性に問題があると思われます。

複数バージョン共存を前提とする

ライブラリBの開発者は何も気にせず開発してください。
ライブラリAとライブラリBを利用するアプリケーション開発者も何も気にせず開発してください。
エンドユーザーはもちろん何も気にしないでください。

とすれば、誰が何か気にしないといけないでしょうか。それは当然、残ったライブラリAの開発者です。ライブラリAを使う利用者は、バージョンが少し違うライブラリAが2つ読み込まれただけでそれに起因する問題が起こるなど微塵も考えていないですし、考えさせるべきではありません。

というわけで、私が出した結論は

JavaScriptモジュール対応ライブラリを作るとき、開発者はそのライブラリが複数バージョン共存することを前提にしなければならない

です。

共存してもいいバージョンの範囲

npmに合わせると、バージョンアップには、メジャー、マイナー、パッチの3種類あります。それぞれドットを挟んで表します(major.minor.patch)。JavaScriptのライブラリはこのバージョン表記に合わせていることが多いです。
パッチは名前が示す通り修正等の軽微な変更で行われますので、複数バージョン共存できると判断されます。メジャーはそのライブラリが抜本から見直された時などに行われ、明らかに破壊的な変更が発生していますので、複数バージョン共存できないとみなされます。

ではマイナーはどうでしょうか。これはライブラリによるとしか言えません。ライブラリ開発者はマイナーバージョンアップで、それ以前のバージョンと共存可能かどうか明確に示す必要があります。

インターフェースの変更

複数バージョン共存可能かどうか、どうやって判断したらよいでしょうか。一つの基準としては、バージョン間で受け渡しが発生するデータ型に変更があるかどうかではないでしょうか。TypeScriptで言うところのインターフェースの変更や、整数が小数点数になる、同じ整数でも取り扱う値の範囲が変わるなどです。

新しい関数が増えたとしても、前のバージョンで使っていなければ影響はありません。関数名やクラス名が変わっても、前のバージョンでは元の名前で利用されるので問題ありません。
ところが、バージョン間で共有するデータのインターフェースに差異があると、新しいバージョンが、存在しないプロパティを参照しようとしたり、存在しないメソッドを実行しようとしてしまう恐れがあります。

ライブラリ開発の際はできればTypeScriptを使って、publicprivateをはっきり区別し、扱うオブジェクト、クラスのインターフェースを明確にしておいたほうが良いですね。

Symbol()だけでなくSymbol.for()も使う

複数バージョン共存で注意しなくてはならないのはsymbol型です。
symbol型は重複しないプロパティ(メソッド含む)名を定義できる仕組みです。あるライブラリが、自分しか使わないプロパティを、自分以外が管理しているオブジェクトに付与したいときに用います。
複数バージョン共存の場合は、自分以外の自分が存在しています。バージョン違いの自分にも使ってほしいプロパティがある場合、Symbol()で定義したsymbol型では使ってもらえないため、Symbol.for()を使う必要があります。

const obj = {}
const symbol1 = Symbol.for('is-mine')
const symbol2 = Symbol.for('is-mine')

obj[symbol1] = true

console.log(obj[symbol2]) // true

Symbol()で定義したsymbolは同じキーワードを用いて再定義しても、別物として扱われるのですが、Symbol.for()で定義したsymbolは同じキーワードを用いると同じsymbolを参照します。
つまり、symbolの意味があまり無く、キーワードが被ると重複する可能性があるのです。この特性を利用して複数バージョン間でプロパティを共有できます。
重複する可能性があるなら__isMineのようなsymbolを使わないプロパティ名にしても良いのですが、symbolを使うとfor...in文やJSON.stringify()で現れないプロパティであることが明確に分かりますので便利です。

同じタイマーが複数起動しないようにする

読み込まれるとタイマーが起動し、定期的に同じ処理を行うライブラリがあったとします。
まず、利用者が意図せず動き出すタイマーは作るべきではありませんので、必ず、何か関数を呼び出したときに起動すべきです。
どうしても必要なときは、2つ同じタイマーが動かないように、複数バージョンがアクセスできるグローバル変数にタイマーIDを保存して管理が必要です。

documentにはライブラリが勝手にアクセスしない

読み込まれると、次のように特定のidが振られた要素にアクセスするライブラリを作りたくなるかもしれませんが、それはNGです。

const el = document.getElementById('id')

export function hello() {
  el.innerHTML = 'hello'
}

documentのDOMツリーには、ライブラリ利用者がアクセスを指示したときのみ、アクセスしなければなりません。そうしないと、複数バージョンが自分勝手にDOMツリーにアクセスして、誰も制御できなくなります。
そのため、このように書き換えるべきです。

export function hello(el) {
  el.innerHTML = 'hello'
}

まとめ

複数バージョン共存で留意しなければならないことは他にもあるかもしれません。気づいたら追加いたします。

  • 複数バージョン重複問題は将来的にImport mapsで解決するかもしれない。
  • Import mapsで解決したとしても、JavaScriptモジュール対応ライブラリ開発者は、複数バージョン共存を前提で開発しなくてはならない。

Discussion