この記事のポイント
- 小さな React のアイランドを
.astro+<script>に書き直すと、記事のページの JS が 119KB → 5KB(gzip 後)になった - 一部の記事でしか使わない CSS(KaTeX)は、
?urlで別ファイルにして、使う記事でだけ読み込む - 画像は
<Picture>で AVIF / WebP に変換し、一覧の画像は遅延読み込みにする - フォントは Fonts API で、使う文字の範囲のファイルだけを登録して preload する
小さな React のアイランドをやめる
目次、テーマの切り替え、ナビゲーションのように、状態が少なく HTML の構造が変わらない部品は、React でなくても作れます。<script> の中で Custom Element にすると、同じ部品がページに複数あっても、それぞれ初期化できます。
<theme-switcher>
<button data-theme="light">Light</button>
<button data-theme="dark">Dark</button>
</theme-switcher>
<script>
class ThemeSwitcher extends HTMLElement {
connectedCallback() {
for (const button of this.querySelectorAll<HTMLButtonElement>('[data-theme]')) {
button.addEventListener('click', () => {
document.documentElement.classList.toggle('dark', button.dataset.theme === 'dark');
});
}
}
}
customElements.define('theme-switcher', ThemeSwitcher);
</script>ドロップダウンは、HTML の popover 属性で作れます。開閉、外側のクリックや Esc キーで閉じる動きは、ブラウザがやってくれます。
<button popovertarget="toc">目次</button>
<div id="toc" popover>...</div>検索や絞り込みのような大きな部品は、React のままで構いません。本文の埋め込みなど、一部のページでしか使わない React の部品は、要素があるときだけ動的 import します。
if (document.querySelector('[data-embed]')) {
const { mountEmbeds } = await import('./react-embeds');
mountEmbeds();
}ページのスクリプトで react-dom/client を静的に import すると、使わないページでも React が読み込まれるので注意します。
一部の記事でしか使わない CSS を分ける
[slug].astro で import した CSS は、そのルートのすべての記事に入ります。build.inlineStylesheets: 'always' にしていると、記事を開くたびに HTML と一緒に届きます。
数式の CSS のように一部の記事でしか使わないものは、?url で別ファイルにして、必要な記事でだけ <link> で読み込みます。
---
import katexCssUrl from 'katex/dist/katex.min.css?url';
const { Content, remarkPluginFrontmatter } = await render(entry);
---
<Layout>
{remarkPluginFrontmatter.hasMath && <link slot="head" rel="stylesheet" href={katexCssUrl} />}
<Content />
</Layout><head> に入れるために、レイアウトには <slot name="head" /> を用意しておきます。
数式があるかどうかは、rehype のプラグインで調べて、frontmatter に印を付けます。ここで付けた値は、render() の remarkPluginFrontmatter で受け取れます。
import { EXIT, visit } from 'unist-util-visit';
export default function rehypeMathFlag() {
return (tree, file) => {
visit(tree, 'element', (node) => {
if (!node.properties?.className?.includes?.('katex')) return;
file.data.astro.frontmatter.hasMath = true;
return EXIT;
});
};
}このプラグインは、rehype のプラグインの最後に置きます。コードブロックを作り直すプラグインより前に置くと、コードの中の記号を数式と判定してしまうことがあります。
数式のない記事の HTML は、44.3KB → 36.5KB(gzip 後)になりました。
画像を必要な大きさにする
public/ の画像はそのまま配信されます。src/assets/ に置いて <Picture> で表示すると、ビルド時に表示サイズに縮め、AVIF と WebP に変換されます(sharp が必要)。
---
import { Picture } from 'astro:assets';
import avatar from '../assets/avatar.png';
---
<Picture src={avatar} formats={['avif', 'webp']} width={128} height={128} densities={[1, 2]} loading="eager" alt="" /><Picture> は既定で loading="lazy" なので、ページを開いてすぐ見える画像は eager にします。これで、1024px の PNG(215KB)が 2〜5KB になりました。
一覧に並ぶ画像には、loading="lazy" を付けます。記事一覧で最初に読み込む画像が、108 個から 4 個に減りました。
フォントを Fonts API で読み込む
Fontsource の CSS を import すると、使わない言語の分も含めて、文字の範囲ごとの @font-face がすべて入ります。また、フォントのファイルは、ブラウザが CSS を読んでから読み込み始めます。
Astro の Fonts API で、使う範囲のファイルだけを登録し、<Font> で preload します。fontProviders.local() で npm のパッケージのファイルを指定すれば、ビルドのときに外部に通信しません。
// astro.config.mjs
import { defineConfig, fontProviders } from 'astro/config';
export default defineConfig({
fonts: [
{
provider: fontProviders.local(),
name: 'Inter',
cssVariable: '--font-inter',
fallbacks: ['Hiragino Kaku Gothic ProN', 'sans-serif'],
optimizedFallbacks: false,
options: {
variants: [
{
src: ['@fontsource-variable/inter/files/inter-latin-wght-normal.woff2'],
weight: '100 900',
style: 'normal',
unicodeRange: ['U+0000-00FF, ...'],
},
],
},
},
],
});---
import { Font } from 'astro:assets';
---
<head>
<Font cssVariable="--font-inter" preload />
</head>CSS では font-family: var(--font-inter) で参照します。
fontProviders.local() には文字の範囲(subset)の情報がないので、複数のファイルを登録すると、すべてが preload されます。日本語のサイトで英数字にだけ使うフォントなら、latin のファイルだけで足ります。
日本語のサイトでは、optimizedFallbacks を false にしておきます。Astro が作るフォールバック(大きさを合わせた Arial)は文字の範囲を持たないので、→ のような、フォントにない記号まで Arial で表示されてしまいます。
使わない @font-face がなくなり、HTML が約 4KB(gzip 後)小さくなりました。