Vue2 组件开发详解
2026/3/20大约 11 分钟
Vue2 组件开发详解:构建可复用的 UI 模块
组件是 Vue 最强大的特性之一。在我多年的前端开发经验中,真正理解组件化思想是从"能写组件"到"会设计组件"的关键转折点。本文将深入探讨 Vue2 组件开发的方方面面。
组件化开发思想
在开始编码之前,我想先聊聊组件化的核心理念。
为什么需要组件化?
传统开发模式的痛点:
├── HTML、CSS、JS 分离,但逻辑上紧密耦合
├── 代码复用困难,复制粘贴是常态
├── 维护成本高,修改一处影响多处
└── 团队协作困难,容易产生冲突
组件化的优势:
├── 高内聚、低耦合
├── 可复用、可组合
├── 易测试、易维护
└── 并行开发、提高效率
组件设计原则
- 单一职责:一个组件只做一件事
- 可配置性:通过 props 控制组件行为
- 无副作用:尽量做纯展示组件
- 合理粒度:既不要太大也不要太碎
组件注册
全局注册
// main.js
import Vue from "vue";
import MyButton from "./components/MyButton.vue";
// 全局注册
Vue.component("MyButton", MyButton);
// 也可以批量注册
const components = require.context("./components", true, /\.vue$/);
components.keys().forEach((fileName) => {
const componentConfig = components(fileName);
const componentName = fileName
.split("/")
.pop()
.replace(/\.\w+$/, "");
Vue.component(componentName, componentConfig.default || componentConfig);
});
全局注册的问题:
- 组件会被打包到最终产物中,即使没有使用
- 依赖关系不明确,不利于维护
- 命名可能冲突
局部注册(推荐)
// ParentComponent.vue
import ChildA from "./ChildA.vue";
import ChildB from "./ChildB.vue";
export default {
components: {
ChildA,
ChildB,
// ES6 简写,等同于 'ChildA': ChildA
},
};
Props:父组件向子组件传递数据
Props 定义方式
export default {
// 简单数组形式
props: ["title", "content", "author"],
// 带类型验证(推荐)
props: {
title: String,
count: Number,
isActive: Boolean,
tags: Array,
author: Object,
callback: Function,
promise: Promise,
},
// 完整验证(生产推荐)
props: {
// 必填字符串
title: {
type: String,
required: true,
},
// 带默认值的数字
count: {
type: Number,
default: 0,
},
// 对象/数组的默认值必须是工厂函数
author: {
type: Object,
default: () => ({
name: "匿名",
avatar: "/default-avatar.png",
}),
},
// 多种类型
id: {
type: [String, Number],
},
// 自定义验证
status: {
type: String,
validator(value) {
return ["pending", "success", "error"].includes(value);
},
},
// 高级验证示例
email: {
type: String,
validator(value) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(value);
},
},
},
};
Props 单向数据流
这是 Vue 组件的核心原则之一:
// ❌ 错误:直接修改 prop
export default {
props: ['initialCount'],
methods: {
increment() {
this.initialCount++ // 警告!不要这样做
}
}
}
// ✅ 正确:用 data 接收初始值
export default {
props: ['initialCount'],
data() {
return {
count: this.initialCount
}
},
methods: {
increment() {
this.count++
}
}
}
// ✅ 正确:用 computed 转换
export default {
props: ['size'],
computed: {
normalizedSize() {
return this.size.trim().toLowerCase()
}
}
}
非 Prop 的 Attribute
未被 props 声明的 attribute 会自动添加到组件根元素上:
<!-- 父组件 -->
<MyInput class="custom-input" data-id="123" />
<!-- MyInput.vue 模板 -->
<template>
<input type="text" class="base-input" />
</template>
<!-- 实际渲染结果 -->
<input type="text" class="base-input custom-input" data-id="123" />
禁用 Attribute 继承:
export default {
inheritAttrs: false,
// 配合 $attrs 手动指定继承目标
// $attrs 包含所有父作用域的非 prop attribute
};
<template>
<div class="wrapper">
<input v-bind="$attrs" class="inner-input" />
</div>
</template>
自定义事件:子组件向父组件通信
基础用法
<!-- 子组件 ChildComponent.vue -->
<template>
<button @click="handleClick">点击我</button>
</template>
<script>
export default {
methods: {
handleClick() {
// 触发自定义事件,可传递数据
this.$emit("click", { timestamp: Date.now() });
this.$emit("update", this.value);
},
},
};
</script>
<!-- 父组件 -->
<template>
<ChildComponent @click="onChildClick" @update="onUpdate" />
</template>
<script>
export default {
methods: {
onChildClick(payload) {
console.log("点击时间:", payload.timestamp);
},
onUpdate(value) {
this.parentValue = value;
},
},
};
</script>
.sync 修饰符
当需要双向绑定 prop 时,可以使用 .sync 修饰符:
<!-- 父组件 -->
<template>
<!-- 完整写法 -->
<TextEditor :title="doc.title" @update:title="doc.title = $event" />
<!-- .sync 简写 -->
<TextEditor :title.sync="doc.title" />
</template>
<!-- 子组件 TextEditor.vue -->
<script>
export default {
props: ["title"],
methods: {
updateTitle(newTitle) {
// 注意事件名称格式:update:propName
this.$emit("update:title", newTitle);
},
},
};
</script>
v-model 与组件
默认情况下,v-model 使用 value prop 和 input 事件:
<!-- 父组件 -->
<CustomInput v-model="searchText" />
<!-- 等价于 -->
<CustomInput :value="searchText" @input="searchText = $event" />
<!-- CustomInput.vue -->
<template>
<input :value="value" @input="$emit('input', $event.target.value)" />
</template>
<script>
export default {
props: ["value"],
};
</script>
自定义 v-model:
export default {
// 自定义 v-model 绑定的 prop 和事件
model: {
prop: "checked",
event: "change",
},
props: {
checked: Boolean,
},
methods: {
toggle() {
this.$emit("change", !this.checked);
},
},
};
插槽系统
插槽是 Vue 中内容分发的重要机制,也是组件设计的灵魂所在。
默认插槽
<!-- MyCard.vue -->
<template>
<div class="card">
<div class="card-body">
<!-- 插槽出口 -->
<slot>
<!-- 默认内容 -->
<p>暂无内容</p>
</slot>
</div>
</div>
</template>
<!-- 使用 -->
<MyCard>
<p>这是卡片内容</p>
</MyCard>
具名插槽
<!-- Layout.vue -->
<template>
<div class="container">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot>
<!-- 默认插槽 -->
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>
</template>
<!-- 使用 -->
<Layout>
<template v-slot:header>
<h1>页面标题</h1>
</template>
<p>主要内容...</p>
<template v-slot:footer>
<p>版权信息</p>
</template>
</Layout>
<!-- 缩写语法 -->
<Layout>
<template #header>
<h1>页面标题</h1>
</template>
<template #default>
<p>主要内容...</p>
</template>
<template #footer>
<p>版权信息</p>
</template>
</Layout>
作用域插槽
作用域插槽允许子组件向插槽内容传递数据:
<!-- UserList.vue -->
<template>
<ul>
<li v-for="user in users" :key="user.id">
<!-- 向插槽传递数据 -->
<slot name="user" :user="user" :index="index">
<!-- 默认渲染 -->
{{ user.name }}
</slot>
</li>
</ul>
</template>
<script>
export default {
props: {
users: {
type: Array,
required: true,
},
},
};
</script>
<!-- 使用作用域插槽 -->
<UserList :users="users">
<template #user="{ user, index }">
<div class="user-card">
<img :src="user.avatar" alt="" />
<span>{{ index + 1 }}. {{ user.name }}</span>
<button @click="removeUser(user.id)">删除</button>
</div>
</template>
</UserList>
<!-- 解构写法 -->
<UserList :users="users">
<template #user="slotProps">
<span>{{ slotProps.user.name }}</span>
</template>
</UserList>
实际案例:可配置表格组件
<!-- DataTable.vue -->
<template>
<table class="data-table">
<thead>
<tr>
<th v-for="column in columns" :key="column.key">
<slot :name="`header-${column.key}`" :column="column">
{{ column.title }}
</slot>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in data" :key="row.id || rowIndex">
<td v-for="column in columns" :key="column.key">
<slot
:name="`cell-${column.key}`"
:row="row"
:column="column"
:value="row[column.key]"
:rowIndex="rowIndex"
>
{{ row[column.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>
<script>
export default {
props: {
columns: {
type: Array,
required: true,
},
data: {
type: Array,
required: true,
},
},
};
</script>
<!-- 使用示例 -->
<DataTable :columns="columns" :data="users">
<!-- 自定义头部 -->
<template #header-name="{ column }">
<span class="required">{{ column.title }}</span>
</template>
<!-- 自定义单元格 -->
<template #cell-avatar="{ value }">
<img :src="value" class="avatar" />
</template>
<template #cell-status="{ value }">
<span :class="['status', value]">
{{ value === 'active' ? '在线' : '离线' }}
</span>
</template>
<template #cell-actions="{ row }">
<button @click="edit(row)">编辑</button>
<button @click="remove(row.id)">删除</button>
</template>
</DataTable>
组件间通信方式汇总
在实际项目中,不同场景需要不同的通信方式:
1. Props / Events(父子通信)
// 最基础、最常用的方式
// 父 → 子:props
// 子 → 父:$emit
2. $parent / $children(不推荐)
// 直接访问父/子组件实例
// 耦合度高,不推荐在生产环境使用
this.$parent.someMethod();
this.$children[0].someData;
3. $refs(访问子组件)
<template>
<ChildComponent ref="child" />
<input ref="inputEl" />
</template>
<script>
export default {
mounted() {
// 访问子组件实例
this.$refs.child.someMethod();
// 访问 DOM 元素
this.$refs.inputEl.focus();
},
};
</script>
4. provide / inject(跨层级通信)
// 祖先组件
export default {
provide() {
return {
theme: this.theme,
// 注意:provide 的数据不是响应式的
// 如果需要响应式,可以传递一个响应式对象
appConfig: this.appConfig
}
},
data() {
return {
theme: 'dark',
appConfig: { version: '1.0.0' }
}
}
}
// 后代组件(无论多少层)
export default {
inject: ['theme', 'appConfig'],
// 或带默认值
inject: {
theme: {
default: 'light'
},
appConfig: {
from: 'appConfig',
default: () => ({})
}
}
}
5. EventBus(兄弟/跨层级通信)
// eventBus.js
import Vue from "vue";
export const EventBus = new Vue();
// 组件 A:发送事件
import { EventBus } from "./eventBus";
EventBus.$emit("user-login", { userId: 123 });
// 组件 B:监听事件
import { EventBus } from "./eventBus";
export default {
created() {
EventBus.$on("user-login", this.handleLogin);
},
beforeDestroy() {
// 重要:记得移除监听,防止内存泄漏
EventBus.$off("user-login", this.handleLogin);
},
methods: {
handleLogin(user) {
console.log("用户登录:", user);
},
},
};
6. Vuex(复杂应用状态管理)
// 当应用状态复杂时,使用 Vuex
// 详见 Vuex 专题文章
通信方式选择指南
| 场景 | 推荐方式 |
|---|---|
| 父 → 子 | props |
| 子 → 父 | $emit |
| 父 → 孙(2-3 层) | props 逐层传递 |
| 祖先 → 任意后代 | provide/inject |
| 兄弟组件 | 共同父组件中转 / EventBus |
| 任意组件(复杂应用) | Vuex |
动态组件与异步组件
动态组件
<template>
<div>
<button v-for="tab in tabs" :key="tab" @click="currentTab = tab">
{{ tab }}
</button>
<!-- 动态组件 -->
<component :is="currentTabComponent" />
<!-- 使用 keep-alive 缓存 -->
<keep-alive>
<component :is="currentTabComponent" />
</keep-alive>
<!-- keep-alive 配置 -->
<keep-alive :include="['TabA', 'TabB']" :max="10">
<component :is="currentTabComponent" />
</keep-alive>
</div>
</template>
<script>
import TabA from "./TabA.vue";
import TabB from "./TabB.vue";
import TabC from "./TabC.vue";
export default {
components: { TabA, TabB, TabC },
data() {
return {
currentTab: "TabA",
tabs: ["TabA", "TabB", "TabC"],
};
},
computed: {
currentTabComponent() {
return this.currentTab;
},
},
};
</script>
keep-alive 生命周期
使用 keep-alive 后,组件会有两个额外的生命周期钩子:
export default {
activated() {
// 组件被激活时调用
// 首次挂载和从缓存中恢复都会触发
this.fetchLatestData();
},
deactivated() {
// 组件被停用时调用
// 可以在这里暂停一些操作
this.pauseAnimation();
},
};
异步组件
// 基础异步组件
Vue.component("AsyncComponent", () => import("./AsyncComponent.vue"));
// 局部注册
export default {
components: {
AsyncComponent: () => import("./AsyncComponent.vue"),
},
};
// 带加载状态的异步组件
const AsyncComponent = () => ({
// 需要加载的组件
component: import("./MyComponent.vue"),
// 加载中显示的组件
loading: LoadingComponent,
// 加载失败显示的组件
error: ErrorComponent,
// 展示 loading 前的延迟(默认 200ms)
delay: 200,
// 超时时间(默认 Infinity)
timeout: 3000,
});
// 结合路由使用
const router = new VueRouter({
routes: [
{
path: "/dashboard",
component: () =>
import(
/* webpackChunkName: "dashboard" */
"./views/Dashboard.vue"
),
},
],
});
Mixin:代码复用的利器
基础用法
// mixins/pagination.js
export default {
data() {
return {
page: 1,
pageSize: 10,
total: 0
}
},
computed: {
totalPages() {
return Math.ceil(this.total / this.pageSize)
}
},
methods: {
goToPage(page) {
this.page = page
this.fetchData()
},
nextPage() {
if (this.page < this.totalPages) {
this.goToPage(this.page + 1)
}
},
prevPage() {
if (this.page > 1) {
this.goToPage(this.page - 1)
}
}
}
}
// 使用 mixin
import paginationMixin from '@/mixins/pagination'
export default {
mixins: [paginationMixin],
methods: {
fetchData() {
// 分页逻辑已由 mixin 提供
api.getList({ page: this.page, pageSize: this.pageSize })
.then(res => {
this.list = res.data
this.total = res.total
})
}
}
}
Mixin 合并策略
// 数据对象:组件数据优先
const mixin = {
data() {
return { count: 0 }
}
}
export default {
mixins: [mixin],
data() {
return { count: 1 } // 最终 count = 1
}
}
// 生命周期钩子:都会被调用,mixin 先执行
const mixin = {
created() {
console.log('mixin created')
}
}
export default {
mixins: [mixin],
created() {
console.log('component created')
}
// 输出顺序:mixin created → component created
}
// methods、components、directives:组件优先
const mixin = {
methods: {
foo() { console.log('mixin foo') }
}
}
export default {
mixins: [mixin],
methods: {
foo() { console.log('component foo') }
}
// 最终调用 component foo
}
Mixin 的问题
使用 mixin 时需要注意以下问题:
- 命名冲突:多个 mixin 可能有同名属性
- 来源不清晰:组件中的属性来自哪个 mixin 不明确
- 隐式依赖:mixin 可能依赖组件的某些属性
// 建议:给 mixin 的属性加前缀
export default {
data() {
return {
$_pagination_page: 1,
$_pagination_pageSize: 10,
};
},
};
自定义指令
全局指令
// 自动聚焦指令
Vue.directive('focus', {
inserted(el) {
el.focus()
}
})
// 使用
<input v-focus>
局部指令
export default {
directives: {
focus: {
inserted(el) {
el.focus();
},
},
},
};
指令钩子函数
Vue.directive("example", {
// 指令第一次绑定到元素时
bind(el, binding, vnode) {
// el: 指令绑定的元素
// binding: 包含指令信息的对象
// vnode: Vue 编译生成的虚拟节点
},
// 元素插入到父节点时
inserted(el, binding, vnode) {},
// VNode 更新时(可能子 VNode 还未更新)
update(el, binding, vnode, oldVnode) {},
// VNode 及其子 VNode 全部更新后
componentUpdated(el, binding, vnode, oldVnode) {},
// 指令与元素解绑时
unbind(el, binding, vnode) {},
});
实用指令示例
// 1. 防抖点击
Vue.directive("debounce-click", {
inserted(el, binding) {
let timer = null;
el.addEventListener("click", () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
binding.value();
}, binding.arg || 300);
});
},
});
// 使用:<button v-debounce-click:500="handleClick">
// 2. 点击外部关闭
Vue.directive("click-outside", {
bind(el, binding) {
el._clickOutside = (e) => {
if (!el.contains(e.target)) {
binding.value(e);
}
};
document.addEventListener("click", el._clickOutside);
},
unbind(el) {
document.removeEventListener("click", el._clickOutside);
delete el._clickOutside;
},
});
// 使用:<div v-click-outside="closeDropdown">
// 3. 权限控制
Vue.directive("permission", {
inserted(el, binding) {
const permission = binding.value;
const userPermissions = store.getters.permissions;
if (!userPermissions.includes(permission)) {
el.parentNode?.removeChild(el);
}
},
});
// 使用:<button v-permission="'user:delete'">删除</button>
// 4. 图片懒加载
Vue.directive("lazy", {
inserted(el, binding) {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
el.src = binding.value;
observer.unobserve(el);
}
});
});
observer.observe(el);
},
});
// 使用:<img v-lazy="imageUrl">
组件设计最佳实践
1. 组件命名规范
// 基础组件:以 Base、App 或 V 开头
BaseButton.vue;
AppTable.vue;
VIcon.vue;
// 单例组件:以 The 开头
TheHeader.vue;
TheSidebar.vue;
// 紧密耦合的子组件:以父组件名为前缀
TodoList.vue;
TodoListItem.vue;
TodoListItemButton.vue;
// 组件名使用多个单词,避免和 HTML 元素冲突
UserProfile.vue; // ✅
Profile.vue; // ❌ 可能和未来的 HTML 元素冲突
2. Props 设计原则
// ✅ 好的设计
props: {
// 具体的类型
status: {
type: String,
validator: v => ['pending', 'success', 'error'].includes(v)
},
// 明确的命名
isDisabled: Boolean,
maxLength: Number
}
// ❌ 不好的设计
props: {
// 过于宽泛
options: Object,
// 命名不清晰
flag: Boolean,
num: Number
}
3. 事件命名规范
// 使用 kebab-case
this.$emit("item-click", item);
this.$emit("update:value", newValue);
// 事件名应该描述动作
("submit");
("cancel");
("row-select");
("page-change");
4. 组件文档化
<script>
/**
* 用户卡片组件
* @displayName UserCard
* @example
* <UserCard
* :user="{ name: '张三', avatar: '/avatar.png' }"
* @click="handleClick"
* />
*/
export default {
name: "UserCard",
props: {
/**
* 用户信息对象
*/
user: {
type: Object,
required: true,
},
/**
* 是否显示详细信息
*/
showDetail: {
type: Boolean,
default: false,
},
},
/**
* @event click 点击卡片时触发
* @param {Object} user 用户信息
*/
};
</script>
总结
组件化开发是现代前端工程的基石。通过本文,我们学习了:
- 组件注册:全局 vs 局部,推荐局部注册
- Props:父子通信的主要方式,注意单向数据流
- 自定义事件:子组件向父组件通信
- 插槽系统:内容分发,提高组件灵活性
- 组件通信:多种方式各有适用场景
- 动态/异步组件:提升性能和用户体验
- Mixin:代码复用,但要注意其局限性
- 自定义指令:扩展元素能力
掌握这些知识,你就能设计出高质量、可复用的 Vue 组件。下一篇文章,我们将深入 Vue2 的响应式原理,理解 Vue 如何实现数据驱动视图。
推荐阅读
- Vue2 官方风格指南
- 《Vue.js 组件精讲》
- Vue2 源码分析系列