戻る

ブログ

Deep dive on the linkage between Misskey and Vue.js

2023/12/19

Deep dive on the linkage between Misskey and Vue.js

ヒント

これは Misskey Advent Calendar 2023 19 日目の記事です.

こんにちは, コアチームメンバーの acid-chicken です. Misskey の開発には nighthike v4 あたりから参加しており, 現在は本業の傍ら, 余暇にリファクタリングやコードレビューなどをやっていることが多いです.

Misskey では 2018 年からフロントエンドの UI フレームワークに Vue.js を採用しており, メジャーアップデートのマイグレーションなどを経て, 現在も継続して使用しています. 今回は, Misskey のフロントエンド構造について, Vue.js の機能との接点を中心に深掘りしていきます.

ヒント

大まかな解説は既に syuilo 連載「Misskey & Webテクノロジー最前線」9月などで触れられています. 一方で, 本記事では連載で触れないような, 細かい部分に焦点を絞った話題を扱うため, もしかすると読んでいてつまらない内容になっているかもしれません. 予めご了承ください.

Misskey のフロントエンド構造

現在 (nasubi 開始時点) の Misskey は, 以下のようなレイヤー構造の構成によってフロントエンドを描画しています.

Child Components
Chart.js, etc.
Page Components
Router
OS
Global Components
UI Components
BOOT
Vue.js
frontend
backend
Fastify
Pug
BOOT LOADER
NestJS
Node.js
1st Party
3rd Party
Misskey のフロントエンド構造

コードベースでは, 図における上部のレイヤーと下部のレイヤーが分かれており, (少なくとも便宜上は) 前者をフロントエンド, 後者をバックエンドと呼んでいます. ビルド時に, フロントエンドは Vite によってバンドルされ, その成果物はバックエンドのアセットとして配置されます. バックエンドは, ユーザーエージェント (多くの場合, Web ブラウザ) からのリクエストに対して適切な HTML を構築し, それにアセットを参照させることで, フロントエンドを描画します.

フロントエンドにおいては, 参照するサードパーティライブラリを必要最低限に抑えることで, コードベースをより統一的な管理下に置き, Misskey の開発指針やデザインテーマが実効性を伴いやすくなっています. 結果, フロントエンドは Vue.js ランタイム, 数百からなるコンポーネントと, ルーター (nirax) やストア (pizzax) といったアプリケーションを管理するためのシステム, そしていくつかの内製 (browser-image-resizer, buraha, etc.) および外製 (Chart.js, PhotoSwipe, etc.) サードパーティライブラリの組み合わせで構成されています.

Vite が生成する Misskey のフロントエンドアセットは, 全体を合計すると, Blotli 圧縮後のサイズでおよそ 1.4 MB にのぼります. このサイズが小さくなるよう努めることは, アプリケーションを提供するうえで重要な要素です.

  • JavaScript や CSS の成果物サイズが小さくなると, ユーザーエージェントがそれらを解析し, 実行する際のコストが削減されます.
    • 特に, JavaScript は多くの場合, Web ブラウザのメインスレッドで解析および実行されるため, 同程度のバイナリサイズで構成される画像ファイルなどと比較して処理にかかる負荷が非常に高く, その負荷を削減することは重要です.
    • また, JavaScript や CSS の成果物サイズが小さいということは, 多くの場合, それがシンプルであることを意味します. シンプルなコードは, 多くの場合, 軽快でパフォーマンスが高いといえます. つまり, コードサイズの削減は, パフォーマンスの観点からみても理にかなっています.
  • フロントエンドアセットのサイズが小さくなると, 当然ながら, ユーザーエージェントにそれらを配信する際の通信量が削減されます.
    • 高速通信技術が発展した現代においても, ユーザーが常にその恩恵を享受できる環境にあるとは限りません. 人と人のコミュニケーションを確立するアプリケーションとして, 不安定な通信環境においても, 快適性を可能な限り向上させるよう努めることは重要です.
    • アセットのサイズが小さくなると, より多くのアセットを CDN のキャッシュに蓄積させることができます. その結果, アセットのキャッシュヒット率が向上するので, ユーザーエージェントがアセットの取得に要する時間は, 削減されたアセットのバイナリサイズ分以上に短縮されることが期待できます.
      • 例えば, 多くのサーバーが利用している Cloudflare では, 同一ドメイン上でドライブファイルなどを配信すると, エッジキャッシュのバジェットがそれらと取り合いになります. これによってキャッシュヒット率の低下を招くと, 逆に Misskey の通信コストが非線形に増加する可能性を見積もれます.

