iT邦幫忙

2025 iThome 鐵人賽

DAY 5
1
Vue.js

需求至上的 Vue 魔法之旅系列 第 5

Day 4 : 為什麼需要資料綁定?用「狀態→樣式」引出 :class

  • 分享至 

  • xImage
  •  

前言|延續 Day 3 的思維

昨天我們把 姓名(必填)/ 備註(選填) 加入點單,並用 v-model 打通「輸入 ↔ 狀態」;

同時沿用 Day 1 的流程控制(v-if / :disabled)與 Day 2 的事件驅動(@click → addOrder)。

今天 Day 4 要解決的是:狀態要「看得見」——當某步驟未完成、完成、或錯誤時,介面要即時反映。這也是需求驅動功能拉~

這正是 資料綁定 → 樣式 的用途:以 :class(v-bind:class)把「狀態」直接綁到「CSS 類別」,讓 UI 和邏輯保持一致。

https://ithelp.ithome.com.tw/upload/images/20250919/20121052RHLTd8yZx7.png

為什麼會事先講v-model在講v-bind呢?

其實我只是要帶出一樣是需求 後的產物

基本上你的點餐服務思考到這個階段

前面的事可以點餐可統計的基本功能

但隨者使用者變多,系統需要更完美UIUX要更華麗的視覺介面根更良好的系統體驗設計

我們就會需要用到CSS

這時候剛好就可以帶到bind的功能介紹喔!!

一、需求分析

我們可以思考,有了基本功能後

應該還需要一些顏色或是提醒文字來告訴使用者現在的輸入階段

這時候我們的css就派上用場了

這時候老闆一定是希望

請提示使用者:尚未輸入名稱飲料-> 甜度-> 冰量的請幫我一個一個步驟來

並且給紅色的顏色提示字 告知使用者填到哪個階段,還有缺少什麼?

1. User Story

我們可以把使用者的角色跟他的故事(操作動作) 一 一 列下來後方便你去列需求表格跟後續的程式設計

角色 故事 驗收條件
點餐者 作為使用者,我希望系統能以更好的樣式與使用者體驗提示每一步是否完成,例如未完成的欄位顯示紅框與提示文字,完成後顯示綠框;送出後在訂單清單中能一眼看出甜度與冰量。 1. 未完成的欄位自動帶有 invalid 樣式。2. 完成的欄位自動帶有 complete 樣式。3. 訂單清單中不同甜度或冰量顯示對應顏色的標籤。

2. 需求表格

如同前幾天的步驟

我們可以把

面向 需求描述
功能 讓表單的每一個步驟(姓名、飲料、甜度、冰量)依完成狀態自動切換樣式,例如未完成顯示紅框、完成顯示綠框。
使用者 點餐者。
資料 狀態:hasDrinkhasSweetnesshasIcecanSubmit,以及訂單清單 orders
互動 使用者輸入或選擇時,即時更新狀態;送出後自動清空並刷新 UI。
UI / UX 顏色標籤明確顯示「哪一步已完成/未完成」,並在訂單清單中對不同屬性加上辨識標籤(甜度、冰量)。
驗收 1. 任一必填步驟未完成時該欄位自動帶有 invalid 樣式。2. 完成後切換為 complete 樣式。3. 清單能顯示含有不同屬性標籤的訂單。

3. 時序圖

上述都列完後可以畫時序圖

這樣就可以清楚的知道我們今天update的內容是蝦咪喽~

https://ithelp.ithome.com.tw/upload/images/20250919/20121052TuN3YML6Dc.png

4. 程式的設計流程

我們今天著重在

class的設計部分也就是completed是否已完成根invalid顏色改變的部分

https://ithelp.ithome.com.tw/upload/images/20250919/201210526j8ImorLKl.png

5. UI設計的部分

我的設計思路

5.1 上面選單filed的部分

  • 設計師會設計上方如果尚未填寫我就是紅色

  • 必填已填的就會是綠色

https://ithelp.ithome.com.tw/upload/images/20250919/20121052KmxQRCrln4.png

