🛡️

webpacker環境での保守性に配慮したJS設計案

2020/12/19に公開

webpacker を JavaScript のビルド環境として選択する状況では、JS に対してあまり高い要求をしないことが多いと思います。
おそらく、JS の役割は、Web アプリケーションが返すいくつかの HTML の部品へ、追加的な振る舞いを提供するに止まるのではないでしょうか。

しかし、そのような控えめな役割でも、複数人で数ヶ月間以上 JS のコードを蓄積すると、それなりの量になります。
そして、コードと仕様の増加は、いわゆる保守性の低下を引き起こすため、ここでなんらかの対策を求める人はいると考えています。

この記事は、そういった問題意識を持った人に向けた、保守性の担保を目的とする webpacker 用の JS 設計を提案する記事となります。

手なりで導入できる自動テストの検討

対策として、まず思いつくのは自動テストの導入です。
手なりで導入可能な自動テストとして、自分にはコストパフォーマンス順に以下の案が考えつきます。

  • webpack-dev-server などで JS 専用の開発環境を構築し、その環境に対して puppeteer などで UI レベルのユニットテストを行う。
  • Rails 側で UI テスト専用の画面を作り、Rails の system test で、UI レベルのユニットテストを行う。
  • Rails の system test で、E2E テストを行う。

しかし、一番上の案でも、結局は「ブラウザの操作をエミュレートしたテストコードを書く。」という工程を経るため、テストを書く難易度が高く、最終的にテストコードの保守コストが高くなると感じています。

また、最近はそこまで気になりませんが、parameterized test のような手法で網羅度の高いテスト一式を書こうとしたときに、手元で動作させる速度がやや遅く感じることもあります。
特にですが、E2E テストで担保しようとしたときには、テストケース数の増加は重大な問題になることが多いでしょう。

以上から、対策の基幹にするのは避けたいという結論になりました。

部品化しユニットテストを書けるようにする

ところで、コード・仕様の増加に伴う保守性の低下は、JS に限らずプログラミング言語を問わずに発生する問題だと認識しています。

そのような状況では、まずは基本中の基本として、「コードを部品化し、仕様を定義し、ユニットテストで実装が正しいことを担保する。」という手法で対策するのではないかと思います。
これができるようになると、安定して効果のある対策ができそうです。

以上から、「部品化しユニットテストを書けるようにする」を対策の基幹にすることにしました。

実装については、他のアイディアも含んだものになるので、この後に Rails アプリケーションのリポジトリを見ながら解説します。

サンプルを使った設計案の解説

はじめに

サンプルとなる Rails アプリケーションの リポジトリ URL は以下です。
https://github.com/kjirou/avoid_losing_extensibility_with_webpacker

1) rails new するまで

https://github.com/kjirou/avoid_losing_extensibility_with_webpacker/commit/0b465c7c1d15bc3c0a0a486bf74c28a685b37d7d

具体的に行った操作は、コミットログに記載しています。

--skip-turbolinks ですが、Turbolinks の body 以下の DOM を強制置換するという振る舞いが、ある DOM 要素以下を管轄下にすることで担保することが多い UI 部品のモジュラリティを破壊するため、外します。
影響を制御する方法もありますが、あまりに大変です。

2) デモ用の route を追加する

https://github.com/kjirou/avoid_losing_extensibility_with_webpacker/commit/65e72a06299d0f7df62fe7f921a61ee110489d65

3) Rails から route 情報を JS へ渡す

https://github.com/kjirou/avoid_losing_extensibility_with_webpacker/commit/260b227323095fc1d691b5d5607f4e50e3b0995f

趣旨については、後のコミットで解説します。

この router 変数は、全ての画面に対して提供する必要があります。[1]

4) route で JS のコードを機械的に分割する

https://github.com/kjirou/avoid_losing_extensibility_with_webpacker/commit/383d71da73d845b64e1c39b79b7b22a4ff95aa62

Rails のいち route に対して、JS 側にいちモジュール作成する設計を入れました。

