Nuxt Server Components で作るリンクカード

作成日: 2024-10-29 /

これは Nuxt / UnJS Advent Calendar 2024 の 2 日目の記事です。

概要

自ブログをリリースして約 3 ヶ月ほど経ちますが、新しく記事を書いたり新機能を作ったりなど、ブログ開発が充実している毎日です。他人のブログを見る機会が増え、参考になるところは取り入れています。今回はそんな作成した機能の中で、Nuxt Server Components を使ってのリンクカードをリリースした話を記事にまとめました。

作ったもの

表示内容としては下の感じになります。

Nuxt: The Intuitive Vue Framework

Nuxt: The Intuitive Vue Framework

https://nuxt.com

Nuxt: The Intuitive Vue Framework

使用技術

ここでは、2 つの技術スタックで作るものとします。

  • Nuxt.js 3.12.4 (SSG アプリケーション)
  • @nuxt/content

Nuxt Server Components について

Web アプリケーションを開発するにあたって、レンダリング方式をどれにするか決めることは避けられません。多くのケースで下の中から選択するのではないでしょうか。(ここではそれぞれがどんなレンダリング方式やメリット、デメリットについては割愛します。)

  • CSR(client-side rendering)
  • SSR(server-side rendering)
  • SSG(static-site generation)
  • ISR(incremental site generation)

Nuxt で Web アプリケーションを開発するときにも、どのレンダリング方式を使用するか決められます。今までの Nuxt 、少なくともメジャーバージョンが 2 のときは、レンダリング方式がアプリケーション全体で 1 つとなっていましたが、今ではページのパスごとで決められるようになりました。これは Hybrid Rendering と呼ばれ、公式サイトに詳しく書かれています。

ページ毎に変更できるだけで素晴らしいことでもすが、このレンダリング方式をコンポーネント単位で決めることも可能になりました。そこれで登場するのが Nuxt Client ComponentsNuxt Server Components になります。この記事では、Nuxt Server Components に焦点を当てます。

Nuxt Server Components は、文字通りコンポーネント 1 つ 1 つがサーバーでレンダリングされる仕組みです。クライアント用の JavaScript がブラウザから取得されないので、Web 開発のテーマの「SHIP LESS JavaScript」を推進するものになります。Hydration が発生しないため、ブラウザ側で実行される onMounted などのライスサイクルや input や button、 scroll イベントハンドリングが実行されません。内部変数のりアクティビティも失われます。さらに、DOM API も動作しないことを事前に考慮する必要があります。

下の experimental.componentIsLands オプションに true を渡し、コンポーネントに .server.vue をつければ、動作検証が可能です。

nuxt.config.ts
export default defineNuxtConfig({
  experimental: {
    componentIslands: true,
  },
})

検討したきっかけ

リンクカードを実装するときに、頭を悩ませたのが、サイトの metadata をどのように適切なタイミングで取得するかでした。下のように、指定したサイトへリクエストを送るコンポーネントを開発していると、ローカルでページ遷移時に、 CORS 問題が出てきました。vite の hot update でブログの記事内容を更新するときに、クライアントのほうで useAsyncDatauseFetch が実行され、同じ問題が起こります。

components/LinkCard.vue
<script setup lang="ts">
import { useAsyncData } from '#imports'

import type { SiteMetadata } from '~/types/metedata'

const props = defineProps<{
  url: string
}>()

const { data } = await useAsyncData<{ metadata: SiteMetadata | null }>(`link-card-${props.url}`, () => {
   try {
    const response = await $fetch.native(url,
      {
        method: 'get',
      })

    const html = await response.text()
    const metadata: SiteMetadata = { url, title: null }
    const loaded = load(html)

    const metaTags = loaded('meta')

    for (const meta of metaTags) {
      const property = meta.attribs.property

      if (property === 'og:title') {
        metadata.title = meta.attribs.content || null
      }
    }

    return { metadata }
  } catch {
    return { metadata: null }
  }
})
</script>

<template>
  <a
    v-if="data"
    :href="url"
  >
    <div>
      <p>
        {{ data.metadata?.title || url }}
      </p>
    </div>
  </a>
</template>

ちなみに、これはローカル開発時だけに起こりうるものになります。SSG アプリケーションであるため、サイトの metadata 取得やレンダリングは事前のビルド時にサーバー内で行われ、本番ではページ遷移時でブラウザからのリクエストが送られることはありません。 そのためそこまで対応が迫られるレベルの問題ではないですが、毎度リロードをかけて動作確認をするのも手間であり、ページ遷移時や記事の内容更新時に正常に動作して欲しいことから、解決方法を考えました。

