おさらい
nodeとブラウザのコードを一つのnpmパッケージで提供する場合エントリーポイントは、
if (typeof window !== 'undefined') {
module.exports = require('./client');
} else {
module.exports = require('./server');
}
こういう感じで書くと思う。実際どうやってnodeとブラウザを区別するかってのは本当はwindowだけじゃなくてglobalやmoduleを使ったりとか、かなりいろいろな方法があるんだけど、その話は別の人がたくさんしてくれてるので省略。
ピュアなJavaScriptだけでできている場合
特に問題はない。lodash
のようにピュアなJavaScriptのビジネスロジックに終始しているのであればnodeでもブラウザでも動くので、そういうパッケージだと使う側からも好ましい。
ただ世の中そんな都合よくできてないので、どこかで環境依存にならざるを得なくなってくる。
ブラウザ依存のコードが有るときに、nodeから使う
nodeから使う分には、ちゃんとエントリーポイントで分岐させていればブラウザ側でwindow
などを触ろうが一切問題ない。
node依存のコードが有るときに、ブラウザから使う
今回はここの話。Webpackを使ってnpmパッケージを組み込む場合に、バンドルに失敗してしまう(Browserifyでは試してないので分からないタレコミ求む)。エントリーポイントでちゃんと分岐していても、だ。
たとえばrequire('./server')
の先でrequire('fs')
やrequire('coffee-script/register');
など、nodeに固有なモジュールをrequireしているとるする。その場合ブラウザからは通過しないはずの'./server'
が解決されて、さらにその依存パッケージとして'fs'
や'coffee-script'
をバンドルに組み込もうとして、ビルドにこけてしまう。実行時の分岐をビルドにチェックしてくれないのである。
## 解決案 だらだら書いてもアレなので先に解決策を示すと、node側の開始地点を少し細工すれば良いのである。
if (typeof window !== 'undefined') {
module.exports = require('./client');
} else {
// webpackにバレずにrequireする
module.exports = eval('require')('./server');
}
こんな感じ。これでWebpackから'./server'
以降のモジュールはWebpack依存モジュールとして解決されなくなる。
解説
Webpakのモジュール解決について
Webpackはバンドルのためのモジュール解決時に、そのJavaScriptを実行しているわけではない。おそらくファイル内にrequireというシンボルが現れたらその周辺を雑に処理して、依存モジュールしてchunkに追加している。だから別にwindow
のチェックをしようがそんなのは無視で、requireがあれば依存解決をしてしまう。
これは実際合理的な仕様とも言える。例えばglobal
を分岐に使っている場合に、行儀の悪いユーザーがwindow.global
に何か書き込んでいたら、実行時にはnode側にエントリーしていくのがJavaScriptしては正しい動作になる。ユーザーの定義に従って中途半端な依存性の検討を行うことは、JavaScriptとしての妥当性を損なうことになるのである。
そんな感じで「ブラウザ側ではどんな環境になっているかほとんど予測不可能」と考えれば、ファイル上に現れるrequireの、その依存先に、本当に依存しているかということを調べるよりは、ファイル上にrequireとして動作しそうなシンボルがあれば、とりあえずその先のモジュールを依存先に追加してしまうのが、少なくともバグの起きない仕様となる。
node的にはvalidかつWebpackの目を盗んでrequireしたい
だからこういうことを考える。node的には普通に動くコードで、どうにかWebpackの目を盗むことはできないか、と。
ただ、Webpackのrequireを見つけ出す能力は結構高い。以下にダメなパターンを示す。
// Webpackにバレる
var REQUIRE = require;
REQUIRE('./client');
// Webpackにバレる
var libName = './client';
require(libName);
上記2つはWebpackにバレてしまう。ただし
// Webpackがぶっ壊れる
var REQUIRE = require;
var libName = './client';
REQUIRE(libName);
これはビルドに失敗する。「雑に処理」と言ったのはこういう仕様のためだ。ちなみにglobal.requireは、requireは厳密にはモジュールごとに定義されたシンボルなのでそもそも動かない(しかしREPLではglobal.require === require
という(また別の話))。
eval('require')
しかない?
相当いろいろ試しては見たが、今のところこれしか上手くいかなかった。
browserifyは使ったこと無いので誰か試して見たら@endaamanまでタレコミお願いします。
どんくらい役に立つか
npmで公開したいパッケージの役割が、nodeとブラウザの両方で動くことで果たされ、それぞれをペアで提供したいというときに効果的である。
SPA向けにSEOするためのパッケージ(spaseo.js)を作ったのだが、大いに役に立った。node側ではサーバーを立て、PhantomJSを起動し、ブラウザ側のコールバックを待って、出来上がったHTMLをGoogleなりにレンダーするというものだ。ブラウザ側は単純なcallPhantom
を呼ぶだけのもので済むが、node側がPhantomJSとhttp
依存になっている。
この細工をしなければ、Webpackにnode側のPhantomJSを依存先に追加しようとしてビルドにこけてしまうのである。これのおかげで、nodeでもブラウザでもrequire('spaseo.js')
から使うことができるようになった、というわけである。