endaaman.com

2015-07-23

Tips

nodeとブラウザ両方向けのパッケージで、node側でnodeに依存したコードに触る

画期的というよりは間に合わせっぷりがすごい

おさらい

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')から使うことができるようになった、というわけである。


©2024 endaaman.com