uni-app 插件怎么开发和发布:以扫码录入组件为例
· 9 分钟阅读

uni-app 插件怎么开发和发布:以扫码录入组件为例

从一个业务里常见的扫码录入组件出发,讲清楚如何按 uni_modules 规范组织目录、本地验证,并发布到 DCloud 插件市场。

日常业务里,最容易被反复造轮子的,往往不是复杂页面,而是那些每个项目都要用、实现却总不太一样的基础组件。扫码录入就是一个典型例子。

仓储、质检、出入库这类场景里,经常都需要一个能接扫码枪、也能手动输入、还能尽量稳住焦点的输入区。如果每个项目都各写一版,前期看着省事,后面就会开始乱:代码重复、版本不好管、修过的 bug 不容易同步,组件规范也越用越散。

更实际的做法,是把这类组件抽成 uni_modules 插件。项目里可以直接复用,整理得足够完整的话,也能顺手发布到 DCloud 插件市场。

这篇文章就拿一个 scan-section 扫码录入组件做例子,讲清楚三件事:插件目录怎么搭、关键文件各自负责什么,以及发布时最容易踩到哪些坑。

先看结论

  • 这类业务组件很适合抽成 uni_modules 插件,长期看比在多个项目里复制代码省事得多。
  • 开发阶段最关键的文件其实就几个:scan-section.vueindex.jspackage.jsonuni_modules.jsonreadme.md
  • 发布前重点检查三件事:id 是否一致、说明文档和截图是否齐全、组件是否依赖了不适合上架的第三方 UI。

为什么要把业务组件做成插件

在实际项目里,能复用的组件不少,比如:

  • 扫码录入组件
  • 通用表单组件
  • 弹窗组件
  • 权限控制组件

这些东西如果一直散落在不同仓库里,问题很快就会冒出来:

  • 同一个功能会被反复开发
  • 版本更新不好统一
  • 技术资产沉淀不下来
  • 团队里的组件规范会越来越乱

所以这篇文章的出发点很简单:把业务里已经稳定、复用率又高的组件抽出来,做成一个能在多个项目里直接接入的 UniApp 插件。

这次的技术选型

我这次用的是下面这套组合:

  • 框架:Vue3
  • 跨端方案:UniApp
  • 插件规范:uni_modules
  • 发布平台:DCloud 插件市场

原因也比较直接:

  1. UniApp 本身就覆盖 H5App、小程序这些常见端。
  2. uni_modules 是官方推荐的插件规范,目录和发布方式都比较统一。
  3. 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>

本地验证时,至少把这几件事跑通:

  • 组件能正常注册
  • 扫码和手动输入都能触发
  • 清空、重置、重新聚焦行为符合预期
  • H5App 两端表现没有明显差异

关键文件各自负责什么

如果你想快速回看,整个插件里最关键的就是下面这几块:

  • 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 里完成注册
  • 页面里已经实际调用过组件
  • 关键功能都测过
  • 多端至少验证过 H5App

确认没有问题之后,再去提交流程会稳很多。

如果你用的是 HBuilderX,发布入口可以直接在项目里的 uni_modules 目录下右击,选择“发布插件市场”。

3. 准备发布材料

除了代码本身,通常还要准备这些内容:

  1. 更新日志
  2. 插件预览截图
  3. 使用说明文档

更新日志不用写成大作文,把本次改了什么讲清楚就行,不要把 readme 里的说明又重复一遍。

4. 完成实名认证

DCloud 插件市场发布前需要先完成账号实名认证。这个步骤绕不过去,认证通过之后才能正式提交审核。

我踩过的几个坑

id 不能为空

这个问题最直接,也最常见。

原因是 package.json 里没有填写 id 字段,或者虽然填了,但和 uni_modules.json 对不上。处理方式也简单:补上 id,并保证两个文件完全一致。

截图上传失败

我当时遇到这个问题,通常是这两类原因:

  • 文件名里带中文
  • HBuilderX 内嵌窗口上传不稳定

如果你也碰到,最省事的办法就是改成英文文件名,然后直接去网页端上传:

https://ext.dcloud.net.cn/

依赖第三方 UI 组件

这个坑比较隐蔽。开发时图省事,组件里如果直接依赖了某些第三方 UI 库,到了审核阶段就可能带来额外问题,甚至影响通过。

我后来的处理方式是尽量改成无依赖版本,基础交互和样式直接用原生组件实现。这样接入方更轻,审核也更稳。

后面还可以怎么继续做

如果这类组件已经在团队里开始稳定复用,后面可以继续往下推进:

  • 建内部组件库
  • 统一版本管理
  • 把更多通用业务组件逐步插件化

这一步不是非做不可,但一旦团队里有多个 UniApp 项目长期并行,早点收拢,后面会省很多重复劳动。

评论