設計の趣旨は、以下です。

  • 可能な限り明確に判断できる情報(本件だと、route を構成する controller_pathaction_name が相当します)から、先んじて JS のコードを分割すること。
    • 読み手が、JS の処理の始点を把握することができ、また読む量を減らせることを期待しています。
  • 分割により影響範囲の狭い JS のコードを書ける場所を作ること。
    • 書き手に、正しく部品化する必要がない、悪く言えば品質の低い JS のコードを書ける場所を提供することが目的です。逆の視点で表現するなら、ここ以外では、正しく部品化したコード以外は許容しないことを明確にしているとも言えます。

そもそも、Rails の route による処理分割にこのような役割があると思いますが、それを JS 側に輸入した形になります。

JS を必要とする route が増えたときは、app/javascript/src/pages/users/show.js のように app/javascript/src/pages/{controller_path}/{action_name}.js へ新たにモジュールを追加し、それを app/javascript/src/pages/index.jsimport し、最終的に pages 変数へ格納します。[2]

懸念点として、Rails の controller_pathaction_name という内部情報が外部に露出する点があります。
これについて、露出できない名称については途中に変換処理を行う、という手間の掛かる対応の他に、解決案は思いつきませんでした。

5) Rails が HTML を介して JS 側へ変数を渡せるようにする

https://github.com/kjirou/avoid_losing_extensibility_with_webpacker/commit/b05fd6d03d6851d5f80df29bef89bdd9567c15b9

Rails から JS へ変数を渡すときの手順を決めました。

重要なのは、手順を統一することと、ブラウザ環境へ与える影響(本件では JS のグローバル変数を定義したことが相当します)を限定することです。
それが守られているなら、経路は自由です。例えば、script タグ経由で情報を渡すなどでも良いと思います。

なお、handleDOMContentLoadedcontext を引数に取る純粋関数的な表現になっていますが、実際は異なります。
handleDOMContentLoaded の処理は、HTML を介して受け取っている巨大な DOM ツリーを暗黙の引数として動作するものなので、そのことは意識する必要があります。

6) 共通の部品を作成し、route 別の処理で呼び出す

https://github.com/kjirou/avoid_losing_extensibility_with_webpacker/commit/80b9de9d19ac8aaafe1a7936694dde485f2d0f2c

まずは、DOM に依存しない、Pure Node.js でも動作する calculator モジュールを作成します。

モジュールの定義方法や呼び出し方法は、Node.js の作法に準じます。
「Node.js に準拠する」という点は、可能な限り守った方が良いと考えています。手法として安定している点と、現代ではフロントエンドを勉強するに当たってまず必要になる知識であるため、一般性が高く、新規参入者の学習コストが抑えられる点からです。[3]

モジュールを配置する位置は、自由に設計して構いません。
今回は src/pages/shared へ配置しましたが、依存の方向さえ管理しているなら、src 以下ならどこに置いても良いです。

7) Jest 環境の構築

https://github.com/kjirou/avoid_losing_extensibility_with_webpacker/commit/f7e516600ae8692e34702ed310dbbfc9c0719a8f

https://github.com/kjirou/avoid_losing_extensibility_with_webpacker/commit/ecc010a177479ba5121ff6315dff7e9293bb1de7

https://github.com/kjirou/avoid_losing_extensibility_with_webpacker/commit/5c37d5d1b8b1ecb7556521a4a8db3b5a757bc19c

ユニットテストを書くために、Jest のインストールをし、設定ファイルをコマンドから生成し、設定を調整しています。

Jest ではないテスティングフレームワークでも、もちろん問題ありません。

8) calculator モジュールへユニットテストを書く

https://github.com/kjirou/avoid_losing_extensibility_with_webpacker/commit/5c6307a029ff8b4c48b820008ffe0690a7949089

この 6, 7, 8 の項目が、Pure Node.js で書けるような、シンプルな部品を作る方法になります。

9) jQuery をグローバル変数を介さないで呼び出す方法

https://github.com/kjirou/avoid_losing_extensibility_with_webpacker/commit/5135dad88b34c9f93e9269975ce57d077a2d44ed

