uni-app 的组件体系继承了 Vue 语法,但运行环境横跨小程序和 H5,组件的注册方式、通信限制、样式隔离都有平台特色。本文讲透 uni-app 组件开发的规范与实战,最后以一个自定义导航栏组件收官。
一、easycom:不用 import 的组件注册
传统 Vue 组件需要 import + components 注册,uni-app 提供了 easycom 规范:组件路径符合约定,即可直接使用。
规范约定
组件路径:components/组件名/组件名.vuesrc/
└── components/
├── user-card/user-card.vue
├── empty-state/empty-state.vue
└── upload-image/upload-image.vue符合规范后,模板里直接写标签,无需任何注册:
<template>
<user-card :user="userInfo" @follow="handleFollow" />
</template>自定义 easycom 规则
组件库或目录结构特殊时,在 pages.json 里扩展规则:
{
"easycom": {
"autoscan": true,
"custom": {
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue",
"^my-(.*)": "@/components/$1/index.vue"
}
}
}<uni-icons>、<my-search> 都能自动解析。这就是 uni-ui 等组件库"引入即用"的原理。
注意:easycom 只解决注册问题,组件内部的通信、传值仍然遵循 Vue 规范。
二、组件通信的平台差异
uni-app 组件通信支持 props / emit,这一点与 Vue 相同。但有几个平台差异必须注意:
差异一:vue2 语法下 this.$refs 可用,vue3 组合式 API 写法部分端受限
<script setup>
import { ref } from 'vue'
const formRef = ref(null)
// 调用子组件方法
function submit() {
formRef.value.validate()
}
</script>Vue 3 项目在 H5 和 App 端正常,部分小程序端旧版本对 expose 支持不完整,遇到问题优先确认基础库/HBuilderX 版本。
差异二:自定义事件在原生组件上的差异
比如 input 组件,各端 v-model 的支持程度不同,表单组件建议同时声明 modelValue prop 和 update:modelValue 事件,与 Vue 3 标准对齐:
<!-- components/my-input/my-input.vue -->
<script setup>
const props = defineProps({
modelValue: { type: String, default: '' },
type: { type: String, default: 'text' }
})
const emit = defineEmits(['update:modelValue'])
function onInput(e) {
emit('update:modelValue', e.detail.value) // 注意:小程序事件对象在 detail 里
}
</script>
<template>
<input
class="my-input"
:type="type"
:value="modelValue"
@input="onInput"
/>
</template>关键差异:小程序原生事件的值在 e.detail.value,H5 端在 e.target.value。做跨端组件时这是必踩的坑,要么用 uni 的统一封装,要么条件编译处理。
三、实战组件一:UploadImage 图片上传
<!-- components/upload-image/upload-image.vue -->
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
modelValue: { type: Array, default: () => [] }, // 已上传图片 url 数组
maxCount: { type: Number, default: 9 },
sourceType: { type: Array, default: () => ['album', 'camera'] }
})
const emit = defineEmits(['update:modelValue', 'change'])
const uploading = ref(0)
const canAdd = computed(() =>
props.modelValue.length + uploading.value < props.maxCount
)
async function choose() {
const remain = props.maxCount - props.modelValue.length
if (remain <= 0) return
const [err, res] = await uni.chooseImage({
count: remain,
sizeType: ['compressed'], // 压缩图,控制体积
sourceType: props.sourceType
})
if (err) return
uploading.value += res.tempFilePaths.length
try {
const urls = await Promise.all(
res.tempFilePaths.map(path => uploadFile(path))
)
const next = [...props.modelValue, ...urls]
emit('update:modelValue', next)
emit('change', next)
} catch (e) {
uni.showToast({ title: '上传失败', icon: 'none' })
} finally {
uploading.value -= res.tempFilePaths.length
}
}
function remove(index) {
const next = props.modelValue.filter((_, i) => i !== index)
emit('update:modelValue', next)
emit('change', next)
}
function preview(index) {
uni.previewImage({
urls: props.modelValue,
current: index
})
}
function uploadFile(filePath) {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: 'https://api.example.com/upload',
filePath,
name: 'file',
success: (res) => {
const body = JSON.parse(res.data) // uploadFile 响应是字符串!
body.code === 0 ? resolve(body.data.url) : reject(body)
},
fail: reject
})
})
}
</script>
<template>
<view class="upload-grid">
<view v-for="(url, index) in modelValue" :key="url" class="upload-item">
<image :src="url" mode="aspectFill" @click="preview(index)" />
<view class="upload-delete" @click="remove(index)">×</view>
</view>
<view v-if="canAdd" class="upload-add" @click="choose">
<text v-if="uploading" class="add-text">{{ uploading }}张上传中</text>
<text v-else class="add-icon">+</text>
</view>
</view>
</template>
<style scoped>
.upload-grid { display: flex; flex-wrap: wrap; gap: 16rpx; }
.upload-item { position: relative; width: 200rpx; height: 200rpx; }
.upload-item image { width: 100%; height: 100%; border-radius: 12rpx; }
.upload-delete {
position: absolute; top: -12rpx; right: -12rpx;
width: 40rpx; height: 40rpx; line-height: 36rpx;
text-align: center; background: #f56c6c; color: #fff;
border-radius: 50%; font-size: 24rpx;
}
.upload-add {
width: 200rpx; height: 200rpx; border: 2rpx dashed #ccc;
border-radius: 12rpx; display: flex;
align-items: center; justify-content: center;
}
</style>使用方式因为 easycom + v-model 而极其简洁:
<upload-image v-model="goods.images" :max-count="9" @change="onImagesChange" />四、实战组件二:自定义导航栏
小程序原生导航栏定制能力有限,很多设计稿要求沉浸式导航。方案是 navigationStyle: "custom" 后自己实现:
<!-- components/nav-bar/nav-bar.vue -->
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
title: { type: String, default: '' },
backVisible: { type: Boolean, default: true },
background: { type: String, default: '#ffffff' },
color: { type: String, default: '#333333' }
})
// 状态栏高度 + 胶囊信息
const statusBarHeight = ref(0)
const navHeight = ref(44)
// #ifdef MP-WEIXIN
const menuButton = uni.getMenuButtonBoundingClientRect()
const { statusBarHeight: sbh } = uni.getSystemInfoSync()
statusBarHeight.value = sbh
navHeight.value = (menuButton.top - sbh) * 2 + menuButton.height
// #endif
// #ifdef H5
const sys = uni.getSystemInfoSync()
statusBarHeight.value = 0
// #endif
const totalHeight = computed(() => statusBarHeight.value + navHeight.value)
function goBack() {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
} else {
// 首个页面无返回栈,回首页
uni.reLaunch({ url: '/pages/index/index' })
}
}
</script>
<template>
<view>
<!-- 占位:防止内容顶到导航栏下面 -->
<view :style="{ height: totalHeight + 'px' }" />
<!-- 固定定位的实际导航栏 -->
<view
class="nav-bar"
:style="{
paddingTop: statusBarHeight + 'px',
height: totalHeight + 'px',
background,
color
}"
>
<view class="nav-content" :style="{ height: navHeight + 'px' }">
<view v-if="backVisible" class="nav-back" @click="goBack">
<text class="back-arrow">‹</text>
</view>
<view class="nav-title">{{ title }}</view>
<!-- 右侧留出微信胶囊按钮位置 -->
<view class="nav-right"><slot name="right" /></view>
</view>
</view>
</view>
</template>
<style scoped>
.nav-bar {
position: fixed; top: 0; left: 0; right: 0; z-index: 999;
}
.nav-content {
display: flex; align-items: center; position: relative;
padding: 0 24rpx;
}
.nav-back { width: 64rpx; height: 64rpx; display: flex; align-items: center; }
.back-arrow { font-size: 44rpx; line-height: 1; }
.nav-title {
position: absolute; left: 50%; transform: translateX(-50%);
font-size: 32rpx; font-weight: 500;
}
.nav-right { margin-left: auto; }
</style>两个核心细节:
- 占位 view:固定定位的导航栏会脱离文档流,需要一个等高占位块把页面内容顶下来
- 胶囊按钮对齐:微信端通过
uni.getMenuButtonBoundingClientRect()拿到右上角胶囊的位置,让自定义导航内容与胶囊垂直居中对齐,这是"看着专业"的关键
页面使用:
// pages.json 中对应页面
{ "path": "pages/order/detail", "style": { "navigationStyle": "custom" } }<nav-bar title="订单详情" background="linear-gradient(#1a73e8, #4a90d9)" color="#fff">
<template #right>
<text class="report-btn">举报</text>
</template>
</nav-bar>五、组件库生态
不想重复造轮子时的选择:
| 库 | 特点 |
|---|---|
| uni-ui | DCloud 官方,easycom 无缝集成,稳定 |
| uview-plus | uview 的 vue3 版,组件数量最多,社区活跃 |
| TuniaoUI | 设计感强,适合 toC 产品 |
| wot-design-uni | ts 编写,暗黑模式支持好 |
组件库选型建议:组件数量和颜值之外,重点看 issues 里平台兼容性问题的响应速度。多端项目里组件库的兼容 bug 会消耗大量时间。
总结
- easycom 靠目录约定免注册,自定义规则支持任意目录映射
- 小程序事件对象的值在
e.detail.value,与 H5 的e.target.value不同 - uploadFile 响应体是字符串,二次封装时必须 JSON.parse
- 自定义导航栏 = 状态栏高度 + 胶囊对齐 + 占位块三件套
- 优先用 v-model + update:modelValue 让组件 API 对齐 Vue 3 标准
写好这三五个基础组件,项目里的重复代码能砍掉一半。下一篇讲性能优化——分包加载、图片优化与长列表渲染。
评论 (0)