はじめに
フロントエンドが複雑になりすぎて、いつの間にか離れていた。最近AIのおかげでまた触るようになったのだが、そんな折、前職の同僚が2012年のコードを掘り起こしてきた。
もともとはAdobe Flex(Flash)で作られていた商品比較チャートのプロダクトだ。Flash Playerの衰退に伴ってJavaScriptに移植された。かつてCMSやメーカーのWebサイトで動いていたが、プロダクト自体はとっくに終了している。
「まだ動くよ」
同僚が見せてくれた画面は、今のChromeでそのまま表示されていた。
中身を開いてみた。npmもwebpackもTypeScriptもない。フレームワークもない。あるのはjQueryとブラウザだけ。ただのJavaScriptだった。
今のフロントエンドはツールチェーン込みで複雑だ。一方でこのコードは、少なくとも「ブラウザで何が動いているか」が見えやすかった。
なお、以降のコードはプロダクトの特定を避けるため名前空間や変数名を改変している。
npmがない世界
当時、フロントエンドに npm install という概念はなかった。ライブラリは公式サイトからJSファイルをダウンロードして、プロジェクトのフォルダに手で置く。jQuery、Flot(グラフ描画プラグイン)、moment.js。全部手動だ。
ファイルが増えてくると、次の問題が来る。読み込み順だ。import がないから、ファイル間の依存はグローバル変数(window)経由で解決するしかない。ユーティリティを先に読み込み、コア機能をその後に、マネージャーを最後に。順番を間違えたら undefined is not a function。
この順番管理を担っていたのが、Apache Antの build.xml だった。Antは元々Java製のビルドツールだが、当時はフロントエンドのビルドにも使われていた。
<concat destfile="${build.js.dir}/${js.concat.name}" encoding="UTF-8">
<fileset dir="${lib.js.dir}" includes="jquery.min.js" />
<fileset dir="${src.js.dir}" includes="app/app.dictionary.js" />
<fileset dir="${src.js.dir}" includes="app/app.util.js" />
<!-- ... 依存関係を考慮した順番で数十ファイル ... -->
<fileset dir="${src.js.dir}" includes="app/app.manager.js" />
</concat>
XMLにJSファイルの結合順を手書きしていた。ファイルを1つ追加するたびに「正しい位置」を考えて挿入する。正気の沙汰ではないが、当時はこれが普通だった。
グローバル変数の衝突を防ぐために、全ファイルがIIFE(即時実行関数式)で書かれていた。
(function (window, $) {
var internal = "外に漏れない";
window.MyApp = window.MyApp || {};
window.MyApp.Manager = {
init: function() { /* ... */ }
};
})(window, jQuery);
関数で囲んで即座に実行する。中の変数は外に漏れない。公開したいものだけ window に登録する。
各ファイルの先頭はこうなっていた。
(function (window, $) {
var Util = MyApp.Util;
var Event = MyApp.Util.Event;
var Browser = MyApp.Util.Browser;
var Cookie = MyApp.Util.Cookie;
// ...
グローバルから必要なモジュールを取り出してローカル変数に入れる。import { Event } from './util/event' の手書き版だ。
classがないなら
2012年のJavaScriptには class も #private フィールドもない。じゃあどうするか。クロージャで閉じ込める。
function setupProduct(product) {
var _visibility = "visible";
var _observers = [];
function fire() {
$.each(_observers, function(_, observer) {
observer.onStatusChange(product);
});
}
product.setVisibility = function (visibility) {
if (_visibility != visibility) {
_visibility = visibility;
fire();
}
};
product.visibility = function () {
return _visibility;
};
}
_visibility は関数スコープに閉じ込められていて、外から直接触れない。状態を変えるには setVisibility() を経由する。値が変わるとオブザーバーに通知が飛ぶ。コメントには「private化するためクロージャのローカル変数として閉じ込める」と書いてあった。
実際のコードには addObserver もあった。
product.addObserver = function (observer) {
_observers.push(observer);
return function() {
var index = $.inArray(observer, _observers);
if (index != -1) {
_observers.splice(index, 1);
}
};
};
登録すると、解除用の関数が返ってくる。不要になったらそれを呼ぶだけ。
通知が止まらない
このオブザーバーパターンはプロジェクト全体で使われていた。お気に入り、軸管理、フィルタ、ポイント描画。自前のイベントシステムだ。
ただ、これには代償があった。
ミニマップとメインのグラフを連携させる機能があった。ミニマップの範囲を変えると、通知がグラフに飛ぶ。グラフの再描画が、さらに別のオブザーバーに通知を飛ばす。お気に入りの状態変更も連鎖する。通知がどこから来てどこへ流れるのか、コードを追うだけでは把握しきれなくなる。
後年、Facebookが一方向データフローを提唱したFluxを見たとき、ああ、当時ぶつかっていたのはこういう問題だったのかと腑に落ちた。
そしてまだ動く
プロダクトは終了している。なのにコードは動く。
理由はシンプルだ。外部依存がない。 jQuery、Flot、全ての自作コードが1つのファイルに結合・圧縮されていた。CDNへの参照もない。npmのパッケージ解決もない。HTMLから <script> タグで1ファイル読み込めば、それだけで完結する。
このコードが依存しているのは全てブラウザの標準仕様だ。DOM API、Canvas API。ブラウザベンダーはこれらの後方互換性を極めて重視している。14年前の document.getElementById も addEventListener も、今のChromeでそのまま動く。
考えてみれば、TypeScriptもJSXもビルド後はただのJavaScriptだ。ブラウザが実行しているものは14年前から変わっていない。変わったのは書く側だけだ。
14年前のこのコードには、外側の部品がなかった。全てが1つのファイルに閉じていた。
昔のフロントエンドが楽だったわけではない。依存順は手で管理し、名前衝突を避け、状態の流れは自分で追わなければならなかった。
ただ、実行環境に近いところで完結していたぶん、何に依存していて、どこで壊れるかは見えやすかった。現代のフロントエンドは開発体験を大きく前進させたが、動かすための仕組みもまた巨大になった。
14年前のJavaScriptが今も動いたのは、昔のやり方が優れていたからではない。依存が少なく、ブラウザの互換性が強かっただけだ。
その「ただのJS」が、まだ動いている。