jointrashposs/content/blog/2023-12-19-mac202319.md
2023-12-19 22:21:55 +09:00

19 KiB
Raw Blame History

date
2023-12-19

Deep dive on the linkage between Misskey and Vue.js

:::tip これは Misskey Advent Calendar 202319日目の記事です. :::

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

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

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

Misskeyのフロントエンド構造

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

::X__Blog__2023-12-19-mac202319__Figure1 ::

Misskeyのフロントエンド構造

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

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

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

  • JavaScriptCSSの成果物サイズが小さくなると, ユーザーエージェントがそれらを解析し, 実行する際のコストが削減されます.
    • 特に,JavaScriptは多くの場合,Webブラウザのメインスレッドで解析および実行されるため, 同程度のバイナリサイズで構成される画像ファイルなどと比較して処理にかかる負荷が非常に高く, その負荷を削減することは重要です.
    • また,JavaScriptCSSの成果物サイズが小さいということは, 多くの場合, それがシンプルであることを意味します. シンプルなコードは, 多くの場合, 軽快でパフォーマンスが高いといえます. つまり, コードサイズの削減は, パフォーマンスの観点からみても理にかなっています.
  • フロントエンドアセットのサイズが小さくなると, 当然ながら, ユーザーエージェントにそれらを配信する際の通信量が削減されます.
    • 高速通信技術が発展した現代においても, ユーザーが常にその恩恵を享受できる環境にあるとは限りません. 人と人のコミュニケーションを確立するアプリケーションとして, 不安定な通信環境においても, 快適性を可能な限り向上させるよう努めることは重要です.
    • アセットのサイズが小さくなると,より多くのアセットを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には数百のコンポーネントがありますから, スタイルデータはそれなりの量があります. そのため, スタイルがどのように管理され, 配信されるかは, 配信戦略において重要な要素の一つになります.

さて,HTMLWebブラウザにスタイルを提供する方法は,大まかに分けて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ブラウザの実装の話などが大きく絡むので, ここでは割愛します.

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

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

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

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

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

:::fukidashi{chara="syuilo" charaName="しゅいろ"} えー、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.jsComposition APIでは,useCssModule() を呼び出すことで, バインド用のマップ全体を取得することを許容しています. このような経緯で, 成果物にマップがそのまま含まれているのです. 逆に, それらの機能を一切使わないのであれば, 完全にそれらは無駄になっているといえます. 完全に無駄なものは安全に除去できるはずです. そこで,Misskeyでは,$style 配下を識別子のメンバーアクセスによる参照のみを認めるルールで運用することを前提に,静的置換を行うRollupプラグインを開発および使用することで, 成果物からマップを除去するようにしました. これにより,バンドルサイズの3% 程度の削減につながりました.

:::tip 詳細は #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の開発では今後も表層的な枠組みに囚われず, 野心的に様々なものを活用し, より良いユーザー体験に貢献できるよう努めていきたいと思っています.