昨日半日くらいbrowserifyの依存解決についてちょいちょい調べてたのでまとめておきます。

browserify
http://browserify.org/

browserify自体は元々はnodeのmoduleの仕組みをbrowserの世界にもってきたmodule bundlerです。
CommonJSのrequire形式で依存を解決していきます。



node_modulesの仕組み

以下のドキュメントにnodeのmodule解決の仕組みがどのように行われているかが記載されています。
how node_modules works
https://github.com/substack/browserify-handbook#how-node_modules-works

まずここでびっくり(?)したというか重要な点は2つほど。

1. モジュールの解決ディレクトリの順番

If however you require a non-relative name such as require(‘xyz’) from /beep/boop/foo.js, node searches these paths in order, stopping at the first match and raising an error if nothing is found

“.”, “..”で始まらないpathを指定した時はpackage.jsonが置いてある単位をモジュールとすると、まずはそのモジュールの配下のnode_modulesからチェックして、見つからなかったら上に上がって解決していきます

  1. /beep/boop/node_modules/xyz
  2. /beep/node_modules/xyz
  3. /node_modules/xyz

2. モジュールのネストで同じmoduleがあったときの解決法

The great thing about node’s algorithm and how npm installs packages is that you can never have a version conflict, unlike most every other platform. npm installs the dependencies of each package into node_modules….This means that packages can successfully use different versions of libraries in the same application

つまり依存モジュールの中でさらに依存モジュールのversion指定をしている場合は、既に親が同じモジュールで別のバージョンを指定していた場合であっても、その依存モジュールは別のバージョンのモジュールを正しくbundleされます

-> この仕組みによってnodeの世界では依存モジュールたちが何を読み込もうが正しく動くので、これが素晴らしいeco systemを作り上げたとは思います。

が、ブラウザの世界でこれは良いのでしょうか…?

nodeだったらてんこ盛りスクリプト作って起動に時間がかかってもさほど問題ないですが、browserでは結構criticalな問題になってしまうと思います。

例えば、自分がrequireしていたjqueryとバージョンと違う指定をpackage.jsonで明記してしまっている依存モジュールがあったとしたら、、気づかないうちに複数のバージョンのjqueryがbundleされてファイルサイズが肥大化している、なんてことが起こると思います。

ちなみに同じバージョンであれば2つbundleされるなんてことはありません。(つまりbrowserifyはpackage.jsonのdependenciesのpathだけではなくversionもきちんとcheckしているのです。)


想定される状況例

browserifyの依存解決で問題となり得る具体的な例を挙げてみます。

自分のモジュール(ここではparentと呼びます)がdep1というライブラリとjquery(2.1.4)を使いたいので読み込みます。
parent -> dep1, jquery 2.1.4に依存
parent: package.json

 "devDependencies": {
    "jquery": "^2.1.4"
    "dep1": "~0.0.1",
}

dep1という外部のモジュールは以下のようにjquery 1.11.3に依存しています。別の人が管理していて気軽に変更できないとします。
dep1 -> jquery 1.11.3に依存
dep1 pacakge.json

  "devDependencies": {
    "jquery": "^1.11.3"
  }

このような状況下(parentのコード/package.jsonは自由にいじれるけど、dep1はコード変更は簡単にできないという前提)で、マルチバージョンでjqueryが読み込まれてしまう問題の解決策を探っていくつかのアプローチを検討してみました。
理想はdep1をいじることなくdep1の依存しているjqueryをparentが依存しているjqueryと同一のものを使うということです。(もしdep1がparentのjqueryで動かないとしたら、、それはどうしようもないのでPRとかして動くようにしてもらうか、悲しいけどマルチバージョンでいきましょう。。)


アプローチ1. dep1を2.x系の全く同じバージョンで動かすようにしてもらう or PRしてバージョン揃える

これは一見シンプルで良さそうなんですが, すべての依存パッケージの中で依存しているjqueryのバージョンを揃えなくてはいけないので、縛りがきついと思います。parentのバージョン更新があった場合などにも、deps全部jqueryのバージョンを更新しなくてはならないのは現実的ではないですし、うっかりdepsのどれかが上げてしまったらマルチバージョン読み込んでしまいます。

ここで少しpackage.jsonでのversionの指定の仕方の紹介。

~もしくは^を使う

  "devDependencies": {
    "jquery": "~2.1.3"
  }

