uni-app 自定义组件开发实战:easycom 规范、组件通信与自定义导航栏

uni-app 自定义组件开发实战:easycom 规范、组件通信与自定义导航栏

admin
2026-07-19 / 0 评论 / 5 阅读

uni-app 的组件体系继承了 Vue 语法,但运行环境横跨小程序和 H5,组件的注册方式、通信限制、样式隔离都有平台特色。本文讲透 uni-app 组件开发的规范与实战,最后以一个自定义导航栏组件收官。

一、easycom:不用 import 的组件注册

传统 Vue 组件需要 import + components 注册,uni-app 提供了 easycom 规范:组件路径符合约定,即可直接使用

规范约定

组件路径:components/组件名/组件名.vue
src/
└── 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>

两个核心细节:

  1. 占位 view:固定定位的导航栏会脱离文档流,需要一个等高占位块把页面内容顶下来
  2. 胶囊按钮对齐:微信端通过 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-uiDCloud 官方,easycom 无缝集成,稳定
uview-plusuview 的 vue3 版,组件数量最多,社区活跃
TuniaoUI设计感强,适合 toC 产品
wot-design-units 编写,暗黑模式支持好

组件库选型建议:组件数量和颜值之外,重点看 issues 里平台兼容性问题的响应速度。多端项目里组件库的兼容 bug 会消耗大量时间。

总结

  • easycom 靠目录约定免注册,自定义规则支持任意目录映射
  • 小程序事件对象的值在 e.detail.value,与 H5 的 e.target.value 不同
  • uploadFile 响应体是字符串,二次封装时必须 JSON.parse
  • 自定义导航栏 = 状态栏高度 + 胶囊对齐 + 占位块三件套
  • 优先用 v-model + update:modelValue 让组件 API 对齐 Vue 3 标准

写好这三五个基础组件,项目里的重复代码能砍掉一半。下一篇讲性能优化——分包加载、图片优化与长列表渲染。

0

评论 (0)

取消
0:00