0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

やってみるNuxt

0
Last updated at Posted at 2026-10-10

Nuxt1 で ToDoリストを作りました。入力欄に1件打つと下に1行出るところから始めて、チェック・消すボタン・2枚目のページ・サーバーへの保存と1つずつ足し、最後は本番用にビルドして node 1本で動かしています。全部 iMac の中で動かしたので、お金はかかっていません。

先に、やってみて分かったことを3つ書きます。

# 分かったこと
1 画面の行を消すコードを1行も書いていません。配列から1件外すと、画面の行も1つ減ります
2 app/pages/ や server/api/ にファイルを置くだけで URL が増えます。URL の設定は1行も書いていません
3 npm run build でできた .output/ は 2.7MB でした。196MB ある node_modules の名前を変えて外しても、画面も API も動きました

この記事を読むと明日できること

npm create nuxt でプロジェクトを作り、ToDoリストに機能を1つずつ足しながら、Vue2 の書き方(ref・v-model・v-for など)と Nuxt の機能(ページ・API・ビルド)を自分の Mac で動かせます。

想定している読者は、jQuery や JSP で Web の画面を作ったことがあり、Vue を書いてみたい人です。新しい書き方が出るたびに、昔の書き方と表で並べました。

前提を書いておきます。

  • iMac(Intel)の macOS 13 で動かしました
  • Node.js3 と npm4 が入っていることを、ターミナルで node -v と npm -v を打って確かめてから始めます。私の iMac では v24.15.0 と 11.12.1 でした。入っていなければ、Node.js の公式サイト(nodejs.org)から入れます。npm は Node.js と一緒に入ります
  • 入った版は Nuxt 4.5.2、Vue 3.5.43、Nitro5 2.13.4、Vite6 8.3.2 です
  • 専門用語の説明は巻末にまとめました。本文の脚注番号を押すと説明へ飛べます

作ったもの

Chrome で http://localhost:3000 を開いて使う、ToDoリストです。できることは6つあります。

# できること
1 入力欄に打って「追加」を押すと、下に1行出る
2 チェックを入れると、その行に取り消し線が付く
3 「消す」を押すと、その行が消える
4 残りの件数が出る。0件になると「全部終わりました」に変わる
5 「完了したものを見る」を押すと、チェックを入れたものだけのページへ移る
6 一覧をサーバーに置くので、/ を開き直しても中身が出る

最後のファイルの並びはこうなりました。自分で書いたのは7本です。

nuxt-study/
├── app/
│   ├── app.vue                  … 枠だけ(中にページを出す)
│   ├── components/
│   │   └── TodoItem.vue         … 1行ぶん
│   └── pages/
│       ├── index.vue            … ToDoリスト(/)
│       └── done.vue             … 完了したもの(/done)
├── server/
│   ├── api/
│   │   ├── todos.get.ts         … GET /api/todos
│   │   └── todos.put.ts         … PUT /api/todos
│   └── utils/
│       └── todoStore.ts         … サーバーのメモリに置く一覧
├── nuxt.config.ts
└── package.json

ページを開いてから、チェックを押して /done へ移るまでを、起きた順に図にしました。左から Chrome、app/ の3つ、server/ の3つの順に並べています。

実線の矢印は、呼び出したり値を渡したりするところです。点線の矢印は、todos.get.ts が一覧を返すところです。いちばん下の枠は、2枚のページが同じ一覧を見ていることを示しています。図の props・emit は5章、useState は6章、GET・PUT は7章で出てきます。

作った順番は次の8つです。1〜7 は、1つ足すたびに git でコミットしました。8 はビルドして動かすだけなので、コミットはありません。

# 足したもの 覚えた書き方
1 入力欄に1件打つと下に1行出る ref・v-model・v-for
2 チェックで完了にする チェックボックスの v-model・:class
3 消すボタン @click・splice
4 残りの件数 computed・v-if・v-else
5 1行ぶんを部品に分ける props・emit
6 2枚目のページ app/pages/・<NuxtLink>・useState
7 サーバーに保存する server/api/・$fetch
8 本番用にビルドして動かす npm run build・.output/

1〜5 は Vue の書き方で、6〜8 が Nuxt の機能です。

1. プロジェクトを作って、1件出す

1-1. npm create nuxt で作る

ターミナルで次を打ちました。nuxt-study はフォルダの名前です。

cd ~/Documents
npm create nuxt@latest nuxt-study

5つ聞かれました。

# 聞かれたこと 選んだもの
1 Need to install the following packages: create-nuxt@3.37.0 Ok to proceed? y
2 Which template would you like to use? minimal(デフォルト)
3 Which package manager would you like to use? npm(デフォルト)
4 Initialize git repository? Yes
5 Would you like to browse and install modules? No(デフォルト)

ひな形は content・minimal・module・ui・v5-nightly の5つから選べます。minimal がいちばん中身の少ないものです。

できたファイルは、node_modules・.git・.nuxt の3つのフォルダを除くと9本でした。

nuxt-study/
├── .gitignore
├── README.md
├── app/
│   └── app.vue
├── nuxt.config.ts
├── package-lock.json
├── package.json
├── public/
│   ├── favicon.ico
│   └── robots.txt
└── tsconfig.json