先にも述べたように, フロントエンドのコードベースはその多くを数多の Vue.js コンポーネントで占めているわけですから, Vue.js を効率的に活用することは, フロントエンドのアセットサイズ削減に直結し, ひいてはユーザー体験の向上につながるといえます.

Misskey における Vue.js の使用方法

Vue.js は, 世界で最も人気のある UI フレームワークの一つです. 人気とは, 一朝一夕に獲得できるものではありません. Vue.js にはモダンフレームワークなりの歴史があり, そして, 多種多様なフロントエンドの需要に応えるために, 様々な機能を提供して成長してきました. もっとも, ここまで読み進めている方の多くは, そんなことは百も承知かもしれませんが, とにもかくにも, Vue.js の使い方は様々な形態があり, ユースケースに合わせて適切な使い方を選択することが重要です. とはいえ, その内の SFC を使用するか否か (使用しています) や, TypeScript を使用するか否か (使用しています), および Composition API を使用するか否か (使用しています) については, 先述の syuilo 連載「Misskey & Webテクノロジー最前線」9月以上に掘り下げることが多くないので, ここでは割愛します.

代わりに, コンポーネントのスタイル連繫について見ていきましょう. Rich Web UI を謳う Misskey は, 個々のコンポーネントに細かくスタイルをつけています. 先述の通り, Misskey には数百のコンポーネントがありますから, スタイルデータはそれなりの量があります. そのため, スタイルがどのように管理され, 配信されるかは, 配信戦略において重要な要素の一つになります.

さて, HTML で Web ブラウザにスタイルを提供する方法は, 大まかに分けて 3 つあります.

<div style="color: red;">Hello, world!</div>
スタイル属性
<style>
.red {
  color: red;
}
</style>
<div class="red">Hello, world!</div>
スタイル要素
<link rel="stylesheet" href="style.css">
<div class="red">Hello, world!</div>
.red {
  color: red;
}
スタイルシート

このうち, 最後のスタイルシートによるスタイル連繫は, コンポーネントのロジック部分とスタイル部分が分離されることで, それぞれのライフタイムの長寿化を期待することができるため, プロダクションにおいては望ましい形式といえます. スタイルシートのスタイルルールは, セレクタを記述して, 条件に合致する要素にスタイルを適用するよう Web ブラウザに指示します. セレクタは大局的なものから局所的なものまで多種多様な指定が可能ですが, コンポーネントのパーツに細かくスタイルをつけていくという状況においては, そのほとんどは局所的かつ単純なものになります. なお, 再利用性を担保してなるべくシンプルにセレクタを記述する方法は, 単一のクラス名を指定するのが, もっともパフォーマンスが高いとされています. この理由をきちんと説明するには, Web ブラウザの実装の話などが大きく絡むので, ここでは割愛します.

Misskey と Vue.js に話を戻すと, SFC にはスタイルシートを直接記述できる機能が備わっています. この機能を使用して SFC にスタイルを直接記述すると, vue/compiler-sfc によってスタイルシートが抽出され, @vitejs/plugin-vue によって仮想モジュールとして Vite に参照されるようになり, 最終的に Vite がそれらをバンドルします. このおかげで, 成果物として適切な様態で CSS が配信されることを保証しながら, 一方で開発体験としてはコンポーネントごとに関心を寄せてスタイルを記述できるようになります.

さて, 個々のコンポーネントが自由にスタイルを記述し, それを統合した場合, 実際にはそれらのルールが意図せず他のコンポーネントに影響を及ぼしたりする問題が予想されます. SFC の機能には, この問題を避けるため, スタイルをコンポーネントのスコープに閉じ込めるよう指示できるものがあります. スコープ付き CSS は, ビルド時にコンポーネント毎に一意の識別子を生成し, コンポーネント内の要素にそれを属性として割り当て, スタイルシートのセレクタにも書き足すことで, ユーザーのコード変更なしにスタイルをスコープに分離することができます. SFC のタグに属性を足すだけでドロップインに使用できる手軽さから, 多くの Vue.js ユーザーに使用され, Misskey もかつて主方針として使用していました. しかしその実, スコープは完全ではなく, また, セレクタが肥大化してしまう問題も孕んでいました.