https://github.com/kjirou/avoid_losing_extensibility_with_webpacker/commit/ab1b1eb8de0b4cfd2946d0638c02b1b86a70e29e

突然な話の流れに沿わない論点ですが、コミットの順番通りに解説します。
コミットを作ったときに、解説のことまで考えていませんでした。

jQuery をグローバル変数として定義しないで呼び出す方法の例です。
jQuery に依存したモジュールを作ったとしても、上記のように import で呼び出せば、例えばテストコード内でも呼び出すことは可能です。

ただし、一部の jQuery に依存したライブラリには、「グローバル変数に存在するはずの jQuery を拡張する。」という処理を書いていることもあり、そのときには残念ながら、グローバル変数として定義する以外に方法がありません。
確か、Bootstarp の jQuery 拡張が、そのような設計だった記憶があります。

10) レイアウトの UI 部品に対しての設計

https://github.com/kjirou/avoid_losing_extensibility_with_webpacker/commit/b2f3b33420e2c626b1de7c21b09e8c71c23e67b1

レイアウト内に存在する UI は、多くの route に対して表示されます。
その UI を JS で拡張したいとき、先の route 別に JS を分割した設計だと、その全ての route で同じ初期化処理を呼び出さないといけなくなってしまいます。

これは程度問題だと思いますが、自分の感覚では、数回なら同じ初期化の処理を書いてもいいと思っています。
しかし、10 回、100 回、そしてさらにその何倍にもなったときのために、違う設計の切り口を用意しても良さそうです。

そこで、「HTML に期待する要素が存在したら、それを条件にして JS の処理を呼び出す。」という設計を、同時に存在させることにします。
これは、旧来の jQuery や、最近だと Stimulus の設計です。

ただし、あくまでも route 別 JS で解決が困難な問題に対してのもので、補助的に使用するに止めます。
JS の呼び出しが Web サーバが生成する HTML の構造に依存すると、JS の修正に際して、Rails 側のプロダクトコードを読まないといけない機会が増えます。
この構造は、JS のルーティングのわかりやすさや分業のしやすさへ悪影響を及ぼします。

11) UI 部品をモジュール化する

https://github.com/kjirou/avoid_losing_extensibility_with_webpacker/commit/e4c93eb4b08facb154737b941545efa70e661cb5

処理本体を、HTMLElement インスタンスを引数に受け取るクラスや関数へ切り分け、モジュール化します。[4]

jQuery を使っているときに、引数の DOM 要素を jQuery インスタンスを介して受け取るのは推奨しません。
jQuery インスタンスは、その名の通り Query なので、「0 個以上の DOM 要素を抽出する式」を表現しています。
モジュールに必要なのは、基本的には決まった数の DOM 要素だと思うので、引数の宣言で伝えられる情報が曖昧になってしまいます。

また、引数の DOM 要素を破壊している点は、意識しておく必要があります。
破壊すること自体については、グローバル変数的に存在する DOM ツリーのいちノードへ影響を与える必要がある関係上、仕方ないことだと考えています。

12) UI 部品のモジュールに対してユニットテストを書く

https://github.com/kjirou/avoid_losing_extensibility_with_webpacker/commit/b1ae14fa92872104134ed86cc5b3fd27e9e6bd9d

https://github.com/kjirou/avoid_losing_extensibility_with_webpacker/commit/e2bf482b2a1169e8b4203cb682c5fe4ec9d5aaeb

jsdom を使うことで、ブラウザ環境にしか存在しない windowdocument 変数をモックできます。
Pure Node.js とブラウザ環境の JS の差は、おおよそこの点に集約されるので、このモックを行うことでユニットテストを書くことができます。

このような激しいモックが正常に動くのか疑問を感じられる方もいらっしゃると思いますが、実感としては、かなり動きます。
とはいえ完全に動く保証はなく、どこまで信頼できるかについては、明確に助言はできません。
個人的に依存するのを避けてるのは、ブラウザ間でも実装が統一されているかわからないような振る舞いに対してです。
例えば、複数のイベントハンドラが同時に発火するときの発火順、.outerHTML で生成される HTML に含まれる属性の順番、これら程度の細かさの振る舞いには依存しないようにしています。

