UniApp 二次开发踩坑实录:Windows 上构建失败,我用 rename 绕了过去

3 阅读11分钟

写在前面:一句报错让我卡了两天

在 Windows 上执行 npm run build:h5,构建直接挂了:

[safe-delete] 操作失败: ERROR .../uniapp/dist/build/h5/assets:
Error during a `trash` operation: Unknown { description: "Some operations were aborted" }
Build failed with errors.

第一反应是缓存问题。清了 node_modules,重装,无效。第二反应是版本问题,降级了 vite,无效。查了两天 Stack Overflow,最后发现问题根本不在 uni-app。

环境里有个钩子接管了文件删除,把 rm / mv 和 Node 的 fs.rmSync 都改成了"移到回收站"。而 uni build 在构建前会清理 dist/,于是被拦下了。

最终解法只有一行——不删,改名:

fs.renameSync(DIST, target);

这篇把整个 UniApp 二开过程中踩的坑都整理一遍,包括这个构建问题、依赖版本的坑、navigateTo 静默失败、7 语言 i18n 怎么维护,以及 tabBar 图标怎么不靠设计师。


一、目录结构:先确认你的 uni-app 版本

如果你之前用过老版 uni-app,注意这个版本强制 vite 风格目录——源码必须放在 src/ 下。

uniapp/
├── vite.config.js          # ★ Vite 配置(H5 产物 base: '/mp/')
├── package.json
├── index.html              # H5 入口模板(含企微 jsapi 注入)
├── scripts/
│   ├── gen-tabbar-icons.cjs       # 零依赖生成 tabBar 图标
│   ├── inject-dashboard-i18n.cjs  # 批量注入 i18n 词条
│   └── clean-dist.cjs             # 构建前归档旧产物(见 §7.2 的坑)
└── src/                    # ★ 所有源码在这
    ├── main.js  App.vue
    ├── pages.json          # 路由 + tabBar
    ├── manifest.json       # 多端编译配置
    ├── uni.scss            # 全局 SCSS 变量
    ├── utils/
    │   ├── api.js          # API 封装 + 离线占位数据
    │   ├── auth.js         # 登录态
    │   ├── cart.js         # 购物车 + 分组 + 满减
    │   ├── config.js       # 环境判定 / API_BASE / DEMO_MODE
    │   ├── i18n.js         # 7 语种字典
    │   └── nav.js          # 跳转统一入口
    ├── pages/
    │   ├── login/  index/  commission/  materials/
    │   ├── training/  posters/  report/  agent/  mine/
    │   └── shop/            # C 端商城 5 页
    ├── components/
    └── static/tabbar/      # 图标(由脚本生成)

从老结构迁移的清单:

  • main.js / App.vue / manifest.json / pages.json / uni.scss 移进 src/
  • pages/ components/ utils/ static/ 移进 src/
  • 新增 vite.config.js
  • index.html 的 script src 改成 /src/main.js

框架是 uni-app (Vue 3) + Vite,一套代码编译 H5 / 微信小程序 / App / 企微侧边栏四个端。


二、依赖版本:别写 ^3.0.0

{
  "dependencies": {
    "@dcloudio/uni-app": "3.0.0-alpha-5020520260824002",
    "@dcloudio/uni-components": "3.0.0-alpha-5020520260824002",
    "@dcloudio/uni-h5": "3.0.0-alpha-5020520260824002",
    "@dcloudio/uni-mp-weixin": "3.0.0-alpha-5020520260824002",
    "vue": "^3.4.0"
  },
  "devDependencies": {
    "@dcloudio/uni-cli-shared": "3.0.0-alpha-5020520260824002",
    "@dcloudio/vite-plugin-uni": "3.0.0-alpha-5020520260824002",
    "vite": "^5.0.0"
  }
}

⚠️ 别写 ^3.0.0。 npm 上 uni-app 的 3.x 只有 alpha 版本,没有 stable,npm install 会直接 ETARGET 报错。

