组件基础与注册方式
组件不是把模板剪成几个文件,而是把页面拆成职责清晰、可以独立理解和复用的界面单元。掌握组件的定义、注册与组合,是进入 Vue 组件化开发的第一步。
上一篇文章中,我们通过 Class 与 Style 绑定让界面能够准确表达状态。随着页面功能增加,模板、逻辑和样式也会不断膨胀。本文将页面拆成多个小组件,学习单文件组件的基本结构、局部注册与全局注册的区别,并通过一个学习看板理解组件组合方式。
写作约定:本系列以 Vue 3
<script setup>为主要写法,示例默认使用 Vue 3.2+ 和 Vite 创建的项目。本文重点是组件定义、注册和渲染,不涉及 Props、自定义事件等通信细节。
一、为什么需要组件
假设一个学习看板同时包含页面标题、学习进度、任务列表和底部提示。如果全部写在一个文件中,模板会越来越长:
<template>
<main>
<header>
<p>Vue.js 从入门到精通</p>
<h1>我的学习看板</h1>
</header>
<section>
<h2>本周进度</h2>
<progress value="7" max="10"></progress>
</section>
<section>
<h2>学习任务</h2>
<ul>
<li>理解模板语法</li>
<li>掌握事件处理</li>
<li>完成组件拆分</li>
</ul>
</section>
</main>
</template>这个页面目前还能阅读,但继续加入筛选、样式和交互后,不同职责会混在一起。组件化可以把页面拆成以下结构:
App
├── PageHeader
├── ProgressPanel
├── TaskList
└── PageFooter每个组件负责一块相对完整的界面。父组件负责组合,子组件负责自己的展示和局部行为。
组件化带来的主要收益是:
职责清晰:看到一个文件名,就能大致判断它负责的界面。
复用方便:相同按钮、卡片和列表项不必重复编写。
修改集中:组件内部结构变化时,减少对页面其他部分的影响。
协作容易:不同组件可以分别开发和检查。
但组件并不是拆得越细越好。一个只有一行文字、没有复用价值也没有独立职责的元素,通常不必单独建成组件。
二、认识单文件组件
Vue 项目中最常见的组件形式是单文件组件,也就是 .vue 文件。它把模板、逻辑和样式组织在同一个文件中:
<!-- PageHeader.vue -->
<template>
<header class="page-header">
<p class="eyebrow">Vue.js 从入门到精通</p>
<h1>我的学习看板</h1>
<p>把大页面拆成职责清晰的小组件。</p>
</header>
</template>
<script setup>
const seriesName = 'Vue.js 从入门到精通'
</script>
<style scoped>
.page-header {
padding: 24px;
border-radius: 12px;
background: #f0fdf4;
}
.eyebrow {
color: #059669;
font-weight: 600;
}
</style>三个代码块分别承担不同职责:
模板中可以直接使用 <script setup> 声明的变量和函数。scoped 表示当前样式主要作用于本组件,避免类名意外影响其他组件。
单文件组件需要由 Vite 等构建工具编译,不能直接把 .vue 文件当作普通 HTML 在浏览器中打开。
三、局部注册:按需引入组件
局部注册表示只有当前组件明确引入的子组件才能使用。它是日常开发中最常用、也最容易追踪来源的方式。
3.1 在 <script setup> 中使用
在 <script setup> 中导入的组件可以直接用于模板:
<!-- App.vue -->
<template>
<main>
<PageHeader />
</main>
</template>
<script setup>
import PageHeader from './components/PageHeader.vue'
</script>这里不需要额外的 components 选项。import 同时完成了组件引入和局部注册,模板中的 <PageHeader /> 会被编译为刚导入的组件。
一个组件可以渲染多次:
<template>
<main>
<PageHeader />
<PageHeader />
</main>
</template>
<script setup>
import PageHeader from './components/PageHeader.vue'
</script>实际项目中当然不需要重复显示页面标题,这个例子只说明组件是可重复使用的构造单位,而不是一段只能出现一次的模板文本。
3.2 使用横线命名
导入的 PageHeader 在模板中也可以写成 <page-header>:
<template>
<page-header />
</template>
<script setup>
import PageHeader from './components/PageHeader.vue'
</script>PageHeader 与 page-header 可以对应。DOM 内模板对大小写和自闭合标签有限制,但在单文件组件中,推荐统一使用 PascalCase 组件名和自闭合写法:
<PageHeader />这样更容易区分原生 HTML 元素与 Vue 组件。
3.3 Options API 的局部注册
如果项目使用 Options API,需要通过 components 选项注册:
<template>
<PageHeader />
</template>
<script>
import PageHeader from './components/PageHeader.vue'
export default {
components: {
PageHeader
}
}
</script>components 对象的键是模板中使用的名字,值是导入的组件。以下写法可以自定义注册名:
export default {
components: {
DashboardHeader: PageHeader
}
}此时模板应使用 <DashboardHeader />。没有特殊兼容需求时,注册名与组件名保持一致更便于搜索。
四、全局注册:整个应用都能使用
全局注册会把组件注册到应用实例上,注册后不需要在每个组件中重复导入。
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import BaseButton from './components/BaseButton.vue'
const app = createApp(App)
app.component('BaseButton', BaseButton)
app.mount('#app')注册后,应用中的组件都可以直接使用:
<template>
<BaseButton>保存</BaseButton>
</template>也可以在 Options API 中通过应用实例连续注册:
app.component('BaseButton', BaseButton)
.component('BaseCard', BaseCard)
.component('BaseDialog', BaseDialog)全局注册适合整个应用反复使用的基础组件,例如:
通用按钮、输入框和图标;
全局统一风格的卡片、弹窗;
各页面都会出现的品牌元素。
全局注册也有代价:
模板里看到组件名时,不能直接通过当前文件的
import找到来源。即使某些页面没有使用,全局组件也可能进入最终构建产物,不利于构建工具按需优化。
注册过多会让基础组件与业务组件边界模糊。
因此,不要为了减少几行 import 就把所有组件都注册为全局组件。
五、局部注册与全局注册如何选择
可以采用一个简单的判断顺序:
只在一个页面或业务模块使用:局部注册。
只在一个功能目录使用:在该模块的父组件中局部注册。
跨多个模块频繁出现,并且风格统一:考虑全局注册。
有些项目使用自动导入插件生成局部导入。自动导入减少的是重复代码,并不等同于全局注册;组件能否按需打包仍取决于具体插件与构建配置。
六、组件命名与文件组织
6.1 使用明确的多单词名称
组件名应尽量避免与现有或未来的 HTML 元素冲突。Vue 官方风格指南建议使用多单词组件名:
推荐:BaseButton、UserCard、TaskList
不推荐:Button、Card、ListApp 是常见的根组件例外。基础组件可以统一使用 Base、App 或项目约定前缀:
components/
├── base/
│ ├── BaseButton.vue
│ └── BaseCard.vue
├── dashboard/
│ ├── ProgressPanel.vue
│ └── TaskList.vue
└── layout/
├── PageHeader.vue
└── PageFooter.vue目录不必一开始就设计得很复杂。组件较少时可以先使用扁平结构,等业务边界稳定后再按功能拆分。
6.2 保持导入路径与命名一致
以下导入虽然可以运行,但会增加理解成本:
import UserCard from './components/ProfilePanel.vue'更推荐让组件用途、文件名和导入名保持一致:
import UserCard from './components/UserCard.vue'文件名建议使用 PascalCase.vue,模板中也使用相同名字。统一命名后,全局搜索和重构都会更可靠。
6.3 一个文件只定义一个主要组件
单文件组件通常只包含一个主要组件。如果两个组件始终共同完成一个职责,可以先放在同一目录;如果它们可以独立复用,应拆成不同文件。
拆分依据是职责和复用关系,而不是代码行数。
七、每次渲染都会创建独立的组件实例
组件最重要的特征之一,是每次使用都会创建独立实例。组件内部状态默认互不共享。
创建一个简单的计数按钮:
<!-- CounterButton.vue -->
<template>
<button class="counter-button" @click="count++">
当前计数:{{ count }}
</button>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<style scoped>
.counter-button {
padding: 8px 16px;
border: 0;
border-radius: 6px;
color: white;
background: #42b883;
cursor: pointer;
}
</style>在父组件中使用三次:
<template>
<section class="counter-list">
<CounterButton />
<CounterButton />
<CounterButton />
</section>
</template>
<script setup>
import CounterButton from './components/CounterButton.vue'
</script>
<style scoped>
.counter-list {
display: flex;
gap: 12px;
}
</style>点击第一个按钮只会修改第一个实例的 count,其余两个按钮保持原值。每个实例都执行自己的组件初始化逻辑,并维护自己的响应式状态。
这也是组件与普通 JavaScript 对象的重要区别:组件定义描述“如何创建实例”,模板中的每次使用都会生成对应实例。
如果需要让多个组件使用同一份数据,后面可以通过 Props、自定义事件或状态管理完成,而不是依赖它们碰巧共享模块变量。
八、组件组合:父组件负责安排页面结构
组件组合是指父组件通过模板把多个子组件放在合适的位置。先创建两个职责单一的组件。
8.1 学习进度组件
<!-- ProgressPanel.vue -->
<template>
<section class="panel">
<div class="panel-heading">
<h2>本周进度</h2>
<strong>{{ percent }}%</strong>
</div>
<progress :value="completed" :max="total"></progress>
<p>已完成 {{ completed }} / {{ total }} 个学习任务</p>
</section>
</template>
<script setup>
import { computed, ref } from 'vue'
const completed = ref(7)
const total = ref(10)
const percent = computed(() => Math.round((completed.value / total.value) * 100))
</script>
<style scoped>
.panel {
padding: 20px;
border: 1px solid #d1d5db;
border-radius: 12px;
}
.panel-heading {
display: flex;
justify-content: space-between;
align-items: center;
}
progress {
width: 100%;
accent-color: #42b883;
}
</style>8.2 任务列表组件
<!-- TaskList.vue -->
<template>
<section class="panel">
<h2>学习任务</h2>
<ul class="task-list">
<li
v-for="task in tasks"
:key="task.id"
:class="{ completed: task.done }"
>
<label>
<input v-model="task.done" type="checkbox">
{{ task.title }}
</label>
</li>
</ul>
</section>
</template>
<script setup>
import { ref } from 'vue'
const tasks = ref([
{ id: 1, title: '复习模板语法', done: true },
{ id: 2, title: '掌握事件处理', done: true },
{ id: 3, title: '完成组件拆分', done: false }
])
</script>
<style scoped>
.panel {
padding: 20px;
border: 1px solid #d1d5db;
border-radius: 12px;
}
.task-list {
display: grid;
gap: 12px;
padding: 0;
list-style: none;
}
.completed label {
color: #6b7280;
text-decoration: line-through;
}
</style>这里直接修改 v-for 当前项的 done,每个任务都有稳定且唯一的 id。任务状态只属于 TaskList 实例。
8.3 在根组件中组合
<!-- App.vue -->
<template>
<main class="dashboard">
<PageHeader />
<div class="dashboard-grid">
<ProgressPanel />
<TaskList />
</div>
</main>
</template>
<script setup>
import PageHeader from './components/PageHeader.vue'
import ProgressPanel from './components/ProgressPanel.vue'
import TaskList from './components/TaskList.vue'
</script>
<style scoped>
.dashboard {
max-width: 960px;
margin: 0 auto;
padding: 32px 20px;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-top: 20px;
}
</style>App.vue 不需要知道任务列表内部如何保存状态,也不需要复制进度卡片的样式。它只负责引入组件并安排页面布局。
这种组合方式也明确了后续通信的位置:当 PageHeader 需要展示动态标题,或者 ProgressPanel 需要读取 TaskList 的任务进度时,再引入 Props、自定义事件或共享状态,而不是提前把所有逻辑写回根组件。
九、组件样式与属性透传
9.1 scoped 减少样式冲突
组件中的样式默认是全局的。如果多个组件都定义 .title,后加载的样式可能影响其他组件:
<style>
.title {
color: #42b883;
}
</style>组件私有样式通常可以添加 scoped:
<style scoped>
.title {
color: #42b883;
}
</style>编译后,Vue 会为当前组件元素添加特殊属性,让选择器更有针对性。scoped 是样式隔离手段,不是 CSS 模块,也不意味着可以忽略选择器设计。
需要修改子组件内部元素或第三方组件时,应先确认组件是否提供样式接口,不要盲目使用全局选择器穿透其内部结构。
9.2 class 和 style 默认传到根元素
父组件可以像使用普通元素一样给单根组件添加类名:
<template>
<PageHeader class="dashboard-header" />
</template><!-- PageHeader.vue -->
<template>
<header class="page-header">
<h1>我的学习看板</h1>
</header>
</template>最终根元素会合并 page-header 和 dashboard-header。style 也有类似的透传行为。
多根节点组件没有唯一根元素,无法自动决定属性落到哪里。此时可以关闭自动透传并显式指定目标:
<template>
<header>页头</header>
<section v-bind="$attrs">正文</section>
</template>
<script setup>
defineOptions({ inheritAttrs: false })
</script>只有确实需要控制透传目标时才关闭 inheritAttrs。普通单根组件保持默认行为即可。
十、组件名与递归组件
组件可以通过 defineOptions 显式声明名称:
<!-- FileTree.vue -->
<template>
<ul>
<li>当前目录</li>
</ul>
</template>
<script setup>
defineOptions({
name: 'FileTree'
})
</script>组件名有助于开发者工具识别和递归引用。一个具名单文件组件可以在自己的模板中通过该名称引用自身,例如树形目录、嵌套评论等场景:
<template>
<ul>
<li>
当前节点
<FileTree v-if="hasChildren" />
</li>
</ul>
</template>
<script setup>
import { ref } from 'vue'
defineOptions({ name: 'FileTree' })
const hasChildren = ref(false)
</script>递归必须有能够结束的明确条件,否则会不断创建组件。本文示例默认 hasChildren 为 false,实际项目中应根据树节点数据判断是否还有子节点。
普通组件不需要为了“规范”全部添加 defineOptions。文件名和导入名已经足够清晰时,保持简洁即可。
十一、常见问题自查
还要特别留意 DOM 内模板的限制。如果直接在 HTML 文件中使用 Vue 模板,浏览器解析规则会影响大小写、自闭合组件和表格等元素中的标签位置。使用单文件组件和构建工具可以避开大多数这类问题。
十二、总结
组件化开发的重点不是文件数量,而是职责边界:
单文件组件用
<template>、<script setup>和<style>组织界面、逻辑与样式。<script setup>中导入的组件可以直接使用,不需要额外的components选项。局部注册来源清晰、易于按需构建,应作为默认选择。
全局注册适合少量高频基础组件,不适合注册所有业务组件。
组件名、文件名和导入名应保持一致,并优先使用明确的多单词名称。
每次使用组件都会创建独立实例,组件内部状态默认互不影响。
父组件通过组合安排页面结构,子组件负责各自职责,私有样式可以使用
scoped。
下一站预告
下一篇《Props与组件通信》将为组件增加数据输入能力,学习 Props 声明、类型校验、单向数据流和默认值,让父组件能够把不同数据传给同一个子组件。
作者:洋洋技术笔记 发布日期:2026-11-06 系列:Vue.js从入门到精通 - 第8篇
评论区