Appearance
單一元件檔 (SFC) 的三個區塊
上一章的範例都寫在同一個 App.vue 裡。這種副檔名是 .vue 的檔案,正式名稱是單一元件檔 (Single-File Component, SFC):一個檔案就是一個元件,畫面、邏輯、樣式都在裡面。當時我們只用一句話帶過它的結構,這一章就來把這件事講完整,因為接下來的元件開發,都會以 SFC 為單位。
為什麼要單一元件檔
傳統的網頁開發習慣把程式碼依「語言」拆開:HTML 一份、CSS 一份、JavaScript 一份。專案小的時候相安無事;等到頁面上有十幾個功能區塊,要改其中一個,就得在三個檔案之間來回對照,還不一定找得齊。
SFC 換了一個切法:以「功能」為單位。一個按鈕、一張卡片、一個表單,各自是一個元件,這個元件的畫面、邏輯、樣式都收在同一個 .vue 檔裡:
vue
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<button type="button" class="counter" @click="count++">
點了 {{ count }} 次
</button>
</template>
<style scoped>
.counter {
padding: 0.5rem 1rem;
}
</style>要改這個按鈕,只需要打開這一個檔案;要在別的頁面重複使用,也是搬這一個檔案。關注點分離 (Separation of Concerns) 並沒有被放棄,只是分離的單位從「語言」變成「功能」。
.vue 不是瀏覽器看得懂的格式。它需要經過編譯,而這正是第一章建好的 Vite 開發環境在背後做的事:開發時即時編譯、打包時輸出瀏覽器能執行的 JavaScript 與 CSS。
編譯後,邏輯與模板會成為 JavaScript 模組,樣式則輸出成帶作用域的 CSS。接著來看三個區塊各自負責什麼。
<script setup>:元件的邏輯
<script setup> 區塊負責元件的邏輯:宣告資料、定義函式、匯入需要的東西。它最重要的特性,是頂層宣告的變數與函式,模板可以直接使用:
vue
<script setup>
import { ref } from 'vue'
const title = ref('待辦清單')
function reset() {
title.value = '待辦清單'
}
</script>
<template>
<h1>{{ title }}</h1>
<button type="button" @click="reset">
還原標題
</button>
</template>title 與 reset 沒有經過任何「註冊」手續,宣告完就能在 <template> 裡用。這不是魔法:<script setup> 是編譯階段的語法糖,編譯器會把頂層宣告自動整理成元件的 setup 內容。我們少寫了樣板程式碼,執行起來跟手動組裝的元件是同一回事。
每個 .vue 檔只能有一個 <script setup> 區塊。裡面的程式碼會在元件每次建立實體時執行一次,這個時間點與生命週期的關係,本章最後一節會回來討論。
也有不加 setup 的 <script>
一般的 <script> 同樣是合法的 SFC 區塊:它是普通的 JavaScript 模組,通常搭配 export default 匯出元件定義,頂層變數不會自動供模板使用。在較舊的 Vue 3 專案或部分套件文件裡會看到這種寫法。現代專案以 <script setup> 為主,本書全程採用;兩種寫法的完整對照,附錄會有專門整理。
<template>:元件的畫面
<template> 區塊放元件的畫面結構,上一章學過的插值、指令、事件綁定全部都寫在這裡。每個 SFC 只能有一個 <template>。
值得一提的是,Vue 3 的模板允許多個根節點:
vue
<script setup>
import { ref } from 'vue'
const subtitle = ref('008 天絕對看不完')
</script>
<template>
<h1>重新認識 Vue.js</h1>
<p>{{ subtitle }}</p>
</template>在 Vue 2 中,模板一定要包在單一根元素裡,很多人都寫過那層只為了符合規定而存在的 <div>。Vue 3 沒有這個限制,模板的結構可以誠實反映畫面的結構。
<style scoped>:元件的樣式
<style> 區塊放元件的樣式。加上 scoped 之後,這裡寫的 CSS 原則上只作用於目前元件,不會一路滲進子元件內部:
vue
<script setup>
import { ref } from 'vue'
const items = ref(['專案結構', '響應式資料', '元件溝通'])
</script>
<template>
<ul class="list">
<li v-for="item in items" :key="item">{{ item }}</li>
</ul>
</template>
<style scoped>
.list {
padding-left: 1.25rem;
line-height: 1.8;
}
</style>它的原理並不神祕:編譯時 Vue 會替這個元件的元素加上一個獨一無二的 data-* 屬性,並把選擇器改寫成 .list[data-v-xxxxxxx] 的形式。用瀏覽器的開發者工具檢查元素,就能親眼看到這些屬性。
不過,子元件的根元素是例外:父元件的 scoped 樣式仍然可以作用到它。這是刻意的設計,讓父元件能為子元件調整定位與外距這類佈局需求;子元件內部的其他元素則不受影響。界線畫成圖是這樣:
常見錯誤:忘記加 scoped,樣式互相污染
沒有 scoped 的 <style> 是全域樣式:這個元件裡寫的 .list,會套用到整個應用程式中所有 class="list" 的元素。專案變大之後,兩個元件各自定義了同名的 class,樣式就會互相覆蓋,而且很難查出是誰改了誰。
元件自己才會用到的樣式,一律加上 scoped。
什麼時候可以不加 scoped?
scoped 不是必填,重點是這份樣式屬於元件,還是整個應用程式。如果樣式本來就要讓整個應用程式共用,例如 CSS reset、全站字型、html/body、CSS 變數或共用 utility classes,就不應該限制在單一元件裡。
這類樣式通常集中放在 src/assets/main.css,再由 main.js 匯入一次。元件自己才會用到的樣式,則繼續放在 <style scoped>。
使用 Tailwind CSS、UnoCSS 等 utility-first 工具時,樣式多半直接由模板上的 class 組合而成,元件甚至可能不需要 <style> 區塊;若仍有元件專屬的自訂 CSS,一樣可以搭配 <style scoped>。
匯入子元件
三個區塊都認識了,接著把元件組合起來。在 <script setup> 裡 import 另一個 .vue 檔,模板就能直接當標籤使用,不需要額外註冊:
src/components/BookCard.vue:
vue
<script setup>
import { ref } from 'vue'
const stock = ref(8)
</script>
<template>
<article class="card">
<h2>重新認識 Vue.js</h2>
<p>剩餘 {{ stock }} 本</p>
</article>
</template>
<style scoped>
.card {
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 1rem;
}
</style>src/App.vue:
vue
<script setup>
import BookCard from './components/BookCard.vue'
</script>
<template>
<main class="app">
<BookCard />
<BookCard />
</main>
</template>
<style scoped>
.app {
display: grid;
gap: 1rem;
padding: 2rem;
}
</style>畫面上會出現兩張卡片,而且各自獨立:每個 <BookCard /> 都是一個新的元件實體,擁有自己的 stock 資料,互不干擾。這是元件化最重要的性質之一,也是第四章討論元件設計的前提。
命名慣例上,元件檔名與標籤都使用 PascalCase(每個單字首字母大寫,如 BookCard.vue、<BookCard />),與原生 HTML 標籤明確區隔,也方便在編輯器裡搜尋。子元件檔案依第一章介紹過的 create-vue 專案結構,放在 src/components/ 目錄下。
現在的 <BookCard /> 有個明顯的限制:兩張卡片的內容一模一樣。要讓同一個元件顯示不同的資料,需要 props,本章稍後就會講到。不過在進入元件溝通之前,先把手上的響應式工具補完整:上一章擱置的 reactive(),就在下一節。
