uni-app 插件怎么开发和发布:以扫码录入组件为例
从一个业务里常见的扫码录入组件出发,讲清楚如何按 uni_modules 规范组织目录、本地验证,并发布到 DCloud 插件市场。
日常业务里,最容易被反复造轮子的,往往不是复杂页面,而是那些每个项目都要用、实现却总不太一样的基础组件。扫码录入就是一个典型例子。
仓储、质检、出入库这类场景里,经常都需要一个能接扫码枪、也能手动输入、还能尽量稳住焦点的输入区。如果每个项目都各写一版,前期看着省事,后面就会开始乱:代码重复、版本不好管、修过的 bug 不容易同步,组件规范也越用越散。
更实际的做法,是把这类组件抽成 uni_modules 插件。项目里可以直接复用,整理得足够完整的话,也能顺手发布到 DCloud 插件市场。
这篇文章就拿一个 scan-section 扫码录入组件做例子,讲清楚三件事:插件目录怎么搭、关键文件各自负责什么,以及发布时最容易踩到哪些坑。
先看结论
- 这类业务组件很适合抽成
uni_modules插件,长期看比在多个项目里复制代码省事得多。 - 开发阶段最关键的文件其实就几个:
scan-section.vue、index.js、package.json、uni_modules.json和readme.md。 - 发布前重点检查三件事:
id是否一致、说明文档和截图是否齐全、组件是否依赖了不适合上架的第三方 UI。
为什么要把业务组件做成插件
在实际项目里,能复用的组件不少,比如:
- 扫码录入组件
- 通用表单组件
- 弹窗组件
- 权限控制组件
这些东西如果一直散落在不同仓库里,问题很快就会冒出来:
- 同一个功能会被反复开发
- 版本更新不好统一
- 技术资产沉淀不下来
- 团队里的组件规范会越来越乱
所以这篇文章的出发点很简单:把业务里已经稳定、复用率又高的组件抽出来,做成一个能在多个项目里直接接入的 UniApp 插件。
这次的技术选型
我这次用的是下面这套组合:
- 框架:
Vue3 - 跨端方案:
UniApp - 插件规范:
uni_modules - 发布平台:
DCloud 插件市场
原因也比较直接:
UniApp本身就覆盖H5、App、小程序这些常见端。uni_modules是官方推荐的插件规范,目录和发布方式都比较统一。Vue3的组合式 API 用来组织组件逻辑更顺手,后面扩展也方便。
插件目录怎么组织
先看目录结构。这个例子里,我按 uni_modules 的标准方式来放:
展开代码
uni_modules/
└── scan-section/
├── components/
│ └── scan-section/
│ └── scan-section.vue
├── index.js
├── package.json
├── uni_modules.json
└── readme.md下面按文件拆开看。
核心组件:scan-section.vue
这个组件主要解决几件事:
- 同时支持扫码枪录入和手动输入
- 触发确认后自动清空内容
- 通过双输入框切换,尽量避免连续扫码时的焦点冲突
- 对外暴露
focusInput,方便页面主动控制聚焦
核心实现如下:
展开代码
<template>
<view class="scan-section">
<!-- 标题区域 -->
<view class="section-header">
<view class="section-title">
<text class="section-desc">{{ title }}</text>
<text v-if="formatDesc" class="format-desc">{{ formatDesc }}</text>
<slot name="title-extra"></slot>
</view>
<slot name="header-right"></slot>
</view>
<!-- 输入区域 -->
<view class="scan-input-area">
<template v-if="inputToggle">
<input
ref="inputRef"
class="scan-input"
:value="localValue"
:placeholder="placeholder"
:focus="autoFocus"
:disabled="disabled"
confirm-type="done"
@input="handleInput"
@confirm="handleConfirm"
@blur="handleBlur"
/>
</template>
<template v-else>
<input
ref="inputRefAlt"
class="scan-input"
:value="localValue"
:placeholder="placeholder"
:focus="autoFocus"
:disabled="disabled"
confirm-type="done"
@input="handleInput"
@confirm="handleConfirm"
@blur="handleBlur"
/>
</template>
</view>
<!-- 按钮 -->
<view v-if="showSearchButton" class="scan-buttons">
<button
class="search-btn"
type="primary"
:disabled="loading"
@click="handleConfirm"
>
{{ loading ? "处理中..." : searchButtonText }}
</button>
<button
class="reset-btn"
type="default"
@click="handleReset"
>
{{ resetButtonText }}
</button>
</view>
<slot name="extra"></slot>
</view>
</template>
<script setup>
import { ref, watch, nextTick } from "vue"
defineOptions({
name: "ScanSection"
})
/* Props */
const props = defineProps({
title: { type: String, required: true },
formatDesc: { type: String, default: "" },
placeholder: { type: String, default: "请扫描或输入" },
searchButtonText: { type: String, default: "查询" },
resetButtonText: { type: String, default: "重置" },
modelValue: { type: String, default: "" },
autoFocus: { type: Boolean, default: true },
loading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
showSearchButton: { type: Boolean, default: true },
debounceTime: { type: Number, default: 1500 }
})
/* Emits */
const emit = defineEmits(["update:modelValue", "confirm", "blur", "reset"])
/* 本地值 */
const localValue = ref(props.modelValue)
watch(() => props.modelValue, val => {
localValue.value = val
})
watch(localValue, val => {
emit("update:modelValue", val)
})
/* 双输入切换 */
const inputToggle = ref(true)
const inputRef = ref(null)
const inputRefAlt = ref(null)
/* 防抖 + 锁 */
let scanTimer = null
const isProcessing = ref(false)
/* 输入 */
const handleInput = (e) => {
localValue.value = e.detail.value
}
/* 确认 */
const handleConfirm = () => {
if (isProcessing.value) return
if (!localValue.value) return
isProcessing.value = true
if (scanTimer) clearTimeout(scanTimer)
scanTimer = setTimeout(() => {
emit("confirm", localValue.value)
localValue.value = ""
inputToggle.value = !inputToggle.value
nextTick(() => {
focusInput(true)
})
isProcessing.value = false
}, props.debounceTime)
}
/* 重置 */
const handleReset = () => {
localValue.value = ""
emit("reset")
inputToggle.value = !inputToggle.value
nextTick(() => {
focusInput(true)
})
}
/* 失焦 */
const handleBlur = (e) => {
emit("blur", e)
}
/* 聚焦方法 */
const focusInput = (hideKeyboard = true) => {
const current = inputToggle.value ? inputRef.value : inputRefAlt.value
if (!current) return
try {
current.focus()
if (hideKeyboard) {
setTimeout(() => uni.hideKeyboard(), 30)
}
} catch (e) {
console.error("focus error:", e)
}
}
/* 暴露方法 */
defineExpose({
focusInput
})
</script>
<style scoped lang="scss">
.scan-section {
background: #ffffff;
border-radius: 12rpx;
padding: 30rpx;
margin: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
.section-header {
margin-bottom: 20rpx;
}
.section-desc {
font-size: 32rpx;
font-weight: bold;
color: #333;
display: block;
}
.format-desc {
font-size: 24rpx;
color: #666;
}
.scan-input {
height: 80rpx;
border: 1px solid #ddd;
border-radius: 8rpx;
padding: 0 20rpx;
font-size: 28rpx;
margin-top: 20rpx;
}
.scan-buttons {
display: flex;
justify-content: space-between;
margin-top: 30rpx;
}
.search-btn,
.reset-btn {
flex: 1;
margin: 0 10rpx;
height: 80rpx;
font-size: 28rpx;
border-radius: 8rpx;
}
.search-btn {
margin-left: 0;
}
.reset-btn {
margin-right: 0;
}
</style>如果你的场景也是连续扫码,这里最值得留意的是“双输入框切换”这一段。它不算复杂,但很实用,能少掉不少焦点丢失和输入冲突的问题。
插件入口:index.js
入口文件的职责很单一,就是把组件注册出去,让宿主项目可以直接 app.use():
展开代码
import ScanSection from './components/scan-section/scan-section.vue'
const install = (app) => {
app.component(ScanSection.name, ScanSection)
}
export default { install }
export { ScanSection }插件基本信息:package.json
package.json 主要负责声明插件本身的信息和平台支持情况:
展开代码
{
"id": "scan-section",
"name": "scan-section",
"version": "1.0.0",
"description": "Vue3 + UniApp 通用扫码录入组件",
"keywords": [
"扫码",
"scan",
"barcode",
"uni-app"
],
"uni_modules": {
"platforms": {
"app": {
"android": true,
"ios": true
},
"h5": true,
"mp-weixin": true,
"cloud": {
"tcb": "-",
"aliyun": "-",
"alipay": "-"
}
}
},
"dcloudext": {
"type": "",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "",
"data": "",
"permissions": ""
},
"npmurl": "",
"darkmode": "-",
"i18n": "-",
"widescreen": "-"
},
"engines": {
"HBuilderX": "^3.1.0",
"uni-app": "^3.1.0",
"uni-app-x": "^3.1.0"
}
}这里有个细节后面一定要检查:id 不能漏,而且要和 uni_modules.json 保持一致。不一致的话,发布阶段就会直接报错。
插件市场展示信息:uni_modules.json
这个文件更偏“上架信息”,控制插件在市场里的展示方式:
展开代码
{
"id": "scan-section",
"displayName": "ScanSection 工业级扫码录入组件",
"version": "1.0.0",
"description": "支持扫码枪/二维码/手动输入,带防抖机制与自动聚焦的通用扫码组件",
"keywords": ["扫码", "工业扫码", "barcode"],
"dcloudext": {
"category": ["前端组件"],
"sale": {
"price": "0"
}
}
}文档说明:readme.md
readme.md 不只是补充材料,它基本决定了别人能不能看懂你的插件怎么接。至少要把功能、接入方式和基本用法写清楚。
示例可以先写到这个程度:
展开代码
# ScanSection 工业级扫码录入组件
## 功能特性
- 支持扫码枪录入
- 支持手动输入
- 防抖处理
- 双输入框切换防止冲突
- 自动聚焦
- v-model 双向绑定
- 支持 H5 / App / 小程序
## 使用方法
在 `main.js` 中注册:
```js
import ScanSection from '@/uni_modules/scan-section'
app.use(ScanSection)
```本地怎么接入和测试
插件写完之后,不要急着发布,先在本地项目里接进去跑一遍。
先在 main.js 里全局注册:
展开代码
import { createSSRApp } from 'vue'
import App from './App.vue'
import ScanSection from '@/uni_modules/scan-section'
export function createApp() {
const app = createSSRApp(App)
app.use(ScanSection)
return {
app
}
}然后在页面里直接使用:
展开代码
<template>
<view style="padding:40rpx;">
<ScanSection
v-model="code"
title="扫码测试"
@confirm="handleConfirm"
/>
</view>
<view style="padding-left:70rpx;">
{{ result }}
</view>
</template>
<script setup>
import { ref } from 'vue'
const code = ref("")
const result = ref('')
const handleConfirm = (val) => {
result.value = `扫码结果: ${val}`
}
</script>本地验证时,至少把这几件事跑通:
- 组件能正常注册
- 扫码和手动输入都能触发
- 清空、重置、重新聚焦行为符合预期
H5和App两端表现没有明显差异
关键文件各自负责什么
如果你想快速回看,整个插件里最关键的就是下面这几块:
components/:放具体组件实现。index.js:插件入口,提供install,让外部项目能app.use()。package.json:定义插件基础信息、版本和平台支持声明。uni_modules.json:定义插件市场展示信息,比如名称、分类、描述、价格。readme.md:写使用说明,至少包含功能介绍、接入方式、参数和事件。
其中最容易漏掉的一点,还是 package.json 里的 id。它必须和 uni_modules.json 保持一致,否则提交流程里就会出问题。
发布到插件市场前要做什么
整个发布流程其实不复杂,我一般会按这个顺序走。
1. 在 UniApp 项目里开发
这个插件是按 UniApp 规范发布的,所以开发阶段本身就应该放在 UniApp 项目里完成。这样目录结构、调试方式和最终发布形态是一致的,不容易到最后再返工。
2. 先做本地验证
正式发布前,先确认下面这些基础项已经过一遍:
- 在
main.js里完成注册 - 页面里已经实际调用过组件
- 关键功能都测过
- 多端至少验证过
H5和App
确认没有问题之后,再去提交流程会稳很多。
如果你用的是 HBuilderX,发布入口可以直接在项目里的 uni_modules 目录下右击,选择“发布插件市场”。
3. 准备发布材料
除了代码本身,通常还要准备这些内容:
- 更新日志
- 插件预览截图
- 使用说明文档
更新日志不用写成大作文,把本次改了什么讲清楚就行,不要把 readme 里的说明又重复一遍。
4. 完成实名认证
DCloud 插件市场发布前需要先完成账号实名认证。这个步骤绕不过去,认证通过之后才能正式提交审核。
我踩过的几个坑
id 不能为空
这个问题最直接,也最常见。
原因是 package.json 里没有填写 id 字段,或者虽然填了,但和 uni_modules.json 对不上。处理方式也简单:补上 id,并保证两个文件完全一致。
截图上传失败
我当时遇到这个问题,通常是这两类原因:
- 文件名里带中文
- HBuilderX 内嵌窗口上传不稳定
如果你也碰到,最省事的办法就是改成英文文件名,然后直接去网页端上传:
依赖第三方 UI 组件
这个坑比较隐蔽。开发时图省事,组件里如果直接依赖了某些第三方 UI 库,到了审核阶段就可能带来额外问题,甚至影响通过。
我后来的处理方式是尽量改成无依赖版本,基础交互和样式直接用原生组件实现。这样接入方更轻,审核也更稳。
后面还可以怎么继续做
如果这类组件已经在团队里开始稳定复用,后面可以继续往下推进:
- 建内部组件库
- 统一版本管理
- 把更多通用业务组件逐步插件化
这一步不是非做不可,但一旦团队里有多个 UniApp 项目长期并行,早点收拢,后面会省很多重复劳动。