最初思いついた案としては、Nuxt.js の server ディレクトリを利用し、API サーバーを立てることを考えました。指定したサイトから CORS ヘッダーが送られていないことがほとんどなので、サーバー側から metadata を取得しながらも CORS ヘッダーを返すように実装します。最初のアクセス時とページ遷移時の両方で、コンポーネントからは自分で実装した API を叩き、内部で metadata を取得します。このアプローチのメリットは、スクレイピング処理に必要な処理やライブラリを隠蔽でき、クライアントへ転送される JavaScript を少なくできます。

components/LinkCard.vue
<script lang="ts" setup>
const { data } = await useFetch<{ metadata: SiteMetadata | null }>('/api/metadata', { query: { url: props.url }})
</script>
server/api/metadata.ts
import { defineEventHandler, getQuery } from '#imports'

import { load } from 'cheerio'
import { $fetch } from 'ofetch'

import type { SiteMetadata } from '~/types/metedata'

export default defineEventHandler(async (event) => {
  const query = getQuery(event)

  try {
    const response = await $fetch.native(url,
      {
        method: 'get',
      })

    const html = await response.text()
    const metadata: SiteMetadata = { url, title: null }
    const loaded = load(html)

    const metaTags = loaded('meta')

    for (const meta of metaTags) {
      const property = meta.attribs.property

      if (property === 'og:title') {
        metadata.title = meta.attribs.content || null
      }
    }

    return { metadata }
  } catch {
    return { metadata: null }
  }
})

しかし、今度は、作りが大きくになるのと、開発途中少しでも飛んでしまう無駄なリクエストが多いことについて懸念として感じました。

そこで、他にも最良な案がないか再度調べたところ、Nuxt Server Components が使えるのではないかと考えました。これで metadata 取得とレンダリングがサーバー内で完結します。 問題となっているページ遷移や記事の内容更新時にリクエストがクライアントで発生させないようにすれば問題解決です。

components/LinkCard.server.vue
<script setup lang="ts">
import { useAsyncData } from '#imports'

import type { SiteMetadata } from '~/types/metedata'

const props = defineProps<{
  url: string
}>()

const { data } = await useAsyncData<{ metadata: SiteMetadata | null }>(`embed-${props.url}`, () => {
   try {
    const response = await $fetch.native(url,
      {
        method: 'get',
        headers: { 'user-agent': userAgent },
      })

    const html = await response.text()
    const metadata: SiteMetadata = { url, title: null }
    const loaded = load(html)

    const metaTags = loaded('meta')

    for (const meta of metaTags) {
      const property = meta.attribs.property

      if (property === 'og:title') {
        metadata.title = meta.attribs.content || null
      }
    }

    return { metadata }
  } catch {
    return { metadata: null }
  }
})
</script>

<template>
  <a
    v-if="data"
    :href="url"
  >
    <div>
      <p>
        {{ data.metadata?.title || url }}
      </p>
    </div>
  </a>
</template>

実際に動作させてみて

実際に使用してみて、クライアント側でレンダリングが発生しないので、ページ遷移や記事の内容更新で hot reload でリクエストが飛ばなくなりました。

実際に検証の結果を表示します。今回 metadata を取得したいサイトのスクレイピングは cheerio というライブラリを使っています。これがブラウザへ転送されていないかを確認します。network で挙動を確認したところ、server components で cheerio が転送されていないことがわかりました。ブラウザへ JavaScript が転送されずに嬉しい限りです。

Client+Server ComponentServer Component

Nuxt Server Components で気をつけるべきこと

実装していて、詰まったことを共有します。

global コンポーネントであること

上記のオプションを ON にして、早速実装したリンクコンポーネントに .server をつけ、動作確認をしました。しかし、試しにボタンクリックで内部にある count がインクリメントされるかを確認したところ、期待通りとはいかず、インクリメントされました。

components/ServerComponent.server.vue
<script lang="ts" setup>
import { ref } from '#imports'

const count = ref(1)
</script>

<template>
  <button @click="count++">{{ count }}</button>
</template>

結論コンポーネントを global 登録するのが大事です。server の suffix をつけたパスのままで import しても Server Component として機能しません。自分のプロジェクトでは、auto-import や component の global 登録を無効にしているので、気づくのに時間がかかりました。

pages/SamplePage.vue
<script lang="ts" setup>
import ServerComponent from '~/components/ServerComponent.server.vue' // <- これがだめ
</script>