必须用 vue dist-tag 上对应的完整 alpha 版本号,且所有 @dcloudio/* 的版本必须完全一致。

最后这条是我踩过的另一个坑:一开始我把 @dcloudio/uni-app 和 @dcloudio/uni-h5 写成了不同版本(都是 3.x 但 alpha 号不同),npm install 能过,编译时报各种诡异的模块找不到。@dcloudio/* 是一套联动的构建工具链,版本不齐会导致它们之间的内部 API 对不上。

对比一下 vue 和 vite 用 ^ 就没问题——因为它们有 stable 版本。

vite.config.js:

import { defineConfig } from 'vite';
import uni from '@dcloudio/vite-plugin-uni';

export default defineConfig({
  plugins: [uni()],
  // H5 产物挂在官网 /mp/ 路径下,用绝对路径前缀
  base: process.env.UNI_PLATFORM === 'h5' ? '/mp/' : '/',
});

这行条件判断很重要。 H5 产物是挂在子路径 /mp/ 下的,如果 base 写死成 /,构建出来的资源引用全是根路径,部署到 /mp/ 目录之后 CSS、JS、图标全 404。非 H5 端则必须用 /,因为小程序不认子路径前缀。


三、加一个页面:三步

步骤 1:建 vue 文件

src/pages/shop/coupon.vue(以「我的优惠券」为例):

<template>
  <view class="page">
    <view v-if="loading" class="loading">{{ t('common.loading') }}</view>

    <view v-else-if="list.length === 0" class="empty">
      <text class="empty-icon">🎫</text>
      <text>{{ t('coupon.empty') }}</text>
    </view>

    <view v-else class="coupon-list">
      <view class="coupon-item" v-for="c in list" :key="c.id">
        <view class="coupon-left">
          <text class="coupon-amount">¥{{ money(c.amount) }}</text>
          <text class="coupon-threshold">满 {{ money(c.threshold) }} 可用</text>
        </view>
        <view class="coupon-right">
          <text class="coupon-name">{{ c.name }}</text>
          <text class="coupon-expire">{{ t('coupon.expireAt') }} {{ c.expireAt }}</text>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
import { t } from '../../utils/i18n.js';
import { getCoupons } from '../../utils/api.js';
import { money } from '../../utils/cart.js';

export default {
  data() {
    return { loading: true, list: [] };
  },
  onLoad() { this.loadData(); },
  onPullDownRefresh() {
    // 下拉刷新:数据回来后必须调 stopPullDownRefresh,否则菊花一直转
    this.loadData().then(() => uni.stopPullDownRefresh());
  },
  methods: {
    t,        // ← 必须挂到 methods,模板才能用
    money,    // ← 同上
    async loadData() {
      this.loading = true;
      const res = await getCoupons();
      if (res.ok && res.data) {
        this.list = res.data.items || [];
      }
      this.loading = false;
    },
  },
};
</script>

<style scoped>
.page { padding: 24rpx; background: #f5f6fa; min-height: 100vh; }
.coupon-item {
  display: flex; align-items: center; gap: 24rpx;
  background: #fff; border-radius: 16rpx; padding: 28rpx; margin-bottom: 20rpx;
}
.coupon-amount { font-size: 44rpx; font-weight: 700; color: #dc2626; }
</style>

三个要点:

  • 单位用 rpx(750rpx = 屏幕宽度),uni-app 会自动换算成对应设备的物理像素。用 px 在不同机型上会大小不一。
  • t 和工具函数要在 methods 里挂出来才能在模板用。这是 Vue 2 风格的 Options API 的规则,我第一次写的时候以为 import 进来就能直接用,模板里报 t is not defined。
  • <style scoped> 防止样式污染。

步骤 2:在 pages.json 注册

{
  "pages": [
    // ... 已有页面
    {
      "path": "pages/shop/coupon",
      "style": {
        "navigationBarTitleText": "我的优惠券",
        "enablePullDownRefresh": true
      }
    }
  ]
}

注意:路径不带 .vue 后缀,且不以 / 开头。

style 常用字段:

字段说明
navigationBarTitleText导航栏标题
navigationStylecustom = 隐藏原生导航栏(自绘时用)
enablePullDownRefresh开启下拉刷新(配合 onPullDownRefresh)

第三个字段和页面代码里的 onPullDownRefresh() 是配套的:这里不开,页面方法不会被触发。这两个都得写,缺一个都不行。

步骤 3:跳过去

import { goto } from '../../utils/nav.js';

goto('/pages/shop/coupon');
goto(`/pages/shop/product?id=${p.id}`);

⚠️ 千万别直接 uni.navigateTo 跳 tabBar 页。

这是一个静默失败——不报错,也不跳转,控制台什么都没有。用户点了没反应,开发者完全无从下手。utils/nav.js 把这个坑封掉了:

const TAB_PAGES = new Set([
  'pages/index/index',
  'pages/shop/index',
  'pages/materials/index',
  'pages/commission/index',
  'pages/mine/index',
]);

export function goto(url) {
  if (!url) return false;
  const path = String(url).split('?')[0].replace(/^\//, '');   // 去掉前导 / 和 query
  try {
    if (TAB_PAGES.has(path)) {
      // tabBar 页必须用 switchTab;fail 兜底退回 navigateTo
      uni.switchTab({ url, fail: () => uni.navigateTo({ url }) });
    } else {
      uni.navigateTo({ url });
    }
    return true;
  } catch (e) {
    console.warn('[nav] goto failed:', url, e);
    return false;
  }
}

三个细节:

  • 先规整路径:split('?') 去掉参数、replace(/^\//, '') 去掉前导斜杠,再和集合比对。否则 goto('/pages/shop/index?id=1') 匹配不上,会误判成普通页面。
  • switchTab 带 fail 兜底:万一 tabBar 页被移除了配置,退回 navigateTo,至少不会静默失败。
  • catch + 警告日志:uni 的跳转 API 部分错误不抛异常,catch 只是兜底,但有日志就能查。

加了新 tabBar 页,记得同步更新 TAB_PAGES,否则跳转会掉到 navigateTo 然后失败。


四、API 层:离线降级 + 演示模式

utils/api.js 的设计是三层的:

请求 → 正常返回? ──是──► 用真数据
        │
        否(网络失败/后端挂了)
        ▼
   离线占位数据(PLACEHOLDERS)

4.1 基础请求

const BASE_URL = (() => {
  // #ifdef H5
  return '/api';                        // H5 由 nginx 反代,同源无跨域
  // #endif
  // #ifndef H5
  return 'http://localhost:3000/api';   // 小程序/App 直连
  // #endif
})();

// #ifdef H5 是 uni-app 的条件编译,构建时按平台剔除代码。写前端同路径,请求当前域名 + /api,由 nginx 反代到后端,天然没有跨域问题。小程序和 App 没有反代这层,只能直连。

4.2 占位数据

const PLACEHOLDERS = {
  dashboard: {
    agentName: '张代理(示例)',
    metrics: [ /* ... */ ],
    todos: [ /* ... */ ],
  },
  commission: { /* ... */ },
  materials: [ /* ... */ ],
  shopProducts: [ /* 6 件商品,刻意跨 2 家店 + 自营 */ ],
  stores: [ /* 2 家店铺 */ ],
  shopOrders: [ /* ... */ },
};

