jointrashposs/content/blog/2023-12-19-mac202319.md

239 lines
19 KiB
Markdown
Raw Normal View History

2023-12-19 14:21:55 +01:00
---
date: 2023-12-19
---
# Deep dive on the linkage between Misskey and Vue.js
:::tip
これは [Misskey Advent Calendar 2023](https://adventar.org/calendars/8742) 19 日目の記事です.
:::
こんにちは, コアチームメンバーの [acid-chicken](https://github.com/acid-chicken) です. Misskey の開発には nighthike v4 あたりから参加しており, 現在は本業の傍ら, 余暇にリファクタリングやコードレビューなどをやっていることが多いです.
Misskey では [2018 年からフロントエンドの UI フレームワークに Vue.js を採用し](https://github.com/misskey-dev/misskey/pull/1116)ており, メジャーアップデートのマイグレーションなどを経て, 現在も継続して使用しています. 今回は, Misskey のフロントエンド構造について, Vue.js の機能との接点を中心に深掘りしていきます.
:::tip
大まかな解説は既に syuilo 連載[「Misskey & Webテクロジー最前線」9月](/blog/2023-09-11-gihyo)などで触れられています. 一方で, 本記事では連載で触れないような, 細かい部分に焦点を絞った話題を扱うため, もしかすると読んでいてつまらない内容になっているかもしれません. 予めご了承ください.
:::
## Misskey のフロントエンド構造
現在 (nasubi 開始時点)  Misskey は, 以下のようなレイヤー構造の構成によってフロントエンドを描画しています.
<figure>
::X__Blog__2023-12-19-mac202319__Figure1
::
<figcaption class="pt-4 text-center">
Misskey&emsp14;のフロントエンド構造
</figcaption>
</figure>
コードベースでは,&ensp;図における上部のレイヤーと下部のレイヤーが分かれており,&ensp;(少なくとも便宜上は)&emsp14;前者をフロントエンド,&ensp;後者をバックエンドと呼んでいます.&ensp;ビルド時に,&ensp;フロントエンドは&emsp14;Vite&emsp14;によってバンドルされ,&ensp;その成果物はバックエンドのアセットとして配置されます.&ensp;バックエンドは,&ensp;ユーザーエージェント&emsp14;(多くの場合,&ensp;Web&emsp14;ブラウザ)&emsp14;からのリクエストに対して適切な&emsp14;HTML&emsp14;を構築し,&ensp;それにアセットを参照させることで,&ensp;フロントエンドを描画します.
フロントエンドにおいては,&ensp;参照するサードパーティライブラリを必要最低限に抑えることで,&ensp;コードベースをより統一的な管理下に置き,&ensp;Misskey&emsp14;の開発指針やデザインテーマが実効性を伴いやすくなっています.&ensp;結果,&ensp;フロントエンドは&emsp14;Vue.js&emsp14;ランタイム,&ensp;数百からなるコンポーネントと,&ensp;ルーター&emsp14;(nirax)&emsp14;やストア&emsp14;(pizzax)&emsp14;といったアプリケーションを管理するためのシステム,&ensp;そしていくつかの内製&emsp14;(browser-image-resizer, buraha, etc.)&emsp14;および外製&emsp14;(Chart.js, PhotoSwipe, etc.)&emsp14;サードパーティライブラリの組み合わせで構成されています.
Vite&emsp14;が生成する&emsp14;Misskey&emsp14;のフロントエンドアセットは,&ensp;全体を合計すると,&ensp;Blotli&emsp14;圧縮後のサイズでおよそ&emsp14;1.4&emsp14;MB&emsp14;にのぼります.&ensp;このサイズが小さくなるよう努めることは,&ensp;アプリケーションを提供するうえで重要な要素です.
- JavaScript&emsp14;&emsp14;CSS&emsp14;の成果物サイズが小さくなると,&ensp;ユーザーエージェントがそれらを解析し,&ensp;実行する際のコストが削減されます.
- 特に,&ensp;JavaScript&emsp14;は多くの場合,&ensp;Web&emsp14;ブラウザのメインスレッドで解析および実行されるため,&ensp;同程度のバイナリサイズで構成される画像ファイルなどと比較して処理にかかる負荷が非常に高く,&ensp;その負荷を削減することは重要です.
- また,&ensp;JavaScript&emsp14;&emsp14;CSS&emsp14;の成果物サイズが小さいということは,&ensp;多くの場合,&ensp;それがシンプルであることを意味します.&ensp;シンプルなコードは,&ensp;多くの場合,&ensp;軽快でパフォーマンスが高いといえます.&ensp;つまり,&ensp;コードサイズの削減は,&ensp;パフォーマンスの観点からみても理にかなっています.
- フロントエンドアセットのサイズが小さくなると,&ensp;当然ながら,&ensp;ユーザーエージェントにそれらを配信する際の通信量が削減されます.
- 高速通信技術が発展した現代においても,&ensp;ユーザーが常にその恩恵を享受できる環境にあるとは限りません.&ensp;人と人のコミュニケーションを確立するアプリケーションとして,&ensp;不安定な通信環境においても,&ensp;快適性を可能な限り向上させるよう努めることは重要です.
- アセットのサイズが小さくなると,&ensp;より多くのアセットを&emsp14;CDN&emsp14;のキャッシュに蓄積させることができます.&ensp;その結果,&ensp;アセットのキャッシュヒット率が向上するので,&ensp;ユーザーエージェントがアセットの取得に要する時間は,&ensp;削減されたアセットのバイナリサイズ分以上に短縮されることが期待できます.
- 例えば,&ensp;多くのサーバーが利用している&emsp14;Cloudflare&emsp14;では,&ensp;同一ドメイン上でドライブファイルなどを配信すると,&ensp;エッジキャッシュのバジェットがそれらと取り合いになります.&ensp;これによってキャッシュヒット率の低下を招くと,&ensp;逆に&emsp14;Misskey&emsp14;の通信コストが非線形に増加する可能性を見積もれます.
先にも述べたように,&ensp;フロントエンドのコードベースはその多くを数多の&emsp14;Vue.js&emsp14;コンポーネントで占めているわけですから,&ensp;Vue.js&emsp14;を効率的に活用することは,&ensp;フロントエンドのアセットサイズ削減に直結し,&ensp;ひいてはユーザー体験の向上につながるといえます.
## Misskey&emsp14;における&emsp14;Vue.js&emsp14;の使用方法
Vue.js&emsp14;は,&ensp;世界で最も人気のある&emsp14;UI&emsp14;フレームワークの一つです.&ensp;人気とは,&emsp14;一朝一夕に獲得できるものではありません.&ensp;Vue.js&emsp14;にはモダンフレームワークなりの歴史があり,&ensp;そして,&ensp;多種多様なフロントエンドの需要に応えるために,&ensp;様々な機能を提供して成長してきました.&ensp;もっとも,&ensp;ここまで読み進めている方の多くは,&ensp;そんなことは百も承知かもしれませんが,&ensp;とにもかくにも,&ensp;Vue.js&emsp14;の使い方は様々な形態があり,&ensp;ユースケースに合わせて適切な使い方を選択することが重要です.&ensp;とはいえ,&ensp;その内の&emsp14;SFC&emsp14;を使用するか否か&emsp14;(使用しています)&emsp14;や,&ensp;TypeScript&emsp14;を使用するか否か&emsp14;(使用しています),&ensp;および&emsp14;Composition API&emsp14;を使用するか否か&emsp14;(使用しています)&emsp14;については,&ensp;先述の&emsp14;syuilo&emsp14;連載[「Misskey & Webテクロジー最前線」9月](/blog/2023-09-11-gihyo)以上に掘り下げることが多くないので,&ensp;ここでは割愛します.
代わりに,&ensp;コンポーネントのスタイル連繫について見ていきましょう.&ensp;Rich Web UI&emsp14;を謳う&emsp14;Misskey&emsp14;は,&ensp;個々のコンポーネントに細かくスタイルをつけています.&ensp;先述の通り,&ensp;Misskey&emsp14;には数百のコンポーネントがありますから,&ensp;スタイルデータはそれなりの量があります.&ensp;そのため,&ensp;スタイルがどのように管理され,&ensp;配信されるかは,&ensp;配信戦略において重要な要素の一つになります.
さて,&ensp;HTML&emsp14;&emsp14;Web&emsp14;ブラウザにスタイルを提供する方法は,&ensp;大まかに分けて&emsp14;3&emsp14;つあります.
<figure>
```html
<div style="color: red;">Hello, world!</div>
```
<figcaption class="text-center">
スタイル属性
</figcaption>
</figure>
<figure>
```html
<style>
.red {
color: red;
}
</style>
<div class="red">Hello, world!</div>
```
<figcaption class="text-center">
スタイル要素
</figcaption>
</figure>
<figure>
```html
<link rel="stylesheet" href="style.css">
<div class="red">Hello, world!</div>
```
```css
.red {
color: red;
}
```
<figcaption class="text-center">
スタイルシート
</figcaption>
</figure>
このうち,&ensp;最後のスタイルシートによるスタイル連繫は,&ensp;コンポーネントのロジック部分とスタイル部分が分離されることで,&ensp;それぞれのライフタイムの長寿化を期待することができるため,&ensp;プロダクションにおいては望ましい形式といえます.&ensp;スタイルシートのスタイルルールは,&ensp;セレクタを記述して,&ensp;条件に合致する要素にスタイルを適用するよう&emsp14;Web&emsp14;ブラウザに指示します.&ensp;セレクタは大局的なものから局所的なものまで多種多様な指定が可能ですが,&ensp;コンポーネントのパーツに細かくスタイルをつけていくという状況においては,&ensp;そのほとんどは局所的かつ単純なものになります.&ensp;なお,&ensp;再利用性を担保してなるべくシンプルにセレクタを記述する方法は,&ensp;単一のクラス名を指定するのが,&ensp;もっともパフォーマンスが高いとされています.&ensp;この理由をきちんと説明するには,&ensp;Web&emsp14;ブラウザの実装の話などが大きく絡むので,&ensp;ここでは割愛します.
Misskey&emsp14;&emsp14;Vue.js&emsp14;に話を戻すと,&ensp;SFC&emsp14;にはスタイルシートを直接記述できる機能が備わっています.&ensp;この機能を使用して&emsp14;SFC&emsp14;にスタイルを直接記述すると,&ensp;<code>vue/compiler-sfc</code>&emsp14;によってスタイルシートが抽出され,&ensp;<code>@vitejs/plugin-vue</code>&emsp14;によって仮想モジュールとして&emsp14;Vite&emsp14;に参照されるようになり,&ensp;最終的に&emsp14;Vite&emsp14;がそれらをバンドルします.&ensp;このおかげで,&ensp;成果物として適切な様態で&emsp14;CSS&emsp14;が配信されることを保証しながら,&ensp;一方で開発体験としてはコンポーネントごとに関心を寄せてスタイルを記述できるようになります.
さて,&ensp;個々のコンポーネントが自由にスタイルを記述し,&ensp;それを統合した場合,&ensp;実際にはそれらのルールが意図せず他のコンポーネントに影響を及ぼしたりする問題が予想されます.&ensp;SFC&emsp14;の機能には,&ensp;この問題を避けるため,&ensp;スタイルをコンポーネントのスコープに閉じ込めるよう指示できるものがあります.&ensp;スコープ付き&emsp14;CSS&emsp14;は,&ensp;ビルド時にコンポーネント毎に一意の識別子を生成し,&ensp;コンポーネント内の要素にそれを属性として割り当て,&ensp;スタイルシートのセレクタにも書き足すことで,&ensp;ユーザーのコード変更なしにスタイルをスコープに分離することができます.&ensp;SFC&emsp14;のタグに属性を足すだけでドロップインに使用できる手軽さから,&ensp;多くの&emsp14;Vue.js&emsp14;ユーザーに使用され,&ensp;Misskey&emsp14;もかつて主方針として使用していました.&ensp;しかしその実,&ensp;[スコープは完全ではなく](https://github.com/vuejs/vue-loader/issues/957),&ensp;また,&ensp;セレクタが肥大化してしまう問題も孕んでいました.
より踏み入った代替策として,&ensp;SFC&emsp14;では&emsp14;CSS&emsp14;モジュールを使用することができます.&ensp;これは,&ensp;ビルド時にセレクタのクラス名を機械的に再構成し,&ensp;そのバインドを&emsp14;JavaScript&emsp14;で参照できるようにするものです.&ensp;コンポーネントにおけるテンプレート内のクラス名は直接指定ではなくバインドされるフィールドへの識別子に置き換える必要があるので,&ensp;コンポーネントのリファクタリングが必要ですが,&ensp;スタイル連繋における課題点は概ね払拭されます.&ensp;現在の&emsp14;Misskey&emsp14;では,&ensp;ほとんどのコンポーネントが&emsp14;CSS&emsp14;モジュールを使用しています.
## CSS&emsp14;モジュール注入の最適化
Misskey&emsp14;&emsp14;CSS&emsp14;モジュールを使うようになった後のある日,&ensp;syuilo&emsp14;は言いました.
:::fukidashi{chara="syuilo" charaName="しゅいろ"}
えー、CSS Modulesってminifyしてくれにゃいんだ
![](https://s3.arkjp.net/misskey/1b008643-4932-40d8-980b-fe3da75db856.png)
:::
&mdash; https://misskey.io/notes/9fd9w06qah
このノートには,&ensp;CSS&emsp14;モジュールのクラス名バインド用マップが成果物に丸々含まれていることを憂う気持ちが込められています.&ensp;例えば,&ensp;次のような&emsp14;SFC&emsp14;があったとします.
<figure>
```vue
<template>
<div :class="$style.redColoredText">Hello, world!</div>
</template>
<style module>
.redColoredText {
color: red;
}
</style>
```
<figcaption class="text-center">
赤色で挨拶文を表示するコンポーネント
</figcaption>
</figure>
このコンポーネントは次のように変換されて欲しいです.
<figure>
```jsx
export const HelloWorld = defineComponent({
setup() {
return () => jsx( // 実際にはより具象的なコードになる
<div class="r3a9t">Hello, world!</div>
);
},
});
```
```css
.r3a9t {
color: red;
}
```
<figcaption class="text-center">
理想的な変換後のイメージ
</figcaption>
</figure>
しかし,&ensp;実際には,&ensp;次のように変換されてしまいます.
<figure>
```jsx
export const HelloWorld = defineComponent({
setup() {
return (_ctx) => jsx( // 実際にはより具象的なコードになる
<div class={_ctx.$style.redColoredText}>Hello, world!</div>
);
},
});
HelloWorld.__cssModules = {
$style: {
redColoredText: "r3a9t",
},
};
```
```css
.r3a9t {
color: red;
}
```
<figcaption class="text-center">
実際の変換後のイメージ
</figcaption>
</figure>
このようなことになってしまうのは,&ensp;バインドの参照を常に静的に置換できるとは限らないためです.&ensp;例えば,&ensp;<code>$style.redColoredText</code>&emsp14;のような参照は静的に置換できても,&ensp;<code>$style\[color + "ColoredText"\]</code>&emsp14;のような参照はビルド時に&emsp14;<code>color</code>&emsp14;の値が定まるとは保証できないので,&ensp;静的に置換できません.&ensp;また,&ensp;Vue.js&emsp14;&emsp14;Composition API&emsp14;では,&ensp;<code>useCssModule()</code>&emsp14;を呼び出すことで,&ensp;バインド用のマップ全体を取得することを許容しています.&ensp;このような経緯で,&emsp14;成果物にマップがそのまま含まれているのです.&ensp;逆に,&ensp;それらの機能を一切使わないのであれば,&ensp;完全にそれらは無駄になっているといえます.&ensp;完全に無駄なものは安全に除去できるはずです.&ensp;そこで,&ensp;Misskey&emsp14;では,&ensp;<code>$style</code>&emsp14;配下を識別子のメンバーアクセスによる参照のみを認めるルールで運用することを前提に,&ensp;静的置換を行う&emsp14;Rollup&emsp14;プラグインを開発および使用することで,&ensp;成果物からマップを除去するようにしました.&ensp;これにより,&ensp;バンドルサイズの&emsp14;3%&emsp14;程度の削減につながりました.
:::tip
詳細は&emsp14;[#10923](https://github.com/misskey-dev/misskey/issues/10923)&emsp14;を参照してください.
:::
## 今後の展望
現在まだ取り組まれていない最適化として,&ensp;ルーティングの静的化を検討しています.&ensp;記事の最初の方に提示した図を見ると&emsp14;Router&emsp14;がレイヤーの中でも上部にあることがわかります.&ensp;そのため,&ensp;Page Components&emsp14;の読み込みはページが読み込まれてしばらくしてから始まります.&ensp;しかし,&ensp;どのルートがどのページを表示するかはビルド時にほぼ決定できると言って差し支えありません.&ensp;この情報を静的に管理してバックエンドに連繫することで,&ensp;バックエンドはより早いタイミングでユーザーエージェントに必要なアセットを知らせることができるので,&ensp;ユーザー体験の向上を見積もることができます.
ここで,&ensp;SFC&emsp14;の機能を利用して,
```vue
<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>
```
といったようにページコンポーネントに直接ルーティング情報を記述できれば,&ensp;ビルドの際ルーティング情報を抽出して静的に集約でき,&ensp;ついでに&emsp14;path props&emsp14;も同一ファイル内で管理でき,&ensp;保守性の向上にもつながります.
あくまでも構想かつ一例にすぎませんが,&ensp;このようにコンパイラの機能を使用するなどして,&ensp;Misskey&emsp14;の開発では今後も表層的な枠組みに囚われず,&ensp;野心的に様々なものを活用し,&ensp;より良いユーザー体験に貢献できるよう努めていきたいと思っています.