Nuxt3でポートフォリオサイトを構築するときに使ったTips
WordPressをHeadlessCMSとして利用し、フロントをNuxt3で構築したポートフォリオサイトを作成しました。
以前はNext.jsで作成していましたが、NuxtでもVercel運用にてISRが利用できるようになったので今回利用してみました。
また、GoogleのLighthouseによるスコアチェックも合格点になる機能(useSeoMetaやNuxtImg)もNuxt3では備わってましたので後ほどご紹介します。
Starter環境を作る
公式マニュアル
インストール
npx nuxi@latest init <project-name>
cd <project-name> # ディレクトリに入る
npm i # 必要パッケージインストール
npm run dev # http://localhost:3000/ で立ち上がる
ディレクトリ構成を確認し、追加ディレクトリ
# nuxt3.7.3初期構成
|-- app.vue(主要コンポーネント)
|-- nuxt.config.ts(設定ファイル)
|-- package.json
|-- package-lock.json
|-- READEME.md
|-- tsconfig.json(TypeScript設定ファイル)
|-- node_modules
|-- module
|-- ...
|-- server(api使用の場合使用)
|-- tsconfig.json
|-- public(パブリックフォルダ)
|-- favicon.ico(img src="/favicon.ico" で出る)
以下ディレクトリを追加
# 以下を追加する
|-- pages(ページファイル)
|-- components(コンポーネントファイル)
|-- composables(状態管理や共通処理をまとめる)
|-- layouts(テンプレート用)
|-- plugins(プラグイン。GTAGなど入れる場合使う)
ポートを変える
ポート3000番はデフォルトなので被ると嫌なので変えます。
"dev": "nuxt dev --port 4023"
最低限必要なパッケージを入れる
私はsassとtailwindcssと@nuxt/imageを入れます。
npm i sass @nuxtjs/tailwindcss @nuxt/image@rc
app.vueを以下に変更
<template>
<div>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</template>
.env(環境変数)の使い方
公式マニュアル
.envファイル作成
WPAPI="https://"
設定ファイル
export default defineNuxtConfig({
runtimeConfig: {
public: {
WPAPI: process.env.WPAPI,
},
},
});
各ページで呼び出すにはuseRuntimeConfig()
<script setup>
const config = useRuntimeConfig();
const { data: postData } = await useFetch(`${config.public.WPAPI}/custom/v1/allposts`);
</script>
デフォルトテンプレートのlayouts/default.vueの設定
layouts/default.vueはサイト共通の初期テンプレートになります。
ここに設定するのは、Metaの初期値設定、SEOの初期値設定、各ページの共通レイアウトの設定です。
大枠の構成は以下のようになります。slotというのがpages内に作成したページが表示されます。
<script setup>
// SEO初期設定
// Metaの初期値設定
</script>
<template>
<div>
<Header />
<slot />
<Footer />
</div>
</template>
SEOはuseSeoMetaが超便利!
公式マニュアル
これを使えばSEOに必要なタイトルやディスクリプション、さらにはOGP設定も自動でやってくれる優れものです。default.vueで初期値を設定し、個別ページで変更箇所を上書きするような形で使用します。
<script setup>
// SEO初期設定
useSeoMeta({
title: '初期タイトル',
ogTitle: '初期タイトル',
description: '初期概要',
ogDescription: '初期概要',
ogImage: 'https://***.***/images/ogp.png',
twitterCard: 'summary_large_image',
});
</script>
ブログの個別ページなどで上書きする場合
<script setup>
// -- slug取得 -------------- //
const route = useRoute();
const pageParams = route.params.slug;
// -- fetch -------------- //
const config = useRuntimeConfig();
const { data: postData } = await useFetch(
`${config.public.WPAPI}/wp/v2/posts?_embed&slug=${pageParams}`
);
useSeoMeta({
title: () => `${postData.value[0].title.rendered} | 'サイト名'`,
ogTitle: () => `${postData.value[0].title.rendered} | 'サイト名'`,
description: () => `${postData.value[0].excerpt.rendered}`,
ogDescription: () => `${postData.value[0].excerpt.rendered}`,
ogImage: () => `${postData.value[0].featured_image.src}`,
});
</script>
これだけです!
その他のMetaはuseMetaで
公式マニュアル
SEOの設定が上記useSeoMetaで済んでしまうので、その他は言語指定、や外部リンク、スクリプト、ファビコンの読み込みぐらいしか使いませんでした。もちろんそれ以外もできるので詳しくはマニュアルを御覧ください。
useHead({
htmlAttrs: {
lang: 'ja',
},
link: [
{
rel: 'preconnect',
href: 'https://fonts.googleapis.com'
},
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Roboto&display=swap',
crossorigin: ''
}
{ rel: 'icon', type: 'image/x-icon', href: '/icons/favicon.ico' },
{ rel: 'apple-touch-icon', sizes: '180x180', href: '/icons/apple-touch-icon.png' },
{ rel: 'icon', type: 'image/png', sizes: '512x512', href: '/icons/icon-512x512.png' },
],
script: [
{ innerHTML: "console.log('Hello world')" },
{
src: 'https://third-party-script.com',
// 読み込む箇所: 'head' | 'bodyClose' | 'bodyOpen'
tagPosition: 'bodyClose'
}
]
});
エラーページはerror.vue
公式マニュアル
何かしらエラーハンドリングをした場合に利用します。もしerror.vueを作成してない場合はNuxtのデフォルトのエラーページが表示されます。
<script setup>
const error = useError();
const handleError = () => {
clearError({
redirect: "/",
});
};
</script>
<template>
<div>
<p>{{error.message}}</p>
<p>{{error.statusCode === 404 ? 'Oops! 404 page not found!' : 'Oops!An error has occurred.'}}</p>
<button @click="handleError">
Go Back
</button>
</div>
</template>
例えばブログ記事のページで記事が見つからなかった場合は404を発生させます。
<script setup>
// -- slug取得 -------------- //
const route = useRoute();
const pageParams = route.params.slug;
// -- fetch -------------- //
const config = useRuntimeConfig();
const { data: postData } = await useFetch(
`${config.public.WPAPI}/wp/v2/posts?_embed&slug=${pageParams}`
);
if (!postData.value) {
throw createError({
statusCode: 404,
statusMessage: `記事が見つかりませんでした`,
});
}
</script>
以上がstarter環境を用意するまででした。
個別のTips
ここからは特にテーマはなく個別に使用しているTipsを紹介します。
WordPressのブログ記事をRestAPIからFetchで持ってきて表示させる
WordPressのブログ記事をRestAPIからFetchで持ってきて表示させる基本的なやり方です。まずは個別記事の取得の仕方から先に解説します。(あと、まだ細かいところは作り込んでません。例えば読み込み中はローディング表示とか、API機能から読み込むのではなく個別ファイルに直書きしています。。。)
記事の日付はdate-fns
JavaScriptの日付の処理は「date-fns」を使用しています。
公式サイト
v-htmlに生のコンテンツを入れない(サニタイズする)
ブログの記事からv-htmlによってRestAPIから取り出したコンテンツ(生のHTML)を入力するとそのまま出力されるため、もし悪意あるコードが入っていた場合はXSS(クロスサイトスクリプティング)攻撃につながる可能性があります。自分で入力するから大丈夫!とは思わず一応対策しておきます。
github
以下ソースです。
<script setup>
import { format } from 'date-fns';
import sanitizeHTML from 'sanitize-html';
// -- slug取得 -------------- //
const route = useRoute();
const pageParams = route.params.slug;
// -- fetch -------------- //
const config = useRuntimeConfig();
const { data: postData } = await useFetch(
`${config.public.WPAPI}/wp/v2/posts?_embed&slug=${pageParams}`
);
// console.log(postData.value[0]);
// -- useSeoMetaの上書き -------------- //
useSeoMeta({
title: () => `${postData.value[0].title.rendered} | ${config.public.META_TITLE}`,
ogTitle: () => `${postData.value[0].title.rendered} | ${config.public.META_TITLE}`,
description: () => `${postData.value[0].excerpt.rendered}`,
ogDescription: () => `${postData.value[0].excerpt.rendered}`,
ogImage: () => `${postData.value[0].featured_image.src}`,
});
// -- sanitizeHTMLホワイトリスト -------------- //
sanitizeHTML.defaults.allowedTags = sanitizeHTML.defaults.allowedTags.concat(['img', 'iframe']);
sanitizeHTML.defaults.allowedAttributes['iframe'] = ['*'];
sanitizeHTML.defaults.allowedAttributes['*'] = ['class', 'style'];
</script>
<template>
<div>
<div>日付:{{ format(new Date(postData[0].date), 'yyyy.MM.dd' )}}</div>
<div>タイトル:{{ postData[0].title.rendered }}</div>
<div v-html="sanitizeHTML(postData[0].content.rendered)"></div>
</div>
</template>
画像の最適化はNuxtImage
公式マニュアル
NuxtImageはNext.jsのnext/imageと同じくブラウザのサポートする画像フォーマットをみて最適な画像フォーマットに変換してくれます。
<NuxtImg
class="inline-block w-full object-cover h-[12rem]"
:src="n.featured_image.src ? n.featured_image.src : '/images/thumb_secret.png'"
:alt="`${n.title.rendered}のサムネイル画像`"
quality="60"
loading="lazy"
/>
なお、各パラメーターは私もあまり把握してないため公式サイトをご参照ください。
一つだけ、自分の場合、WordPressの記事を表示させるという事で画像もWordPrsssから持ってきております。つまりドメインの違う場所から読み込んでいる画像を最適化する場合はnuxt.config.tsにdomainの記述が必要です。
export default defineNuxtConfig({
image: {
domains: ['https://your.domain.com/'],
},
});
Infinityスクロールはv3-infinite-loading
ブログ記事を一覧した時はページングするのが煩わしいので無限スクロールを実装しました。今回はVue3に対応した無限スクロール機能v3-infinite-loadingを利用します。
公式サイト
<script setup>
let postData = ref([]);
let page = 1;
const load = async ($state) => {
console.log('loading...');
try {
const response = await fetch('https://domain.com/wp-json/wp/v2/posts?_embed&per_page=10&&page=' + page);
const json = await response.json();
if (json.length < 10) $state.complete();
else {
postData.value.push(...json);
$state.loaded();
}
page++;
} catch (error) {
$state.error();
}
};
</script>
<template>
<div class="">
<article v-for="n in postData" :key="n.id">
(記事一覧省略)
</article>
<InfiniteLoading @infinite="load" />
</div>
</template>
開発環境と本番環境の分岐
nuxt.configからprocess.env.npm_lifecycle_eventを使用しruntimeConfigにdev環境かprod(本番)環境かブール値を入れます。
const isDev = process.env.npm_lifecycle_event === 'dev' ? true : false;
export default defineNuxtConfig({
runtimeConfig: {
public: {
isDev: isDev,
},
},
});
以下サンプルはconfig.public.isDevで本番環境は一部のカテゴリの記事だけ取得しない処理
<script setup>
const config = useRuntimeConfig();
const response = await fetch(
config.public.isDev
? 'https://domain.com/wp-json/wp/v2/posts?_embed&per_page=10&page=' + page
: 'https://domain.com/wp-json/wp/v2/posts?_embed&per_page=10&categories_exclude=1+6+10+11&page=' +
page
);
</script>
ISR(ハイブリッドレンダリング)の設定
ISRはIncremental Static Regenerationの頭文字を取った略語で、静的サイトをビルドするサーバー(今回使用するVercel、他にはNetlifyなど)の機能の一つで、通常WordPressで記事を更新した場合、全ての記事をhtmlで書き出す作業を毎回行わなければならないが、ISRの機能があると、更新されたものだけ書き出すという事が可能です。NuxtではHybrid Rendering(ハイブリッドレンダリング)として紹介されています。
ISRについて詳しくは私の昔の記事をご参考いただければ
今回は、Nuxt3でHybrid Renderingの設定をします。
export default defineNuxtConfig({
routeRules: {
'/**': { isr: 60 },
},
});
以上です。簡単ですね。要は全てのページを60秒間隔で監視して、更新があったものだけビルドを発動をするという感じです。ただ、まだあまり資料がなく、そういう解釈をしてるだけなので間違ってたらごめんなさい。
Hybrid Rendering
vercel社の「NuxtでISR使えるようになったよみんなー」という記事
Google Tag Managerを埋め込む
vue-gtag-nextを使います。Google Analyticsアカウントの取得やGTMとの連携はこちらでは開設しませんのでご了承ください。
github
import VueGtag from 'vue-gtag-next';
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(VueGtag, {
property: {
id: 'GTM-******',
},
});
});
ちなみにvue-gtag.client.jsのようにclient.jsとファイル名に書くとクライアントサイドで実行されるファイルになります。
最後に
今回Nuxtを利用して構築しましたが、Webデザインから入った人間はやはりわかりやすいです。個人的にはコンポーネントを読み込むときにimport書かなくていいのが楽で良いです。
昔はNext.jsの方が静的サイトを作るのにはISRだとか画像最適化だとか最新の機能が備わってましたけど、Nuxtでもできるようになったならもうそんなに大差ないんじゃないでしょうか。あるとしたらNuxt.jsは資料が圧倒的に少ないです。日本はVueとReactの割合は半々と言われますが世界は圧倒的にReactなので、そのライブラリであるNextの方が主流です。
まあでも私はNuxtの方が相性がいいのでこれからも使い続けますよ!
Discussion
この度は記事は拝見させていただきました!大変参考になりました!
サニタイズについての質問です。以前、sanitize-htmlをインポートして使用されていたようですが、Nuxt 3でViteを使用して実装しています。v-sanitizeが利用できないため、Vite環境でsanitize-htmlをインポートし、独自のsanitize.jsファイルでルールを実装することは可能でしょうか?