5.2 下方list的部分

  • 這邊我會把甜度(正常甜根無糖)跟冰量(正常冰跟去冰) 分別用不同色系表示讓使用者更加明確的觀賞清單

https://ithelp.ithome.com.tw/upload/images/20250919/20121052bhtAIxrzZ2.png

二、對應的 Vue 技術

1.V-bind的介紹

  • 功能:在模板中把 HTML 屬性(class、style、src、value、id、disabled…)綁定到 JavaScript 變數或運算結果。

  • 語法糖:: 是 v-bind 的縮寫。

  • 效果:當對應的資料改變時,Vue 自動更新屬性或樣式,不需手動操作 DOM。

他也可以使用縮寫的寫法

<!-- 完整寫法 -->
<div v-bind:class="{ active: isActive }"></div>

<!-- 縮寫 -->
<div :class="{ active: isActive }"></div>

簡單的範例

透過前面學到的@click我們嘗試把class顯示啟動跟未啟動的class

順便把value綁上去

https://ithelp.ithome.com.tw/upload/images/20250919/201210526v2sLEtuAQ.png

https://ithelp.ithome.com.tw/upload/images/20250919/20121052JA8DUDFM6u.png

<!-- VBindDemo.vue -->
<template>
  <h2>v-bind:class 最小範例</h2>

  <button :class="{ active: isActive }" @click="toggle">
    {{ isActive ? '✅ 已啟動' : '❌ 未啟動' }}
  </button>

  <p>目前狀態:{{ isActive ? '啟動中' : '關閉中' }}</p>
</template>

<script setup>
import { ref } from 'vue'

// 狀態變數
const isActive = ref(false)

// 切換狀態
function toggle() {
  isActive.value = !isActive.value
}
</script>

<style scoped>
button {
  padding: 8px 16px;
  border-radius: 6px;
  border: 1px solid #aaa;
  cursor: pointer;
}
/* 只有當 isActive 為 true 時才會被套用 */
.active {
  background-color: #4caf50;
  color: #fff;
  border-color: #4caf50;
}
</style>

需求 Vue 技術與重點
步驟完成與否切換樣式 :class(v-bind:class)<div :class="{ invalid: !name, complete: !!name }">,依據狀態動態套用 CSS 類別。
對不同甜度/冰量加上標籤色 :class + 條件判斷:<span :class="sweetness === '去糖' ? 'is-nosugar' : 'is-sugar'">
判斷每步是否完成 computed 屬性:hasDrinkhasSweetnesshasIce,讓模板判斷更乾淨。
送出按鈕啟用與樣式 :disabled="!canSubmit" 搭配 :class="canSubmit ? 'enabled' : 'disabled'"
清單動態渲染 v-for 迭代 orders 陣列,並結合 :class 呈現不同屬性標籤。

三、程式實作

流程圖可以參照我們前面畫的需求部分flow chart

再根據決定的色系來實作程式碼

完成後會是這樣