設定のファイルは4本あります。今回、自分で開いて書き換えたものはありません。

# ファイル 何が書いてあるか
1 package.json 使う部品(nuxt・vue・vue-router)と、npm run のあとに付ける名前。"dev": "nuxt dev" と書いてあるので、npm run dev で nuxt dev が動く
2 package-lock.json 実際に入った部品の、細かい版の記録
3 tsconfig.json TypeScript7 の設定
4 nuxt.config.ts Nuxt の設定。中身は compatibilityDate と devtools の2つだけだった

node_modules は、npm が入れた部品の置き場です。Java でいう WEB-INF/lib に近いものです。1万4千本近いファイルが入り、200MB 近くありました。.gitignore に最初から書いてあるので、git の記録には入りません。.nuxt は、Nuxt が自分で作る作業用のフォルダです。

画面のファイルは app/app.vue の1本だけです。Nuxt 4 では、画面のファイルが app/ の下に入ります。中身はこれだけでした。

app/app.vue
<template>
  <div>
    <NuxtRouteAnnouncer />
    <NuxtWelcome />
  </div>
</template>

<NuxtWelcome /> は、次の 1-2 で出る Welcome to Nuxt! の画面そのものです。<NuxtRouteAnnouncer /> は、ページが替わったことを画面読み上げソフトへ知らせる部品です。

1-2. npm run dev で開く

cd nuxt-study
npm run dev

約7秒で起動しました。出た行のうち、3つを抜き出します。

Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.3.2 and Vue 3.5.43)
Local: http://localhost:3000/
Network: use --host to expose

Chrome で http://localhost:3000 を開くと、Welcome to Nuxt! の画面が出ます。昔でいう Hello World はここまでです。

npm run dev を打ったターミナルは、動いているあいだ使えなくなります。この記事で出てくる curl などは、ターミナルの別のタブ(⌘T)で打ちます。止めるときは、npm run dev を打ったタブで Ctrl+C を押します。

Network: use --host to expose は、--host を付けない限り、この iMac の中からしか開けないという意味です。同じ家の LAN にある別の機械からは開けません。

Tomcat のようなサーバーを入れていないのに、画面が出ます。Nitro というサーバーが、npm create nuxt のときに一緒に入っていました。Nitro は、Node.js の上で動く JavaScript のプログラムです。Tomcat が Java の上で動くのと同じ関係で、ポート3000で待っていたプロセスは node の1つだけでした。

.html のファイルも1本もありません。Chrome が開くたびに、Nitro が app/app.vue から HTML を作って返しています8。

# 昔(JSP) 今(Nuxt)
1 リクエストを受けるもの Tomcat を別に入れる Nitro。npm create nuxt で一緒に入る
2 その下で動いているもの Java Node.js
3 画面のもとになるファイル index.jsp app/app.vue
4 書いたものが動くところ .jsp を Java に変換し、サーバーだけで動かす .vue を JavaScript に変換し、サーバーの node と Chrome の両方で動かす(1-3 で書く)

1-3. 入力欄とリストを書く

app/app.vue を次のように書き換えます。<NuxtWelcome /> は消し、<NuxtRouteAnnouncer /> は残しました。

app/app.vue
<script setup lang="ts">
// 入力欄の文字(v-model で入力欄とつながる)
const newText = ref('')
// やることの一覧(中身が変わると画面も変わる)
const todos = ref<string[]>([])

function addTodo() {
  const text = newText.value.trim()
  if (!text) return
  todos.value.push(text)
  newText.value = ''
}
</script>

<template>
  <div>
    <NuxtRouteAnnouncer />
    <h1>ToDoリスト</h1>
    <input v-model="newText" placeholder="やることを入力" @keyup.enter="addTodo">
    <button @click="addTodo">追加</button>
    <ul>
      <li v-for="(todo, i) in todos" :key="i">{{ todo }}</li>
    </ul>
  </div>
</template>

保存すると、開発サーバーに hmr update /app.vue と出て、Chrome の画面が読み込み直しなしで書き換わりました9。

入力欄に「牛乳を買う」と打って「追加」を押すと、下に1行出ます。これで、端から端まで通る一番小さい形ができました。

.vue のファイルは3つの部分でできています。<script setup> が処理、<template> が画面の HTML、あとで出てくる <style> が CSS です。

使った書き方は次のとおりです。

# 書き方 何をするか
1 ref 中身が変わると画面も変わる変数を作る。<script> の中では .value を付けて読み書きする
2 v-model 入力欄と変数を結ぶ。打った文字がそのまま newText に入る
3 v-for 配列の数だけ <li> を出す
4 {{ todo }} 値を画面に埋め込む。JSP の ${todo} と同じ
5 @keyup.enter・@click @ は、起きたことに関数を結ぶ印。@click は昔の onclick に当たる
6 :key="i" : を付けた属性は、文字ではなく式として読まれる。key は、v-for が出した行を Vue が1行ずつ見分けるための印

ref を使うのに import を書いていません。Nuxt が自動で読み込んでいます10。

<script setup lang="ts"> の lang="ts" は、この中を TypeScript で書くという指定です。今回出てくる TypeScript の書き方は、Java と並べると読めます。