export function getPlaceholder(key) {
  return PLACEHOLDERS[key] ?? null;
}

shopProducts 那条注释有点讲究:刻意构造成跨 2 家店 + 自营。这样演示数据本身就覆盖了跨店铺拆单的场景,点一下购物车就能看到分组效果,不用专门造数据。

新增一个模块的占位数据:在 PLACEHOLDERS 里加 key,然后在对应的 API 函数里接上降级逻辑:

export async function getCoupons() {
  if (DEMO_MODE) return demoRes('coupons');     // 演示模式直接返回占位
  const res = await get('/coupons');
  if (isOffline(res)) {
    // 网络不通 → 返回占位数据,但标记 offline:true
    return { ok: true, data: { items: getPlaceholder('coupons') }, offline: true };
  }
  return res;
}

返回 ok: true 配 offline: true 而不是 ok: false,是为了让页面的正常渲染逻辑不用改——页面只认 ok 和 data.items,降级数据走同一条路径。offline 标记留着以后要做提示用(比如顶栏显示"当前为离线数据")。

4.3 DEMO_MODE

// utils/config.js
function detectDemoMode() {
  if (!IS_H5) return false;
  try {
    const href = window.location.href || '';
    const path = window.location.pathname || '';
    if (/[?&]demo=1/.test(href)) return true;      // URL 带 demo=1
    return /^\/mp(\/|$)/.test(path);               // 或路径就是 /mp/
  } catch {
    return false;
  }
}
export const DEMO_MODE = detectDemoMode();

