UniApp 鸿蒙实战:自定义组件开发与插件设计完全指南

在实际项目中,组件设计往往决定着代码的可维护性与团队协作效率。一个设计良好的自定义组件可以在多个页面复用,减少重复代码,降低维护成本。UniApp + 鸿蒙的场景尤其考验组件的跨端一致性——同一套代码必须在 ArkUI 和 Android View 系统下都表现正常。
本文通过一个完整的多选标签选择器组件(TagPicker),系统讲解 TypeScript 类型定义、Vue 3 Composition API 数据流设计、uni_modules 插件规范与 npm 包发布流程,覆盖从需求分析到生产交付的全链路。
一、我们要做什么
1.1 需求背景
AtomGit(atomgit.com)是面向开发者的开源代码协作平台,其 UniApp 客户端需要展示仓库列表、Issue、PR 等多维数据。核心挑战在于:同一套 UI 组件如何在 iOS、Android、鸿蒙三端保持一致?
我们选取标签选择器组件(TagPicker)作为贯穿全文的实战案例:
| 功能点 | 说明 |
|---|---|
| 多选标签 | 支持单选/多选模式 |
| 远程数据 | 从 API 加载标签列表 |
| 搜索过滤 | 支持按名称模糊搜索 |
| 自定义样式 | 颜色、圆角、尺寸可配置 |
| 双向绑定 | v-model 语法糖直接使用 |
1.2 技术挑战
- 多端一致性:同一组件在鸿蒙 Next 上的渲染表现需与 Android/iOS 对齐,包括 rpx 尺寸、字体渲染、动画帧率等方面保持一致
- 数据流统一:父组件如何向子组件传参(props),子组件如何向父组件回传事件(emit),深层孙组件如何访问顶层配置(provide/inject)
- 插件化封装:组件如何打包为可复用的 uni_modules 或 npm 包,并确保多端条件编译正确生效
- TypeScript 类型安全:全链路类型推导,从 props 到 emit 事件均需类型约束,减少运行时错误
- 性能优化:标签列表可能包含成百上千项,如何避免长列表渲染导致的帧率下降
二、数据模型设计
2.1 TypeScript Interface 定义
在 src/components/TagPicker/types.ts 中定义所有接口:
// 标签实体
export interface TagItem {
id: string | number
name: string
color?: string // hex 色值,如 "#e33e7f"
description?: string
count?: number // 该标签下的 issue 数量
}
// 组件 Props
export interface TagPickerProps {
modelValue?: TagItem[] // v-model 绑定值
placeholder?: string // 占位文本
mode?: 'single' | 'multiple' // 选择模式
size?: 'small' | 'medium' | 'large'
disabled?: boolean
remoteMethod?: (query: string) => Promise<TagItem[]>
colorMap?: Record<string, string>
}
// 组件 Emit
export interface TagPickerEmits {
(e: 'update:modelValue', val: TagItem[]): void
(e: 'change', val: TagItem[]): void
(e: 'search', query: string): void
(e: 'open'): void
(e: 'close'): void
}
// provide/inject 配置项
export interface TagPickerConfig {
sizeMap: Record<string, number>
defaultColor: string
maxSelect: number
}
2.2 目录结构规范
2.3 组合式函数封装(useTagPicker)
为实现逻辑复用与多组件共享,可将 TagPicker 的核心状态管理提取为独立的组合式函数 useTagPicker.ts:
// src/components/TagPicker/useTagPicker.ts
import { ref, computed, type Ref } from 'vue'
import type { TagItem, TagPickerConfig } from './types'
export function useTagPicker(
modelValue: Ref<TagItem[]>,
config: TagPickerConfig,
emit: (event: string, ...args: any[]) => void
) {
const isOpen = ref(false)
const searchQuery = ref('')
const options = ref<TagItem[]>([])
const loading = ref(false)
const selected = computed({
get: () => modelValue.value,
set: (val) => emit('update:modelValue', val),
})
function toggleTag(tag: TagItem) {
const list = [...selected.value]
const idx = list.findIndex(t => t.id === tag.id)
idx > -1 ? list.splice(idx, 1) : list.push(tag)
selected.value = list
emit('change', list)
}
return { isOpen, searchQuery, options, loading, selected, toggleTag }
}
组合式函数将状态逻辑与 UI 模板解耦,使得同一套业务逻辑可以在不同页面、不同组件中复用,同时也便于单元测试。
src/components/TagPicker/
├── index.vue # 入口组件(父组件)
├── types.ts # 类型定义
├── useTagPicker.ts # 组合式函数(逻辑复用)
├── TagPickerPanel.vue # 弹层面板(子组件)
├── TagChip.vue # 单个标签(孙组件)
└── uni.scss # 全局样式变量
三、核心设计决策
3.1 组件通信方案对比
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| props + emit | 父子直接通信 | 显式、类型安全、易追踪 | 深层传值需逐层透传 |
| v-model | 表单类双向绑定 | 语法简洁,符合 Vue 习惯 | 仅适合值类型数据 |
| provide/inject | 跨多级组件共享配置/状态 | 减少 props 链路 | 隐式依赖,调试困难 |
| eventBus | 跨组件广播 | 解耦彻底 | 全局状态污染 |
| Pinia/Vuex | 全局持久状态 | 状态可追踪、重放 | 引入成本高 |
选型结论:
- props/emit:组件对外接口首选,TagPicker 面板展开/折叠状态通过 emit 回传父组件
- v-model:替代
modelValue + update:modelValue手动绑定,节省约 30% 模板代码,是 Vue 3 推荐的双向绑定写法 - provide/inject:TagPicker 内部的 TagChip 需要访问 sizeMap 等配置,通过 inject 注入而非 props 逐层传递,保持组件树扁平
- eventBus / Pinia:不在 TagPicker 内部使用,避免与页面级状态管理耦合加重
3.2 v-model 深层解析
v-model 在 Vue 3 中经历了重大升级:不再限于 <input> 元素,任何组件都可以通过 useVModel 或 useModel 组合式函数建立双向绑定。AtomGit TagPicker 的核心绑定逻辑:
// 手动实现 v-model 等价逻辑(了解原理后可用 v-model 替代)
const selected = computed({
get: () => props.modelValue, // 读取父组件传入的值
set: (val) => emit('update:modelValue', val) // 通知父组件更新
})
// 使用时: v-model="repoTags" 即可,省去 get/set 编写
多 v-model 场景(如弹窗组件需要同时绑定 visible 和 value):v-model:visible="show" v-model:data="formData",每个 v-model 各自生成独立的 update:xxx 事件。
3.3 插件格式对比
| 格式 | 加载方式 | 鸿蒙兼容 | 发布生态 |
|---|---|---|---|
| uni_modules | uni_modules/ 目录安装 |
✅ 完全兼容 | DCloud 插件市场 |
| npm 包 | npm install |
✅ 兼容(需 vite/rollup 编译) | npmjs.com |
| CDN | script 标签 | ⚠️ 仅 H5 | 不推荐 |
选型结论:优先发布为 uni_modules,同时在 package.json 中补充 npm 导出字段,兼顾 DCloud 插件市场与 npm 生态。AtomGit 组件库采用此双轨策略。
四、完整代码实现
4.1 TagPicker 主组件(index.vue)
<script setup lang="ts">
import { ref, computed, watch, inject } from 'vue'
import type { TagItem, TagPickerProps, TagPickerConfig } from './types'
const props = withDefaults(defineProps<TagPickerProps>(), {
modelValue: () => [],
placeholder: '请选择标签',
mode: 'multiple',
size: 'medium',
disabled: false,
})
const emit = defineEmits<{
'update:modelValue': [val: TagItem[]]
'change': [val: TagItem[]]
'search': [query: string]
'open': []
'close': []
}>()
// provide 配置给子组件(inject 需同步调用,不能在 async 中)
const config = inject<TagPickerConfig>('tagPickerConfig', {
sizeMap: { small: 24, medium: 32, large: 40 },
defaultColor: '#808080',
maxSelect: 10,
})
const isOpen = ref(false)
const searchQuery = ref('')
const options = ref<TagItem[]>([])
const loading = ref(false)
const selected = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
const filtered = computed(() => {
if (!searchQuery.value) return options.value
return options.value.filter(t =>
t.name.toLowerCase().includes(searchQuery.value.toLowerCase())
)
})
async function loadData(query = '') {
if (!props.remoteMethod) return
loading.value = true
try { options.value = await props.remoteMethod(query) }
finally { loading.value = false }
}
function toggleTag(tag: TagItem) {
const list = [...selected.value]
const idx = list.findIndex(t => t.id === tag.id)
if (idx > -1) list.splice(idx, 1)
else if (list.length < config.maxSelect) list.push(tag)
selected.value = list
emit('change', list)
}
function open() {
if (props.disabled) return
isOpen.value = true
emit('open')
loadData()
}
function close() {
isOpen.value = false
searchQuery.value = ''
emit('close')
}
watch(searchQuery, (q) => emit('search', q))
defineExpose({ open, close })
</script>
<template>
<view class="tag-picker">
<view class="tag-picker__trigger" @click="open">
<text v-if="selected.length === 0">{{ placeholder }}</text>
<view v-else class="tag-picker__chips">
<view
v-for="tag in selected"
:key="tag.id"
class="tag-chip"
:style="{ background: tag.color || config.defaultColor }"
>
<text class="tag-chip__text">{{ tag.name }}</text>
</view>
</view>
</view>
<TagPickerPanel
v-if="isOpen"
:items="filtered"
:selected="selected"
:loading="loading"
@select="toggleTag"
@close="close"
/>
</view>
</template>
<style lang="scss" scoped>
.tag-picker {
&__trigger {
padding: 12rpx 16rpx;
border: 1px solid #d0d7de;
border-radius: 6px;
min-height: 80rpx;
}
&__chips { display: flex; flex-wrap: wrap; gap: 8rpx; }
}
.tag-chip {
padding: 4rpx 16rpx;
border-radius: 100px;
&__text { font-size: 24rpx; color: #fff; }
}
</style>
4.2 TagPickerPanel 弹层面板
<script setup lang="ts">
import { ref } from 'vue'
import type { TagItem } from './types'
const props = defineProps<{
items: TagItem[]
selected: TagItem[]
loading: boolean
}>()
const emit = defineEmits<{
select: [tag: TagItem]
close: []
}>()
const searchQuery = ref('')
const searchFiltered = ref<TagItem[]>([])
function handleSearch() {
const q = searchQuery.value.trim().toLowerCase()
searchFiltered.value = q
? props.items.filter(t => t.name.toLowerCase().includes(q))
: props.items
}
function isSelected(tag: TagItem) {
return props.selected.some(t => t.id === tag.id)
}
</script>
<template>
<view class="panel">
<view class="panel__header">
<input
class="panel__search"
placeholder="搜索标签..."
v-model="searchQuery"
@input="handleSearch"
/>
<text class="panel__close" @click="emit('close')">✕</text>
</view>
<view v-if="loading" class="panel__loading">加载中...</view>
<view v-else-if="items.length === 0" class="panel__empty">暂无标签</view>
<scroll-view v-else class="panel__list" scroll-y>
<view
v-for="tag in items"
:key="tag.id"
class="panel__item"
:class="{ 'is-selected': isSelected(tag) }"
@click="emit('select', tag)"
>
<view class="panel__dot" :style="{ background: tag.color }" />
<text class="panel__name">{{ tag.name }}</text>
<text v-if="tag.count !== undefined" class="panel__count">{{ tag.count }}</text>
</view>
</scroll-view>
</view>
</template>
<style lang="scss" scoped>
.panel {
position: fixed;
bottom: 0; left: 0; right: 0;
height: 600rpx;
background: #fff;
border-radius: 24rpx 24rpx 0 0;
box-shadow: 0 -4px 20px rgba(0,0,0,0.15);
&__header { display: flex; padding: 24rpx; border-bottom: 1px solid #eaecef; }
&__search { flex: 1; padding: 12rpx; border: 1px solid #d0d7de; border-radius: 6px; }
&__close { padding: 12rpx 24rpx; color: #57606a; }
&__list { height: 480rpx; padding: 16rpx; }
&__item { display: flex; align-items: center; padding: 20rpx 0; border-bottom: 1px solid #f0f0f0; }
&__dot { width: 24rpx; height: 24rpx; border-radius: 50%; margin-right: 16rpx; }
&__name { flex: 1; }
&__count { color: #8b949e; font-size: 24rpx; }
&__loading, &__empty { text-align: center; padding: 80rpx; color: #8b949e; }
.is-selected { background: #f6f8fa; }
}
</style>
4.3 uni_modules 插件包 package.json
{
"name": "atomgit-tag-picker",
"version": "1.0.0",
"displayName": "AtomGit 标签选择器",
"description": "UniApp 多端标签选择器组件,支持 v-model、远程数据、搜索过滤",
"main": "index.vue",
"keywords": ["uniapp", "tag", "picker", "harmonyos"],
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": ["app", "h5", "mp-weixin", "mp-alipay"]
},
"repository": {
"type": "git",
"url": "https://github.com/atomgit/atomgit-app.git",
"path": "uni_modules/atomgit-tag-picker"
},
"publishConfig": { "registry": "https://registry.npmjs.org" },
"files": ["index.vue", "types.ts", "TagPickerPanel.vue", "TagChip.vue"]
}
4.4 npm 包发布(可选路径)
当希望将组件发布至 npmjs.com 时,需在项目根目录补充 package.json npm 专用字段:
{
"name": "@atomgit/uni-tag-picker",
"version": "1.0.0",
"main": "dist/index.umd.cjs",
"module": "dist/index.esm.js",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.umd.cjs"
}
},
"peerDependencies": {
"vue": "^3.0.0",
"@dcloudio/uni-app": ">=3.0.0"
},
"scripts": {
"build": "vite build"
}
}
构建命令 npm run build 通过 Vite 将组件打包为 ESM(用于 tree-shaking)和 UMD(用于 script 标签引入)两种格式。发布前务必确认 peerDependencies 已正确声明,UniApp 组件库的 peerDependencies 应包含 Vue 3 和 @dcloudio/uni-app 版本范围。
4.5 页面使用示例
// pages/repo-detail/index.vue
<script setup lang="ts">
import { ref } from 'vue'
import type { TagItem } from '@/components/TagPicker/types'
async function fetchTags(query: string): Promise<TagItem[]> {
// 实际项目替换为: return await api.tags.list({ q: query })
return [
{ id: 1, name: 'bug', color: '#d73a4a', count: 12 },
{ id: 2, name: 'enhancement', color: '#a371f7', count: 8 },
{ id: 3, name: 'documentation', color: '#0969da', count: 5 },
]
}
const repoTags = ref<TagItem[]>([])
const pickerRef = ref<{ open: () => void } | null>(null)
function openTagPicker() {
pickerRef.value?.open()
}
</script>
<template>
<view class="page">
<text class="label">仓库标签</text>
<TagPicker
ref="pickerRef"
v-model="repoTags"
placeholder="点击添加标签"
:remote-method="fetchTags"
mode="multiple"
size="medium"
@change="(tags) => console.log('选中:', tags)"
/>
</view>
</template>
五、深度技术原理
5.1 组件实例化与响应式原理
UniApp 编译 Vue 3 组件时,经过四个阶段:
① 编译期: .vue 单文件 → setup() 函数 + render function
② 实例化: createComponent() → setupComponent() → setupStatefulComponent()
③ 响应式: reactive() / ref() → Proxy 劫持 getter/setter,数据变更自动触发更新
④ 渲染: patch() → 根据差异更新真实 DOM 或原生视图(鸿蒙端为 ArkUI 组件树)
withDefaults(defineProps<...>(), {...}) 在编译阶段生成 prop 合并逻辑,并注入默认值。鸿蒙 Next 的 ArkUI 引擎通过 ACE(App Capability Engine)适配层接收 Vue 虚拟 DOM,转换为原生 NDK 组件树,两端共享同一套 Vue 响应式系统因此行为一致。
5.2 provide/inject 链式查找机制
provide/inject 不依赖 props 链式传递,而是基于组件实例的 provides 原型链:
孙组件 inject → 子组件 provides → 父组件 provides → App 根实例 provides
Vue 3 的实现为每次 provide 调用沿着组件树向上查找 provides 对象,直至根节点。关键约束:inject 调用必须在 setup 同步阶段完成,不可在 async 函数的 await 之后调用。AtomGit TagPicker 通过 inject<TagPickerConfig>('tagPickerConfig', defaultValue) 获取配置,默认值作为兜底,无需在每个子组件重复定义。
5.3 插槽编译与作用域传递
普通插槽与作用域插槽在编译层面的差异:
// 父组件: <MyList>
// <template #item="{ row, index }">{{ row.name }}</template>
// ↓ 编译后
h(MyList, null, {
item: (ctx) => h('span', null, ctx.row.name)
})
作用域插槽将子组件暴露的变量(row、index)作为渲染函数的参数传入,父组件模板可以访问但作用域限定在插槽函数内部。uni_modules 组件包的 changelog.md 记录插槽参数变更,是保障向后兼容的重要文档。建议所有提供插槽的组件在 README 中明确列出插槽名称及参数类型。
5.4 uni_modules 编译时解析
uni_modules 插件在项目编译阶段被自动扫描和合并:
项目编译 → 扫描 uni_modules/*/package.json →
解析 dependencies 字段 → 递归合并依赖树 →
将插件入口文件注入到 pages.json 或 manifest.json
uni_modules.dependencies 数组声明插件间依赖(避免重复打包),uni_modules.platforms 数组声明支持的平台(不兼容时编译阶段报错)。条件编译文件扩展名约定(如 .nvue 仅在 App 端编译)也由编译器在此时处理。
5.5 条件编译与多端差异处理
UniApp 的条件编译是实现真正跨端的关键机制,AtomGit TagPicker 在实际项目中会涉及以下场景:
// 平台检测
// #ifdef APP-PLUS
import { platform } from '@dcloudio/uni-app'
// #endif
// #ifdef H5
// H5 特有:使用浏览器原生 API
// #endif
// #ifdef APP-PLUS
// App 端特有:使用原生系统能力
// #endif
// #ifdef MP-WEIXIN
// 微信小程序端
// #endif
HarmonyOS Next 编译平台标识为 app,通过 #ifdef APP-PLUS 覆盖。若需要在鸿蒙端做差异化处理,可在运行时使用 uni.getSystemInfoSync() 获取平台信息后做条件分支。
六、常见问题解答
Q1: v-model 和 modelValue + update:modelValue 有什么区别?
无本质区别,前者是后者的语法糖。v-model:title="val" 等价于 :model-value="val" @update:model-value="val = $event"。一个组件支持多个 v-model 时使用 v-model:xxx 修饰符,如 v-model:visible="show" 控制弹窗显隐。
Q2: 组件在鸿蒙端样式与 Android 不一致怎么办?
检查两点:① 是否使用了条件编译 #ifdef APP-PLUS 为不同平台写了两套样式;② 是否通过 uni.scss 全局变量统一设计 token。鸿蒙的 rpx 转换规则与微信小程序相同,无需特殊处理。若仍有问题,建议在鸿蒙真机上开启样式调试,检查是否有 CSS 属性不兼容。
Q3: uni_modules 和 npm 包如何选?
uni_modules 适合内部团队插件,通过 uni-share 等内置 API 可直接调用系统分享能力;npm 包适合开源社区分发,版本管理工具链成熟。AtomGit 组件库两者兼顾:uni_modules/ 目录自用,package.json 对外 npm 导出。
Q4: provide/inject 的值不是响应式的,怎么处理?
provide 传入 ref/reactive 对象本身时,inject 接收后仍是响应式。传入原始值(如 string/number)则不具备响应性。正确做法:
// 错误:config 被 reactive 包装,但 sizeMap 是原始对象
provide('cfg', reactive({ sizeMap: { medium: 32 } }))
// 正确:整个配置对象均为响应式
provide('cfg', reactive({
sizeMap: { medium: 32 },
defaultColor: ref('#808080')
}))
Q5: 组件卸载后异步回调报错 Cannot read property of undefined?
通常是异步操作在组件 unmounted 后触发。可使用 onUnmounted 注册清理函数,或使用 Vue 的 try/catch/finally 确保状态复位:
onUnmounted(() => {
cancelAnimationFrame(rafId)
timer && clearTimeout(timer)
})
Q6: 组件库如何管理多端差异代码?
使用条件编译指令:#ifdef H5 仅在 H5 生效,#ifdef APP-PLUS 仅在 App 生效,#ifndef H5 在非 H5 平台生效。HarmonyOS 的编译平台标识为 app,通过 #ifdef APP-PLUS 引入后,可用运行时 uni.getSystemInfoSync().platform === 'harmony' 做进一步区分。
七、运行效果


八、扩展方向
8.1 虚拟列表
标签数量超过 500 时,即使使用 scroll-view,一次性渲染所有列表项仍会造成内存峰值和帧率下降。可引入虚拟列表策略:只渲染当前可视区域内的固定数量的项目(约 10-15 个),滚动时动态替换内容。AtomGit 客户端的 Issue 列表即采用此策略,配合 scroll-view 的 @scroll 事件计算偏移量,切换渲染数据。
8.2 分组与折叠
在实际使用中,标签可能按类型分组(如 Bug、Feature、Docs),支持折叠/展开可以减少视觉噪音。实现思路:在 TagPickerPanel 中增加分组维度,props 增加 groupBy 字段,每组渲染 <uni-section> 标题,用户点击标题切换该组显隐。
8.3 HarmonyOS 原生能力
通过 uni.requireNativePlugin('moduleName') 可调用 HarmonyOS 原生ArkUI 组件或三方原生模块。当 TagPicker 需要承载超过 1000 个标签的复杂搜索场景时,可考虑将搜索算法下沉至原生侧,利用 ArkUI 的 @State 响应式 + 原生 List 组件,获得接近原生应用的滚动体验。
8.4 国际化
将所有文案抽离至 locales/zh-Hans.json 和 locales/en.json:
{
"tagPicker": {
"placeholder": "点击添加标签",
"search": "搜索标签...",
"empty": "暂无标签",
"loading": "加载中..."
}
}
组件内部通过 uni.i18nLocale 获取当前语言,自动切换文案,无需修改组件代码。
8.5 组件市场发布
上架 DCloud 插件市场需准备:完整的 README.md(含 API 文档、使用示例、注意事项)、组件动态效果 GIF(建议 480px 宽)、CHANGELOG.md 记录版本变更历史。uni_modules 的 package.json 中 displayName 字段会直接作为插件市场展示名称,应填写中文并控制在 20 字以内。
更多推荐



所有评论(0)