iT邦幫忙

2026 iThome 鐵人賽

0
Modern Web

重新認識Vue 走過路過不要錯過系列 第 30 篇

Vue 走過路過不要錯過 Day30 - 30 天走完,一個 Vue 3 小專案應該長什麼樣子

  • 分享至 

  • xImage
  •  

這 30 天寫下來,每一篇都是一個單點:ref、v-for、watch、slots、Pinia、Transition……單獨看都懂了,但真的要開一個新專案的時候,常常會卡在更前面的問題:

  • 資料夾要怎麼分?
  • API 請求寫在元件裡還是別的地方?
  • 這個狀態放元件、composable 還是 Pinia?
  • 寫完之後要怎麼放到網路上?
    最後一篇不教新語法,而是把前面講過的東西放進同一個專案裡,看看它們各自該待在哪裡。範例以一個「任務管理 app」為主,因為它夠小、但又需要列表、表單、API、跨頁狀態。

起手:用 create-vue 建專案

第 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 來說,我會勾:

  • Vue Router:至少會有「列表」和「詳情」兩頁
  • Pinia:任務清單需要跨頁共享
  • ESLint / Prettier:之前文章提到的坑(像是 v-if 配 v-for),有些 linter 可以先幫你抓到
    建好之後:
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

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 → store → view → component

用「載入任務清單,並可勾選完成」來走一次資料流,每一層只做自己的事。

第一層:api,只負責溝通

// 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。

第二層:store,管理狀態與「loading、error、資料」三種情況

用 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,跟平常寫組件幾乎沒有差別。

第三層:view,接起 store 與畫面

// 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)。

第四層:component,只管畫面

// 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 server
    最重要的一條:VITE_ 變數不能放機密。官方明講這些值會在建置時被打包進原始碼,任何人打開瀏覽器都看得到。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 天的東西落在哪裡

這 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 / Prettier
  • 資料夾的重點是責任單一:views 組頁面、components 畫畫面、composables 放邏輯、stores 管共享狀態、api 負責溝通
  • 一個功能的資料流:api → store → view → component,資料向下用 props,事件向上用 emit
  • 環境變數用 VITE_ 開頭,值是字串,而且不能放機密
  • 先寫在一起,重複或痛了再拆
  • 上線用 npm run build,GitHub Pages 要設 base,並處理重新整理 404 的問題
    這 30 天的內容到這裡告一段落。Vue 能聊的東西還有很多,不過如果把這些基礎弄熟,再去讀官方文件或別人的專案,應該不會再覺得每個東西都是獨立的碎片,而是知道它們在整體裡面的位置。

參考資料


上一篇
Vue 走過路過不要錯過 Day29 - 常見踩坑整理:v-if 配 v-for、忘了清除的計時器與監聽
系列文
重新認識Vue 走過路過不要錯過 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言