# TypeScript 意味 Java で書くと
1 ref<string[]>([]) 文字の配列を入れる ref。<> の中が中身の型 List<String> の <> と同じ
2 i: number(3章) 型は名前のうしろに : で書く int i
3 type Todo = { text: string; done: boolean }(2章) 1件の形に名前を付ける フィールドだけを並べたクラス

書いたコードがどこで動いているかも、ここで分けておきます。

# いつ どこで動くか 何をするか
1 Chrome で開いたとき サーバー(Nitro) app.vue から HTML を作って返す
2 そのあと「追加」を押したとき Chrome の中(Vue) addTodo が動き、画面を書き換える。サーバーへは何も送らない

3章を書いていたときに curl http://localhost:3000/_nuxt/app.vue を打つと、app.vue が JavaScript に変換されて返ってきました。Chrome はこれを読み込んで動かしています。

jQuery で書いていたころと並べると、こうなります。

# 昔(jQuery) 今(Vue)
1 打った文字を読む $('#text').val() newText.value(v-model で結んである)
2 1行足す $('ul').append('<li>' + text + '</li>') todos.value.push(text)
3 画面に <li> を足すのは 自分のコード v-for。配列が1件増えると <li> が1つ増える

このコードには、日本語の入力と相性の悪いところが1つ残っています。2章で直します。

2. チェックで完了にする

この章で変えるのは app/app.vue だけです。章の終わりに、ファイル全体を載せます。

2-1. 1件に done を持たせる

1件の形を、文字だけから「文字と、完了したかどうか」に変えます。<script setup> の頭に type Todo を足し、todos の型と、addTodo の中の push を書き換えます。

type Todo = { text: string; done: boolean }
const todos = ref<Todo[]>([])

// addTodo の中の push(text) を、こう替える
todos.value.push({ text, done: false })

{ text, done: false } は、{ text: text, done: false } を短く書いたものです。名前と変数が同じときは、片方を省けます。

画面の {{ todo }} も {{ todo.text }} に変えます。見た目は変わりません。

2-2. チェックボックスを付ける

<li> の中を書き換え、各行の頭にチェックボックスを置きます。v-model で done と結びます。

<li v-for="(todo, i) in todos" :key="i">
  <input v-model="todo.done" type="checkbox">
  {{ todo.text }}
</li>

入力欄と同じ v-model が、チェックボックスにも使えます。確かめるために、</ul> のすぐ下に <pre>{{ todos }}</pre> を置き、一覧の中身を画面にそのまま出してみました。チェックを入れると、その行が "done": true に変わります。この <pre> は 2-4 で消します。

2-3. 変換の Enter で追加されないようにする

「牛乳を買う」と打つと、「牛乳を」の時点で1件追加されました。日本語の変換を確定する Enter で、1章の @keyup.enter が動いたと見ています。

入力欄と「追加」を <form> で囲み、送信したときだけ追加するように変えます。入力欄の @keyup.enter と、ボタンの @click は外します。

<form @submit.prevent="addTodo">
  <input v-model="newText" placeholder="やることを入力">
  <button>追加</button>
</form>

.prevent は、<form> を送信したときのページの読み込み直しを止める指定です。Enter や「追加」で <form> を送信する動きは昔のまま使い、サーバーへは何も送りません。

このあとは、「牛乳を買う」と打って Enter で確定し、もう一度 Enter を押すと、「牛乳を買う」が1件で入るようになりました。

2-4. 完了した行に取り消し線を付ける

<li> の中の {{ todo.text }} を、<span> で囲んだ形に置き換えます。

<span :class="{ done: todo.done }">{{ todo.text }}</span>

:class は、値によって class を付けたり外したりする書き方です。done が true の行にだけ class="done" が付きます。

CSS は、ファイルの最後(</template> のあと)に <style scoped> を足して書きます。scoped は、この CSS をこのファイルの中の要素にだけ効かせる指定です。確かめ用に置いた <pre>{{ todos }}</pre> は、ここで消します。

2章を終えたときの app/app.vue は、こうなりました。

app/app.vue
<script setup lang="ts">
// 1件の形:やることの文字と、完了したかどうか
type Todo = { text: string; done: boolean }

// 入力欄の文字(v-model で入力欄とつながる)
const newText = ref('')
// やることの一覧(中身が変わると画面も変わる)
const todos = ref<Todo[]>([])

function addTodo() {
  const text = newText.value.trim()
  if (!text) return
  todos.value.push({ text, done: false })
  newText.value = ''
}
</script>

<template>
  <div>
    <NuxtRouteAnnouncer />
    <h1>ToDoリスト</h1>
    <!-- Enter と「追加」はフォームの送信にまとめる。.prevent でページの読み込み直しを止める -->
    <form @submit.prevent="addTodo">
      <input v-model="newText" placeholder="やることを入力">
      <button>追加</button>
    </form>
    <ul>
      <li v-for="(todo, i) in todos" :key="i">
        <input v-model="todo.done" type="checkbox">
        <!-- done が true のときだけ class="done" が付く -->
        <span :class="{ done: todo.done }">{{ todo.text }}</span>
      </li>
    </ul>
  </div>
</template>

<style scoped>
.done {
  text-decoration: line-through;
  color: gray;
}
</style>