为什么需要它? H5 产物是挂在静态站子路径下、用 iframe 嵌入展示的。那个路径没有反代 /api,如果走真实请求会 401,甚至打到生产库。

DEMO_MODE 下所有 API 直接返回占位数据,保证演示永远可点可滑,也不污染生产数据。

三重保护是必要的:if (!IS_H5) 保证小程序端不会误判;try/catch 兜住 SSR 或受限环境下的 window 访问;两个条件都能独立触发,调试时直接 ?demo=1 最省事。

配合 utils/auth.js:

export function isLoggedIn() {
  if (DEMO_MODE) return true;    // 演示模式恒为已登录
  return !!getToken();
}

export function logout() {
  if (DEMO_MODE) {
    uni.showToast({ title: '演示模式无需登录', icon: 'none' });
    return;                       // 不清态,也不跳登录页
  }
  uni.removeStorageSync(TOKEN_KEY);
  uni.removeStorageSync(AGENT_INFO_KEY);
  uni.reLaunch({ url: '/pages/login/index' });
}

⚠️ isLoggedIn() 在 DEMO_MODE 返回 true 是必须的——否则工作台的 onLoad 会 reLaunch 到登录页,演示 iframe 里就是一片白。

这个坑排查起来很费劲:表现为"打开演示链接一片空白",控制台无报错,会怀疑是路由配置、CORS、或者 iframe 策略问题。实际原因是一个 if 少了一行。


五、国际化:7 语种

5.1 结构

// utils/i18n.js
export const LOCALES = [
  { code: 'zh-CN', label: '简体中文', flag: '🇨🇳' },
  { code: 'zh-TW', label: '繁體中文(台灣)', flag: '🇨🇳' },
  { code: 'zh-HK', label: '繁體中文(香港)', flag: '🇭🇰' },
  { code: 'zh-MO', label: '繁體中文(澳門)', flag: '🇲🇴' },
  { code: 'ja',    label: '日本語', flag: '🇯🇵' },
  { code: 'en',    label: 'English (UK)', flag: '🇬🇧' },
  { code: 'en-US', label: 'English (US)', flag: '🇺🇸' },
];

const I18N = {
  'zh-CN': { 'app.title': '分销代理多端触达', 'tab.home': '工作台', /* ... */ },
  'zh-TW': { /* ... */ },
  // ... 7 份
};

// 商城词条单独一块,最后合并进主字典
const SHOP_I18N = { 'zh-CN': { /* ... */ }, /* 7 份 */ };
Object.keys(SHOP_I18N).forEach((loc) => {
  if (I18N[loc]) Object.assign(I18N[loc], SHOP_I18N[loc]);
  else I18N[loc] = SHOP_I18N[loc];
});

export function t(key, fallback) { /* ... */ }
export function getLocale() { /* ... */ }
export function setLocale(locale) { /* ... */ }

商城词条单独拆一块再合并,是因为商城是后加的模块,词条量不小。放一起会让字典文件长到没法维护——而且合并逻辑里的 else I18N[loc] = SHOP_I18N[loc] 保证了即使某语言在主字典里不存在,也不会丢词条。

5.2 加词条:用脚本,别手工

手工加 7 份很容易漏,而且漏了之后表现是"切到那个语言显示 key 名",不报错,很难发现。项目里用脚本批量注入:

// scripts/inject-dashboard-i18n.cjs
const ENTRIES = [
  {
    anchor: "    'dashboard.aiAssistant': 'AI助手',\n",
    inject:
      "    'dashboard.mallEntryTitle': 'C 端商城',\n" +
      "    'dashboard.mallEntryDesc': '浏览商品 · 一键代客下单 · 跨店自动拆单',\n",
  },
  {
    anchor: "    'dashboard.aiAssistant': 'AIアシスタント',\n",
    inject: /* 日文 */,
  },
  {
    anchor: "    'dashboard.aiAssistant': 'AI Assistant',\n",  // 英文两份共用
    inject: /* 英文 */,
  },
];

