ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

uni-app 项目模板搭建指南:从目录结构到分包预加载的工程化实践

uni-app 项目模板搭建指南:从目录结构到分包预加载的工程化实践

简介:这是一份面向具备一年以上 uni-app 开发经验的前端工程师的开源项目模板,由一位五年经验的 Web 前端开发者整理,旨在提供一套可直接复用的快速开发脚手架,帮助开发者跳过重复搭建环节,把精力集中在业务逻辑与组件设计上。资源包共 114 个文件,压缩后约 274KB,以 34 个 js 脚本、26 个 vue 组件、33 个 png 图片资源为主,另含 6 个 md 说明文档、4 个 scss 与 4 个 css 样式文件及少量 json、html 等配置,覆盖组件封装、样式组织与工具函数等常见模块。内容中包含表情组件、消息组件等聊天场景实现,以及下拉刷新相关样式与解析工具,便于读者理解 nvue 组件写法、样式分层与工具库组织方式。目前已有 2430 人学习下载,适合希望参考成熟目录结构、提升组件化与工程化能力的中高级开发者研读借鉴。

1. 从零搭一个 uni-app 项目模板:为什么你每次开新项目都在重复劳动

每次开新项目,你是不是都要重新配一遍 pages.json、manifest.json、封装 request、配环境变量、搭目录结构?一个项目折腾半天,真正写业务的时间不到三分之一。uni-app-template 要解决的就是这件事:把跨端项目里那些「每次都要做、但每次做法还不一样」的初始化工作,固化成一套可复用、可维护、可扩展的工程骨架。它适合两类人:一是刚接触 uni-app、不想在工程配置上踩坑的新手;二是手上同时维护多个小程序/H5/App 项目、需要统一开发规范的老手。一套好的模板不是把代码写死,而是把决策点提前暴露出来——状态管理用不用、请求怎么封装、分包怎么切、多环境怎么切,这些在模板阶段定好,后面每个项目直接继承,省下的时间够你多写两个页面。

2. 拆解 uni-app-template 的目录结构与核心配置

2.1 一套能跑三端的目录骨架长什么样

uni-app 的目录结构不是随便摆的,它直接决定了编译到微信小程序、H5、App 时会不会出问题。我一般会按下面的方式组织,核心原则是:平台无关的放外层,平台相关的收进条件编译或独立目录。

uni-app-template/ ├── src/ │ ├── api/ # 接口统一管理,按模块拆分 │ │ ├── request.js # 请求拦截、响应拦截、错误统一处理 │ │ └── user.js # 用户模块接口 │ ├── components/ # 全局公共组件,自动注册 │ ├── pages/ # 主包页面 │ ├── subPackages/ # 分包目录,按业务模块切 │ ├── static/ # 静态资源,注意小程序有 2M 限制 │ ├── store/ # 状态管理,Pinia 或 Vuex │ ├── utils/ # 工具函数,日期、校验、存储等 │ ├── config/ # 环境配置,dev/prod 分离 │ ├── App.vue │ ├── main.js │ ├── manifest.json # 应用配置,appid、权限、启动图 │ ├── pages.json # 页面路由、导航栏、tabBar │ └── uni.scss # 全局样式变量 ├── .env.development ├── .env.production ├── vite.config.js └── package.json

这个结构里有两个容易翻车的地方。第一,static目录在小程序端会被完整打包,图片超过 2M 直接编译失败,所以大图必须走 CDN 或放服务器。第二,subPackages的划分不是按页面数量,而是按用户访问路径——首页必须留在主包,低频功能才丢分包,否则主包体积压不下来,小程序启动速度直接受影响。

2.2 pages.json 与 manifest.json 的关键参数怎么设

pages.json是 uni-app 的路由表,但它比普通 Vue 路由多了导航栏、tabBar、分包、预加载这些配置。下面是一个最小可用的配置片段:

{ "pages": [ { "path": "pages/index/index", "style": { "navigationBarTitleText": "首页", "enablePullDownRefresh": true, "onReachBottomDistance": 50 } } ], "subPackages": [ { "root": "subPackages/user", "pages": [ { "path": "profile", "style": { "navigationBarTitleText": "个人中心" } } ] } ], "preloadRule": { "pages/index/index": { "network": "all", "packages": ["subPackages/user"] } }, "globalStyle": { "navigationBarTextStyle": "black", "navigationBarBackgroundColor": "#FFFFFF", "backgroundColor": "#F5F5F5" } }