チェックはデータ(done)を変え、取り消し線はそのデータに合わせて変わります。ここでも、画面を直接いじるコードは書いていません。

# 昔(jQuery) 今(Vue)
1 チェックで線を引く change のときに $(this).next().toggleClass('done') を自分で呼ぶ :class="{ done: todo.done }" を書いておくだけ
2 完了したかの記録 画面のチェックボックスの状態 配列の done

3. 消すボタンを付ける

<li> の中の <span> のうしろに「消す」ボタンを置き、<script setup> の addTodo の下に removeTodo を足します。

<button type="button" @click="removeTodo(i)">消す</button>
function removeTodo(i: number) {
  todos.value.splice(i, 1)
}

i は、v-for="(todo, i) in todos" が数えている行の番号です。splice(i, 1) は、配列の i 番目から1件を取り除きます。

type="button" を付けたのは、<form> の中に置いても送信ボタンにならないようにするためです。type を書かない <button> は、<form> の中では送信になります。

消しているのは配列の1件だけです。<li> を消すコードはありません。

# 昔(JSP) 昔(jQuery) 今(Vue)
1 押すと フォームで id=3 を POST する $(this).closest('li').remove() todos.value.splice(i, 1)
2 画面の行を消すのは サーバーが一覧を作り直し、画面をまるごと出し直す 自分のコードで <li> を消す 配列から外すだけ。<li> は v-for が減らす
3 サーバーのデータ DELETE 文で消える 別に Ajax で送らないと残る まだどこにも送っていない(7章で送る)

動きを1つ確かめました。「牛乳・パン・みつ豆」の3件を入れ、みつ豆にだけチェックを入れてから、真ん中のパンを消します。残ったのは牛乳とみつ豆で、みつ豆にはチェックと取り消し線が付いたままでした。

みつ豆は、配列の3番目から2番目に繰り上がっています。それでもチェックが外れないのは、チェックの状態が画面の行ではなく、配列の done に入っているからです。

4. 残りの件数を出す

<script setup> の todos の下に1行足します。

const remaining = computed(() => todos.value.filter(todo => !todo.done).length)

<template> では、</ul> のすぐ下に2行足します。

<p v-if="remaining > 0">残り {{ remaining }} 件</p>
<p v-else>全部終わりました</p>

computed は、ほかの値から計算して出す値です。todos の中身や done が変わると、自動で数え直します。追加・チェック・消すの3か所に、数え直す処理を書かずに済みました。computed も import は書いていません。

=> は、関数を短く書く書き方です。昔の書き方に直すと、こうなります。

# 今の書き方 昔の書き方
1 todo => !todo.done function (todo) { return !todo.done }
2 () => todos.value.filter(...).length function () { return todos.value.filter(...).length }

filter は、条件に合うものだけを残した配列を作ります。done が false のものだけを残し、その length(件数)を数えています。

v-if と v-else は、どちらか1つだけを出す書き方です。curl http://localhost:3000/ で HTML を見ると、選ばれなかった方の <p> は入っていませんでした。隠しているのではなく、作っていません。

# 昔(jQuery) 今(Vue)
1 件数を出す 追加・チェック・消すのそれぞれで数え直し、$('#count').text(n) で書き込む computed を1行書く
2 表示を切り替える show() と hide() を自分で呼ぶ v-if と v-else

一覧が空のときも「全部終わりました」と出ます。remaining が 0 だからです。今回はこのままにしました。

5. 1行ぶんを部品に分ける

1行ぶん(チェックボックス・文字・「消す」)を、app/components/TodoItem.vue という別のファイルに移します11。components のフォルダは自分で作ります。

app/components/TodoItem.vue
<script setup lang="ts">
// 親から1件ぶんを受け取る。受け取ったものは読むだけで、書き換えない
defineProps<{ todo: { text: string; done: boolean } }>()
// 親へ知らせるもの2つ。toggle=チェックが押された、remove=「消す」が押された
const emit = defineEmits<{ toggle: []; remove: [] }>()
</script>

<template>
  <!-- 押されたことを親へ知らせるだけ。done を書き換えるのは親 -->
  <input :checked="todo.done" type="checkbox" @change="emit('toggle')">
  <!-- done が true のときだけ class="done" が付く -->
  <span :class="{ done: todo.done }">{{ todo.text }}</span>
  <button type="button" @click="emit('remove')">消す</button>
</template>

<style scoped>
.done {
  text-decoration: line-through;
  color: gray;
}
</style>

取り消し線の <style scoped> も、このファイルへ移しました。scoped の CSS は、書いたファイルの中の要素にしか効かないからです。親の app/app.vue の <style scoped> は、丸ごと消します。

toggle: []; remove: [] の [] は、知らせるときに渡す値が無いという意味です。

親の app/app.vue では、<li> の中を1行にします。

<li v-for="(todo, i) in todos" :key="i">
  <TodoItem :todo="todo" @toggle="toggleTodo(i)" @remove="removeTodo(i)" />
</li>

チェックが押されたときの関数も、removeTodo の上に足します。

// i 番目の done を反対にする
function toggleTodo(i: number) {
  const todo = todos.value[i]
  if (todo) todo.done = !todo.done
}