より踏み入った代替策として, SFC では CSS モジュールを使用することができます. これは, ビルド時にセレクタのクラス名を機械的に再構成し, そのバインドを JavaScript で参照できるようにするものです. コンポーネントにおけるテンプレート内のクラス名は直接指定ではなくバインドされるフィールドへの識別子に置き換える必要があるので, コンポーネントのリファクタリングが必要ですが, スタイル連繋における課題点は概ね払拭されます. 現在の Misskey では, ほとんどのコンポーネントが CSS モジュールを使用しています.

CSS モジュール注入の最適化

Misskey が CSS モジュールを使うようになった後のある日, syuilo は言いました.

しゅいろ

えー、CSS Modulesってminifyしてくれにゃいんだ

https://misskey.io/notes/9fd9w06qah

このノートには, CSS モジュールのクラス名バインド用マップが成果物に丸々含まれていることを憂う気持ちが込められています. 例えば, 次のような SFC があったとします.

<template>
  <div :class="$style.redColoredText">Hello, world!</div>
</template>

<style module>
.redColoredText {
  color: red;
}
</style>
赤色で挨拶文を表示するコンポーネント

このコンポーネントは次のように変換されて欲しいです.

export const HelloWorld = defineComponent({
  setup() {
    return () => jsx( // 実際にはより具象的なコードになる
      <div class="r3a9t">Hello, world!</div>
    );
  },
});
.r3a9t {
  color: red;
}
理想的な変換後のイメージ

しかし, 実際には, 次のように変換されてしまいます.

export const HelloWorld = defineComponent({
  setup() {
    return (_ctx) => jsx( // 実際にはより具象的なコードになる
      <div class={_ctx.$style.redColoredText}>Hello, world!</div>
    );
  },
});

HelloWorld.__cssModules = {
  $style: {
    redColoredText: "r3a9t",
  },
};
.r3a9t {
  color: red;
}
実際の変換後のイメージ

このようなことになってしまうのは, バインドの参照を常に静的に置換できるとは限らないためです. 例えば, $style.redColoredText のような参照は静的に置換できても, $style[color + "ColoredText"] のような参照はビルド時に color の値が定まるとは保証できないので, 静的に置換できません. また, Vue.js の Composition API では, useCssModule() を呼び出すことで, バインド用のマップ全体を取得することを許容しています. このような経緯で, 成果物にマップがそのまま含まれているのです. 逆に, それらの機能を一切使わないのであれば, 完全にそれらは無駄になっているといえます. 完全に無駄なものは安全に除去できるはずです. そこで, Misskey では, $style 配下を識別子のメンバーアクセスによる参照のみを認めるルールで運用することを前提に, 静的置換を行う Rollup プラグインを開発および使用することで, 成果物からマップを除去するようにしました. これにより, バンドルサイズの 3% 程度の削減につながりました.

ヒント

詳細は #10923 を参照してください.

今後の展望

現在まだ取り組まれていない最適化として, ルーティングの静的化を検討しています. 記事の最初の方に提示した図を見ると Router がレイヤーの中でも上部にあることがわかります. そのため, Page Components の読み込みはページが読み込まれてしばらくしてから始まります. しかし, どのルートがどのページを表示するかはビルド時にほぼ決定できると言って差し支えありません. この情報を静的に管理してバックエンドに連繫することで, バックエンドはより早いタイミングでユーザーエージェントに必要なアセットを知らせることができるので, ユーザー体験の向上を見積もることができます.

ここで, SFC の機能を利用して,

<template>
  <MkNoteDetailed v-model:note="note" />
</template>

<script setup lang="ts">
import type { Note } from 'misskey-js';
import { defineProps, ref, watch } from 'vue';

const props = defineProps<{
  noteId: string;
}>();
const note = ref<Note | null>(null);

watch(() => props.noteId, async () => {
  note.value = await os.api('notes/show', { noteId: props.noteId });
}, { immediate: true });
</script>

<route lang="yaml">
name: note
path: /notes/:noteId
</route>

といったようにページコンポーネントに直接ルーティング情報を記述できれば, ビルドの際ルーティング情報を抽出して静的に集約でき, ついでに path props も同一ファイル内で管理でき, 保守性の向上にもつながります.

あくまでも構想かつ一例にすぎませんが, このようにコンパイラの機能を使用するなどして, Misskey の開発では今後も表層的な枠組みに囚われず, 野心的に様々なものを活用し, より良いユーザー体験に貢献できるよう努めていきたいと思っています.