<!-- DrinkOrdersDay4.vue -->
<template>
  <h2>飲料點單(Day 4:狀態→樣式 :class)</h2>

  <!-- Day 3:姓名/備註 -->
  <div :class="['block', name ? 'complete' : 'invalid']">
    <label>姓名(必填)
      <input type="text" v-model.trim="name" placeholder="請輸入你的名字" />
    </label>
    <p class="hint" v-if="!name">尚未填寫姓名</p>
  </div>

  <div class="block">
    <label>備註(選填)
      <textarea v-model.trim="note" placeholder="例如:三點拿、少冰"></textarea>
    </label>
  </div>

  <!-- 步驟 1:飲料 -->
  <fieldset :class="['block', hasDrink ? 'complete' : 'invalid']">
    <legend>步驟 1:選擇飲料</legend>
    <label><input type="radio" name="drink" value="紅茶" @change="onDrinkChange('紅茶')" /> 紅茶</label>
    <label><input type="radio" name="drink" value="綠茶" @change="onDrinkChange('綠茶')" /> 綠茶</label>
    <p class="hint" v-if="!hasDrink">尚未選取飲料</p>
  </fieldset>

  <!-- 步驟 2:甜度(僅在選好飲料後顯示) -->
  <fieldset v-if="hasDrink" :class="['block', hasSweetness ? 'complete' : 'invalid']">
    <legend>步驟 2:選擇甜度</legend>
    <label><input type="radio" name="sweetness" value="正常甜" @change="onSweetnessChange('正常甜')" /> 正常甜</label>
    <label><input type="radio" name="sweetness" value="去糖" @change="onSweetnessChange('去糖')" /> 去糖</label>
    <p class="hint" v-if="!hasSweetness">尚未選擇甜度</p>
  </fieldset>

  <!-- 步驟 3:冰量(僅在選好甜度後顯示) -->
  <fieldset v-if="hasDrink && hasSweetness" :class="['block', hasIce ? 'complete' : 'invalid']">
    <legend>步驟 3:選擇冰量</legend>
    <label><input type="radio" name="ice" value="正常冰" @change="onIceChange('正常冰')" /> 正常冰</label>
    <label><input type="radio" name="ice" value="去冰" @change="onIceChange('去冰')" /> 去冰</label>
    <p class="hint" v-if="!hasIce">尚未選擇冰量</p>
  </fieldset>

  <!-- 送出 -->
  <button :disabled="!canSubmit" @click="addOrder" :class="['submit', canSubmit ? 'enabled' : 'disabled']">
    {{ canSubmit ? '送出' : '請完成所有必填' }}
  </button>

  <!-- 清單:用 :class 呈現屬性標籤樣式 -->
  <section v-if="orders.length" class="list">
    <h3>目前已送出的訂單</h3>
    <ul>
      <li v-for="(o, i) in orders" :key="i" class="order">
        <span class="idx">{{ i + 1 }}.</span>
        <span class="name">{{ o.name }}</span>
        <span class="pill">{{ o.drink }}</span>
        <span class="pill" :class="o.ice === '去冰' ? 'is-noice' : 'is-ice'">{{ o.ice }}</span>
        <span class="pill" :class="o.sweetness === '去糖' ? 'is-nosugar' : 'is-sugar'">{{ o.sweetness }}</span>
        <span v-if="o.note" class="note">備註:{{ o.note }}</span>
      </li>
    </ul>
  </section>
</template>

<script setup>
import { ref, computed } from 'vue'

/* Day 3:輸入欄位 */
const name = ref('')
const note = ref('')

/* Day 1/2:三個選項(維持 @change) */
const drink = ref('')
const sweetness = ref('')
const ice = ref('')

/* 清單 */
const orders = ref([])

/* 事件 */
function onDrinkChange(v) { drink.value = v }
function onSweetnessChange(v) { sweetness.value = v }
function onIceChange(v) { ice.value = v }

/* Day 4:步驟完成flag(讓 :class 好讀) */
const hasDrink = computed(() => !!drink.value)
const hasSweetness = computed(() => !!sweetness.value)
const hasIce = computed(() => !!ice.value)

/* 送出條件:姓名 + 三選 */
const canSubmit = computed(() => !!(name.value && hasDrink.value && hasSweetness.value && hasIce.value))

/* 送出 */
function addOrder() {
  if (!canSubmit.value) return
  orders.value.push({
    name: name.value,
    note: note.value,
    drink: drink.value,
    sweetness: sweetness.value,
    ice: ice.value
  })
  // 清空,準備下一筆
  name.value = ''
  note.value = ''
  drink.value = ''
  sweetness.value = ''
  ice.value = ''
}
</script>