if (todo) を付けているのは、TypeScript の設定のためです。Nuxt が作る .nuxt/tsconfig.json には "noUncheckedIndexedAccess": true が入っていて、配列の [i] は「無いかもしれない」値として扱われます。無いときは何もしないように書いておかないと、型の上で通りません。

値の受け渡しは2つの向きがあります。

# 向き 書き方 今回渡したもの
1 親から子 props(子は defineProps で受け取る) 1件ぶん(text と done)
2 子から親 emit(子は defineEmits で決めておく) 「チェックが押された」「消すが押された」の2つの知らせ

子のチェックボックスは、v-model ではなく :checked="todo.done" にしています。v-model にすると、親の配列の done を子が直接書き換えることになるからです。子は知らせるだけで、配列を書き換えるのは親の toggleTodo と removeTodo だけにしました。一覧がおかしくなったら、親のファイルだけを見れば済みます。

<TodoItem> を使うのに import は書いていません。components/ に置いたファイルも、Nuxt が自動で読み込みます。開発サーバーが作る .nuxt/components.d.ts を開くと、TodoItem の行が足されていました。

このくらいの大きさの ToDoリストなら、分けなくても困りません。この章は props と emit を覚えるために置きました。

6. 2枚目のページを作る

6-1. 今の画面を pages/ に移す

app/pages のフォルダを作り、app/app.vue をそのまま app/pages/index.vue へ移します。移した index.vue からは、<NuxtRouteAnnouncer /> の1行を消します。ページをまたぐ部品なので、外側の app.vue に置くためです。

app/app.vue は、新しく次の形で作ります。

app/app.vue
<template>
  <div>
    <!-- ページが替わったことを画面読み上げソフトへ知らせる部品。ページをまたぐのでここに置く -->
    <NuxtRouteAnnouncer />
    <!-- app/pages/ の中から、URL に合うファイルをここに出す(/ なら index.vue) -->
    <NuxtPage />
  </div>
</template>

app/pages/ にファイルを置くと、その名前が URL になります。index.vue は / です。

6-2. done.vue を足して行き来する

app/pages/done.vue を作ります。この時点では、見出しと戻るリンクだけです。

app/pages/done.vue
<template>
  <div>
    <h1>完了したもの</h1>
    <!-- NuxtLink は <a> を作るが、押してもサーバーから HTML を取り直さず、Chrome の中で画面を差し替える -->
    <NuxtLink to="/">ToDoリストへ戻る</NuxtLink>
  </div>
</template>

index.vue には、<p v-else> の下に1行足します。

<NuxtLink to="/done">完了したものを見る</NuxtLink>

URL の設定は1行も書いていません。/done を curl で開いた結果は、ファイルを置くたびにこう変わりました。

# いつ /done の結果
1 app/pages/ を作る前 200。どの URL でも app.vue の画面が出ていた
2 app/pages/ を作ったあと 404
3 app/pages/done.vue を置いたあと 200。「完了したもの」の画面が出る

<NuxtLink> は、HTML としてはふつうの <a href="/done"> になります。違うのは押したときで、サーバーから HTML を取り直さず、Chrome の中で画面を差し替えます。

ここで、2件入れてから /done へ移り、戻ってきたら一覧が空になっていました。一覧は index.vue の ref に入っていて、ページを離れると一緒に消えるからです。

6-3. useState でページをまたいで持つ

index.vue の1行を替えます。

// 前
const todos = ref<Todo[]>([])
// 後
const todos = useState<Todo[]>('todos', () => [])

'todos' は値に付ける名前です。同じ名前で useState を呼べば、別のページからも同じ中身が取れます。() => [] は function () { return [] } のことで、最初の値(空の配列)を作る関数です。todos.value の使い方は ref と同じなので、ほかの行は変えていません。

done.vue でも同じ名前で呼び、チェックが入ったものだけを並べます。done.vue の全体はこうなります。

app/pages/done.vue
<script setup lang="ts">
// index.vue と同じ 'todos' という名前で useState を呼ぶと、同じ一覧が取れる
const todos = useState<{ text: string; done: boolean }[]>('todos', () => [])
// done が true のものだけ。index.vue でチェックが変わると自動で選び直す
const doneTodos = computed(() => todos.value.filter(todo => todo.done))
</script>

<template>
  <div>
    <h1>完了したもの</h1>
    <ul>
      <li v-for="(todo, i) in doneTodos" :key="i">{{ todo.text }}</li>
    </ul>
    <NuxtLink to="/">ToDoリストへ戻る</NuxtLink>
  </div>
</template>

4章の computed を、2枚目のページでも同じ形で使っています。/ でチェックを入れたものが、/done に並ぶようになりました。

useState の中身は、Chrome の中にあります。ページを移っても残りますが、Chrome で読み込み直すと最初の空の配列に戻ります。ここが、サーバーに置く JSP の session との違いです。

# 昔(JSP) 今(Nuxt)
1 ページを足す done.jsp を置く app/pages/done.vue を置く
2 行き来する <a href="done.jsp">。押すとサーバーから HTML をまるごと取り直す <NuxtLink to="/done">。最初の1回だけサーバーが HTML を作り、あとは Chrome の中で画面を差し替える
3 ページをまたいで値を持つ session。サーバーに置くので、読み込み直しても残る useState。Chrome の中に置くので、ページを移っても残るが、読み込み直すと消える