13) 独立した UI を React で作る

https://github.com/kjirou/avoid_losing_extensibility_with_webpacker/commit/5c76cace524c7b46003909b7660b05c08b70d91c

https://github.com/kjirou/avoid_losing_extensibility_with_webpacker/commit/c720e52f481b4ef3a6349f16a3a5734886c3415c

https://github.com/kjirou/avoid_losing_extensibility_with_webpacker/commit/5583ab5c84ff2d0d084602a4155ec06584cc864d

HTML の拡張の範囲を超えて、独立した UI を作成する必要もあると思います。
そこで念のためですが、webpacker の方法に従って、独立した UI を React で作れるのかを検証しました。

React Component 群の設計をどうするか、については、おそらくは一般的な React の世界に閉じることができるので、ここでの説明は割愛します。
「おそらくは」という表現をしているのは、webpacker が React 世界には不要な配慮をしているかもしれないためです。知見がないのですが、大丈夫そうには見えます。

正しく React Component を作成すれば、未来に紆余曲折を経てフロントエンドを Next.js 化したとしても、再利用することができるでしょう。

以上で、サンプル Rails アプリケーションを使っての解説は終了します。
最後に補足事項を列挙して終了にします。

補足

違う route が同じ画面になるとき

Rails において、GET /{controller}/newPOST /{controller}/createGET /{controller}/editGET /{controller}/update が、全く同じ画面を表示する状況はよくあると思います。
そのときに、route 別の JS へ同じ初期化処理を重複して書かないといけないのか、という点についてです。

自分の実務経験では、Rails に従った CRUD の画面群が多くなかったので、重複して初期化処理を書いてしまいました。
そのため、特に回答がありません。

推測するなら、「route 別にいち JS モジュール」という枠組みは壊さない方が良さそうなので、そこで呼び出している handleDOMContentLoaded を共通化する場所を作りそうです。
src/pages/{controller_path}/new_or_create.js や、いっそコントローラー内部の処理を共通化する場所を作るために、src/pages/{controller_path}/controller.js を作るかもしれません。

DOM ツリーを前提にできるコードは限定する

結論を先に書くと、基本的には、app/javascript/packs 以下と app/javascript/src/pages/{controller_path}/{action_name}.js 以外は、ブラウザ環境の DOM ツリーが存在しない前提でコードを書いた方が良いと思います。

部品が DOM ツリーと入出力したい場合は、部品の内部から DOM ツリーを直接参照するのではなく、上記でサンプルコードとして書いた Button.js のような、引数を介して受け取る形式にすることを推奨します。

理由は、DOM ツリーというものの実態が、巨大で mutable で操作に対してブラウザへ副作用を発生するグローバル変数であるため、依存により暗黙的な影響を受ける度合いが強いためです。
例えば、部品が参照すれば不慮の DOM ツリーの変化の影響を受ける可能性がありますし、書き込めば DOM ツリーを介して他の部品に影響を与える可能性があります。

依存すること自体は、最終的な目的が DOM ツリーへ影響を与えることである都合上仕方ありませんが、可能な限り少なく明確にした方が良いと思います。

脚注
  1. router というグローバル変数名は、サンプルとしての分かりやすさを重視した命名です。実際は衝突しないような名前にした方が良いと思います。 ↩︎

  2. index.js というファイル名は Node.js で特別な意味を持ってしまうので、action_nameindex のときは、index_.js などにして回避する必要があります。 ↩︎

  3. 個人的に、「Node.js 非準拠を選択しているのに得られる利益が薄い」と感じている、モジュール周りであまり好きではない工夫をいくつか挙げると、webpack の require.context でモジュール呼び出しの省力化を図る、TypeScript の baseUrl でモジュールのパスを絶対パス的に読み込めるようにする、などがあります。 ↩︎

  4. サンプルはクラスで書いていますが、特にインスタンス変数で状態を保持する必要がないなら、個人的には関数で書くことが多いです。 ↩︎

Discussion