<style scoped>
/* 最小必要樣式:用於展示 :class 的效果 */
.block { padding: 8px; border: 1px solid #ddd; border-radius: 8px; margin: 10px 0; }
.invalid { border-color: #e57373; background: #fff5f5; }
.complete { border-color: #66bb6a; background: #f3fff3; }
.hint { font-size: 12px; color: #c62828; margin-top: 4px; }

.submit { padding: 8px 12px; border-radius: 6px; border: 1px solid #ccc; margin: 8px 0; }
.submit.enabled { background: #1976d2; color: #fff; border-color: #1976d2; }
.submit.disabled { background: #f0f0f0; color: #888; }

.list { margin-top: 14px; }
.order { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 6px 0; }
.idx { width: 24px; text-align: right; margin-right: 4px; color: #666; }
.name { font-weight: 600; margin-right: 6px; }
.pill { padding: 2px 8px; border-radius: 9999px; border: 1px solid #ccc; font-size: 12px; }
.is-ice { background: #e3f2fd; border-color: #90caf9; }
.is-noice { background: #e8f5e9; border-color: #a5d6a7; }
.is-sugar { background: #fff3e0; border-color: #ffcc80; }
.is-nosugar { background: #fce4ec; border-color: #f48fb1; }
.note { color: #555; font-size: 12px; }
</style>

今天我們家了style的CSS樣式

也就是在vue的寫法裡面,除了前幾天介紹的templatescript多了一個style

至於為什麼要加scope呢?

目的:讓這個元件的 CSS 不會滲漏到其他元件,也不會被外面的 CSS 汙染。

原理(重點理解就好):Vue 會在渲染時自動替你的 HTML 節點加上一個像 data-v-xxxxxx 的屬性;同時把 CSS 選擇器也自動加上同樣的屬性,達到**樣式「只在本元件生效」**的效果。

其實就是以後你程式碼大之後要拆解多個vue的組件元數用的-> 這邊先這樣有個概念即可

可以透過這邊驗證程式有沒有對

Day4的程式碼跟play ground

🏁 今日進度總結

  1. 需求回顧

    • 在 Day 1 的流程控制(v-if / :disabled)、Day 2 的事件驅動(@click)、Day 3 的雙向資料流(v-model)基礎上,
    • Day 4 進一步解決 「狀態要看得見」 的需求:輸入或選擇的完成度要能即時反映在畫面上。
  2. 技術關鍵

    • :class(v-bind:class)
      依據 refcomputed 的狀態即時切換 CSS 類別,例如 { invalid: !hasDrink, complete: hasDrink }
    • <style scoped>
      讓 CSS 只在元件內生效,不會污染其他元件或被外部干擾。
    • computed flag
      例如 hasDrinkhasSweetnesshasIce,讓模板判斷簡單、可讀。
    • v-for + :class
      在訂單清單中為不同甜度、冰量等屬性加上不同的標籤樣式。
  3. 完成的功能

    • 任何未完成的步驟都會自動套用 invalid 樣式(紅框或提示),完成後即時切換為 complete(綠框)。
    • 送出訂單後,清單以標籤顯示甜度與冰量,使用者一眼就能辨識。
    • 所有樣式皆受 <style scoped> 保護,不會影響其他頁面或元件。

今日必記 Tips

類別 注意事項
scoped 的作用 確保 CSS 只套用在當前元件;若要改子元件內部樣式需用 ::v-deep
狀態切換 使用 refcomputed 建立flag,再用 :class 對應 UI;不要直接在模板寫過長條件。
樣式必須存在 :class 只負責套用 class,不會自動產生 CSS,必須在 <style scoped> 中定義。
v-for 與 class 在訂單清單使用 v-for 迭代並加條件 class,可直觀呈現每筆訂單屬性。
DOM 更新自動化 只要 refcomputed 值改變,Vue 會自動刷新畫面,無需手動操作 DOM。

小結

  • 觀念進化:Day 1 → 條件控制;Day 2 → 事件驅動;Day 3 → 雙向資料流;Day 4 → 狀態驅動樣式
  • 我們學會把「資料狀態」直接映射到「CSS 樣式」,並透過 <style scoped> 保持元件獨立。
  • 這讓 UI 與邏輯完全同步,為下一步 Day 5 的清單統計與進一步資料處理 打下堅實基礎。
  • 好的UI跟UX設計可以幫助前端工程師設計的時候讓使用者操作起來更流暢更喜歡喔!!

上一篇
Day 3 : 輸入框的需求:v-model 背後的雙向資料流思維
系列文
需求至上的 Vue 魔法之旅5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言