跑之前先确认:anchor 字符串在每个语言块里是唯一的,否则会注入到错误的位置。

英文块有 en 和 en-US 两份,同一个 anchor 会被替换两次——这正好是我们想要的,省得为两份几乎一样的英文各写一个 entry。

这个"锚点注入"的模式看起来土,但比引入 i18n 管理工具轻得多。7 种语言、条目结构完全一致的情况下,脚本方案的可靠性反而更高——因为它是幂等的,重复跑不会出错。

5.3 页面里切换语言

import { t, setLocale, getLocale, LOCALES } from '../../utils/i18n.js';

export default {
  data() {
    return { currentLocale: getLocale(), locales: LOCALES };
  },
  methods: {
    t,
    changeLocale(code) {
      setLocale(code);
      this.currentLocale = code;
      this.loadData();      // 重新拉数据(后端也支持多语言时)
    },
  },
};

切换后如果界面没刷新,用 this.$forceUpdate() 强制重渲染。


六、tabBar:图标不用找设计师

5 个 tab:工作台 / 商城 / 素材 / 佣金 / 我的。

scripts/gen-tabbar-icons.cjs 是零依赖的 PNG 编码器(用 Node 内置 zlib),直接画出图标:

node scripts/gen-tabbar-icons.cjs
# 生成 10 个 81×81 PNG 到 src/static/tabbar/

这个脚本手写 PNG 编码有点反直觉,但考虑到需求就是"10 个简单几何图标",比装一个图像处理库轻。zlib 做 deflate 压缩,81×81 的简单图标压出来只有几 KB。

配置:

{
  "tabBar": {
    "color": "#9ca3af",
    "selectedColor": "#2563eb",
    "borderStyle": "black",
    "backgroundColor": "#ffffff",
    "list": [
      {
        "pagePath": "pages/index/index",
        "text": "工作台",
        "iconPath": "static/tabbar/home.png",
        "selectedIconPath": "static/tabbar/home_on.png"
      },
      {
        "pagePath": "pages/shop/index",
        "text": "商城",
        "iconPath": "static/tabbar/shop.png",
        "selectedIconPath": "static/tabbar/shop_on.png"
      }
      // ...
    ]
  }
}

📌 tabBar 的 pagePath 必须也在 pages 数组里,且建议放前面。

tabBar 图标必须是本地静态文件,不能用网络图,也不用 base64。这个限制在小程序端尤其严格——网络图片要配置域名白名单,base64 会撑爆包体积。


七、编译与构建

npm install

npm run dev:h5          # H5 开发(默认 8081,/api 代理到 3000)
npm run build:h5        # 构建 H5 → dist/build/h5/
npm run dev:mp-weixin   # 微信小程序开发
npm run build:mp-weixin # 构建 → dist/build/mp-weixin/(用微信开发者工具打开)
npm run build:app       # 构建 App

7.1 H5 产物部署

构建产物在 dist/build/h5/,index.html 里的资源路径已带 /mp/ 前缀(这就是 §2 里 base 那行条件判断的作用)。

整个目录丢到 nginx 的 /mp/ 目录下即可:

location ^~ /mp/ {
    alias /var/www/www-site/mp/;
    index index.html;
    try_files $uri $uri/ /mp/index.html;   # hash 路由回落
}
location = /mp { return 301 /mp/; }

