Skip to content

單一元件檔 (SFC) 的三個區塊

重新認識 Vue.js: 008 天絕對看不完的 Vue.js 3.x 指南

上一章的範例都寫在同一個 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>

titlereset 沒有經過任何「註冊」手續,宣告完就能在 <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、全站字型、htmlbody、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(),就在下一節。