RailsフロントエンドにおけるCode splitting

fsubal氏(以下、fsubal):では始めさせていただきます。ピクシブ株式会社から来ました。『Code splitting in Rails Frontend. Turbolinksがdynamic importになるまで』という題で発表します。

まず自己紹介なんですが、subal(スバル)と申します。

2016年にピクシブに新卒入社しました。そこからいろいろやってきたんですけれども、直近やってきたこととしては1月ごろにpixivの投稿画面のスマホ版を丸ごとリニューアルしたりとか。

5月から異動してpixivFACTORYというプロジェクトに行って、そこでフロントエンジニアをやっております。

TypeScriptとかReactとかVue.jsとかいろいろ書いていて、主に巨大なフォームの設計とかをやったりしています。

pixivFACTORYなんですけど、こんな感じで画像をアップロードするとブラウザ上でグッズが作れるみたいなサービスです。

こういう感じに使われるサービスのフロントをやっています。

最近やったこととして、ちょっと前にWebpackerを脱出するような記事を書いたんですけれども。今日はその話ではなくて、Code splittingとDynamic importの話を紹介させていただきたいと思います。

Code splittingって何でしたっけ? という話ですけど、JavaScriptなどのバンドルファイルを分割してビルドすることですね。

webpackなどのモジュールバンドラの機能として提供されるやつで、dynamic importによる遅延読み込みとかを伴うものです。native ES moduleとかありますけど、今日はその話はしません。

Dynamic importはご存知import()関数を用いてモジュールを遅延読み込みするやつで、必要なタイミングでモジュールをロードすることが可能になります。

実態としては<script>要素を入れて利用可能になったら.then()できるPromiseみたいなやつですね。

通常のJSやDSのimport文は上のような感じで静的に書くんですが、dynamic importを書いているとこういう感じで、import(〇〇).then(〇〇)で使うと、読み込んだモジュールが降ってきます。

使い方としてはSPAのページ遷移を表現したり、重いモジュールを後から遅延で読み込んだり、あるいは1枚の*.bundle.jsに全部入ってるみたいな状況をとにかくやめたい場合に使う。dynamic import自体はSPAじゃなくても有用です。今回はどちらかと言うとこちら( 1枚の*.bundle.jsに全部入ってる状況をやめる )話をします。

たとえばみなさんこういうお悩みはないでしょうか? 1個のファイルにこういう感じでimportがバーっとあって、webpackのバンドルをすると[big]って出てくる、みたいな。

トップレベルのJSファイルで全部importするみたいなことって結構ありがちな状況で、バンドルサイズが不要に大きくなるし、モジュールの境界が不明になって影響範囲も読みづらくなったり、けっこう苦しいことになります。

pixivFACTORYにおけるユースケース

うちのプロジェクトのケースをご紹介したいと思います。Ruby on RailsのプロジェクトでTurbolinksに載っていてwebpackでビルドしています。TurbolinksというのはRailsについてくる、pjaxでページ遷移を表現するためのライブラリです。

その実装の都合上、JSファイルが全部1枚にバンドルされているほうが都合が良いらしくて。擬似SPAをするために、JSはそのままでHTMLだけがサーバーから降ってきて差し替える設計になっている。これのせいでバンドルファイルが全部1個に集まって大変なことになりがちで、変更の影響範囲を読むのも困難になります。

当時のpixivFACTORYのコードの紹介をしたいと思います。