那行 try_files 的回落很关键。manifest.json 里 H5 路由是 hash 模式(#/pages/shop/cart),所以路径永远只有 /mp/index.html 一个真实文件。这条规则保证任何不存在的路径都回落到 index.html,否则用户直接粘贴一个深层链接的 URL 就会 404。

改 iframe 的 hash 就能跳页:

/mp/#/pages/shop/index
/mp/#/pages/shop/cart

7.2 Windows 上构建失败的解法(开头那个坑)

现象:

[safe-delete] 操作失败: ERROR .../uniapp/dist/build/h5/assets:
Error during a `trash` operation: Unknown { description: "Some operations were aborted" }
Build failed with errors.

原因:部分环境注入了 safe-delete 钩子,它接管了 shell 的 rm / mv 和 Node 的 fs.rmSync,把删除操作改成"移到回收站"。而 uni build 在构建前会清理 dist/,于是被拦截。

解法:fs.renameSync 不在拦截范围内。项目里的 scripts/clean-dist.cjs 用"重命名归档"绕过去:

// 把旧 dist 重命名到 .dist-cache/<时间戳>/,让 outDir 变成「不存在」
fs.renameSync(DIST, target);
node scripts/clean-dist.cjs && npm run build:h5

这里的思路值得单独说:拦截规则是按 API 名字匹配的,而不是按语义。 所以 fs.rmSync 会被拦,fs.renameSync 不会。想清楚了这一点,解法就是自然的了——目标不是"删除",而是"让输出目录不存在",改名同样能满足这个目标。

而且改名比删除更安全:旧产物还在 .dist-cache/<时间戳>/ 里,构建成功了可以回退,构建失败了可以对比排查。删除则是什么都不剩。

如果你在别的机器遇到同样报错,用同样的思路解决:别删,改名。

7.3 另一个坑:@rollup/pluginutils 报模块找不到

Cannot find module @rollup/pluginutils/dist/cjs/index.js

npm install 有时产出空的 dist/cjs/ 目录。重装即可:

rm -rf node_modules/@rollup/pluginutils
npm install @rollup/pluginutils@5.1.0 --no-save

--no-save 是因为这只是修复当前环境的构建,不该改 package.json——这个包是 vite 的传递依赖,不该出现在直接依赖里。


八、小程序端的 API 地址

小程序和 App 不能用相对路径 /api,必须直连后端域名:

// #ifndef H5
return 'http://localhost:3000/api';   // ← 上线前改成 https 公网地址
// #endif

改完还要在微信公众平台 → 开发 → 开发管理 → 服务器域名 → request 合法域名里加上你的后端域名,且必须是 https。

"必须 https"是平台的硬性要求,不是建议。小程序发出去的请求如果检测到是 http,会被直接拦截,且这个拦截在开发工具里不一定复现,真机上才会暴露。所以本地开发用 http://localhost 没问题(开发者工具允许),但打包上传前一定要改。


九、加新功能前的自查清单

  • 新页面建在 src/pages/ 下
  • pages.json 注册了(路径不带 .vue、不带前导 /)
  • 跳转统一用 goto(),新增 tabBar 页时同步更新 nav.js 的 TAB_PAGES
  • 新增 API 时加了 DEMO_MODE 分支和离线占位数据
  • 文案走 t(),7 种语言都补了词条
  • 样式用 rpx + <style scoped>
  • 构建前跑了 node scripts/clean-dist.cjs
  • 小程序上线前把 BASE_URL 改成 https 并配好域名白名单

小结

这次 UniApp 二开下来,最值钱的经验不是某个 API 怎么用,而是几个排查思路:

  1. 报错信息里的话要先读完。 「trash 操作被中止」直接指明了问题跟"回收站"有关,不是一个随机的构建错误。
  2. 静默失败最费时间。 navigateTo 跳 tabBar 页不报错不跳转,排查成本是普通 bug 的好几倍。所以像 goto() 这种封装,宁可多写几行,也要保证任何路径都有明确的反馈。
  3. 遇到被拦截的底层操作,换语义等价的实现。 rmSync 不行就 renameSync,"让目录不存在"的目的一样能达到。
  4. 重复性劳动交给脚本。 7 份 i18n、10 个 tabBar 图标,手工维护的错误率远高于脚本。

最后一点补充:@dcloudio/* 的版本必须严格一致这一条,是这个生态特有的约束。alpha 版本没有 stable 可依赖,就意味着没有 semver 的保护网,你必须自己当那个锁版本的人。

代码已开源,Apache 2.0 协议,可自取:atomgit.com/pangzi_zi/d…