第4章 4.2 自定义组件:easycom 与 components
🎯 开场:为什么你的代码越来越难管?
上一章我们学会了用 #ifdef MP-WEIXIN 这种「条件编译」来让同一套代码在不同平台长不同的样子,就像给同一个人准备不同的衣服——天冷穿羽绒服,天热穿短袖。
但是问题来了:衣服(代码)越来越多,怎么管理?
想象一下,如果你有 10 个页面,每个页面都要显示「用户头像 + 名字 + 等级」,你会怎么做?
<!-- 方案一:复制粘贴 -->
<view class="user-info">
<image src="{{user.avatar}}"></image>
<text>{{user.name}}</text>
<text>{{user.level}}</text>
</view>
然后在另一个页面又粘贴一遍,第三个页面再粘贴一遍……
三个月后产品说「头像要改成圆形」,你哭了——要改 10 个地方!
这就是今天要解决的问题:如何封装一个「用户卡片」组件,像乐高积木一\n\n
\n\n
\n\n样,哪里需要哪里搬,改一处全生效。
学完这一章,你就能:
- 自己造「积木块」,在 app 里反复用
- 让代码从「一锅粥」变成「模块化」
- 彻底告别复制粘贴带来的维护噩梦
🧱 基础 25 分钟:组件是什么?怎么造?
4.2.1 先理解「组件」这个概念
是什么?
组件就像一个封装好的小盒子。你把东西放进去,按下按钮,它就给你输出一个固定格式的结果。你不需要知道里面是怎么工作的。
生活类比:
想象你在麦当劳点餐:
- 你不需要知道汉堡怎么做(不用管里面的生菜怎么切、肉怎么煎)
- 你只要说「要一个巨无霸」
- 服务员就给你一个完整的汉堡
组件就是这个「汉堡」——你调用它,它就给你完整的「用户卡片」界面。
为什么要用?
| 不用组件 | 用组件 |
|---|---|
| 改需求要改 10 个文件 | 改一个文件,10 个地方全生效 |
| 代码重复 1000 行 | 代码复用,干净利落 |
| 别人看不懂你的代码 | 模块化,职责清晰 |
4.2.2 两种创建组件的方式
uniapp 里创建组件有两种方式:
方式一:easycom(偷懒神器)⭐推荐
easycom 是 uniapp 的「自动发现」机制。你把组件往特定目录一放,不用引入、不用注册,直接用。
// 假设你在 pages.json 同级下创建了 components/user-card/user-card.vue
// 你的页面可以直接用 <user-card>,不需要任何额外操作!
方式二:传统 components 注册
手动在页面或全局注册组件,就像去派出所报户口。
怎么选?
- 简单组件、临时用 → easycom
- 复杂组件、要全局共享 → 传统注册
4.2.3 用 easycom 5 分钟创建一个组件
Step 1:创建组件文件
在项目根目录创建 components/user-card/user-card.vue:
<template>
<view class="user-card">
<image class="avatar" :src="avatar"></image>
<view class="info">
<text class="name">{{name}}</text>
<text class="level">Lv.{{level}}</text>
</view>
</view>
</template>
<script>
export default {
name: 'user-card',
props: {
avatar: {
type: String,
default: '/static/default-avatar.png'
},
name: {
type: String,
default: '匿名用户'
},
level: {
type: Number,
default: 1
}
}
}
</script>
<style scoped>
.user-card {
display: flex;
align-items: center;
padding: 20rpx;
background: #fff;
border-radius: 16rpx;
}
.avatar {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
}
.info {
margin-left: 20rpx;
}
.name {
font-size: 32rpx;
font-weight: bold;
}
.level {
font-size: 24rpx;
color: #666;
}
</style>
Step 2:直接在页面使用
在任何页面中:
<template>
<view class="container">
<!-- 直接用,不用 import,不用注册! -->
<user-card
avatar="https://example.com/avatar1.jpg"
name="王小明"
:level="10"
></user-card>
<user-card
avatar="https://example.com/avatar2.jpg"
name="李小花"
:level="5"
></user-card>
</view>
</template>
<script>
export default {
data() {
return {}
}
}
</script>
运行效果:
┌─────────────────────────┐
│ [头像] │
│ 王小明 Lv.10 │
└─────────────────────────┘
┌─────────────────────────┐
│ [头像] │
│ 李小花 Lv.5 │
└─────────────────────────┘
这一段在干嘛:
props里的avatar/name/level就是这个组件的「输入参数」- 父组件传什么,组件就显示什么
:level="10"的冒号表示「这是 JS 表达式」,会当成数字 10 传递
4.2.4 组件的「输入」和「输出」
组件和外部数据交流有两种方式:
方式 A:props(父传子)
// 组件内部定义
props: {
title: String,
count: {
type: Number,
default: 0
}
}
<!-- 父组件使用时传值 -->
<my-component title="商品列表" :count="100"></my-component>
方式 B:$emit(子传父)
当用户点击组件按钮时,通知父组件「嘿,我被点了」:
<!-- 子组件 user-card.vue -->
<template>
<view class="user-card" @click="handleClick">
...
</view>
</template>
<script>
export default {
methods: {
handleClick() {
// 通知父组件「我被点击了」,顺便传点数据
this.$emit('card-click', {
name: this.name,
level: this.level
})
}
}
}
</script>
<!-- 父组件监听这个事件 -->
<user-card @card-click="onCardClicked"></user-card>
// 父组件的 methods
methods: {
onCardClicked(e) {
console.log('用户点击了卡片:', e.detail)
// e.detail = { name: '王小明', level: 10 }
}
}
生活类比:
- props 就像你给服务员说「我要一个巨无霸+薯条+可乐」(你告诉它要什么)
- $emit 就像服务员喊「巨无霸好了!」(它通知你来拿)
4.2.5 传统 components 注册(全局注册)
有时候你希望某个组件在所有页面都能直接用,不用每个页面都放组件标签。
这时要用 main.js 全局注册:
// main.js
import Vue from 'vue'
import App from './App'
// 导入你要全局注册的组件
import userCard from './components/user-card/user-card.vue'
// 全局注册(所有页面都能用)
Vue.component('user-card', userCard)
new Vue({
...App
})
之后在任何页面都可以直接写 <user-card>,不需要 import。
什么时候用全局注册?
| 场景 | 推荐方式 |
|---|---|
| 底部导航栏、所有页面都要用 | 全局注册 |
| 只有几个页面用 | easycom 就好 |
🔥 实战 35 分钟:3 个递进小项目
项目 1(5 分钟):做一个「点赞按钮」组件
需求: 页面上有一个点赞按钮,点击后数字 +1,再次点击 -1(取消赞)。
完整代码 - 组件 components/like-button/like-button.vue:
<template>
<view class="like-button" @click="toggleLike">
<text class="icon">{{ isLiked ? '❤️' : '🤍' }}</text>
<text class="count">{{ count }}</text>
</view>
</template>
<script>
export default {
name: 'like-button',
props: {
// 初始点赞数
initialCount: {
type: Number,
default: 0
}
},
data() {
return {
isLiked: false,
count: this.initialCount
}
},
methods: {
toggleLike() {
this.isLiked = !this.isLiked
this.count += this.isLiked ? 1 : -1
// 通知父组件状态变了
this.$emit('change', {
isLiked: this.isLiked,
count: this.count
})
}
}
}
</script>
<style scoped>
.like-button {
display: flex;
align-items: center;
padding: 10rpx 20rpx;
background: #f5f5f5;
border-radius: 30rpx;
width: fit-content;
}
.icon {
font-size: 40rpx;
}
.count {
margin-left: 10rpx;
font-size: 28rpx;
color: #333;
}
</style>
使用页面 pages/index/index.vue:
<template>
<view class="container">
<like-button
:initial-count="66"
@change="onLikeChange"
></like-button>
<text class="tips">点击上方按钮试试</text>
</view>
</template>
<script>
export default {
methods: {
onLikeChange(e) {
console.log('点赞状态:', e.detail.isLiked ? '赞了' : '取消了')
console.log('当前数量:', e.detail.count)
}
}
}
</script>
<style scoped>
.container {
padding: 50rpx;
}
.tips {
margin-top: 30rpx;
color: #999;
font-size: 24rpx;
}
</style>
预期输出:
❤️ 66 ← 点击后变成
🤍 65 ← 再点击
❤️ 66 ← 再点一次
这一段在干嘛: 用 data 保存内部状态,用 $emit 把变化通知给父组件。
项目 2(15 分钟):做一个「新闻列表」组件,支持从 JSON 数据渲染
需求: 封装一个新闻列表组件,接收一个新闻数组,每条新闻显示「标题 + 来源 + 时间」。
组件 components/news-list/news-list.vue:
<template>
<view class="news-list">
<view
class="news-item"
v-for="(item, index) in list"
:key="index"
@click="onItemClick(item)"
>
<view class="content">
<text class="title">{{ item.title }}</text>
<view class="meta">
<text class="source">{{ item.source }}</text>
<text class="time">{{ formatTime(item.publishTime) }}</text>
</view>
</view>
<image
v-if="item.cover"
class="cover"
:src="item.cover"
mode="aspectFill"
></image>
</view>
<view v-if="list.length === 0" class="empty">
<text>暂无新闻</text>
</view>
</view>
</template>
<script>
export default {
name: 'news-list',
props: {
list: {
type: Array,
default: () => []
}
},
methods: {
formatTime(timestamp) {
if (!timestamp) return ''
const date = new Date(timestamp)
const month = date.getMonth() + 1
const day = date.getDate()
return `${month}月${day}日`
},
onItemClick(item) {
this.$emit('item-click', item)
}
}
}
</script>
<style scoped>
.news-list {
padding: 20rpx;
}
.news-item {
display: flex;
padding: 24rpx 0;
border-bottom: 1rpx solid #eee;
}
.news-item:last-child {
border-bottom: none;
}
.content {
flex: 1;
}
.title {
font-size: 32rpx;
color: #333;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.meta {
display: flex;
margin-top: 16rpx;
font-size: 24rpx;
color: #999;
}
.source {
margin-right: 20rpx;
}
.cover {
width: 200rpx;
height: 150rpx;
margin-left: 20rpx;
border-radius: 8rpx;
}
.empty {
text-align: center;
padding: 100rpx 0;
color: #999;
}
</style>
使用页面 - 模拟新闻数据:
<template>
<view class="container">
<text class="title">今日头条</text>
<news-list
:list="newsData"
@item-click="onNewsClick"
></news-list>
</view>
</template>
<script>
export default {
data() {
return {
newsData: [
{
id: 1,
title: 'Python 连续八年霸榜最受欢迎编程语言',
source: '编程日报',
publishTime: Date.now() - 3600000,
cover: 'https://picsum.photos/200/150?random=1'
},
{
id: 2,
title: 'uniapp 4.0 发布:一套代码变六套App',
source: '跨平台开发',
publishTime: Date.now() - 7200000,
cover: 'https://picsum.photos/200/150?random=2'
},
{
id: 3,
title: '前端工程师薪资又涨了?来看看最新数据',
source: '薪资观察',
publishTime: Date.now() - 10800000,
cover: ''
}
]
}
},
methods: {
onNewsClick(news) {
console.log('点击了新闻:', news.title)
uni.showToast({
title: '跳转详情页...',
icon: 'none'
})
}
}
}
</script>
<style scoped>
.container {
background: #f5f5f5;
min-height: 100vh;
}
.title {
display: block;
padding: 30rpx;
font-size: 40rpx;
font-weight: bold;
}
</style>
预期输出:
今日头条
─────────────────────────────
Python 连续八年霸榜... [图片]
编程日报 6月26日
─────────────────────────────
uniapp 4.0 发布... [图片]
跨平台开发 6月26日
─────────────────────────────
前端工程师薪资又涨了?
薪资观察 6月26日
这一段在干嘛: 用 v-for 循环渲染列表,用 props 接收外部数据,用 $emit 把点击事件传出去。
项目 3(15 分钟):组合技——做个「商品评价卡片」
需求: 把用户信息 + 评分 + 评论内容封装成一个可复用的「评价卡片」组件。
组件 components/review-card/review-card.vue:
<template>
<view class="review-card">
<view class="header">
<image class="avatar" :src="review.userAvatar || '/static/default.png'"></image>
<view class="user-info">
<text class="name">{{ review.userName || '匿名用户' }}</text>
<view class="rating">
<text
v-for="i in 5"
:key="i"
class="star"
>{{ i <= review.rating ? '⭐' : '☆' }}</text>
</view>
</view>
<text class="date">{{ formatDate(review.createTime) }}</text>
</view>
<text class="content">{{ review.content }}</text>
<view v-if="review.images && review.images.length" class="images">
<image
v-for="(img, idx) in review.images"
:key="idx"
class="img-item"
:src="img"
mode="aspectFill"
@click="previewImage(img)"
></image>
</view>
</view>
</template>
<script>
export default {
name: 'review-card',
props: {
review: {
type: Object,
required: true
}
},
methods: {
formatDate(timestamp) {
if (!timestamp) return ''
const d = new Date(timestamp)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
},
previewImage(url) {
uni.previewImage({
urls: [url],
current: url
})
}
}
}
</script>
<style scoped>
.review-card {
background: #fff;
padding: 24rpx;
border-radius: 16rpx;
margin-bottom: 20rpx;
}
.header {
display: flex;
align-items: center;
}
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
}
.user-info {
margin-left: 20rpx;
flex: 1;
}
.name {
font-size: 28rpx;
font-weight: bold;
}
.rating {
margin-top: 8rpx;
}
.star {
font-size: 24rpx;
}
.date {
font-size: 24rpx;
color: #999;
}
.content {
display: block;
margin-top: 20rpx;
font-size: 28rpx;
line-height: 1.6;
color: #333;
}
.images {
display: flex;
flex-wrap: wrap;
margin-top: 20rpx;
}
.img-item {
width: 200rpx;
height: 200rpx;
margin-right: 16rpx;
margin-bottom: 16rpx;
border-radius: 8rpx;
}
</style>
使用页面 - 模拟评价数据列表:
<template>
<view class="container">
<text class="page-title">商品评价</text>
<review-card
v-for="review in reviewList"
:key="review.id"
:review="review"
></review-card>
</view>
</template>
<script>
export default {
data() {
return {
reviewList: [
{
id: 1,
userName: '程序员小李',
userAvatar: 'https://picsum.photos/80/80?random=10',
rating: 5,
content: '这个 uniapp 课程真的太棒了!老师讲得特别清楚,我这种小白也能听懂。强烈推荐!',
images: [
'https://picsum.photos/200/200?random=20',
'https://picsum.photos/200/200?random=21'
],
createTime: Date.now() - 86400000
},
{
id: 2,
userName: '设计小王',
userAvatar: '',
rating: 4,
content: '内容很实用,就是更新有点慢,等得好着急😂',
images: [],
createTime: Date.now() - 172800000
}
]
}
}
}
</script>
<style scoped>
.container {
padding: 30rpx;
background: #f5f5f5;
min-height: 100vh;
}
.page-title {
display: block;
font-size: 36rpx;
font-weight: bold;
margin-bottom: 30rpx;
}
</style>
预期输出:
商品评价
┌──────────────────────────────────┐
│ [头像] 程序员小李 │
│ ⭐⭐⭐⭐⭐ │
│ 2026-06-25 │
│ 这个 uniapp 课程真的太棒了... │
│ [图片1] [图片2] │
└──────────────────────────────────┘
┌──────────────────────────────────┐
│ [头像] 匿名用户 │
│ ⭐⭐⭐⭐☆ │
│ 2026-06-24 │
│ 内容很实用,就是更新有点慢... │
└──────────────────────────────────┘
这一段在干嘛: 把用户信息、评分、图片预览全部封装进组件,父组件只需要传递数据,职责单一。
💪 进阶 20 分钟:常见坑 + 调试技巧
坑 1:组件名和引入名不一致
// ❌ 错误:组件名是 user-card,但文件夹是 userCard
// components/user-card/user-card.vue
export default {
name: 'user-card' // 这里的 name 要和文件名一致!
}
// ✅ 正确:保持命名一致
// components/user-card/user-card.vue
export default {
name: 'user-card' // 或者干脆不写,uniapp 会自动推断
}
坑 2:props 传值类型搞错
// ❌ 错误:以为是字符串,实际是数字
props: {
level: Number // 要求是数字
}
// 父组件传了字符串
<user-card :level="'10'"></user-card> // 字符串!会报警告
// ✅ 正确:明确指定类型和默认值
props: {
level: {
type: Number,
default: 1
}
}
// 父组件
<user-card :level="10"></user-card> // 数字,正确
坑 3:忘了 this.$emit 是异步的
// ❌ 错误:以为 emit 后立刻能拿到新值
this.$emit('update', newValue)
console.log(this.value) // 这里可能还是旧值!
// ✅ 正确:如果需要等父组件处理完,用回调函数
props: {
value: Number
},
methods: {
updateValue(newVal) {
this.$emit('update', newVal, () => {
// 回调函数,等父组件处理完再执行
console.log('父组件处理完了')
})
}
}
坑 4:样式隔离导致子组件不生效
<!-- ❌ 错误:在子组件里写了 scoped,但父组件的样式传不进去 -->
<style scoped>
/* 这些样式只能在当前组件内生效 */
</style>
<!-- ✅ 正确:去掉 scoped 或用深度选择器 -->
<style>
/* 或者 */
<style scoped>
/* 父组件的样式想穿透子组件: */
::v-deep .child-class {
color: red;
}
</style>
坑 5:数组/对象 props 被直接修改
// ❌ 错误:直接修改 props
props: {
list: Array
},
methods: {
addItem(item) {
this.list.push(item) // 坑!直接修改了 props
}
}
// ✅ 正确:先拷贝,再修改
props: {
list: Array
},
data() {
return {
localList: []
}
},
watch: {
list: {
immediate: true,
handler(newVal) {
this.localList = [...newVal] // 拷贝一份
}
}
},
methods: {
addItem(item) {
this.localList.push(item) // 修改拷贝,不影响原数据
this.$emit('update:list', this.localList) // 通知父组件
}
}
调试技巧:打印组件生命周期
当组件不显示或行为异常时,在 created 和 mounted 里加日志:
export default {
created() {
console.log('✅ 组件创建了', 'props:', this.$props)
},
mounted() {
console.log('✅ 组件挂载了', 'data:', this.$data)
},
updated() {
console.log('🔄 组件更新了')
}
}
✏️ 练习题 + 作业题
练习题(10 分钟)
练习 1(2 分钟):改改标题
- 输入:把项目 1 的点赞按钮文字从 emoji 改成「赞」和「取消」
- 预期输出:点击后文字在「赞」和「取消」之间切换
- 提示:修改 like-button.vue 的 template 部分
练习 2(2 分钟):加个判断
- 输入:在项目 2 的新闻列表里,如果 source 是「薪资观察」,显示红色
- 预期输出:只有薪资观察那条新闻的来源是红色的
- 提示:用 v-if 或者动态 class
练习 3(3 分钟):换个数据源
- 输入:用项目 2 的组件渲染以下数据:
[
{ title: '今天吃什么', source: '美食推荐', publishTime: Date.now() },
{ title: '周末去哪儿玩', source: '旅游攻略', publishTime: Date.now() }
]
- 预期输出:两条新闻正常显示
- 提示:直接把这段数据赋值给
newsData
练习 4(5 分钟):串联项目 1 和项目 2
- 输入:在项目 2 的新闻列表每条新闻后面加一个点赞按钮
- 预期输出:点击点赞按钮,对应新闻的点赞数 +1
- 提示:在 news-item 里面加 <like-button>,每个新闻数据里加个 likeCount 字段
练习 5(3 分钟):找出报错原因
- 输入:下面这段代码运行后页面空白,控制台报错「Cannot read property 'title' of undefined」
export default {
data() {
return {
news: {
title: '测试新闻'
}
}
}
}
<news-card :item="newsList[5]"></news-card>
- 预期输出:说出为什么报错,怎么修
- 提示:
newsList只有 2 条数据,但你在访问第 6 条
作业题(30 分钟 - 2 小时)
作业:做一个「图书展示卡片」组件
需求描述:
做一个图书展示组件,可以在任意页面复用。图书有:书名、作者、出版社、封面图、评分。
功能点:
1. 组件 book-card 接收一个 book 对象作为 props
2. 显示书名(大字)、作者和出版社(小字灰)
3. 显示评分(五星制)
4. 点击卡片时 $emit 一个 book-click 事件,传递整本书的数据
加分项:
1. 如果没有封面图,显示一张占位图
2. 如果评分低于 3 星,显示「不推荐」标签
验收标准:
- 组件能正常显示数据
- 点击能触发事件
- 在不同页面引用都能正常工作
提交方式: 评论区贴代码或 GitHub 链接
📚 总结 + 资源
本文学了 3 件事:
- easycom 机制:把组件往
components目录一放,不用引入直接用 - 组件的输入输出:
props负责接收数据,$emit负责发送事件 - 组件封装思维:把重复的 UI 封装成组件,改一处全生效
延伸学习资源:
- uniapp 官方组件文档 - 官方出品,最权威
- Vue 组件基础 - uniapp 的组件系统源于 Vue
- 《uniapp 跨平台开发实战》- 实体书,适合系统学习
互动钩子:
你在项目里有没有封装过什么组件?是在哪里卡住的?评论区聊聊,老粉优先回复!
下章预告:
学会了造组件,你肯定想知道「别人已经造好的组件去哪找」——总不能每次都自己造轮子吧?
下一章我们就来聊聊 第三方插件市场,教你怎么 3 分钟把别人的轮子装到自己的车上 🚗

评论(0)