7. サーバーに保存する

ここまでの一覧は Chrome の中にしかないので、Chrome を読み込み直すと消えます。サーバーに置くように変えます。

7-1. GET の API を置く

server/api/todos.get.ts を置きます。置き場は app/ の下ではなく、プロジェクト直下の server/ です。

server/api/todos.get.ts
export default defineEventHandler(() => {
  return []
})

defineEventHandler は、リクエストが来たときに動く関数を登録する書き方です。return したものが、そのまま返事になります。配列を返すと JSON になります。export default は、このファイルが外へ出す中身はこの1つ、という印です。

# 昔(Servlet) 今(Nuxt)
1 GET で動く処理 doGet メソッドを書く todos.get.ts の defineEventHandler
2 URL との対応 web.xml か @WebServlet に書く ファイルの置き場所と名前で決まる。server/api/todos.get.ts は GET /api/todos
3 JSON を返す 自分で文字列にして response に書く 配列やオブジェクトを return する

ファイルを置いた直後に curl http://localhost:3000/api/todos を打つと、404 が返りました。開発サーバーのログにも何も出ていません。npm run dev を Ctrl+C で止めて打ち直すと、次のように返るようになりました。

curl -i http://localhost:3000/api/todos

HTTP/1.1 200 OK と content-type: application/json が返り、中身は [] でした。

ファイル名の .get は、GET のときだけ動くという意味です。同じ URL に curl -X POST を送ると 404 になりました。

7-2. PUT の API を置き、画面から呼ぶ

サーバー側は3本です。一覧を置く場所を server/utils/todoStore.ts に作り、GET と PUT の両方から使います。

server/utils/todoStore.ts
// サーバーのメモリに置く一覧。todos.get.ts と todos.put.ts の両方から使う
// ファイルにもデータベースにも書いていないので、npm run dev を止めると消える
export const store: { todos: { text: string; done: boolean }[] } = { todos: [] }
server/api/todos.get.ts
import { store } from '../utils/todoStore'

// サーバーのメモリにある一覧をそのまま返す
export default defineEventHandler(() => {
  return store.todos
})
server/api/todos.put.ts
import { store } from '../utils/todoStore'

// 画面から送られてきた一覧で、サーバーの一覧をまるごと置き換える
export default defineEventHandler(async (event) => {
  store.todos = await readBody(event)
  return store.todos
})

ここだけ import を書いています。どのファイルの store を使っているかが見て分かるように、書いておきました。書かなくても動くかは試していません。

今回は server/api/ がもうあったので、ファイルを足すと開発サーバーが組み直しを始めました(ログに Nuxt Nitro server built と出た)。打ち直しは要りませんでした。

画面側の index.vue には、<script setup> の remaining の下に2つ足します。

// 画面を開いたとき(Chrome で表示し終わったあと)に、サーバーから一覧を読む(GET)
onMounted(async () => {
  todos.value = await $fetch<Todo[]>('/api/todos')
})

// 一覧が変わるたびに(追加・チェック・消す)、一覧をまるごとサーバーへ送る(PUT)
watch(todos, (list) => {
  $fetch('/api/todos', { method: 'PUT', body: list })
}, { deep: true })

$fetch が、画面からサーバーを呼ぶ書き方です。開いたときに GET で一覧を読み、一覧が変わるたびに一覧をまるごと PUT で送ります。watch の { deep: true } は、配列の中の done が変わったときにも送るための指定です。

await は、サーバーから返事が来るのを待ってから、次へ進むという印です。待つのはこの関数の続きだけで、画面の操作は止まりません。await を使う関数には、頭に async を付けます。jQuery では返事が来たときの処理を success に関数で渡していましたが、ここでは上から順に書けます。

Chrome で「牛乳」と「こんにゃく」を足し、牛乳にチェックを入れてから curl http://localhost:3000/api/todos を打ちました。2件とも返ってきて、牛乳は "done": true になっていました。

あとから / を開き直すと、2件とも出ました。牛乳にはチェックと取り消し線が付き、「残り 1 件」です。開いたときの GET で、サーバーから読み直しています。

ただし /done を直接開き直すと、一覧は空でした。done.vue は $fetch を呼んでいないので、useState の最初の空の配列のままになります。/ を開いてから「完了したものを見る」で移れば、並びます。

7章を終えたときの index.vue の全体です。

app/pages/index.vue
<script setup lang="ts">
// 1件の形:やることの文字と、完了したかどうか
type Todo = { text: string; done: boolean }

// 入力欄の文字(v-model で入力欄とつながる)
const newText = ref('')
// やることの一覧。useState の 'todos' という名前で持つので、ページを移っても同じ中身が残る(ref はページを離れると消える)
const todos = useState<Todo[]>('todos', () => [])
// 残りの件数(done が false のものを数える)。todos や done が変わると自動で数え直す
const remaining = computed(() => todos.value.filter(todo => !todo.done).length)

// 画面を開いたとき(Chrome で表示し終わったあと)に、サーバーから一覧を読む(GET)
onMounted(async () => {
  todos.value = await $fetch<Todo[]>('/api/todos')
})

