iT邦幫忙

2025 iThome 鐵人賽

DAY 22
0
Modern Web

Angular、React、Vue 三框架實戰養成:從零打造高品質前端履歷網站系列 第 22

Day 22 Vue 資料綁定 – 用 v-for 渲染清單 + 分類與搜尋

  • 分享至 

  • xImage
  •  

今日目標

  • src/data/ 準備 skills / projects 資料
  • ref/readonly 管理資料,v-for 產生清單
  • 使用 v-model + computed 做「分類 + 關鍵字」過濾
  • 了解 :key 的重要性(效能與狀態穩定)

準備資料(放在 src/data/

src/data/skills.ts

export type SkillCategory = 'frontend' | 'backend' | 'tools'
export interface Skill { name: string; category: SkillCategory }

export const skills: Skill[] = [
  { name: 'HTML / CSS / SCSS', category: 'frontend' },
  { name: 'TypeScript', category: 'frontend' },
  { name: 'Vue / Angular / React', category: 'frontend' },
  { name: 'Node.js / Express', category: 'backend' },
  { name: 'Git / GitHub / Docker', category: 'tools' },
  { name: 'Vite / Webpack', category: 'tools' },
]

src/data/projects.ts

export interface Project {
  id: number
  slug: string
  title: string
  tech: string
  desc: string
  tags: string[]
  images: string[]
  demo: string
  repo: string
  featured: boolean
}

export const projects: Project[] = [
  {
    id: 1,
    slug: 'maomao-shop',
    title: '毛毛購物(寵物電商)',
    tech: 'Vue + Node.js|購物車、結帳、RWD',
    desc: '主導前端架構,完成商品列表、購物流程與訂單頁。',
    tags: ['vue', 'node', 'rwd', 'ecommerce'],
    images: ['assets/projects/maomao-1.png'],
    demo: '#',
    repo: '#',
    featured: true
  },
  {
    id: 2,
    slug: 'line-bot-reservation',
    title: 'LINE Bot 預約系統',
    tech: 'Cloud Functions + LINE API|時段預約',
    desc: '整合 LINE 聊天介面與雲端排程,完成會員預約流程。',
    tags: ['line', 'gcp', 'serverless'],
    images: ['assets/projects/line-1.png'],
    demo: '#',
    repo: '#',
    featured: false
  }
]


Skills.vue:資料化 + 分類 & 搜尋

把昨天的靜態 Skills 改成資料驅動。

<!-- src/components/Skills.vue -->
<template>
  <section id="skills" class="container section" aria-labelledby="skills-title">
    <div class="section-header">
      <h2 id="skills-title">技能 Skillset</h2>

      <!-- 分類 -->
      <div role="tablist" aria-label="技能分類" class="filters">
        <button class="chip" role="tab"
                :aria-selected="category==='all'"
                @click="category='all'">全部</button>
        <button class="chip" role="tab"
                :aria-selected="category==='frontend'"
                @click="category='frontend'">前端</button>
        <button class="chip" role="tab"
                :aria-selected="category==='backend'"
                @click="category='backend'">後端</button>
        <button class="chip" role="tab"
                :aria-selected="category==='tools'"
                @click="category='tools'">工具</button>
      </div>
    </div>

    <!-- 關鍵字搜尋 -->
    <div class="field" style="margin:12px 0;">
      <label for="skill-search">關鍵字搜尋</label>
      <input id="skill-search" type="text" v-model.trim="keyword" placeholder="例如:Vue、Docker…" />
      <small class="muted">結果:{{ filtered.length }} 項</small>
    </div>

    <!-- 清單:用 v-for 渲染;記得 :key -->
    <ul class="skill-grid">
      <li v-for="s in filtered" :key="s.name">
        {{ s.name }}
      </li>
    </ul>
  </section>
</template>

<script setup lang="ts">
import { ref, computed, readonly } from 'vue'
import { skills as seed, type Skill } from '@/data/skills'

type Cat = 'all' | 'frontend' | 'backend' | 'tools'

// 固定資料用 readonly 包起來避免無意修改
const all = readonly<Skill[]>(seed)
const category = ref<Cat>('all')
const keyword = ref('')

const filtered = computed(() => {
  const kw = keyword.value.toLowerCase()
  return all.filter(s => {
    const byCat = category.value === 'all' || s.category === category.value
    const byKw  = !kw || s.name.toLowerCase().includes(kw)
    return byCat && byKw
  })
})
</script>

小提醒

  • v-model.trim 自帶去空白。
  • :key="s.name" 讓 Vue 能穩定追蹤項目(效能&避免重渲染出錯)。
  • 之後要做 debounce 或把狀態抽到 Pinia 再進階。

Projects.vue:資料化 + v-for 渲染卡片

<!-- src/components/Projects.vue -->
<template>
  <section id="projects" class="container section" aria-labelledby="projects-title">
    <h2 id="projects-title">作品集 Projects</h2>

    <!-- 精選切換(可選) -->
    <div class="field" style="margin:12px 0;">
      <label><input type="checkbox" v-model="onlyFeatured" /> 只看精選</label>
    </div>

    <div class="project-grid">
      <article class="card" v-for="p in view" :key="p.id">
        <h3>{{ p.title }}</h3>
        <p class="muted">{{ p.tech }}</p>
        <p>{{ p.desc }}</p>
        <div class="actions" style="display:flex; gap:8px; margin-top:8px;">
          <a class="btn small" :href="p.demo" target="_blank" rel="noopener">Live Demo</a>
          <a class="btn small btn-outline" :href="p.repo" target="_blank" rel="noopener">GitHub</a>
        </div>
      </article>
    </div>
  </section>
</template>

<script setup lang="ts">
import { ref, computed, readonly } from 'vue'
import { projects as seed, type Project } from '@/data/projects'

const all = readonly<Project[]>(seed)
const onlyFeatured = ref(false)

const view = computed(() => onlyFeatured.value
  ? all.filter(p => p.featured)
  : all
)
</script>

後續 Day 23 會導入 vue-router,把每張卡片的「查看詳情」做成 /projects/:slug,並拆出 ProjectDetail.vue。


今日成果

  • Skills / Projects 改為「用資料陣列驅動 UI」,新增/刪除項目只動資料、不動模板
  • 完成 Skills 的 分類 + 搜尋v-model + computed
  • Projects 支援 只看精選;清單改用 v-for 渲染、穩定 :key
  • 專案邁向「資料→畫面」的標準流,之後接 API 或換狀態管理都更容易

小心踩雷(常見誤用 → 正確)

  1. 忘了 :key
  • <li v-for="s in filtered">(可能導致狀態錯亂)
  • <li v-for="s in filtered" :key="s.name">
  1. computed 裡改 state
  • computed(() => { keyword.value = ''; return list })
  • computed 只能「算資料」,不要「改資料」
  1. 直接改 readonly 的陣列
  • all.push(...)
  • readonly 是防呆;要改資料請在來源(service / store)處理
  1. 把昂貴計算放模板
  • v-for="s in skills.filter(...)" 每次渲染都跑
  • ✅ 把過濾邏輯放在 computed(可快取)

下一步(Day 23 預告)

  • vue-router 導入路由:首頁 /、作品詳情 /projects/:slug
  • 在卡片上用 <RouterLink> 導向詳情頁
  • 詳情頁讀取 :slug,顯示單一專案內容(先從本地 projects.ts 查)
  • (進階)巢狀路由 + 子頁籤(Info / Gallery)

上一篇
Day 21 Vue 起手式 – 用 Vite + TypeScript 初始化專案,搬入網站骨架
系列文
Angular、React、Vue 三框架實戰養成:從零打造高品質前端履歷網站22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言