document.addEventListerner('turbolinks:load' というのがTurbolinksの上でのベージ遷移を表すイベントですね。これでやってきたら、今いるページのlocation.pathnameを正規表現で判定して、実行するかを判定する。このせいで何度か事故っているというのもあったんですけど。そのうえでそういうやつらを全部importしてドカっとまとめるみたいな感じです。

この状況をどうすると良いか、いくつかあげてみましょう。まず各ページのJSファイルは、影響範囲の閉じたモジュールになってほしいですし、ページの判定は正規表現ではやらないほうがいいです。また、必要ないページでは余分なJSファイルを読まないようにもっていけるとだいぶ改善しますね。

それで、それぞれどうすればいいか具体的に挙げると、各JSのファイルを閉じるためには関数モジュールにリファクタリングするのがよい。ページの判定、今どこにいるかを正規表現でやらないためにはルーターを入れるとよい。

必要ないページでは余分なJSファイルを読み込まないようにしたい場合はルーティング解決時にdynamic importをすると良いっていう話になってきます。

関数モジュールにリファクタリング

というわけで、1個1個具体的にやったことを説明しましょう。

関数モジュールにリファクタリングするんですけど、それをやるためにはそもそもTurbolinksが邪魔なので一旦なくそうという話になります。

turbolinks:loadっていうイベントDOMは一旦ただのDOMContentLoadedに変えます。やると遷移は遅くなるんですが、一旦許容して進むって感じになりますね。

ですが、本当はどうしたいかと言うと、こういう形にしたいはずです。

先ほど関数モジュールと言ったのは、要するに各ページがこの形式になっている状態です。これを本当は目指したいと。

そのためにはルーターに載せるとよくて、各ページを関数モジュールにした場合それを受け取る層が必要だよね、という話になります。各ページを関数モジュールに変更し、ルーターがそれを受け取る設計にする。関数モジュール化を完遂するためにはルーターに載せる必要がある、という話になります。

ここでクライアントサイドルーターにあるあるの悩みなんですが、react-routerとかvue-routerなどが有名なんですけど、特定のビュー実装にくっついてなんか苦しい感じがあります。全ページReactに載せないと改善を進めることができないのでは? みたいな悩みを持ったりすることがあるかと思います。

仮に全ページ変更するとして、どうするかを見回したとき、アプリっぽい動きが激しいページはともかく、優先度としてLPとかFAQをReact化してもなぁみたいな問題があります。

見回してみると、jQueryのページとかReactのページとかいろいろ混ざっている。いろいろ混ざってるんだけど、とりあえず同じ土俵に乗らないんですか? という風に考えたくなります。

実際pixivFACTORYの事例をお話すると、jQueryのページとBackboneのページとReactのページとReduxのページとfluxbleのページがありました。これはちょっと死んでしまいますね(笑)。

それで、どうしようかな……となって見つけたのが、kriasoft/universal-routerというもので、これは特定のビュー実装に依存しないルーターです。

kriasoft/universal-router

中を見るとUniversal JavaScriptガチ勢といった感じの作りをしていて、中はpath-to-regexpなのでだいたいexpressと同じですね。

ルーティング解決とミドルウェアをやる機能とURLをルーターから生成するだけみたいなすごくシンプルな機能を提供していて、たとえば他のルーターだと、SPAで使えるようにブラウザバックでスクロール位置を復元するといった機能があるんですけど、そういったものが一切ありません。

どういうことになるかと言うと、例えばこういう感じですね。

new UniversalRouterして、path設定と中にactionを書いて、これは1個しか書いてないですけど実際にはダーっといっぱい書いていきます。下のほうでDOMContentLoadedを書き、中でルーターのインスタンスにlocation.pathnameを渡して解決します。

これで正規表現をやめてpath-to-regexp記法にできました。

加えて、actionの中でさっき言ったdynamic importをやるようにすると、このページで必要になるpages/books/ordersだけが降ってきます。

1個注意する点として、universal-routerはaction()の返り値がnullとかundefinedだとNot foundと見なしてしまうらしく。しょうがないので、setup() はparamsをもらってvoidを返すのではなく、paramsを受け取って「voidを返す関数」を返す形にしました。それを実行します。

ところで、例えばこれをあとあと(関数の代わりに、)JSX.Elementを返すようにすれば、これを使ってSPAにすることもできるかもしれません。ちょっと大変ですけど。

setupがこういう形になると、各ページは中がjQueryだろうとReactだろうとなんだろうと、ただこれさえexportしていればよくなります。

ReactDOM.renderを実行したり、$(...).onを実行したり、new SomeWidget()とかを実行したりするでしょうが、外からのinterfaceを保ったままとりあえずReact化を推し進めていけてるので、とっても便利なんですね。

dynamic importを入れるには

肝心のdynamic importってどうやって入れるんですか? という話ですが、いたって簡単です。

import関数が構文エラーにならないように設定し、ビルド済みのファイルに名前がつくようにし、import()をする関数を書くだけです。

構文エラーにしない方法はいくつかあるんですが、Babelの場合はプラグインを入れます。yarn add babel-plugin-syntax-dynamic-importとかをして、babel-loaderあたりにこいつを読ませましょう。

TypeScriptの場合はtsconfig.jsonに”module”っていう設定があって、これが”es2015”だと読めないけど、”esnext”とかにすると使えるようになります。

また、dynamic importのときによく使うもので /* webpackChunkName: */というマジックコメントがあります。

import を関数で行う場合、動的な文字列でも好きに渡せるようになります。結果、読まれるファイル名が自明ではなくなり、また何も指定しないと0.bundle.jsみたいな適当な番号のついたファイルが勝手に作られるような形になります。

そこで、決まったファイル名を与えるためにマジックコメントで指定するということをやります。こうするとorder.bundle.jsみたいな名前でwebpackが吐いてくれて、ファイル名がわかりやすくなります。

このwebpackChunkNameなんですが、webpack 2.4あたりから入ったんですけど。そもそもpixivFACTORYはwebpackのバージョンが古かったのでwebpackChunkNameが使えなくて。しかしバージョンを上げるためにはwebpacker(Rails の提供するwebpackのラッパー)が邪魔だったので剥がしました。

留意すべき点とこれから

一応留意する点として、「細かくバンドル切るとモジュール読み込み回数が増えたりするんじゃないですか?」と言われるんですが、もちろん増えます。HTTP/2じゃない環境だとちょっと辛い可能性があって。pixivFACTORYはすでにHTTP/2になっていたのでとりあえずいけるだろうという感じでやってます。

今後なんですけど、全ページルーターに載せ切ったわけじゃないので今後も引き続きやっていくという感じになるのと、できあがったバンドルを軽くする、という目標はまだここからやっていきます。

また、ページごとにいろいろな実装が混ざっているのはそもそもよくないので、これもなんとかします。しかしルーター化をやったことで、とりあえず今後戦える基盤は整いました。「僕たちの戦いはこれからだ」という感じです。

まとめます。JSのモジュールはでかいと大変なので、適切に切ると良いです。dynamic importですが、導入は簡単です。ルーターを入れるほうがプロジェクトによっては困難ですが、とにかくuniversalに寄せることでうまく着地することができます。

以上です。ありがとうございます。

(会場拍手)