// 一覧が変わるたびに(追加・チェック・消す)、一覧をまるごとサーバーへ送る(PUT)
watch(todos, (list) => {
  $fetch('/api/todos', { method: 'PUT', body: list })
}, { deep: true })

function addTodo() {
  const text = newText.value.trim()
  if (!text) return
  todos.value.push({ text, done: false })
  newText.value = ''
}

// i 番目の done を反対にする(子の TodoItem から toggle が知らされたときに呼ぶ)
function toggleTodo(i: number) {
  const todo = todos.value[i]
  if (todo) todo.done = !todo.done
}

// i 番目を配列から1件取り除く。画面の行はこちらで消さない(配列が減れば v-for が1行減らす)
function removeTodo(i: number) {
  todos.value.splice(i, 1)
}
</script>

<template>
  <div>
    <h1>ToDoリスト</h1>
    <!-- Enter と「追加」はフォームの送信にまとめる。.prevent でページの読み込み直しを止める -->
    <form @submit.prevent="addTodo">
      <input v-model="newText" placeholder="やることを入力">
      <button>追加</button>
    </form>
    <ul>
      <li v-for="(todo, i) in todos" :key="i">
        <!-- 1行ぶんの見た目は components/TodoItem.vue。import は書かない(Nuxt の自動インポート) -->
        <TodoItem :todo="todo" @toggle="toggleTodo(i)" @remove="removeTodo(i)" />
      </li>
    </ul>
    <!-- remaining が 1 以上なら上の行、0 なら下の行のどちらか1つだけを出す -->
    <p v-if="remaining > 0">残り {{ remaining }} 件</p>
    <p v-else>全部終わりました</p>
    <!-- 2枚目のページ(app/pages/done.vue)へ。ルーティングの設定は書いていない -->
    <NuxtLink to="/done">完了したものを見る</NuxtLink>
  </div>
</template>

jQuery の $.ajax と並べると、こうなります。

# 昔(jQuery) 今(Nuxt)
1 PUT で送る $.ajax({ url: '/api/todos', type: 'PUT', contentType: 'application/json', data: JSON.stringify(list) }) $fetch('/api/todos', { method: 'PUT', body: list })
2 返事を受け取る success: function (list) { ... } に関数を渡す const list = await $fetch(...) で、上から順に書く
3 中で使っている部品 XMLHttpRequest fetch12

$fetch は、body に配列を渡すと JSON の文字に変えて送ります。そのため JSON.stringify は書いていません。

$fetch の中身は、ofetch という部品でした。node_modules/ofetch を grep すると、XMLHttpRequest は0か所、globalThis.fetch が16か所ありました。画面を読み込み直さずにサーバーへリクエストを送るという考え方は、$.ajax と同じです。中の部品が XMLHttpRequest から fetch に替わっています。

7-3. 開発サーバーを起動し直すと消える

npm run dev を止めて起動し直し、もう一度 curl http://localhost:3000/api/todos を打つと、[] が返りました。2件が消えています。

server/utils/todoStore.ts の一覧は、npm run dev で動いている node のメモリの中にしかありません。止めるとメモリごと無くなります。残すには、ファイルかデータベースへ書く処理が別に要ります。今回はここまでにしました。

8. 本番用にビルドして動かす

npm run dev を止めてから、ビルドします。

npm run build

12.72秒で終わり、最後に次の行が出ました。

[nitro] ✔ You can preview this build using node .output/server/index.mjs

できた .output/ の中身は3つです。

# 中身 何が入っているか
1 public/ Chrome に渡すファイル(244KB)
2 server/ node で動かすファイル(2.5MB)
3 nitro.json ビルドした日時と、node-server(node で動かす形)でビルドしたという記録

7章で server/api/ に置いた2本も、.output/server/chunks/routes/api/ の下に todos.get.mjs(426B)と todos.put.mjs(491B)として入っていました。

大きさを比べます。どちらもビルドした日に数えました。

# フォルダ 大きさ ファイルの数
1 .output/ 2.7MB 159本
2 node_modules/ 196MB 14,013本

node_modules の名前を変えて外してから、.output/ の中だけで動かします。

mv node_modules node_modules.bak
node .output/server/index.mjs

出たのは Listening on http://[::]:3000 の1行だけでした。npm run dev のときに出ていた Nuxt や Vite の表示はありません。

Chrome で開くと、npm run dev のときと同じ ToDoリストが出ました。1件足してチェックを入れたあと curl http://localhost:3000/api/todos を打つと、その1件が "done": true で返ってきます。画面も API も動いています。

.output/server/ の中を見ると、別の node_modules がありました。2.1MB で、vue・vue-router・unhead など13個です。動かすのに要る部品だけが、ビルドのときにここへ写されています。196MB のほうが無くても動いたのは、このためでした。

# 昔(JSP) 今(Nuxt)
1 本番用に固めたもの .war ファイル .output/ フォルダ
2 動かす先 別に用意した Tomcat に置く node .output/server/index.mjs。サーバー(Nitro)ごと入っている
3 本番の機械に別に要るもの Java と Tomcat Node.js だけ