~と^の違いはminorかmajorレベルのでの最新バージョンを取りに行く際にminorレベルの最新を取るか、majorレベルの最新を取るかという違いです。例えば、jqueryが2.1.4が最新である時に”~2.1.3″や”^2.1.3″と指定しておくと、最新である2.1.4を実際にはinstallしてくれます(という理解です)。

*を使う

  "devDependencies": {
    "jquery": "*"
  }

*は最新のバージョンをinstallしてくれるので、依存ライブラリのバージョンが上がるたびにpackage.jsonを更新する必要は無くなるが、逆に動かなくなることもある。
実は*は厄介で、parentがjqueryのバージョンを明記してしまうと、マルチバージョンのjqueryをbundleしてしまいます。


アプローチ2. global汚染をする

parentがwindow.$をセットし、dep1は$をrequireせずに使う。
こちらもシンプルですが、dep1は外側の呼び出す側がglobalの$を事前に与えられるのを前提で動きますので、dep1の開発時にはbrowserifyの外側で$をセットしてあげる必要があります。$だけならいいソリューションかもしれませんが、やりすぎると依存解決のための仕組みが崩壊しそうな気が。。


アプローチ3. dep1がrequireせずに$を使い、browserify-shimのdependsで$をセットする

browserify-shim
https://github.com/thlorenz/browserify-shim

browserify-shimは元々はCommonJS wayで書かれていないライブラリを変換するもので、例えばCommonJS wayではないモジュールがglobal変数にモジュールをセットしている場合(例えばjqueryの$)など、指定したグローバル変数をrequireした時に返す(exportする)ようにできます。

さらに、dependsという仕組みがあって、以下のy.jsがx.jsに依存している時に、まずx.jsの中で定義したグローバル変数$をrequireでexportするようにして、x.jsの$をyの中でglobalに存在している$にセットすることができる!みたいです。

{
  "browserify": {
    "transform": [ "browserify-shim" ]
  },
  "browserify-shim": {
    "./vendor/x.js"    :  "$",
    "./vendor/y.js"    :  { "exports": "Y", "depends": [ "./vendor/x.js:$" ] }
  }
}

今回のケースで使ってみると、結局global汚染されるので2の方法とあまり変わらないかなぁ…と思います。ちょっと怪しいのが、dep1が読み込むentryのjsからrequireするモジュールが更に$を使っていたとしたら、entryから読み込まれる全てのpathにglobal $をshimでセットしなくてはいけないのだろうか。(やはりあくまで、shimはcommonJS形式でないものをその形式に変換するツールとして利用すべきか。。)

あと、アプローチ5で紹介するbrowserフィールドと組み合わせると、./vendor/foo.jsと書かなくてFooだけでrequireできるようになるようです。
例:

{
  "browser": {
    "foo": "./vendor/foo.js"
  },
  "browserify": {
    "transform": "browserify-shim"
  },
  "browserify-shim": {
    "foo": "FOO"
  }
}

ここら辺は未検証な部分があるので、詳しい方は是非お知らせください。


アプローチ4. dep1のnode_modules/jqueryを削除してnodeのロードの仕組みを利用する / npm dedupeを使う

dep1の下にnode_modules/jqueryがない場合は、dep1以下でrequireした時に親を辿ります。なので、親の持っているjqueryを使うことができます。
手動で毎回そのディレクトリを消すのは現実的ではないので、dep1の公開package.jsonからjqueryのdependenciesを消しておくのが良いだろうか。。そうすると公開用と開発用にpackage.jsonを切り替える必要がでてきますね。

調べてみると、npm dedupeというコマンドでdetectionできるみたいです。
https://docs.npmjs.com/cli/dedupe

これは、同じmoduleをnestして読み込んでいて、それぞれのdependenciesの中で共通モジュールに依存している時は親のnode_modulesの方へ統一してくれるという素敵なコマンドです。(統一されたときはドキュメントの図によると多分versionがその中の最新のものになる。)

まだexperimental featureだからなのか、何故か自分の環境だと実行しても何も起きない。。

これがうまく動くと多分dep1がinstallするnode_modules以下のjqueryを取り除いてくれて、素敵なんですが、、開発環境にしっかりと組み込まないと、npm installしてしまうとクリアされてしまうので危険な香りもします。


アプローチ5. browserifyのbrowserオプションを使ってjqueryにすり替える

browserifyにあるbrowserというオプションを活用します。

browserify browser field
https://github.com/substack/browserify-handbook#browser-field