preloadRule是很多人忽略的配置。它的作用是在进入首页时提前下载分包代码,用户点进分包页面时就不用等。但注意network参数:设成all会在 4G 下也预加载,如果分包体积大,用户流量消耗明显;设成wifi则只在 WiFi 下预加载,更保守。我一般对体积小于 200KB 的分包用all,超过就用wifi。

manifest.json里最关键的三个配置:appid(微信小程序必须填对,否则真机调试白屏)、vueVersion(确认是 2 还是 3,影响所有语法)、app-plus下的distribute权限配置(Android 和 iOS 的权限描述必须写清楚,否则上架被拒)。

2.3 环境变量与请求封装的落地写法

多环境切换是模板的核心价值之一。uni-app 基于 Vite 构建,支持.env文件,但小程序端不能直接读process.env,需要用import.meta.env配合条件编译。

// src/config/index.js const ENV_CONFIG = { development: { baseURL: 'https://dev-api.example.com', debug: true }, production: { baseURL: 'https://api.example.com', debug: false } } // Vite 注入的环境变量,注意小程序端要用 import.meta.env const env = import.meta.env.MODE || 'development' export default { ...ENV_CONFIG[env], timeout: 10000, version: '1.0.0' }

请求封装要解决三个问题:统一 baseURL、统一错误提示、统一 loading 管理。下面是一个经过多个项目验证的写法:

// src/api/request.js import config from '@/config' let requestCount = 0 function showLoading() { if (requestCount === 0) { uni.showLoading({ title: '加载中', mask: true }) } requestCount++ } function hideLoading() { requestCount-- if (requestCount <= 0) { requestCount = 0 uni.hideLoading() } } export function request(options) { const { url, method = 'GET', data = {}, header = {}, showLoad = true } = options if (showLoad) showLoading() return new Promise((resolve, reject) => { uni.request({ url: config.baseURL + url, method, data, header: { 'Content-Type': 'application/json', ...header }, timeout: config.timeout, success: (res) => { // 业务层错误码判断,按后端约定调整 if (res.statusCode === 200) { if (res.data.code === 0) { resolve(res.data.data) } else { uni.showToast({ title: res.data.msg || '请求失败', icon: 'none' }) reject(res.data) } } else { uni.showToast({ title: `网络错误 ${res.statusCode}`, icon: 'none' }) reject(res) } }, fail: (err) => { uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' }) reject(err) }, complete: () => { if (showLoad) hideLoading() } }) }) }

这里用requestCount做计数器而不是简单开关,是因为并发请求时如果第一个请求结束就 hideLoading,后面的请求就没 loading 了,用户体验很割裂。这个坑我踩过不止一次。

3. 状态管理与分包加载:模板里最容易被做错的两件事

3.1 Pinia 在小程序端的初始化与持久化

uni-app 支持 Vuex 和 Pinia,新项目我建议直接用 Pinia,原因是它的模块化更自然,TS 支持更好,而且没有 mutation 那层冗余。但在小程序端用 Pinia 有一个坑:默认不支持持久化,用户杀掉小程序再进来,store 里的数据全没了。

// src/store/index.js import { createPinia } from 'pinia' const pinia = createPinia() // 简易持久化插件,只持久化指定字段 pinia.use(({ store }) => { const persistedKeys = store.$options?.persist?.keys || [] if (persistedKeys.length === 0) return // 初始化时从 storage 恢复 const saved = uni.getStorageSync(`pinia_${store.$id}`) if (saved) { persistedKeys.forEach(key => { if (saved[key] !== undefined) store[key] = saved[key] }) } // 订阅变化写入 storage store.$subscribe((mutation, state) => { const toSave = {} persistedKeys.forEach(key => { toSave[key] = state[key] }) uni.setStorageSync(`pinia_${store.$id}`, toSave) }) }) export default pinia

使用的时候在 store 定义里加persist选项:

// src/store/user.js import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ token: '', userInfo: null, tempData: {} // 不需要持久化的字段 }), persist: { keys: ['token', 'userInfo'] // 只持久化这两个 }, actions: { setToken(token) { this.token = token } } })

参数说明:keys数组里只放需要跨会话保留的字段。tempData这种临时数据不要放进去,否则 storage 会越写越大,小程序对单个 key 有 1M 限制,总大小 10M,写满了直接报错。

3.2 分包预加载与主包体积的平衡策略

分包不是切得越细越好。我见过一个项目切了 12 个分包,结果每个分包都要单独下载,用户点一个页面等一次,体验反而更差。合理的做法是:按业务闭环切分,一个分包内的页面互相跳转不需要跨包。

主包体积控制在 1.5M 以内(微信小程序限制 2M,留 500K 余量给后续迭代)。如果主包超了,优先检查三样东西:static目录里有没有大图、components里有没有全局注册但很少用的组件、utils里有没有引入整个第三方库但只用了其中一个函数。

// 按需引入第三方库,不要全量引入 // 错误写法:import _ from 'lodash' // 正确写法: import debounce from 'lodash/debounce' import throttle from 'lodash/throttle'

对于确实需要预加载的分包,在pages.json里配preloadRule,但要注意:预加载的时机是首页onLoad之后,如果首页本身请求就多,预加载会抢带宽。我的做法是延迟 2 秒再触发预加载,用setTimeout包一层,或者放到onReady之后。

4. 避坑与排查:uni-app 模板搭建中的 5 个血泪教训

4.1 条件编译写错导致 H5 正常、小程序白屏

现象:本地 H5 跑得好好的,编译到微信小程序直接白屏,控制台没有任何报错。

原因:条件编译语法写错了。比如#ifdef H5写成了#ifdef h5,大小写敏感,编译时不会报错,但代码块被整个忽略,导致某些关键初始化逻辑没执行。

解决:条件编译的平台标识必须全大写:H5、MP-WEIXIN、APP-PLUS。另外#endif必须成对出现,嵌套时尤其容易漏。建议在编辑器里装 uni-app 的语法高亮插件,写错会有颜色提示。

4.2 manifest.json 里 appid 为空,真机调试直接闪退

现象:模拟器正常,真机调试打开就闪退,或者提示「未配置 appid」。

原因:manifest.json里mp-weixin.appid留空了。模拟器不校验 appid,真机必须校验。

解决:去微信公众平台申请测试号或正式 appid,填到manifest.json对应位置。如果是多端项目,H5 端的appid可以随便填,但小程序端必须真实有效。

4.3 全局样式污染导致组件库样式被覆盖

现象:引入了某个 UI 库,部分组件样式错乱,按钮变成默认样式。

原因:App.vue里写了全局样式,或者uni.scss里定义了同名变量覆盖了组件库的变量。

解决:全局样式只放 reset 和通用工具类,不要写具体组件样式。uni.scss里的变量命名加前缀,比如$app-primary-color,避免和组件库的$primary-color冲突。如果必须覆盖组件库样式,用::v-deep或:deep()限定作用域。

4.4 分包页面跳转报「页面不存在」

现象:uni.navigateTo跳分包页面,控制台报「页面不存在」,但路径明明是对的。

原因:分包路径配置错误。subPackages的root是分包根目录,pages里的path是相对于root的路径,不是相对于项目根目录。

解决:假设root是subPackages/user,页面文件在src/subPackages/user/profile.vue,那么path应该写profile,跳转时用/subPackages/user/profile。注意跳转路径要加前导斜杠,且包含root目录名。

4.5 环境变量在小程序端读不到

现象:.env.development里定义了VITE_API_URL,H5 端能读到,小程序端import.meta.env.VITE_API_URL是undefined。

原因:uni-app 的 Vite 版本对小程序端的环境变量注入有差异,部分版本只注入MODE和BASE_URL,自定义变量需要额外配置。

解决:在vite.config.js里显式定义:

