在这里插入图片描述

在实际项目中,组件设计往往决定着代码的可维护性与团队协作效率。一个设计良好的自定义组件可以在多个页面复用,减少重复代码,降低维护成本。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 技术挑战

  1. 多端一致性:同一组件在鸿蒙 Next 上的渲染表现需与 Android/iOS 对齐,包括 rpx 尺寸、字体渲染、动画帧率等方面保持一致
  2. 数据流统一:父组件如何向子组件传参(props),子组件如何向父组件回传事件(emit),深层孙组件如何访问顶层配置(provide/inject)
  3. 插件化封装:组件如何打包为可复用的 uni_modules 或 npm 包,并确保多端条件编译正确生效
  4. TypeScript 类型安全:全链路类型推导,从 props 到 emit 事件均需类型约束,减少运行时错误
  5. 性能优化:标签列表可能包含成百上千项,如何避免长列表渲染导致的帧率下降

二、数据模型设计

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> 元素,任何组件都可以通过 useVModeluseModel 组合式函数建立双向绑定。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 场景(如弹窗组件需要同时绑定 visiblevalue):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.jsonlocales/en.json

{
  "tagPicker": {
    "placeholder": "点击添加标签",
    "search": "搜索标签...",
    "empty": "暂无标签",
    "loading": "加载中..."
  }
}

组件内部通过 uni.i18nLocale 获取当前语言,自动切换文案,无需修改组件代码。

8.5 组件市场发布

上架 DCloud 插件市场需准备:完整的 README.md(含 API 文档、使用示例、注意事项)、组件动态效果 GIF(建议 480px 宽)、CHANGELOG.md 记录版本变更历史。uni_modules 的 package.jsondisplayName 字段会直接作为插件市场展示名称,应填写中文并控制在 20 字以内。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