Leverage OJフロントエンド書き換え:Nuxt 4 + Naive UI SPA
Leverage OJのバックエンド書き換えはすでに進行中だった——クリーンなアーキテクチャ、適切なマイグレーション、本物の認証——しかしフロントエンドはまだオリジナルのコードベースのままだった:散在するAPI呼び出し、型安全性なし、動作させるのに創造性が必要なビルドパイプラインを持つVue 2アプリ。基礎を直すなら、屋根も直した方がいい。
この投稿はフロントエンド書き換えについてだ:なぜ行ったか、何を選んだか、持ちこたえたアーキテクチャの決定、そしてPlaywrightテストが実際に使おうとしたときにのみ現れたバグ。
なぜフロントエンドを書き換えたか
古いフロントエンドは高速に進むプロジェクトの通常の罪を蓄積していた:
- Vue 2 — 2023年12月にEOL。エコシステムはすでに移行しており、プラグインは更新されず、何百ものファイルに散らばるOptions APIコードはリファクタリングを痛いものにした。
- API層の抽象化なし —
axios呼び出しがコンポーネント内にインラインで、一部は重複、一部は微妙に異なるエラー処理。認証ヘッダーの追加はすべてに触れる必要があった。 - 型安全性なし — APIレスポンスは
anyとして型付け。TypeScriptは名前だけ存在していた。 - Naive UIは部分的に使用されていたが一貫していなかった。一部のコンポーネントはElement Plusを使用し、一部は生のHTMLだった。
- 認証状態はトークンリフレッシュロジックなしでVuexに保存されていた。トークンは静かに期限切れになり、ユーザーは提出中にログアウトされた。
最後の一撃:バックエンド書き換えでAPIレイヤーを再設計したとき、フロントエンドは非常に多くの場所で更新が必要になり、ターゲットを絞ったリファクタリングは結局すべてに触れることになった。その時点で、ゼロから始める方が理にかなっていた。
技術スタック
Nuxt 4、SPAモード
SSRではなくSPAモードのNuxt 4を特定の理由で選んだ:Leverage OJはほぼすべてのページが認証を必要とするジャッジ-ユーザー-提出プラットフォームだ。SSRは複雑さを追加する(認証状態ハイドレーション、クッキー転送、SSR安全なlocalStorageアクセス)が、実際の利点はない——検索エンジンはログインウォールの後ろの問題文をインデックスする必要がない。
SPAモードはNuxtのプロジェクト構造、自動インポート、ルーティング、ビルドツールを、ハイドレーションのフットプリントなしで提供する。
Naive UI
古いフロントエンドですでにNaive UIの採用を始めていたが、一貫していなかった。新しいコードベースではNaive UIが唯一のコンポーネントライブラリだ。テーブル、フォーム、モーダル、データピッカー、コードハイライト——必要なすべてをカバーし、Vue 3 Composition APIとうまく連携する。
CodeMirror 6
コードエディタはOJフロントエンドで最も重要なコンポーネントだ。ユーザーは他の何よりもエディタとの対話に多くの時間を費やす。
2つの理由でCodeMirror 6をMonacoより選んだ:バンドルサイズと柔軟性。MonacoはVS Codeライクな体験には優れているが、重く、レンダリング方法について意見を持っている。CodeMirror 6の拡張モデルは必要な機能を正確に構成できる:C++/Python/Javaの構文ハイライト、vimキーバインディング(競技プログラマーに人気)、カスタムテーマ。
KaTeX
競技プログラミングの問題文は数学が多い。MathJaxは古いOJシステムでの既存の選択肢だったが、レンダリングが遅く、DOM挿入後に別のパスが必要だ。
KaTeXは同期的にレンダリングし、劇的に速い。問題コンテンツの$ / $$デリミタをパッチするVueディレクティブで使用している。MathJaxが生成するフリッカーなしで、シンプルなインライン分数から複雑なシグマ表記まですべてを処理する。
アーキテクチャ
API層としてのコンポーザブル
コンポーネント全体にaxios呼び出しを散在させる代わりに、すべてのAPI操作はcomposables/api/のコンポーザブルを通過する。各コンポーザブルは1つのドメインをラップする。
useRequestは認証ヘッダーが添付され、エラーが正規化され、トークンリフレッシュがトリガーされる単一の場所だ。他のものは直接axiosに触れない。
Pinia認証ストア + JWT自動リフレッシュ
認証状態はPiniaストアに存在する——コンポーネントローカル状態でも、Vuexでも、古い「ページロードごとにlocalStorageをチェック」パターンでもない。
ユーザーがログインすると、JWTペイロードからトークン期限を抽出し、期限切れ前に自動リフレッシュをスケジュールする。リフレッシュが失敗した場合(ネットワークエラー、取り消されたセッション)、logout()を呼び出してログインページにリダイレクトする——静かな失敗はない。
ストアはpinia-plugin-persistedstateを介してsessionStorageに永続化されるので、ページリフレッシュはユーザーをログアウトしない。
統合の課題
NuxtでのCodeMirror 6
CodeMirror 6のコアはESMのみで、それは問題ない——しかしいくつかの拡張パッケージはSSRコンテキストで微妙なインポート問題がある。SPAモードでも、Nuxtのビルドは静的生成中にブラウザ専用APIを参照するインポートを分析しようとすることがある。
解決策:エディタをClientOnlyコンポーネントでラップし、CodeMirrorインポートを遅延ロードする。.client.tsサフィックスはNuxtにこのプラグインがブラウザ専用であることを伝える。
KaTeX数学レンダリング
KaTeXは更新するコンポーネント内でレンダリングするまでうまく機能する。リアクティブな問題文(例えば、非同期API呼び出しからロード)は、注意しないとDOMを生のLaTeX文字列で置き換えてしまう。
解決策はすべての更新サイクル後にrenderMathInElementを実行するVueディレクティブ。
throwOnError: falseは重要だ——問題文の不正な式はフォールバックを表示すべきで、レンダラーをクラッシュさせるべきではない。
AI支援開発
反復的なページスキャフォールディングの大部分——リストページ、詳細ページ、CRUD管理パネル——はAIコーディングエージェントで生成された。アーキテクチャを最初に設計し(コンポーザブル、ストアパターン、コンポーネント規約)、その後エージェントにパターンを与えてそれに準拠するページを生成するタスクを与えた。
これは高ボリューム、低バリエーションの作業にうまく機能した。おそらく2〜3週間のコピー&ペーストコーディングを節約した。興味深い問題——コンポーザブル設計、認証フロー、エディタ統合——はまだ人間の注意が必要だった。
Playwrightが見つけたもの
基本的なページが動作するようになったら、完全なユーザージャーニーを自動化するPlaywright E2Eテストを追加した:登録、ログイン、問題閲覧、コード提出、結果確認。手動テストをすり抜けた4つのバグが浮上した。
バグ1:Naive UIコンポーネント登録
NuxtでのNaive UIはオートインポートプラグイン(unplugin-vue-components)を介して機能する。デフォルトでは、テンプレートで<n-xxx>タグをスキャンし、対応するコンポーネントをオートインポートする。
問題:プラグインがインストールされていなかった。コンポーネントは粗い手段としてapp.vueでグローバルにインポートされていた。これは明示的にリストされたコンポーネントには機能したが、遅延ロードされたページで使用される任意のコンポーネントには静かに失敗した。Playwrightの提出フォームテストで、言語セレクタで使用されるNSelectが欠けていることがわかった。コンソールエラーなし;空のdivとしてレンダリングされた。
修正:Naive UIリゾルバー付きのunplugin-vue-componentsを追加。
バグ2:NuxtLayoutが非同期ページをラップしない
非同期データに依存するページにフラッシュがあった:最初のロードで、レイアウト(ナビバー、サイドバー)がレンダリングされ、消え、ページのuseAsyncDataが解決した後に再出現した。
原因:Nuxt 4では、<NuxtLayout>はapp.vueで<NuxtPage>をラップする必要があるが、レイアウトコンポーネントが内部で<Suspense>を使用し、ページが非同期の場合、待機中にレイアウトがアンマウントする可能性がある。レイアウトをページレベルで定義していた(definePageMeta({ layout: 'dashboard' }))が、これは非同期ページとはアプリレベルでのラッピングとは異なる方法で相互作用する。
修正:<NuxtLayout>をapp.vueに移動し、個々のページからレイアウト定義を削除。
バグ3:imports.dirsがネストされたコンポーザブルをカバーしない
Nuxtのオートインポートはデフォルトでcomposables/をカバーするが、1レベル深くだけ。APIコンポーザブルはcomposables/api/にあり、スキャンされなかった。
Playwrightの問題リストページのテストがランタイムエラーを投げた:useProblemApi is not defined。開発では機能した(ViteのHMRはより寛容に物事をホットパッチする)が、ビルドされた出力では失敗した。
修正:nuxt.config.tsにimports.dirsを追加:
export default defineNuxtConfig({
imports: {
dirs: ['composables', 'composables/api', 'composables/utils']
}
})バグ4:axios res.data二重アンラップ
useRequestコンポーザブルはaxiosからresponse.dataを返した——正しい。しかしリファクタリングのどこかで、APIコンポーザブルもreturn response.dataをしていた。バックエンドが{ data: ... }エンベロープでレスポンスをラップした場合、最終値はresponse.data.dataだった。
バグは開発では見えなかった。UIを見ていて、生のオブジェクトではなかった。Playwrightのsubmission.status === 'AC'アサーションが失敗した。submissionは実際には{ data: { status: 'AC' } }だった。
修正:二重アンラップを削除——.data抽出はuseRequestで1回、個々のAPI関数ではゼロ。
振り返り
書き換えはターゲットを絞ったリファクタリングより時間がかかったが、1つずつパッチするのではなくバグのカテゴリ全体を排除した。Playwrightスイートは今やすべてのプッシュで実行され、本番に届く前に回帰を捕捉する。
違うやり方をするいくつかのこと:
- もっと早くPlaywrightをセットアップする。 ページが構築された後にテストを追加した。開発中に実行していれば、コンポーネント登録とレイアウトのバグをすぐに捕捉できただろう。
- コンポーザブルディレクトリ構造を最初に定義する。
imports.dirsの問題は開始時の5分の設定で完全に回避可能だった。
スタック——Nuxt 4 SPA + Naive UI + CodeMirror 6 + KaTeX——はうまく機能した。選択に後悔はなく、もっと早くテストしなかったことだけ。