<template>
  <ServerComponent />
</template>

幸い @nuxt/content ではマークダウンで利用するコンポーネントについては、最初からグローバル登録する ~/components/content ディレクトリに入れる仕様があります。それにより global 登録する前提条件はクリアしているので、今回実装したリンクカードコンポーネントをマークダウンで使用できます。

テスト

ロジックがある程度定義されているので、テストをどう書いたかも共有します。

テストは vitest を使用しています。@nuxt/test-utils から提供されている mountSuspended を使い、リンクカードコンポーネントをレンダリングします。このとき注意すべきなのは、 global import すると、コンポーネントの中の HTML が空となります。(これは調査していきます。)

components/LinkCard.nuxt.spec.ts
import { mountSuspended } from '@nuxt/test-utils/runtime'

import { LinkCard } from '#components'

describe('LinkCard', () => {
  describe('サイトへのリクエストが成功したとき', () => {
    describe('すべてのプロパティがレスポンスに入ったとき', () => {
      test('タイトルが表示されること', async () => {
        const component = await mountSuspended(LinkCard, { props: { link: 'https://example.com' } })
        console.log(`text: ${component.text()}`) // ←  出力結果は text: のみ。なぜか空となってしまう。
      })
    })
  })
})

それにより、Server Component をテストファイルからインポートするときは、パスを指定しましょう。最終的なテストコードはこちらとなります。

components/LinkCard.nuxt.spec.ts
import { mountSuspended } from '@nuxt/test-utils/runtime'

import LinkCard from '~/components/content/LinkCard.server.vue'
import type { SiteMetadata } from '~/types/metedata'
import { fetchMetadata } from '~/utils/post/metadata'

vi.mock('~/utils/post/metadata')

const metadata: SiteMetadata = {
  title: 'site metadata title',
  description: 'description',
  url: 'https://example.com',
  imageUrl: 'https://example.com/img',
}

describe('LinkCard', () => {
  describe('サイトへのリクエストが成功したとき', () => {
    describe('すべてのプロパティがレスポンスに入ったとき', () => {
      beforeEach(() => {
        vi.mocked(fetchMetadata).mockResolvedValue({ metadata })
      })

      test('タイトルが表示されること', async () => {
        const component = await mountSuspended(LinkCard, { props: { link: 'https://example.com' } })
        expect(component.text()).toContain('title')
      })
    })
  })
})

これからの改善点

ここからは少し余談になりますが、@nuxt/content を使用していることから妥協したこともお書きします。

本当は Qiita や Zenn.dev などの技術投稿ブログと一緒で、マークダウンの中に 1 つリンクだけを設置する予定でした。

--- a/src/content/tech/link-card-by-nuxt-server-component.md
+++ b/src/content/tech/link-card-by-nuxt-server-component.md
@@ -16,10 +16,7 @@ categories: ['Nuxt.js']

-::LinkCard{link='https://nuxt.com/'}
-::
+https://nuxt.com/ <!-- ← 理想系はこっち -->

@nuxt/content の仕様で、パースされた DOM をそれぞれのタグコンポーネントに置換し、HTML を生成しますが、このときのタグコンポーネントを自分でカスタマイズできます。しかし、今回カスタマイズするタグは a タグのコンポーネントになります。中身を上記のコードに書き換えたとき、Hydration mismatch が発生してしまいました。それにより、妥協して今回のようなコンポーネントを使うと言う方向になりました。

原因としては、どうやら a タグの中でインライン要素ではなく、ブロック要素を展開することから DOM 構造が変わってしまうのがよくないようです。すでに issue は上がっているようですが、解決に向けて議論が進んでいるわけではないので、妥協してコンポーネントを呼び出す形を取りました。

Hydration node mismatch when overwriting prose components · Issue #1537 · nuxt/content

Hydration node mismatch when overwriting prose components  · Issue #1537 · nuxt/content

https://github.com

Hydration node mismatch when overwriting prose components  · Issue #1537 · nuxt/content

まとめ

リンクカードがあるかないかでブログの見た目がだいぶ変わってくるので、実装できてよかったです。Nuxt Server Components を実運用できただけではなく、Nuxt で API サーバーを立てるなど、一歩深く Nuxt と向き合えたのではないかと感じます。今回リンクカードコンポーネントだけ Server Components を利用しているのですが、そもそものマークダウンレンダラー自体も Server Components 化していく予定です。日々「Ship Less JavaScript」を目指していきます。