元々はnode server側とbrowser側のコードで同一のモジュールを使う時に、browserで実行される時は特定のパスで読み込むモジュールを切り替えるというものです。

  "browser": {
    "./js/modules/module1.js": "./js/modules/module2.js"
  }

こんな感じですり替えられます。ただし、注意点はこのpackage.jsonは自身のモジュールのものでnode_modulesを辿るわけではありません。

  "browser": {
      "jquery": false
  }

とかすると、require(“jquey”)が空が返ってきたりします。これはbrowserifyのexcludeオプションと同じような使い方ができそうですね。

例えば、dep1がparentのnode_modules以下のjqueryを読み込むように、dep1自身のpackage.jsonで指定することもできます。

  "browser": {
    "jquery": "../jquery/dist/jquery.js"
  }

なかなか興味深い機能ですが、parentからdep1が読み込むモジュールのすり替えはできないようなので、今回の要件では使えなさそうです。


アプローチ6. npm shrinkwrapでoverideする

調べていたら、npm shrinkwrapというコマンドがありました。

npm shrinkwrap
https://docs.npmjs.com/cli/shrinkwrap

説明によると、依存先のモジュールが読み込んでいるモジュールのバージョンなどをparentがoverrideできるという機能のようです。

まさにこのような機能を探し求めていたのですが、今回のケースだと何故かerrorになりましたorz
dep1のjqueryをはずすとうまく通ったりするので、なんか挙動がおかしいですね…。iojsだからか。。ちと後回し。


アプローチ7. aliasifyをglobal transformで実行する

aliasifyはアプローチ5のbrowserの機能とほとんど同じで、それをtransformで実行するというものです。
通常transformはそのモジュール内で閉じて処理されるものなのですが、node_modules以下も辿ってapplyする–global-transformという機能がbrowserify自身にあることを発見しました。browserifyのドキュメントにありました。なのでaliasify + global transformでいけそうな予感。

aliasify
https://github.com/benbria/aliasify

こんな感じで実行します。

browserify -g aliasify

parentのpackage.json

  "browserify": {
    "transform": [
      "aliasify"
    ]
  },
  "aliasify": {
      "aliases": {
          "jquery": "./node_modules/jquery/dist/jquery.js"
      }
  }

verbose: true にするときちんとログでてきて。

aliasify - /parent/js/main.js: replacing jquery with ./../node_modules/jquery/dist/jquery.js
aliasify - /parent/node_modules/dep1/common/config/config.js: replacing jquery with ./../../../jquery/dist/jquery.js

dep1でrequire(‘jquery’)したときに、きちんと親を辿ってaliasifyで指定したparentのjqueryを読み込んでくれました。

shrinkwrapだとversionのoverrideしかできないように見えましたが、こちらの方法ではmodule自体をすり替えることができるので、場合によってはdep1のrequire(‘jquery’)の実態をzeptoにすり替えるなんてことが、モジュールの外側から実現できるということです。

今回やりたかったことはこれで、一応この方法で問題解決でした。


そんなこんなで色々browserify調べてみたら、npm含めて知らないことがたくさんあったので備忘録としてメモしました。
webpackだと違うのかな。近々調査してみます。


Categories: JS

Related Posts

JS

requirejsをbowerでインストールしてgruntでビルドする

私はJSを書くときはAMD推奨派なので、比較的大きめのアプリを作るときはまずrequireJSなどを使用します。 requirejsを使ってモジュールを作成すると依存関係が明確になり、テンプレートなどもrequirejs-textなどを使用すれば再利用がとても容易な形で記述することができます。 ただし、AMD時の一つの懸念点としてビルドして依存モジュールをconcat&uglifyしなくてはパフォーマンスがでないという点が挙げられます。そこで探してみるとgruntのtaskモジュールで内部的にr.jsを使用してビルド(依存モジュールの連結+compress)を行ってくれる便利ツールを発見。これを使えばパフォーマンスに関する問題は大丈夫でしょう。 以下、少し長いですがセットアップ手順です。

JS

Array.prototype.reduce使ってみた

ES5から導入されているArray.prototype.reduceを使ってみたら便利だったので紹介。

JS

非同期処理でのwindow.openはポップアップブロックされる

実はあまり知らなかったですがユーザーアクション(onClick等)の処理の中にajaxやsetTimeoutによる非同期な処理があるとwindow.open()によるpopup blockが起きてしまいます。 何故かブロックされるケースとされないケースがあったので、初めはクロスドメインだとブロックされるのかなと思ったりしていましたが関係なかったようです。 以下、詳細と回避方法。