LIFULLの中島です。
近頃、LIFULL HOME'Sのフロントエンド(ここではJavaScriptのみを焦点とします)もようやく進む道を見出し、そろそろ設計方針を一新しようと試みています。
今回はそれについて話したいと思います。
現在の私たちの課題感
私たちの管理する多くのレガシーコードはDOM操作ライブラリとしてjQueryを、UI設計の格子としてBackbone.Viewのような設計方式を導入しています。 (もちろんそうでないマイクロサービスも多くありますが)
具体的なコード例を示すことこんな感じになります
let Slider = Backbone.View({ events: { '.next click': 'next', '.prev click': 'prev' }, next() { this.$(...).css({left: '111px'}); }, ... }); let photoSlider = new Slider({el: '#photo_slider1'});
View間の連携を取りたい時はBackbone.Eventsをglobalに放出するpubsub実装パターン(like USA today)のようなものを用意し、コミュニケーションをとるように実装しています。
ファイル分割の単位が明確化され、またUIの振る舞いが統一的に規格化(@events)され、コードの追い易さは野良コードに比べると随分マシな状態になりました。
しかしながら、10年も運用していると(実際のところもっと早くから苦しんでいるが)、これらのコードに存在する、いくつかの腐りうる隙が目につくようになります。
DOM探索の害悪
数年コードを運用してわかることはDOM探索という行為は運用上、基本的にはコードを汚くする主要因であるということです。
セレクタでの要素探索は壊れやすく、探索した要素はNodeListやHTMLCollectionといった紛らわしい要素となり、それらのnormalizeに我々はまたコードを一つ書かなくてはなりません。
またせっかくView単位でファイル分割しても親ViewはDOM探索によって簡単に子Viewの要素にアクセスできてしまい(その逆もしかり)、Viewの境界線が曖昧になってしまいます。
その結果我々のコードは子Viewの責務を兼ねた再利用のきかない大きな親Viewが多く誕生し、その再利用性の低さから、「非常によく似た、しかし少し違う」コードが増え、無駄にプロジェクトを肥大化させることになりました。
これはbundlerやtranspilerのビルド時間を無駄に長引かせ、結果として開発効率を大きく落とす結果となります。
動的に追加されるコンテンツに対する振る舞いのアタッチ
動的(XHR等による)に追加されるコンテンツに振る舞いをアタッチする際に、そのDOMをelとしてViewをインスタンス化せねばなりません。
これはViewのインスタンス化を一元管理することが困難になることを意味します。
コードの統一性や、そのインスタンスがどのように扱われるかに注意を払わねばいけなくなるのはリードコストを増大させ、これまた開発効率を落とすことにつながりました。
グローバルイベント(pubsub)に依存した実装
グローバルイベントの採用は一定の成功を収めましたが、Viewの唯一のコミュニケーション手段としてしまったのは失敗でした。
左右間のViewの連携においては、グローバルイベントを用いたコミュニケーションは効果的ですが、親子間のコミュニケーションにはしばしば課題を伴います。
A1- |-B1 |-B2 |-B3 A2- |-B4 |-B5 |-B6
B2が自身の親のA1にだけ情報を伝えたい時、グローバルイベントをなげてしまっては、A2を除きA1だけが呼応するという実装を伴う必要がでてくるからです。
素直にCustomEventを投げ、バブルアップさせるべきでした。 結果としてこれもコード量を無意味に増やすことにつながりました。
見えてきた次の設計に必要な観点
これまでの反省を踏まえると次の設計では以下の点にこだわる必要があります。
- DOM"探索"の排除
- DOMの振る舞いの自動アタッチ
- (Bubble up eventで)親子間でコミュニケーションがとれる
3つ目は普通のことであるとして、前者の二つを兼ね備えるものはあるのでしょうか
かのDHHはこの観点を「HTMLとJavaScriptの概念的距離」と表現しており、圧縮すべきだと主張しています。
HTMLとJavaScriptが離れたところにあるがゆえに探索は必要であり、探索した要素にイベントを自主的に割り当てる必要があると言えます。
もしHTMLとJavaScriptの境界がもっとぼやけていて、JavaScriptからDOMに変数やプロパティへのアクセスのようにアクセスでき、HTMLからJavaScriptの振る舞いを呼び出せればこの辺の複雑性はなくなると言えます。
モダンライブラリにおける「HTMLとJavaScriptの概念的距離」
あまりこういう言い方で流行りのライブラリを表現することはありませんが、かなりのシェアを集めているReact/Vueもこの概念的距離の圧縮によって成功を収めているライブラリに見えます。
new Vue({ el: '#app', template: ` <button type="button" @click="notify">click me</button> `, methods: { notify() { alert('click button!'); } } })
このコードはDOM(button)に振る舞いを与えるものですがDOM探索は行われていません。
VueやReactはJavaScript側でHTMLを生成することで探索という工程を排することに成功しています。
もちろん他にもテンプレート側からみて振る舞いが宣言的であったり、データバインディング機構があったりと魅力的な点は多くありますが、私の観点ではここがもっとも重要に思います。
これらのモダンライブラリを採用するのか?
答えはNoです。
もちろん、新規でマイクロサービスを作ったり、もつべき状態がすこぶる多いのであれば採用を考えたかもしれません。(事実そういうマイクロサービスもあります)
しかしながらLIFULL HOME'S本体のサイトはいくつか様子が違います。
多少の検索UIが状態を持つとはいえ、基本的には物件情報を取り扱うドキュメントサイトです。
そこでは振る舞いよりも文章の重要度が高く、且つ、これまで積み上げてきたSEO地位に対してのリスクは非常にシビアに評価する必要があります。
これらのモダンライブラリはJavaScript側からHTMLを生成することにより概念的距離を縮めたが、それゆえにHTMLの生成がJavaScriptによって"後から"生成されることとなり、クローラビリティやプログレッシブエンハンスメントの観点においていくつかの懸念を残します。
SSRも元々そこに存在しなかった問題に対する対処であり、害虫を狩るために猛獣を飼いならす必要がある状況のように感じます。
設定やビルドといった新しい複雑性を極力伴わず、HTMLはそこにあり、その上でHTMLとJavaScriptの概念的距離を圧縮するアプローチこそが我々の望んでいるものなのです。
採用した概念圧縮の方法
我々はBasecamp製のStimulusに命を預けることにしました。
(もしかすると1年後にはそれとturboを組み合わせたhotwireに命を預けると言ってるかもしれません)
Stimulusはなんなのか
これはRails7にデフォルトで導入されるHotwireに組み込まれているライブラリなのでそのうち大きく広まるかもしれません。
React/Vue同様にHTMLとJavaScriptの概念的距離の圧縮に成功したライブラリと言えますが、大きく違う点はそれ自身がHTMLを生成しないところにあります。
MutationObserverでDOMの変更を監視し、変更されたDOMに振る舞いが必要であることがわかれば自動的に必要な振る舞いをアタッチするように動きます。
対象となるDOMにどのような振る舞いが必要か、その要素内のどの要素に参照が必要か、それをDOM自身に記述することでアタッチやDOMの参照を自動化するのです。
しかもビルドレスでなんならCDNをimportするだけで動きます。
テンプレートレイヤを侵すことはないため、既存のコードを式年遷宮する必要もありません。
具体的にコードを書きましょう。
` <div data-controller="counter" data-counter-num-value="0"> <p data-counter-target="view">0</p> <button type="button" data-action="click->counter#increment">+1</button> <button type="button" data-action="click->counter#decrement">-1</button> </div> ` import {Application, Controller} from 'https://cdn.skypack.dev/stimulus'; let app = Application.start(); // MutationObserverでDOM全体の監視を始める app.register('counter', class extends Controller { static targets = ['view']; static values = {num: Number}; increment() { this.numValue++; } decrement() { this.numValue--; } numValueChanged() { this.viewTarget.textContent = this.numValue; } })
HTMLに注目してみましょう。 Stimulusはいくつかのdata属性を利用して動きます。
data-controllerが属性が付与された要素がmutation observerに引っかかれば、即時にその名前でregisterされたcontrollerをその要素に対して適応します。
data-action属性が付与された対象の要素で発生するイベントに対し、値部分に書かれた振る舞いが自動でアタッチされます。
さらにdata-[controller]-target属性が付与された要素がJavaScript側からthis.[controller]Targetとしてアクセスすることができるようになっています。(これは実際にはstimulusによってstatic targetsを元に自動で作られるgetter関数です)
(残るdata-[controller]-xxx-value="{value}"はstimulus2で実装された、データ変更コールバックを伴う状態管理機能です)
これらの機構によりDOM参照はプロパティアクセスで実現され、振る舞いのアタッチは自動でされる世界線が実現することになります。
HTMLに振る舞いと状態を記述しておけば勝手に振る舞いがアタッチされるので、XHR等で動的に追加されるHTMLも、ただ挿入するだけでよくなります。
我々は常にサーバからはHTMLを返せばいいのです。
それはとてもシンプルに感じます、 HTMLの組み立てロジックをサーバサイドに集約できるのはレガシーシステム観点で考えるととても痛みのない方法です。
もしSPAを実現するとなった時、Turbolinksはもう一度息を吹き返すかもしれません。 おそらくBasecampはそういう意図でTurbolinksに投資を続け、この度turboをリリースしたのでしょう
必然的にクライアントサイドで大きなデータ操作をするシーンは減ることになり、型やスキーマの必然性が下がることになります。
(これはもしかすると将来ビルドレスを推し進めた先のtranspilerと決別するシーンで役に立つかもしれません)
さらにStimulusは興味深いことにコントローラを一つの要素に複数つけることを許容し、各コントローラを単一責任にせよと示すのです。
よくある機能を単一責任なcontrollerとして切り出す時の例をあげていきましょう
- disclosure
- disclosure(パカパカ)の機能
- 適切なaria-expand付与等
- disclosure(パカパカ)の機能
- removal
- 要素削除機能
- modal
- dialog roleやlabel系ariaの設定等
- content-loader
- contentのxhrロード機能
- combobox
- サジェストの機能
- 候補の表示と適切なrole,labelの設定など
- サジェストの機能
- xhr-form
- form条件を元にxhrしコンテンツを部分リフレッシュする
etc...
弊社のようなドキュメントメインのサイトだと、この辺が用意されていて再利用性が高いコントローラとして設計されていればわざわざページごとのロジックを精査する必要もないのかもしれません。
ただパズルのようにHTMLにcontrollerやactionを付与していけばそれだけでサイトの振る舞いが完成するのは私たちの次の理想です。
我々はStimulusを導入し、プリミティブなコントローラを揃え、コード量を1/20にすることを次の目標にしていきたいと思っています。