Eight Engineering Unitで主にEightのWebフロントエンドを担当している青山です。
今回はEightが長年利用していたjQueryを依存から削除した、という内容を、その経緯を交えながらお伝えします。
レガシーコードをリファクタリングされているエンジニアの方々への情報共有となれば幸いです。
jQueryによる課題
EightのWebフロントエンドは過去、Ruby on Railsの流れに乗ってBackbone.js with CoffeeScript という時代があり、6年ほど前にReactによるSPA化が成されました このあたりの歴史については以前イベントで紹介したことがあるので良ければご覧ください。
このときjQueryへの依存は残りましたが、以下のような経緯があったと推測しています。
- 当時世間でもReactによる実装の情報がそれほど多くなく、DOMを直接触るパターンがわりと残っていた。
- 今ほどfetchライブラリが充実しておらず、上記の理由からも利用されているjQueryの通信メソッドを利用するのが妥当だった。
- Backbone.jsでも組み合わせて利用されていたため、エンジニアが実装に慣れていた。
これらによりReactの世界にjQueryが入った状態で開発が進んでいました。
しかしjQueryそのものと、Eightにおける使い方において、以下のような課題も見えてきました。
- jQueryがあることで、仮想DOMを使わず直接DOMにアクセスすることを良しとしかねない。*1
- 実際に利用している箇所が少ないわりにjQuery本体はサイズが大きく、アプリケーション全体のJSファイルサイズが大きくなる。
通信部分と一部のDOM操作にのみ使われていた。 - DeferredというjQuery特有の形式の非同期インターフェースがPromiseと整合性がとれない場合もあり、接合面で両者の型を意識することになる。
- 通信まわりのユニットテストにおいて、古いjQueryの挙動に依存しており、セキュリティパッチがあてられない。
道筋
上記課題を解決するため、以下のような方針を検討しました。
- DOM操作部分を調査し、ほかの手法に置き換える。
- 通信部分を別ライブラリに置き換える。
- jQueryを依存から削除 🎉
やったこと
道筋を立てたのでそれぞれ以下のように実施していきました。
DOM操作部分の置き換え
道筋を立てる段階で軽い調査はしており、それほど置き換え部分が多くないことは見えていました。 以下のように、それぞれの関数ごとに置き換える作業をPRにまとめていきました。 この作業では、DOM操作を別のライブラリ(dom-helpers)に置き換えています。
通信部分の置き換え
ここでの一番の目的は通信まわりで発生していたDeferred形式の置き換えです。
通信部分はもともとjQuery.ajaxをラップした関数群が準備されており、それぞれのAPIで利用していました。
置き換えるためのライブラリとして、jQueryと同様にXMLHttpRequestベースであり、すでに利用実績もあったaxiosを選定しました。
各APIごとに置き換えていくことで、少しずつ市場に出しても問題ない進め方を選択しています。
具体的には、ほぼ同様のI/Fとなるようなaxiosをラップした関数群を作成し、置き換えていきました。
例えばgetの場合、以下のように対応する関数を準備し、 API.get
を apiAxios.get
に置き換える、といった具合です。
export const API = { get: function (url, query) { const settings = merge( { url: url, data: query, method: 'GET', }, defaultSettings, ); return $.ajax(settings); }, ... };
export const axiosGet = (url, params) => axiosInstance.get(url, params); ... export const apiAxios = { get: axiosGet, ... };
当初の方針どおりAPIをある程度グルーピングしながら、小さなPRを積み重ねて、少しずつ置き換えをリリースしていきました。
jQueryを依存から削除🎉
先日ようやくすべての作業を完了し、見事jQueryを依存から消すことができました。
これによりプロダクションビルドでのファイルサイズは82KBほど削減されました。
苦労した点
コンポーネント層の修正
Eightではredux-thunkを使って非同期処理(API呼び出し)を実現しており、Deferredの結果をdispatchの戻り値にしていました。これは処理中や完了といった非同期処理の状況を、コンポーネント側で知りたいというニーズに応えるための実装でしたが、今回のDeferredのI/Fである done
や fail
などがコンポーネント層に散らばることにもなりました。
このため、先の通信部分の置き換え
では修正した非同期処理を利用している箇所を確認しながら進めることとなりました。
ここでも少しずつ進める、という戦略のおかげで、それぞれのチェック範囲は少なくすみ、作業負荷の分散につなげられました。
(とはいえ、実際に確認が漏れてエラー監視のアラートに引っかかる、という事態も経験しました)
非同期処理のテスト修正
これまでReduxのaction部分はユニットテストが作成されていましたが、jQuery.Deferredのresolved,rejectedなDeferredに対するコールバックは同期的に動作する という挙動に依存していました。 具体的には以下のようなテストが正常扱いになってしまいます。*2
sinon.stub(API, 'get').returns($.Deferred.resolve()); asyncAction(); // テスト対象 expect(API.get).to.have.been.called;
Promisesの場合、常に非同期で動作するため、このテストは失敗します。 このため、以下のように修正する必要がありました。
sinon.stub(API, 'get').returns(Promise.resolve()); await asyncAction(); // テスト対象 expect(API.get).to.have.been.called;
実際、このパターンを持ったテストケースが多く、コード本体よりも修正コストが高いものでした。
まとめ
最初の仕込みからは足掛け5年という長期リファクタリングとなりました。 当時はフロントエンドを改善する人員がおらず、できる範囲からという視点で整理を進めていましたが、ここ1、2年でフロントエンドを得意とするメンバーも増え、一気に改善を進めることができました。 レガシーコードの改善においては、以下のようなことを意識しておくと、負担が分散でき比較的安全に実現しやすいのではないかと思います。
- 段階的に進める
- 途中で止める場合は状況を残す
- あきらめない
これらの思いを持って、今後も粘り強く改善を進めていきます 💪
*1:jQueryをglobalに配置していたためどこでも利用できる前提になっていた
*2:jQueryでもこの挙動は改善されており、3.0系から変更されていますjquery.com