Open29

Nuxt3をはじめる①

HIR0HIR0

目的

・実務でNuxt3を扱うことがある
・AIの力に助けられながらなんとかタスクをこなせているが正直よくわかっていない→ちゃんと理解したい
・0からフロントエンドアプリケーションが構築できるようになりたい
・学習記録を残しておきたい

HIR0HIR0

Nuxt3とは?

Vue.jsでよく利用されるモジュール類をひとまとめにしたフレームワークがNuxt.js(略してNuxt)。
Nuxt3では、内部のコードをすべてTypeScriptで実装されており、Nuxtプロジェクト自体もTypeScriptによるコーディングをサポートしている。また、非同期でのデータ取得や、SSR機能なども追加されている。

主な機能

機能 内容
ファイルシステムルーティング フォルダ構成がそのままルーティング
レイアウト 画面の共通レイアウトを作れる
オートインポート 明示的にインポートをしなくてもコンポーネントが利用できる
ミドルウェア 画面遷移する前に処理を挟める
レンダリングモードの切替 4種類のレンダリングモードをパスごとに設定できる
HIR0HIR0

Vue3について

Vue.jsは2014年にEvan You氏によってリリースされたフレームワーク。軽量軽快に動作するフレームワークとして人気を集め、JavaScriptコード上の変数とDOM要素が自動的に連動するリアクティブシステムを採用している。2020年9月にVue3としてメジャーアップデートが実施された。
Vue3では、TypeScriptを採用したり、Viteというビルドツールを採用したりと様々なアップデートが行われた。

Vueの主な特徴

  • VueRouter:シングルページアプリケーションを手軽に実現できる
  • Pinia:コンポーネント間で横断でデータを管理できるステート管理モジュール
  • Vitest, VueTestUtils:Vueのユニットテストを容易にできる
HIR0HIR0

Nuxtプロジェクトを始める

Nuxtのプロジェクトを新たに作るにはnpx nuxi init <プロジェクト名>コマンドを使う。

# プロジェクトの新規作成
npx nuxi init nuxt-app
...
# プロジェクトのディレクトリに移動してパッケージをインストール
cd nuxt-app
npm install

# 開発用nuxtサーバーの起動(http://localhost:3000/)
npm run dev

※ ブラウザを直接起動する
npm run dev -- -o

HIR0HIR0

ディレクトリ構成

Nuxtプロジェクトを新規作成した直後のディレクトリ構成。

ディレクトリorファイル 内容
.nuxt/ Nuxtアプリケーションが格納されたディレクトリ
node_modules/ 依存パッケージが格納されたディレクトリ
public/ Web上に公開するファイルを格納するためのディレクトリ
server/ APIサーバー処理を行うファイルを格納するためのディレクトリ
.gitignore Git対象外ファイルを管理するファイル
.npmrc npmコマンドの設定ファイル
app.vue メインのNuxtコンポーネントファイル
nuxt.config.js Nuxtアプリケーションの設定ファイル
package-lock.json npmの依存関係に関する設定ファイル
package.json npmに関する設定ファイル
README.md いわゆるりーどみーファイル
tsconfig.json TypeScriptに関する設定ファイル
HIR0HIR0

開発を進めていった際のディレクトリ構成

ディレクトリorファイル 内容
.nuxt/ 同上
.output/ デプロイ用のファイル一式が格納されたディレクトリ
assets/ 画像やCSSファイルといったファイル類を格納するためのディレクトリ
components/ コンポーネントファイルを格納するためのディレクトリ
composables/ コンポーザブル定義ファイルを格納するためのディレクトリ
layouts/ レイアウト用のコンポーネントファイルを格納するためのディレクトリ
middleware/ ミドルウェアファイルを格納するためのディレクトリ
modules/ 独自のモジュールを格納するためのディレクトリ
node_modules/ 依存パッケージが格納されたディレクトリ
pages/ ルーティングに必要なコンポーネントファイルを格納するためのディレクトリ
plugins/ プラグインを格納するためのディレクトリ
public/ 同上
server/ 同上
utils/ ヘルパー関数ファイルなどを格納するためのディレクトリ
.env 環境変数を設定するファイル
.gitignore 同上
.npmrc 同上
app.vue 同上
nuxt.config.js 同上
package-lock.json 同上
package.json 同上
README.md 同上
tsconfig.json 同上
HIR0HIR0