最後に、node .output/server/index.mjs を打ったタブで Ctrl+C を押して止め、名前を戻します。

mv node_modules.bak node_modules
lsof -nP -iTCP:3000 -sTCP:LISTEN

lsof が何も返さなければ、ポート3000で待っているものは残っていません。


付録A:かかったお金

0円です。クラウドは使わず、全部 iMac の中で動かしました。npm から入れた部品もすべて無料です。

付録B:かかった時間

# 見たもの 時間
1 npm run dev の起動 約7秒
2 npm run build 12.72秒

付録C:つまずいたところ

# つまずき 分かったこと
1 日本語の変換を確定する Enter で、打ちかけの「牛乳を」が1件追加された @keyup.enter が変換の確定にも反応したと見ている。<form @submit.prevent> に替えたあとは、「牛乳を買う」が1件で入った
2 5章で app.vue の <style> を丸ごと TodoItem.vue へ移したら、Chrome に Unknown word const のエラーが出た 消したあとも、サーバーが返す HTML に app.vue のスタイルを読みに行く行が残っていた。npm run dev を打ち直したら消えた。コードは直していない
3 6章で app/pages/ に移した直後、Hydration node mismatch の警告が6回出た13 移したあとに index.vue から <NuxtRouteAnnouncer /> を消すまでの数秒に、サーバーが途中の形で HTML を作ったと見ている。Chrome を読み込み直したら出なくなった
4 7章で server/api/todos.get.ts を置いても、404 が返った npm run dev を打ち直したら 200 になった。理由は確かめきれていない(起動したときに server/ が無かったからか、あいだに iMac がスリープしていたからか)。2本目からは打ち直さなくても読み込んだ
5 /api/ を開くと、[VUE_ROUTER_R0004] No match found の警告が出た server/api/ に無い URL は、404 の JSON ではなく画面側の「ページが無い」になる。Nitro は server/api/ のファイルを先に探し、無ければ画面側(app/pages/)の振り分けへ回すから
6 /done を直接開き直すと、一覧が空だった done.vue は $fetch を呼んでいない。useState は Chrome の中の値なので、開き直すと最初の空の配列から始まる。/ から移れば並ぶ
7 止めたあとも lsof -i :3000 に1行残った 残ったのは Chrome のタブ側の接続(CLOSE_WAIT)だった。サーバーが残っていないかを見るなら -sTCP:LISTEN を付ける
8 TypeScript で書いているのに、型を確かめる部品が入っていなかった tsconfig.json はできていたが、npm ls typescript vue-tsc は空だった。npx nuxt --help には typecheck(vue-tsc で型を確かめる)があるが、今回は打っていない
  1. Nuxt(ナクスト)。Vue に、ページの振り分け・サーバー(Nitro)・ビルドを足したフレームワーク。言語ではありません。書く言語は TypeScript です。 ↩

  2. Vue(ビュー)。画面を作るための JavaScript の部品。データを変えると、画面がそれに合わせて書き換わる作りになっています。 ↩

  3. Node.js。JavaScript を、ブラウザの外(Mac の上)で動かすためのもの。コマンドの名前は node です。Java でいう JVM の位置にあります。 ↩

  4. npm。Node.js の部品を入れたり、package.json の scripts に書いたものを動かしたりするコマンド。npm create でプロジェクトを作り、npm run dev で開発サーバーを動かします。 ↩

  5. Nitro(ナイトロ)。Nuxt に入っているサーバー。Node.js の上で動き、ポート3000で待ち受けます。画面の HTML を作ったり、server/api/ の処理を動かしたりします。Java でいう Tomcat の位置にありますが、別に入れる必要はありません。 ↩

  6. Vite(ヴィート)。.vue や TypeScript のファイルを、Chrome が読める JavaScript に変えるもの。開発中は、保存したファイルをすぐ画面に反映する役目も持ちます。 ↩

  7. TypeScript。JavaScript に型を書けるようにした言語。2012年に Microsoft が出しました。動かす前に JavaScript へ変換されます。 ↩

  8. サーバーサイドレンダリング(SSR)。画面を開いたとき、サーバーが .vue から HTML を作って返すこと。そのあとの追加・チェック・消すは、Chrome の中の Vue が画面を書き換えます。 ↩

  9. HMR(Hot Module Replacement)。保存したファイルのぶんだけを、読み込み直しなしで Chrome の画面に反映する機能。開発サーバーのログに hmr update と出ます。 ↩

  10. 自動インポート。Nuxt は ref・computed などの Vue の関数や、components/ に置いたファイルを、import を書かなくても使えるようにします。 ↩

  11. コンポーネント。画面の一部を1つの .vue ファイルに切り出したもの。<TodoItem> のように、HTML のタグと同じ形で置けます。 ↩

  12. fetch。サーバーへリクエストを送る関数で、ブラウザに最初から入っています。今の Node.js にも入っています。globalThis は、ブラウザでも Node.js でも使える一番外側の入れ物で、globalThis.fetch はこの fetch を指します。 ↩

  13. ハイドレーション。サーバーが作った HTML に、Chrome の Vue があとから動きを付けること。サーバーと Chrome の両方が同じ形の HTML を作る前提なので、形が違うと警告が出ます。 ↩

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?