第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\nSimple tech illustration expla\n\nAI comic creation scene, creat\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)  // 通知父组件
}
}

调试技巧:打印组件生命周期

当组件不显示或行为异常时,在 createdmounted 里加日志:

export default {
created() {
console.log('✅ 组件创建了', 'props:', this.$props)
},
mounted() {
console.log('✅ 组件挂载了', 'data:', this.$data)
},
updated() {
console.log('🔄 组件更新了')
}
}

✏️ 练习题 + 作业题

练习题(10 分钟)

练习 1(2 分钟):改改标题
- 输入:把项目 1 的点赞按钮文字从 emoji 改成「赞」和「取消」
- 预期输出:点击后文字在「赞」和「取消」之间切换
- 提示:修改 like-button.vuetemplate 部分

练习 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 件事:

  1. easycom 机制:把组件往 components 目录一放,不用引入直接用
  2. 组件的输入输出props 负责接收数据,$emit 负责发送事件
  3. 组件封装思维:把重复的 UI 封装成组件,改一处全生效

延伸学习资源:

  1. uniapp 官方组件文档 - 官方出品,最权威
  2. Vue 组件基础 - uniapp 的组件系统源于 Vue
  3. 《uniapp 跨平台开发实战》- 实体书,适合系统学习

互动钩子:

你在项目里有没有封装过什么组件?是在哪里卡住的?评论区聊聊,老粉优先回复!


下章预告:

学会了造组件,你肯定想知道「别人已经造好的组件去哪找」——总不能每次都自己造轮子吧?

下一章我们就来聊聊 第三方插件市场,教你怎么 3 分钟把别人的轮子装到自己的车上 🚗

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。