.vueファイルについて

Nuxtプロジェクトはコンポーネント単位でアプリケーションを構成し、各コンポーネントは*.vueファイルで表現される。*.vueでは、単一のファイルにHTML、CSS、JavaScriptといった要素をまとめて記述する。これを単一ファイルコンポーネント(Signle File Components)、略してSFCという形で表現する。

.vueファイルの基本構成

.vue
<script setup lang="ts">
// JavaScriptやTypeScriptコードを記述するスクリプトブロック
</script>

<template>
// 画面を構成するHTMLやコンポーネントを記述するテンプレートブロック
</template>

<style>
// 見た目を構成するCSSなどを記述するスタイルブロック
</style>

スクリプトブロック内で定義された変数やメソッドはテンプレートブロック内で呼び出すことが可能。
テンプレートブロック内で変数を表示する際には、マスタッシュ構文を利用する必要があり、{{}}で囲むことで変数として扱われる。

HIR0HIR0

app.vue

Nuxtアプリケーションを実行時に一番最初に呼び出されるコンポーネントがapp.vueとなる。
すべてのページやコンポーネントは本ファイルを大元としながら構成されていくことになる。

プロジェクト作成直後のapp.vue
app.vue
<template>
  <div>
    <NuxtRouteAnnouncer />
    <NuxtWelcome />
  </div>
</template>
HIR0HIR0

SFCを記述する際の基本構文

Vueの機能には、一定の条件で記述された変数の値が変化すると、それに合わせて自動的に画面の表示内容も変化するリアクティブシステムという仕組みが存在する
このリアクティブな変数を定義するためには、ref()関数やcomputed()関数を利用する。

リアクティブな変数を定義する ref()

リアクティブ(値が変化したら画面の表示が自動的に更新される状態)な変数を定義したい場合はref()関数を用いる。

const 変数名 = ref(初期値);

リアクティブな変数の値を変更する際は直接に値を変更することができず、.valueプロパティへアクセスする必要がある。

計算結果などをリアクティブな値したい computed()

定義済みのリアクティブな変数を用いてなんらかの計算結果を行った値をリアクティブな変数として保持したい場合はcomputed()関数を用いる。computed()関数は引数に関数を渡すことになっており、この際Vueにおいてはアロー関数を用いることが推奨されている。

const 変数名 = computed(
    (): 戻り値のデータ型 => {
        // なんらかの計算処理など
        return 戻り値;
    }
);

computed()関数は計算結果を自動でリアクティブにして保持したい場合に利用する。
参照用の変数として利用するケースがほとんど。

オブジェクトをまとめてリアクティブにする reactive()

リアクティブなオブジェクトを定義したい場合はreactive()関数を用いる。reactive()によって定義されたリアクティブなテンプレート変数は、そのオブジェクトのプロパティという形でアクセスする(scriptブロック内で.valueは不要)。

const 変数名 = reactive(オブジェクト)

リアクティブ変数の変換を監視する watchEffect(), watch()

リアクティブな変数の変化を監視し、変化した際に任意の処理を実行させるウォッチャーという機能が存在する。ウォッチャーにはwatchEffect()関数とwatch()関数の2種類が存在する。

watchEffect()では、アロー関数内に記述したリアクティブな変数を自動で検知して、それらが変化する度にアロー関数内に記述した処理が実行される。明示的にどの変数を監視するかを指定しなくて済む一方、制御がしずらいといった特徴を持つ。初回実行は自動で行われる。

watchEffect(
    (): void => {
        // リアクティブ変数を用いた処理
    }
);

`watch()`では、明示的に指定した監視対象のリアクティブ変数が変化する度に、アロー関数内に記述した処理が実行される。`watchEffect`とは違い、自動でアロー関数内に記述したすべてのリアクティブ変数を監視対象とするわけではないため、制御がしやすいといった特徴を持つ。初回実行は`immediate`引数によって制御可能。

