はじめに
こんにちは、株式会社ファストコーディングのフルスタックエンジニア、独身貴族Fireです。
Webディレクターやプロジェクトマネージャーの方々、最近のWeb制作で「重くて遅いランディングページ」に悩んだことはありませんか?縦に長いページや、いろいろな要素が詰まったページになればなるほど、「なんかちょっと遅いよね」とか「もっさりしているよね。。。」、「ページ分割したほうがいいんじゃない?」みたいな声、聞こえますよね?
フロントエンド開発のプロジェクトを日々進めている弊社では、そういうお困りに対応するのが日常茶飯事、、とも言えます。弊社内ではフロントエンドエンジニア間でそういった場合の対処法を共有しているのですが、最近使った(考え方のもとともなる)方法 “段階ハイドレーション” について今回はNuxt3/Vue3をベースにご案内いたします。
段階ハイドレーションとは?
ハイドレーションという言葉、あまり聞きなれませんね。マラソンや登山をされる方なら聞いたことあるかもしれませんが、水分補給や栄養補給といった雰囲気で使われることがあります。
今回ご案内する段階ハイドレーションというのは、Vue.jsのフレームワークであるNuxt3を使い、SSRを活用して先にHTMLを描画し、必要なタイミングでだけクライアントのJSを当てて(=ここをハイドレートして、と表現します。Reactなんかを使ったことがある人はHydrationエラー等見たことある人は多いのではないでしょうか)、“動的に変わっていくUI”にしていく手法です。
Nuxt3/Vue 3 では以下のトリガーを組み合わせて、ハイドレーションを簡単に段階化できます。
- 可視化トリガー:要素がビューポートに入ったら初めてマウント(例:レビュー、ギャラリー)
- 相互作用トリガー:クリック等の操作が来たら初めてマウント(例:モーダル、タブ)
- アイドルトリガー:
requestIdleCallback
等でCPUが空いたら後回し領域をマウント - 条件トリガー:メディアクエリ/ネットワーク状態で分岐(低速回線は動画UIを遅らせる 等)
これにより初期表示時のJavaScriptの配布量を下げてと端末の負荷を下げつつ、上部はすぐ見える/触れる体験を両立できます。
Nuxt3の段階ハイドレーションでの解決
Nuxt3の“段階ハイドレーション”は、こうした問題を解決する新しい技術です。コンポーネントを「Island」に分け、それぞれの必要な時にだけクライアント側で初期化(ハイドレート)します。具体的な流れは以下の通りです。
1. Islandごとの分割
各セクションを独立したIslandとして扱い、Nuxtのサーバサイドレンダリング(SSR)を用いて初期表示を高速化します。
実装例(構造)
<!-- pages/index.vue -->
<template>
<HeroIsland /> <!-- 上部:SSRで即表示 -->
<LazyGalleryIsland /> <!-- 下部:後段ハイドレーション -->
<LazyReviewsIsland />
</template>
ポイント
- 各 Island はPropsで疎結合に。副作用を内部完結させ、外部に波及させない。
- 画像・テキストはSSRで骨組みを描画して、CLSを防ぐ(幅/高さを明示)。
2. ファーストビューはSSRで表示
ユーザーが最初に目にする部分はSSRで描画し、すぐに表示されるようになります。ここには、ヒーローセクションや主要なアクションボタンなどが含まれます。
実装例(ヒーローはSSR、アニメだけクライアントに)
<!-- components/HeroIsland.vue -->
<template>
<section class="hero">
<h1>{{ title }}</h1>
<p>{{ lead }}</p>
<!-- アニメーション等のブラウザAPIはクライアント限定 -->
<ClientOnly>
<FancyParticles />
</ClientOnly>
</section>
</template>
<script setup lang="ts">
defineProps<{ title: string; lead: string }>()
</script>
ポイント
ClientOnly
は必要最小限で包む(包み過ぎるとSSRの恩恵が減る)。- SSR部は静的HTMLで完成させ、JS待ちの白抜けを出さない。
3. 下部は可視時にマウント
ギャラリーやレビューは、IntersectionObserverを使い、画面に表示された時にだけマウントされるようにします。これにより、初期ロード時のJS負荷を軽減できます。
実装例(@vueuse/core の useIntersectionObserver
)
<!-- components/LazyGalleryIsland.vue -->
<template>
<section ref="target" class="gallery">
<div v-if="!mounted" class="skeleton" aria-busy="true" />
<ClientOnly>
<Gallery v-if="mounted" :items="items" />
</ClientOnly>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useIntersectionObserver } from '@vueuse/core' // npm i @vueuse/core
const props = defineProps<{ items: Array<{src:string; alt:string}> }>()
const target = ref<HTMLElement | null>(null)
const mounted = ref(false)
useIntersectionObserver(target, ([entry]) => {
if (entry?.isIntersecting) mounted.value = true
}, { rootMargin: '200px', threshold: 0 })
</script>
<style scoped>
.skeleton{height:320px;background:linear-gradient(90deg,#eee,#f6f6f6,#eee);
background-size:200% 100%;animation:sk 1.2s linear infinite}
@keyframes sk{to{background-position:200% 0}}
</style>
ポイント
rootMargin: '200px'
で手前先読み。threshold: 0
で“かすったら”開始。- 代替として自前のIntersectionObserverや
v-intersect
ディレクティブでもOK。 - マウント後はオブザーバー解除(上記は
mounted
切替で即座にDOMが置換される)。
4. 共有状態の管理
Island間のデータはPiniaで管理し、必要なデータはpropsを介して渡すことで無駄な再レンダリングを防ぎます。
実装例(クリックで遅延import→マウント)
<!-- components/LazyReviewsIsland.vue -->
<template>
<section class="reviews">
<button @click="mount">レビューを表示</button>
<ClientOnly>
<component :is="Comp" v-if="Comp" />
</ClientOnly>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const Comp = ref<any>(null)
const mount = async () => {
Comp.value = (await import('./Reviews.vue')).default
}
</script>
ポイント
import()
はルート単位のコード分割を発生させる(JS配布量を抑制)。- 初回マウント時はフォーカス管理とアナウンス(SR向け)を忘れずに。
実装例
// コンポーネント単位での分割
<template>
<MainIsland />
<GalleryIsland />
<ReviewIsland />
</template>
// IntersectionObserverを活用した遅延マウント
<template>
<div v-intersect="mountComponent">
<component :is="lazyComponent" />
</div>
</template>
<script>
import { ref } from 'vue';
export default {
setup() {
const lazyComponent = ref(null);
function mountComponent() {
lazyComponent.value = import('./GalleryComponent.vue');
}
return { lazyComponent, mountComponent };
},
};
</script>
実際のプロジェクト例
実際のプロジェクトにこの方法を導入したところ、LCP(最大コンテンツ描画時間)が約30%も改善しました。ユーザー調査でも、ページの反応速度が高評価を受け、デバイスの画面転換や向きを変えたときもスムーズに動作し、「遅い」、「もっさりしている」というような評価を改善することに成功しました。
おわりに
Nuxt3の“段階ハイドレーション”を活用することで、多要素が含まれるLPでもスムーズで軽快な表示が可能になります。この手法を、次のプロジェクトでぜひ試してみてください。株式会社ファストコーディングでは、このようなフロントエンド技術を駆使したWeb制作をサポートしています。詳しくはこちらの問い合わせフォームからご相談ください。