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/ の下に入ります。中身はこれだけでした。
<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 /> は残しました。
<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 は、こうなりました。
<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 のフォルダは自分で作ります。
<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 は、新しく次の形で作ります。
<template>
<div>
<!-- ページが替わったことを画面読み上げソフトへ知らせる部品。ページをまたぐのでここに置く -->
<NuxtRouteAnnouncer />
<!-- app/pages/ の中から、URL に合うファイルをここに出す(/ なら index.vue) -->
<NuxtPage />
</div>
</template>
app/pages/ にファイルを置くと、その名前が URL になります。index.vue は / です。
6-2. 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 の全体はこうなります。
<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/ です。
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 の両方から使います。
// サーバーのメモリに置く一覧。todos.get.ts と todos.put.ts の両方から使う
// ファイルにもデータベースにも書いていないので、npm run dev を止めると消える
export const store: { todos: { text: string; done: boolean }[] } = { todos: [] }
import { store } from '../utils/todoStore'
// サーバーのメモリにある一覧をそのまま返す
export default defineEventHandler(() => {
return store.todos
})
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 の全体です。
<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 で型を確かめる)があるが、今回は打っていない |
-
Nuxt(ナクスト)。Vue に、ページの振り分け・サーバー(Nitro)・ビルドを足したフレームワーク。言語ではありません。書く言語は TypeScript です。 ↩
-
Vue(ビュー)。画面を作るための JavaScript の部品。データを変えると、画面がそれに合わせて書き換わる作りになっています。 ↩
-
Node.js。JavaScript を、ブラウザの外(Mac の上)で動かすためのもの。コマンドの名前は
nodeです。Java でいう JVM の位置にあります。 ↩ -
npm。Node.js の部品を入れたり、
package.jsonのscriptsに書いたものを動かしたりするコマンド。npm createでプロジェクトを作り、npm run devで開発サーバーを動かします。 ↩ -
Nitro(ナイトロ)。Nuxt に入っているサーバー。Node.js の上で動き、ポート3000で待ち受けます。画面の HTML を作ったり、
server/api/の処理を動かしたりします。Java でいう Tomcat の位置にありますが、別に入れる必要はありません。 ↩ -
Vite(ヴィート)。
.vueや TypeScript のファイルを、Chrome が読める JavaScript に変えるもの。開発中は、保存したファイルをすぐ画面に反映する役目も持ちます。 ↩ -
TypeScript。JavaScript に型を書けるようにした言語。2012年に Microsoft が出しました。動かす前に JavaScript へ変換されます。 ↩
-
サーバーサイドレンダリング(SSR)。画面を開いたとき、サーバーが
.vueから HTML を作って返すこと。そのあとの追加・チェック・消すは、Chrome の中の Vue が画面を書き換えます。 ↩ -
HMR(Hot Module Replacement)。保存したファイルのぶんだけを、読み込み直しなしで Chrome の画面に反映する機能。開発サーバーのログに
hmr updateと出ます。 ↩ -
自動インポート。Nuxt は
ref・computedなどの Vue の関数や、components/に置いたファイルを、importを書かなくても使えるようにします。 ↩ -
コンポーネント。画面の一部を1つの
.vueファイルに切り出したもの。<TodoItem>のように、HTML のタグと同じ形で置けます。 ↩ -
fetch。サーバーへリクエストを送る関数で、ブラウザに最初から入っています。今の Node.js にも入っています。
globalThisは、ブラウザでも Node.js でも使える一番外側の入れ物で、globalThis.fetchはこのfetchを指します。 ↩ -
ハイドレーション。サーバーが作った HTML に、Chrome の Vue があとから動きを付けること。サーバーと Chrome の両方が同じ形の HTML を作る前提なので、形が違うと警告が出ます。 ↩