// vite.config.js import { defineConfig, loadEnv } from 'vite' import uni from '@dcloudio/vite-plugin-uni' export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd()) return { plugins: [uni()], define: { // 手动注入,确保小程序端也能读到 'import.meta.env.VITE_API_URL': JSON.stringify(env.VITE_API_URL) } } })

改完之后重启编译,不要热更新,环境变量的注入只在冷启动时生效。

5. 让模板真正可复用的三个进阶技巧

5.1 用脚本自动生成新页面,减少手写路由

模板搭好之后,每次新建页面还要手动改pages.json,容易漏。我一般会写一个 Node 脚本,输入页面路径和标题,自动在pages.json里插入配置并创建对应的.vue文件。

// scripts/create-page.js const fs = require('fs') const path = require('path') const [,, pagePath, title = '新页面'] = process.argv if (!pagePath) { console.error('用法: node scripts/create-page.js pages/demo 页面标题') process.exit(1) } const pagesJsonPath = path.resolve(__dirname, '../src/pages.json') const pagesJson = JSON.parse(fs.readFileSync(pagesJsonPath, 'utf-8')) // 检查是否已存在 const exists = pagesJson.pages.some(p => p.path === pagePath) if (exists) { console.error(`页面 ${pagePath} 已存在`) process.exit(1) } // 插入路由 pagesJson.pages.push({ path: pagePath, style: { navigationBarTitleText: title } }) fs.writeFileSync(pagesJsonPath, JSON.stringify(pagesJson, null, 2)) // 创建 vue 文件 const vuePath = path.resolve(__dirname, `../src/${pagePath}.vue`) const dir = path.dirname(vuePath) if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) const template = `<template> <view class="page"> <text>${title}</text> </view> </template> <script setup> // 页面逻辑 </script> <style scoped> .page { padding: 20rpx; } </style> ` fs.writeFileSync(vuePath, template) console.log(`已创建: ${pagePath} -> ${title}`)

这个脚本的价值在于:路由配置和文件创建是原子操作,不会出现「文件建了但路由忘了加」的情况。参数说明:第一个参数是页面路径(不带.vue后缀),第二个参数是导航栏标题。跑完之后直接编译就能看到新页面。

5.2 用 uni.scss 统一设计变量,换主题只改一个文件

模板里最值得花时间的是uni.scss。把所有颜色、间距、字号、圆角都定义成变量,组件里只引用变量,不写硬编码值。这样换主题时只改这一个文件,全项目生效。

// src/uni.scss $app-primary: #2979FF; $app-success: #19BE6B; $app-warning: #FF9900; $app-error: #FA3534; $app-text-main: #303133; $app-text-regular: #606266; $app-text-secondary: #909399; $app-bg-page: #F5F5F5; $app-bg-card: #FFFFFF; $app-radius-sm: 8rpx; $app-radius-md: 16rpx; $app-radius-lg: 24rpx; $app-spacing-xs: 10rpx; $app-spacing-sm: 20rpx; $app-spacing-md: 30rpx; $app-spacing-lg: 40rpx;

注意uni.scss是全局注入的,不需要在每个组件里@import,直接写$app-primary就能用。但如果你用了 TypeScript,需要在tsconfig.json里配types让编辑器识别这些变量,否则会报红。

5.3 模板版本管理:用 git tag 而不是复制文件夹

很多人管理模板的方式是复制一份改个名,结果半年后不知道哪个版本是最新的。正确做法是用 git 分支 + tag:main分支保持稳定版,dev分支开发新特性,每个稳定版本打 tag。新项目直接git clone然后git checkout v1.2.0,干净利落。

如果团队多人用,可以把模板推到内部 git 仓库,用degit或git clone --depth 1拉取,避免把整个历史记录带进新项目。我自己的习惯是:模板仓库只保留src和配置文件,node_modules、dist、.git全部忽略,新项目拉下来先npm install,再改manifest.json里的appid和项目名,五分钟就能进入业务开发。

这套模板我用了两年多,最大的体会是:模板不是越全越好,而是越稳定越好。每加一个依赖、每改一个配置,都要问自己「这个改动会不会让下一个项目多一个坑」。那些花哨的自动导入、复杂的构建优化,如果团队里只有你一个人懂,那就是负债不是资产。保持简单,保持可预测,比什么都重要。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进