```ts
// newVal: 監視対象の変化後の値, oldVal: 監視対象の変化前の値
watch(監視対象のリアクティブ変数, (newVal: データ型, oldVal: データ型): void => {
    // 監視対象が変化した際に実行される処理
    },
    {immediate: true} // 初回起動時に呼び出す場合はtrue、falseの場合は省略可
);

watchEffect()は新旧値に関する情報をもたないため、値がどれだけ変化したか?といったチェック処理には向いていない。

HIR0HIR0

ref()関数

コード
<script setup lang='ts'>

const count = ref(0) // 0で初期化されたリアクティブな変数

// リアクティブな変数を更新する処理
function increment() {
    count.value++  // scriptブロックでリアクティブな変数を操作する際は.valueプロパティで参照する
}
</script>
<template>
    <p>現在の数値: {{ count }}</p>
    <button @click="increment">加算する</button>
</template>
HIR0HIR0

computed()関数

コード
<script setup lang='ts'>

const count = ref(0)
const doubleCount = computed( // countの2倍した値を常にリアクティブにしたい
    (): number => {
        count.value * 2
    }
);

function increment() {
    count.value++
}
</script>
<template>
    <p>元の数値: {{ count }}</p>
    <p>2倍にした値: {{ doubleCount }}</p>
    <button @click="increment">加算する</button>
</template>
HIR0HIR0

reactive()関数

コード
<script setup lang='ts'>
// ユーザー情報をまとめてリアクティブな値としたい
const user = reactive({
    name: "Tanaka",
    age: 20
})

function incrementAge() {
    user.age++
}
</script>
<template>
    <p>Name: {{ user.name }}</p>
    <p>Age: {{ user.age }}</p>
    <button @click="incrementAge">年齢加算</button>
</template>
HIR0HIR0

watchEffect()関数

コード
<script setup lang="ts">

const count = ref(0)

watchEffect() = {
    console.log(`countの値が変化しました: ${count.value}`)
})

function increment() {
    count.value++
}
</script>
<template>
    <p>{{ count }}</p>
    <button @click="increment">加算する</button>
</template>
HIR0HIR0

weatch()関数

コード
<script setup lang="ts">
const count = ref(0)

watch(count, (newVal, oldVal) => {
    console.log(`countの値が変化しました: ${oldVal}${newVal}`)
})

function increment() {
    count.value++
}
</script>
<template>
    <p>{{ count }}</p>
    <button @click="increment">加算する</button>
</template>
HIR0HIR0

テンプレートブロックの基本構文

動的に値を割り当てる v-bind

v-bindはテンプレート側から子コンポーネントへのプロパティ(props)やHTML属性に動的に値を割り当てるときに使うディレクティブ。一般的にはv-bindを省略して:だけを使って記述することが多い。

v-bind:属性名 = テンプレート変数
// もしくは
:属性名 = テンプレート変数

イベント発火時の処理を設定する v-on

v-onはテンプレート内でクリックやキー入力といったイベントが発火した際の処理を指定するときに使うディレクティブ。一般的にはv-onを省略して@イベント名の形式で記述することが多い。

v-on:イベント名 = "イベント発火時に実行するメソッド名"
// もしくは
@イベント名 = "イベント発火時に実行するメソッド名"

双方向データバインディングを行う v-model

v-modelはフォーム要素やカスタムコンポーネントとの間で双方向のデータバインディングを行うためのディレクティブ。テンプレートの入力フォームなどにv-modelを利用すると、ユーザーの入力内容をリアクティブな変数で常に同期することができるようになる。

v-model="テンプレート変数"

条件分岐を行う v-if

v-ifは条件に応じて要素の挿入・削除を切り替えるためのディレクティブ。条件がtrueとなった場合にレンダリングされる。

v-if="条件"
v-else-if="条件"
v-else

表示・非表示を切り替える v-show

v-showは条件に応じて要素の表示・非表示を切り替えるためのディレクティブ。v-ifは条件がfalseである場合、そもそもDOM上に存在しないが、v-showの場合は要素のdisplayCSSを切り替えるだけとなり、falseの場合でもDOM上には存在する。
使い分けは、v-ifは表示・非表示のレンダリングコストが都度かかるため、表示・非表示が画面表示段階で決まっているような要素に利用する。v-showは初回のでレンダリングコストはかかるが表示・非表示の切替コストが低いため、画面表示後に頻繁に表示切替が発生するような要素に利用する。

v-show="条件"

ループ制御 v-for

v-forはリストや配列を繰り返し表示するためのディレクティブ。
v-bind:keyディレクティブを記述し、Vueが要素を識別できるようにしてあげることが推奨されている。

v-for="エイリアス" in "ループ対象"
HIR0HIR0

v-bind

コード
ParentComponent.vue
<script setup lang="ts">
 const message = ref('Hello')
</script>
<template>
    <ChildComponent v-bind:msg="message" />
</template>
ChildComponent.vue
<script setup lang="ts">
 const props = defineProps<{
    msg: string
}>()
</script>
<template>
    <p>{{ props.msg }}</p>
</template>
HIR0HIR0

v-on

コード
<script setup lang="ts">
const count = ref(0)

function increment() {
    count.value++
}
</script>
<template>
    <button v-on:click="increment">カウントアップ</button>
    <p>Count:{{ count }}</p>
</template>
HIR0HIR0

v-model

コード
<script setup lang="ts">
const message = ref('')
</script>
<template>
    // 入力された内容が自動でmessage変数に反映される
    // コード内でmessage.valueを書き換えれば自動でinputの値も変わる
    <input v-model="message" type="text">
    <p>入力内容: {{ message }}</p>
</template>
コード(コンポーネント)
ParentComponent.vue
<script setup lang="ts">
const userInput = ref('')
</script>
<template>
    <ChildComponent v-model="userInput">
    <p>入力値: {{ userInput }}</p>
</template>
ChildComponent.vue
<script setup lang="ts">
const props = defineProps<{
    modelValue: string
}>()

const emit = defineEmits<{
    (e: 'update:modelValue', value:  string): void
}>()

function handleInput(e: Event) {
    const target = e.target as HTMLInputElement
    emit('update:modelValue', target.value)
}
</script>
<template>
    // 内部の入力値が変わったらhandleInput()で親に通知する
    <input :value="props.modelValue" @input="handleInput" type="text" />
</template>
HIR0HIR0

v-if

コード
<script setup lang="ts">
const isLoggedIn = ref(false)
</script>
<template>
    <div v-if="isLoggedIn">
        ログイン中
    </div>
    <div v-else>
        未ログイン
    </div>
</template>
HIR0HIR0

v-show

コード
<script setup lang="ts">
const isVisible = ref(true)
</script>
<template>
    <div v-show="isVisible">
        表示・非表示の切替
    </div>
</template>
HIR0HIR0

v-for

コード
<script setup lang="ts">
interface Task {
    id: number
    title: string
    done: boolean
}
const tasks = ref<Task[]>([
    { id: 1, title: "タスク1,  done: false },
    { id: 2, title: "タスク2,  done: true },
])
</script>
<template>
   <ul>
       <li v-for="task in tasks" :key="task.id">
           {{ task.title }}
       </li>
   </ul>
</template>
HIR0HIR0

親から子へデータを渡す仕組み Props

Propsは親のコンポーネントから子のコンポーネントへデータを渡すための仕組み。Propsを定義する際にはdefineProps()関数を利用する。
基本的にデータの受け渡しは親→子の流れとなり、子から親へ何かを通知したり、値を変更したい場合は後述するEmitを活用する。
Propsによって子に渡されたデータは読み取り専用となるため注意が必要。

// 型定義を用いる場合
interface Props {
    プロップス名: データ型
}
const props = defineProps<Props>();

// 直接定義する場合
const props = defineProps<{
    プロップス名: データ型
}>()
HIR0HIR0

Propsの定義

コード
ChildComponent.vue
<script setup lang="ts">
interface Props {
    title: string
    subtitle: string
}
const props = defineProps<Props>();
</script>
<template>
    <h1>{{ props.title }}</h1>
    <h2>{{ props.subtitle }}</h2>
</template>
ParentComponent.vue
<script setup lang="ts">
const mainTitle = ref('タイトル')
const subTitle = ref('サブタイトル')
</script>
<template>
    <ChildComponemt :title="mainTitle" :subtitle="subTitle" />
</template>
HIR0HIR0

子から親へ通知する仕組み Emit

Emitは子のコンポーネントから親のコンポーネントへ通知を送るための仕組み。ここで言う通知とは、子コンポーネントがイベントを発火させることで、親コンポーネントはそのイベントを受け取り、何かしらの処理を実施することを意味する。Propsではデータの流れが親→子へ一方向であるため、Emitを使って親へ子が情報を伝える。
簡単に表現すると、子が親のメソッドを実行する仕組み。

// typeキーワードを用いる場合
type Emits {
    (event: 'イベント名', 引数: データ型,)
}
const emit = defineEmits<Emits>();

// 直接定義する場合
const emit = defineEmits<{
    (event: 'イベント名', 引数:データ型)
}>()
HIR0HIR0

Emitの定義

コード
ChildComponent.vue
<scirpt setup lang="ts">
type Emits = {
    (event: 'child-cliclked', payload: string)
}
const emit = defineEmits<Emits>();

function handleClick() {
    emit('child-clicked', 'Hello from child')
}
</scirpt>
<template>
    <button @click="handleClick">子コンポーネントのボタン</button>
</template>
ParentComponent.vue
<scirpt setup lang="ts">
function handleChildClicked(message: string) {
    console.log('子でクリックが発生', message)
}
</scirpt>
<template>
    <ChildComponent @child-clicked="handleChildClicked" />
</template>
HIR0HIR0

ステート管理

ステート管理とは、アプリケーション内で行うデータの状態(state)をアプリケーション全体で一元的に管理し、各コンポーネントはそこからデータを取得したり、更新したりといったことをする仕組み。
アプリケーションの規模が大きくなり、コンポーネント数が増えてくると、どのデータを誰が管理してどう更新するか?が複雑になるため、その複雑さを解消する仕組みとして提供される。

useState()関数を使ったステート管理

スクリプトブロック内でuseState()関数を実行すると、そのデータはステートとして管理され、コンポーネントを跨いで利用できるリアクティブデータとして保持される。
定義されたステートを利用する際もuseState()関数を利用し、戻り値としてステートの値を受け取る。useState()で取得したデータは、リアクティブな変数であるため、データにアクセスしたり更新を行う際は.valueプロパティを利用する。

// ステートの定義①
useState<データ型>(
    ステート名,
    (): データ型 => {
        ステートの初期値生成処理
    return ステートの初期値;
    }
);

// ステートの利用
const ステートを格納する変数 = useState<データ型>(ステート名);

// ステート定義の方法②
const ステートを格納する変数 = userState<データ型>(ステート名, () => 初期値)

Piniaを使ったステート管理

PiniaはVue3向けのステート管理ライブラリで、Nuxt3でも標準で利用することが可能。軽量かつシンプルなことが特徴。

npm install pinia @pinia/nuxt
nuxt.config.ts
export default defineNuxtConfig({
    modules: [
        '@pinia/nuxt'
    ],
    pinia: {
        autoImports: [
            // 自動インポートの設定
            'defineStore'
        ],
    },
})
HIR0HIR0

useState()

コード
composables/useUser.ts
<script setup lang="ts">
export const useUserState = () => {
    // "user"というステートを定義
    const user = useState<{ name: string }>('user', () => ({ name: '' }))

    function setUserName(name: string) {
        user.value.name = name
    }

    return { user, setUserName }
}
</script>
LoginForm.vue
<script setup lang="ts">
import { useUserState } from '~/comosables/useUser'

const { setUserName } = useUserState()

function onLogin() {
    setUserName("Hoge")
}
</script>
<template>
    <button @click="onLogin">ログイン</button>
</template>
Home.vue
<script setup lang="ts">
import { useUserState } from '~/comosables/useUser'

const { user } = useUserState()
</script>
<template>
    <p>こんにちは、{{ user.name }}さん</p>
</template>
HIR0HIR0

Pinia

コード
stores/counter.ts
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
    // state: ストアで保持するリアクティブな値
    state: () => ({
        count: 0
    }),

    // getters: stateから派生する値。Vueのcomputedと似たイメージ
    gettrers: {
        doubleCount: (state) => state.count * 2,
    },

    // actions: stateを更新したりするメソッド
    actions: {
        increment() {
            this.count++
        },
    },
})
components/CounterExample.vue
<script setup lang="ts">
import { useCounterStore } from '~/stores/counter'

// ストアを呼び出す
const counterStore = useCounterStore()

// 値やメソッドへアクセス
function increment() {
    counterStore.increment()
}
</script>
<template>
    <div>
        <p>Count: {{ counterStore.count }}</p>
        <p>DoubleCount: {{ counterStore.doubleCount }}</p>
        <button @click="increment">+1</button>
    </div>
</template>
HIR0HIR0

続き

長くなってきたのでスクラップを分ける

Part2