這 30 天寫下來,每一篇都是一個單點:ref、v-for、watch、slots、Pinia、Transition……單獨看都懂了,但真的要開一個新專案的時候,常常會卡在更前面的問題:
第 2 天提過 CDN 版和 Vite + SFC 的差別,真的要做專案,用官方的腳手架 create-vue:
npm create vue@latest
官方文件的前置條件是要先安裝 Node.js,版本為 ^22.18.0 || >=24.12.0。
執行後會有一串選項:要不要 TypeScript、JSX、Vue Router、Pinia、Vitest、端對端測試、ESLint、Prettier、Vue DevTools 等。官方建議不確定的就先選 No。以任務管理 app 來說,我會勾:
cd <your-project-name>
npm install
npm run dev
另外,官方提醒生成的範例元件是用 Composition API 加 <script setup> 寫的,跟我們這 30 天用的是同一種寫法。
選了 Router 和 Pinia 之後,src/ 底下大致會是這樣(api/ 和 composables/ 是自己加的):
src/
├─ main.js # 入口:建立 app、掛上 Pinia 與 Router
├─ App.vue # 根組件:放 <RouterView>
├─ router/
│ └─ index.js # 路由設定
├─ stores/
│ └─ tasks.js # Pinia:任務清單的共享狀態
├─ api/
│ └─ tasks.js # 只負責跟後端溝通
├─ composables/
│ ├─ useFetch.js # 可重用的邏輯
│ └─ useEventListener.js
├─ views/ # 「頁面」:對應路由
│ ├─ TaskListView.vue
│ └─ TaskDetailView.vue
├─ components/ # 「零件」:可重複使用的 UI
│ ├─ TaskItem.vue
│ └─ BaseModal.vue
└─ assets/ # 樣式、圖片
重點不是資料夾名稱,而是每個地方的責任要單一:
| 位置 | 負責什麼 | 不該做什麼 |
|---|---|---|
views/ |
組合頁面、接路由參數、呼叫 store | 不放可重複使用的 UI 細節 |
components/ |
畫畫面:props 進、事件出 | 不直接打 API、不直接讀 store(盡量) |
composables/ |
可重複使用的「邏輯」 | 不放畫面 |
stores/ |
跨頁面、跨元件共享的狀態與操作 | 不放只有單一元件用的暫時狀態 |
api/ |
發請求、檢查回應、回傳資料 | 不碰 Vue 的響應式 |
這樣分的好處是:要改 API 網址,只會動 api/;要換畫面,只會動 components/;出了 bug 也大概知道該去哪一層找。
main.js 要做的事很少,就是建立 app,把 Pinia 和 Router 掛上去:
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
createApp(App).use(createPinia()).use(router).mount('#app')
App.vue 則保持很乾淨,只放 <RouterView>。如果想要切頁動畫,就是前面 Transition 那篇的結構:
<template>
<RouterView v-slot="{ Component }">
<Transition name="fade" mode="out-in">
<component :is="Component" />
</Transition>
</RouterView>
</template>
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'tasks',
component: () => import('@/views/TaskListView.vue')
},
{
path: '/tasks/:id',
name: 'task-detail',
component: () => import('@/views/TaskDetailView.vue')
}
]
})
export default router
兩個小地方:
component: () => import(...) 是動態載入,打包時每個頁面會變成獨立的檔案,第一次進到該頁才下載@ 是 create-vue 預設設定好的路徑別名,指向 src/,這樣不用寫一長串 ../../
用「載入任務清單,並可勾選完成」來走一次資料流,每一層只做自己的事。
// api/tasks.js
const BASE_URL = import.meta.env.VITE_API_BASE_URL
async function request(path, options) {
const res = await fetch(`${BASE_URL}${path}`, options)
// fetch 遇到 404、500 不會丟錯,要自己檢查
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
export const fetchTasks = () => request('/tasks')
export const createTask = (data) =>
request('/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
這裡有 Day 22 講過的重點:fetch 只有在網路層面失敗才會 reject,HTTP 狀態碼是 404 或 500 的時候它照樣會 resolve,所以要自己檢查 res.ok。
用 Day 25 的 Setup Store 寫法:
// stores/tasks.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { fetchTasks, createTask } from '@/api/tasks'
export const useTaskStore = defineStore('tasks', () => {
const tasks = ref([])
const loading = ref(false)
const error = ref(null)
const doneCount = computed(() => tasks.value.filter((t) => t.done).length)
async function load() {
loading.value = true
error.value = null
try {
tasks.value = await fetchTasks()
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
async function add(title) {
const task = await createTask({ title })
tasks.value.push(task)
}
function toggle(id) {
const task = tasks.value.find((t) => t.id === id)
if (task) task.done = !task.done
}
return { tasks, loading, error, doneCount, load, add, toggle }
})
ref 就是 state、computed 就是 getters、函式就是 actions,跟平常寫組件幾乎沒有差別。
// views/TaskListView.vue
<script setup>
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useTaskStore } from '@/stores/tasks'
import TaskItem from '@/components/TaskItem.vue'
const store = useTaskStore()
// 要解構狀態時用 storeToRefs,才不會失去響應式
const { tasks, loading, error } = storeToRefs(store)
onMounted(store.load)
</script>
<template>
<p v-if="loading">載入中…</p>
<p v-else-if="error">載入失敗:{{ error.message }}</p>
<ul v-else>
<TaskItem
v-for="task in tasks"
:key="task.id"
:task="task"
@toggle="store.toggle(task.id)"
/>
</ul>
</template>
注意幾個細節:v-for 用 task.id 當 key 而不是 index(Day 11);狀態用 v-if / v-else-if / v-else 分成三種畫面;列表上沒有再疊 v-if(Day 29)。
// components/TaskItem.vue
<script setup>
defineProps({
task: { type: Object, required: true }
})
defineEmits(['toggle'])
</script>
<template>
<li>
<label>
<input type="checkbox" :checked="task.done" @change="$emit('toggle')" />
{{ task.title }}
</label>
</li>
</template>
TaskItem 完全不知道 store、API 的存在:資料從 props 進來,要做什麼用事件通知外面。這樣它在別的專案、別的頁面也能直接重用,測試時也只需要丟 props 進去看輸出。
上面 api/tasks.js 用了 import.meta.env.VITE_API_BASE_URL,對應的設定放在專案根目錄的 .env 檔:
# .env
VITE_API_BASE_URL=http://localhost:3000/api
# .env.production(npm run build 時載入)
VITE_API_BASE_URL=https://example.com/api
Vite 的規則有幾個要記:
VITE_ 開頭的變數會被暴露到前端程式碼裡,其他的不會.env.[mode] 優先權高於 .env;npm run dev 是 development 模式,npm run build 是 production 模式.env.local 這類 .local 檔案不會進 git,要確認 .gitignore 有包含 *.local
.env 之後要重啟 dev serverVITE_ 變數不能放機密。官方明講這些值會在建置時被打包進原始碼,任何人打開瀏覽器都看得到。API 金鑰這類東西,應該放在後端,由後端去呼叫。另外還有幾個內建常數可以用,例如 import.meta.env.DEV 能判斷是否為開發環境,import.meta.env.BASE_URL 是部署的基礎路徑(上面 router 就用到了)。
上面是「長大之後的樣子」,不代表小專案一開始就要全部到位。我自己的判斷順序是:
| 情況 | 該怎麼做 |
|---|---|
| 狀態只有一個元件用 | ref 放在元件裡就好 |
| 同一段邏輯寫第二次 | 抽成 composable |
| 狀態要給兩個以上、不同頁面的元件用 | 放進 Pinia |
| 只是爺爺傳孫子,中間隔了好幾層 | 先想 slots 或 provide / inject,不一定要上 Pinia |
| 模板越寫越長 | 把一塊獨立的 UI 拆成 component |
先寫在一起,痛了再拆,通常比一開始就切得很細更不容易走歪。
專案做完,要放到網路上,流程很單純:
npm run build # 產出 dist/ 資料夾
npm run preview # 在本機預覽 build 的結果
npm run build 會把成品放進 dist/,這個資料夾可以丟到任何靜態網站主機上。npm run preview 會在 http://localhost:4173 起一個本機伺服器讓你確認 build 出來的東西沒問題,但官方有特別說明,它只是用來預覽,不是正式環境的伺服器。
如果要部署到 GitHub Pages,有兩件事要注意:
1. 設定 base
網址如果是 https://<USERNAME>.github.io/<REPO>/,要在 vite.config.js 把 base 設成 '/<REPO>/';如果是 https://<USERNAME>.github.io/ 或自訂網域,維持預設的 '/' 即可。
// vite.config.js
export default defineConfig({
base: '/<REPO>/'
// ...
})
這個 base 也會透過 import.meta.env.BASE_URL 傳給前面寫的 createWebHistory,路由才會跟著正確。
2. 重新整理頁面出現 404
用 createWebHistory 的時候,網址是 /tasks/3 這種「真實路徑」,但靜態主機上並沒有這個檔案,直接重新整理就會 404。解法是讓主機把所有路徑都導回 index.html(像 Firebase Hosting 就是用 rewrites 設定),或是在不方便設定的主機上改用 createWebHashHistory,網址會變成 /#/tasks/3。
| 這 30 天講過的 | 在專案裡的位置 |
|---|---|
Proxy、ref / reactive、computed |
stores 與 composables 裡的狀態和衍生值 |
watch / watchEffect |
composables、view 裡的副作用處理 |
v-if、v-for 與 key、v-model |
views 與 components 的模板 |
props / emit、defineModel、slots |
components:定義零件的介面 |
| 模板 ref、生命週期 | components 與 views 的 DOM 操作、載入時機 |
| provide / inject | 一整個區塊內共享的東西(例如一組 Modal 的控制) |
| Composables | composables/:可重用的邏輯 |
| 串接 API(loading、error、資料) | api/ 加 store 裡的三種狀態 |
| Pinia | stores/:跨頁面共享的狀態 |
| Teleport、Transition、KeepAlive | App.vue 的切頁動畫、BaseModal、需要保留狀態的頁面 |
| 常見踩坑與清除 | 每個用到計時器、事件監聽的地方:onUnmounted 要成對 |
npm create vue@latest 起手,勾選 Router、Pinia 與 ESLint / Prettierviews 組頁面、components 畫畫面、composables 放邏輯、stores 管共享狀態、api 負責溝通VITE_ 開頭,值是字串,而且不能放機密npm run build,GitHub Pages 要設 base,並處理重新整理 404 的問題