简介:这份微信小程序开发完整代码包面向希望系统练习小程序页面搭建与业务逻辑的开发者,也适合作为课程设计、毕业设计或入门项目的参考实现。压缩包共106个文件,约253KB,涵盖wxml页面结构、wxss样式、js逻辑、json配置以及png图片与gif动图等素材,并附xmind思维导图与md说明,便于从界面、交互到配置逐层理解一个完整小程序的组成。内容涉及列表展示、搜索、详情浏览、视频播放、个人中心及网络请求封装等常见模块,读者可据此梳理页面跳转、数据获取与组件复用的实现思路,也可在现有骨架上替换接口与素材,快速形成自己的练习项目。已有775人学习下载,适合需要对照源码查漏补缺、积累小程序开发经验的学习者参考。
1. 一套能跑通的微信小程序开发完整代码,填的是“从能编译到能上线”的坑
接手一个二手项目,最怕的不是需求怪,而是对方扔来一句“代码都在这儿了”。打开一看,页面能编译、接口全是假数据、登录写死了一个 token,改个字段要翻五个文件。这种项目的解法通常不是重写,而是先有一套结构清晰、跑得起来的微信小程序开发完整代码当参照系,再回头对照自己手里那堆东西缺了哪一层。这份代码包干的就是这件事:它不押注某个具体业务,而是把 app.json 路由配置、页面四件套、请求封装、登录态管理、组件通信这些骨架部分摊开摆明。会写 WXML 但没独立发过版的人,可以照着走一遍完整流程;接过烂摊子的熟手,可以拿它当检查表,看自己的目录分层断在哪。下面按“能干什么、怎么跑、怎么改”的顺序拆开说。
2. 工程骨架拆解:app.json 配置、页面四件套与目录分层
小程序项目的复杂度不在语法,在结构。同样一套页面,有人把请求写进 onLoad,有人抽了 api 层,三个月后前者改接口要全局搜索,后者改一行配置就完事。这一章先把代码包的骨架讲清楚,后面跑和改才不会迷路。
2.1 一个小程序到底由哪几层组成
微信小程序的最小运行单元是“页面”,但真正决定项目能不能长大的,是页面外面那几层。最外层是app.json,它管路由、窗口表现、tabBar、分包,相当于整个项目的路由表加皮肤配置;它旁边是app.js,负责应用级生命周期和全局状态;app.wxss放全局样式和公共类。这三份东西构成了应用级外壳。
页面级是大家熟悉的四件套:.wxml描述结构、.wxss描述样式、.js处理逻辑与数据、.json做页面级配置(导航栏标题、是否允许下拉刷新、引用了哪些自定义组件)。少一份也能跑,但补齐之后页面才是自解释的,接手的人不用去猜这个页面有没有用自定义组件。
先看应用级配置长什么样:
{ "pages": [ "pages/index/index", "pages/detail/detail", "pages/mine/mine" ], "window": { "navigationBarTitleText": "示例应用", "navigationBarBackgroundColor": "#ffffff", "navigationBarTextStyle": "black", "backgroundColor": "#f5f6f8" }, "tabBar": { "color": "#8a8a8a", "selectedColor": "#1a1a1a", "list": [ { "pagePath": "pages/index/index", "text": "首页" }, { "pagePath": "pages/mine/mine", "text": "我的" } ] }, "subPackages": [ { "root": "packageOrder", "pages": ["pages/list/list"] } ], "sitemapLocation": "sitemap.json", "style": "v2" }pages数组的第一项是启动页,这个顺序不是装饰,写错了首屏就变成别的页面。window里的背景色和导航栏色建议一次性定好,页面级.json只覆盖差异项,不重复写。tabBar的list项不配iconPath时就只显示文字,做后台管理类小程序够用;真要做带图标的产品,图标尺寸和路径得同时校验,缺一个就整条 tabBar 不渲染。subPackages是分包配置,主包只放首页和公共资源,订单、个人中心这类低频大模块丢进分包,能有效绕开主包体积红线。
sitemap.json经常被当成摆设删掉,其实它决定页面是否被索引,做内容型小程序时留着比删了省事。style: "v2"则是启用新版组件样式,新项目建议直接开,老项目贸然切换会让按钮、输入框的默认外观变化,这一点在改造存量代码时要有心理准备。
2.2 目录结构与文件职责清单
结构清不清,看目录就知道。这套代码的分层原则很简单:pages 只负责编排和展示,api 只负责取数,utils 只放纯函数,components 只放可复用 UI。四类目录各管一摊,谁也不越界。
├── app.js # 应用入口:生命周期、全局状态、登录态初始化 ├── app.json # 路由、窗口、tabBar、分包配置 ├── app.wxss # 全局样式变量与公共类 ├── project.config.json # 开发者工具项目配置,不进业务逻辑 ├── sitemap.json # 页面索引规则 ├── pages/ # 页面层,只做数据编排与视图绑定 │ ├── index/ # 首页:列表 + 下拉刷新 + 触底分页 │ ├── detail/ # 详情页:接收 id 参数,二次取数 │ └── mine/ # 个人中心:登录态展示与退出 ├── components/ # 自定义组件,纯展示,不直接发请求 │ ├── card/ # 列表卡片 │ └── empty/ # 空状态占位 ├── api/ # 接口层,一个业务文件对应一组接口 │ ├── index.js # 首页相关接口 │ └── user.js # 用户相关接口 ├── utils/ # 工具层:请求封装、格式化、常量 │ ├── request.js # wx.request 的 Promise 封装与拦截 │ ├── auth.js # 登录、token 读写 │ └── format.js # 时间、金额等纯函数 └── config/ # 环境配置:dev / prod 两套域名 └── env.js这个分层有个隐形好处:排查问题时能快速缩小范围。接口返回不对,先看api/;页面渲染空白,先看pages/的data和 WXML 绑定;样式串味,先看app.wxss有没有写全局选择器。反过来,如果请求逻辑散落在各个页面的onLoad里,一次后端字段调整就得全项目搜wx.request,那才是真正的血泪经验。
要提醒的是,components/里的组件不要自己去调接口。组件一旦发请求,复用性立刻归零,还容易出现同一个组件在多个页面重复请求的问题。正确姿势是页面取数、组件收 props,需要交互时往上抛事件。
2.3 技术选型:原生框架还是跨端方案
很多人拿到一份小程序代码,第一反应是“为什么不用 uni-app”。答案通常很朴素:这个项目只投小程序端。原生框架的包体积最小、调试链路最短、新 API 跟进最快,缺点是只能跑在小程序里,多端要重写。
| 维度 | 原生小程序 | uni-app | Taro |
|---|---|---|---|
| 上手成本 | 低,官方文档直接对应 | 中,需理解编译产物 | 中高,需熟悉框架生命周期映射 |
| 包体积 | 最小 | 略大,含运行时 | 略大,含运行时 |
| 多端能力 | 仅小程序 | H5、App、多小程序 | 多端 + React 生态 |
| 调试体验 | 工具内直接断点 | 需对照编译后代码 | 需对照编译后代码 |
| 适合场景 | 单端产品、性能敏感 | 多端复用、团队熟悉 Vue | 多端复用、团队熟悉 React |
这份代码选原生,逻辑是“先把小程序这一端吃透”。原生写法里Page、Component、App三套构造器的差异、setData的边界、生命周期的执行顺序,都是跨端框架绕不开的底层知识。先在原生里踩明白,再上跨端框架,遇到编译产物行为异常时才知道往哪查。反过来,如果团队已经有一套 Vue 或 React 组件库要复用,硬上原生反而是重复造轮子,这时候选跨端更划算。
判断标准可以简化成一句话:只投小程序、团队人少、对包体积敏感,用原生;已经有多端诉求、组件资产要复用,用跨端。选型没有对错,只有“后面的维护成本谁来扛”。
3. 核心链路拆解:登录态、请求封装与页面数据流
骨架讲完,进入真正决定项目能不能维护的三条链路:登录态怎么存、请求怎么发、页面数据怎么流。这三块写歪了,后面每加一个功能都是在还债。
3.1 app.js 里的登录态与全局数据
登录态是小程序里最容易写歪的一块。常见错误是把wx.login拿到的code直接当 token 用,或者把用户信息塞进globalData却不做持久化,冷启动一次全丢。合理做法是:code只用于换取后端会话,换回来的 token 落本地缓存,globalData只做内存缓存加速。
// app.js const { loginByCode } = require('./api/user') App({ globalData: { token: '', userInfo: null, systemInfo: null }, onLaunch() { // 启动时先读缓存,避免首屏请求拿不到 token this.globalData.token = wx.getStorageSync('token') || '' this.globalData.userInfo = wx.getStorageSync('userInfo') || null this.globalData.systemInfo = wx.getSystemInfoSync() }, // 登录:code 换 token,成功后写内存 + 写缓存 async ensureLogin() { if (this.globalData.token) return this.globalData.token const { code } = await wx.login() const res = await loginByCode({ code }) this.globalData.token = res.token this.globalData.userInfo = res.userInfo wx.setStorageSync('token', res.token) wx.setStorageSync('userInfo', res.userInfo) return res.token }, // 退出:两处都要清,漏一处就会出现“退了但没完全退” logout() { this.globalData.token = '' this.globalData.userInfo = null wx.removeStorageSync('token') wx.removeStorageSync('userInfo') } })onLaunch里读缓存这一步不能省。小程序的冷启动顺序是App.onLaunch→ 页面onLoad,如果首页在onLoad里就发请求,而 token 要等异步登录回来,第一次请求必然 401。先同步读缓存、再异步补登录,能把大部分首屏 401 挡掉。
ensureLogin设计成幂等函数:已有 token 直接返回,没有才走登录流程。这样页面里不用判断“登没登录”,统一await app.ensureLogin()即可。注意wx.login返回的code是一次性的,五分钟内有效,别缓存它,也别拿它当用户标识。
logout要同时清内存和缓存。只清globalData的话,下次冷启动又从缓存里把旧 token 读回来,用户会看到“退出后仍然是登录态”的诡异现象,这种问题排查起来非常费劲。
3.2 请求层:把 wx.request 包成可维护的 Promise
页面里直接写wx.request,是项目失控的起点。域名散落、错误码没人管、loading 到处开关,最后没人说得清一个接口失败时到底发生了什么。请求层的职责就三件:统一域名、统一鉴权、统一错误处理。
// utils/request.js const env = require('../config/env') const CODE = { SUCCESS: 0, UNAUTHORIZED: 401 } function request(options) { const { url, method = 'GET', data = {}, header = {}, loading = false } = options const app = getApp() if (loading) wx.showLoading({ title: '加载中', mask: true }) return new Promise((resolve, reject) => { wx.request({ url: env.baseUrl + url, // 统一拼接环境域名 method, data, header: { 'content-type': 'application/json', Authorization: app.globalData.token || '', // 每次实时取,不缓存旧值 ...header }, success(res) { const body = res.data || {} // 业务码与 HTTP 码分开判断,避免后端统一 200 返回错误码时漏判 if (res.statusCode === 200 && body.code === CODE.SUCCESS) { resolve(body.data) return } if (body.code === CODE.UNAUTHORIZED) { // token 失效:清掉本地态,回到未登录,由调用方决定是否重新登录 app.logout() reject({ type: 'AUTH', message: '登录已失效' }) return } wx.showToast({ title: body.message || '请求失败', icon: 'none' }) reject({ type: 'BIZ', message: body.message }) }, fail(err) { wx.showToast({ title: '网络异常,请稍后重试', icon: 'none' }) reject({ type: 'NET', message: err.errMsg }) }, complete() { if (loading) wx.hideLoading() } }) }) } module.exports = { get: (url, data, opts = {}) => request({ url, method: 'GET', data, ...opts }), post: (url, data, opts = {}) => request({ url, method: 'POST', data, ...opts }) }几个参数值得说清楚。url只传相对路径,域名由env.baseUrl统一提供,切环境时不用改业务代码。Authorization在每次请求时从globalData实时读取,不能在外面闭包缓存,否则登录完成后已发出的请求还是旧 token。loading做成可选,列表刷新这类自带骨架屏的场景就别再叠一层全屏 loading,两个转圈同时出现体验很差。
错误处理分了三类:AUTH交给调用方决定是否重新登录,BIZ是业务错误直接 toast,NET是网络层失败提示重试。分开的好处是上层能按类型分流,比如详情页遇到AUTH就跳登录,遇到NET就展示重试按钮,而不是一律弹“请求失败”。
3.3 组件通信:properties 下行、triggerEvent 上行
自定义组件最容易出的问题,是把它写成“小页面”——自己发请求、自己改全局状态。组件的正确姿势是纯展示:数据靠properties进来,交互靠triggerEvent出去。
// components/card/card.js Component({ properties: { item: { type: Object, value: {}, observer(val) { // 父级数据变化时同步派生字段,避免在 WXML 里做复杂计算 this.setData({ priceText: (val.price / 100).toFixed(2) }) } }, showArrow: { type: Boolean, value: true } }, data: { priceText: '0.00' }, methods: { onTap() { // 只抛事件,不带跳转逻辑,跳转由页面决定 this.triggerEvent('select', { id: this.data.item.id }) } } })properties的observer用来做派生数据,比如把分转成元、把时间戳格式化成日期。放在组件里做,WXML 会干净很多,也不用担心 WXML 表达式里调函数(那里本来也不支持复杂逻辑)。
triggerEvent的事件名建议用动词,select、delete、change,父级用bind:select接收。组件内部绝对不要写wx.navigateTo,一旦组件决定跳哪,它就只能在这个页面用,复用心思白费了。
3.4 列表页分页与详情页取数的完整数据流
把上面几块串起来,就是一条完整数据流:页面onLoad调api,api走request,数据回填data,WXML 渲染组件。
// pages/index/index.js const { getList } = require('../../api/index') Page({ data: { list: [], page: 1, pageSize: 10, hasMore: true, loading: false }, onLoad() { this.fetchList(true) }, onPullDownRefresh() { this.fetchList(true).finally(() => wx.stopPullDownRefresh()) }, onReachBottom() { if (!this.data.hasMore || this.data.loading) return // 双保险,防止重复请求 this.fetchList(false) }, async fetchList(reset) { if (this.data.loading) return const page = reset ? 1 : this.data.page this.setData({ loading: true }) try { const res = await getList({ page, pageSize: this.data.pageSize }) // 追加而不是覆盖,同时用 concat 生成新数组触发视图更新 const list = reset ? res.list : this.data.list.concat(res.list) this.setData({ list, page: page + 1, hasMore: res.list.length === this.data.pageSize }) } catch (e) { // 错误已在 request 层提示,这里只兜底状态 console.warn('fetchList failed', e) } finally { this.setData({ loading: false }) } }, onCardSelect(e) { wx.navigateTo({ url: `/pages/detail/detail?id=${e.detail.id}` }) } })reset参数控制“覆盖还是追加”,下拉刷新传true,触底加载传false。loading标志位和onReachBottom里的判断是双保险——用户快速滑动时onReachBottom会连续触发,没有这层判断就会打出重复请求,列表出现重复项。
hasMore用“本次返回条数是否等于 pageSize”判断,比问后端要总页数简单,代价是最后一页刚好满页时会多请求一次空列表,可以接受。
详情页反过来,只接id再取数:
// pages/detail/detail.js const { getDetail } = require('../../api/index') Page({ data: { detail: null }, onLoad(query) { // query 里的值都是字符串,需要数字就自己转 const id = Number(query.id) if (!id) { wx.showToast({ title: '参数缺失', icon: 'none' }) return } this.fetchDetail(id) }, async fetchDetail(id) { const detail = await getDetail({ id }) this.setData({ detail }) } })onLoad的query参数全部是字符串,这点新人常翻车:id === 1永远为 false。要么在入口统一转类型,要么在接口层容错。
4. 跑起来:开发者工具导入、mock 切换到真机预览
代码看得再明白,不跑一遍都是纸上谈兵。这一章按真实操作顺序走:导入、配环境、切 mock、真机预览、上传体验版。
4.1 导入项目与 appid、基础库设置
打开开发者工具,选“导入项目”,目录选到含app.json的那一层,不要多选一层也不要少选一层。AppID 没有正式号就选测试号,测试号能跑通绝大部分功能,但云开发、部分开放能力用不了,需要提前知道。
project.config.json里有几个字段值得手动确认:
{ "appid": "touristappid", "projectname": "mini-demo", "compileType": "miniprogram", "libVersion": "latest", "setting": { "urlCheck": false, "es6": true, "enhance": true, "postcss": true, "minified": false } }urlCheck: false是本地调试的关键,打开它开发者工具会校验请求域名是否在后台白名单里,本地localhost接口必然被拦。注意这只是开发阶段的开关,真机预览和体验版依然走真实校验,所以上线前域名必须配好。
es6和enhance建议都开,前者做语法转换,后者启用增强编译,能少踩一些新语法不支持的坑。minified本地关掉,方便看堆栈。
基础库版本在工具右上角“详情 → 本地设置”里选。选太低,新组件用不了;选太高,部分老机型覆盖不到。稳妥做法是看后台的机型分布,取覆盖大多数用户的那一档,别一味追最新。
4.2 本地 mock 与后端联调的切换开关
前后端并行时,最烦的是接口没好、页面没法调。解法是在config/env.js里做一层环境开关,mock 和真实接口共用同一套api调用。
// config/env.js const ENVS = { mock: { baseUrl: 'https://mock.example.com/api', // 本地 mock 服务或工具内置 mock useMock: true }, dev: { baseUrl: 'http://localhost:3000/api', useMock: false }, prod: { baseUrl: 'https://api.example.com/api', useMock: false } } // 切环境只改这一行 const CURRENT = 'dev' module.exports = { ...ENVS[CURRENT], current: CURRENT }useMock留给api层做分支:为 true 时直接返回本地假数据,不走网络。这样后端接口一好,改一行CURRENT就切过去,页面代码零改动。
切到dev之后如果请求发不出去,按顺序查三件事:开发者工具“详情 → 本地设置”里“不校验合法域名”有没有勾;baseUrl是不是http且端口对;真机预览时手机和电脑是不是同一个网络。第三条最容易被忽略,电脑上跑得好好的,一到手机就超时,八成是手机连了另一个网段。
另外,localhost在真机上是无效地址,真机联调必须换成电脑的局域网 IP,并且后端服务要监听0.0.0.0而不是只监听127.0.0.1。这两点不知道的话,能对着“请求超时”查一下午。
4.3 真机预览、分包与上传体验版
工具里点“预览”扫码就能在手机上跑,但预览和体验版的行为不完全一样。预览包不压缩、不校验部分配置,体验版更接近线上,所以关键流程一定要在体验版上再走一遍。
分包要在app.json里提前规划。主包体积有硬上限,图片、字体这类静态资源是体积大户,能放 CDN 就别打进包里。分包之间不能互相引用页面,公共组件和工具函数必须放主包,这条约束在拆分模块时就得考虑,不然拆完发现依赖绕回来了。
上传体验版之前建议过一遍清单:urlCheck在正式包里必须是 true 的等价状态(域名已在后台配置)、CURRENT切成prod、console.log清理掉、minified打开。上线后才发现环境配错,回滚成本比想象中高。
如果团队有 CI 流程,可以用平台提供的命令行上传工具把“构建 + 上传”脚本化,把版本号和描述从环境变量注入,避免每次手填。
5. 排查清单:小程序高频翻车的五类问题
下面这五条,基本覆盖了新手跑这套代码时最可能卡住的地方。每条按“现象 → 原因 → 解决”写,照着对号入座就行。
5.1 编译通过,页面一片白
现象:工具控制台没有红色报错,模拟器里就是白屏,什么都不显示。
原因通常有三个:app.json的pages里路径写错或大小写不一致(文件系统不敏感、工具敏感);页面.json里usingComponents引用了不存在的组件,整个页面渲染中断;WXML 里绑定了data中不存在的字段并做了方法调用,运行时报错但被吞掉。
解决:先看控制台有没有黄色警告,再逐个注释usingComponents定位。路径问题最干脆的办法是复制文件树里的相对路径,别手敲。养成页面.json只写必要配置的习惯,空{}也比乱写安全。
5.2 请求发不出去或一直 404
现象:wx.request走到fail,或者返回 404,但同样的地址在浏览器里能打开。
原因:开发阶段多半是域名校验没关,或者baseUrl拼错导致路径重复(/api写了两遍);真机上则是localhost不可达,或者后端只监听了本机回环地址。
解决:先在request里把完整 URLconsole.log出来,肉眼比对;再确认工具里“不校验合法域名”已勾选;真机联调换成局域网 IP,并让后端监听所有网卡。上线前记得把域名加到后台白名单,https证书要有效,自签证书在真机上不认。
5.3 列表滚动卡顿、越滑越慢
现象:列表加载几十条之后明显掉帧,滑动发涩,低端机尤其明显。
原因:典型是setData滥用——每次分页把整个大数组重新setData一遍,或者把接口返回的原始大对象整个塞进data,其中大量字段视图根本不用。
解决:分页追加时只传新增部分,用路径赋值this.setData({ ['list[' + index + ']']: item })做局部更新;列表项只保留视图需要的字段,接口原始数据在api层裁剪;长列表考虑用可复用节点或虚拟列表思路,别一次性渲染几百个节点。单次setData的数据量控制住,是这类问题最有效的解法。
5.4 授权登录拿不到用户信息
现象:点登录按钮没反应,或者拿到的用户信息是空的、昵称显示“微信用户”。
原因:旧的获取用户信息接口已经调整,直接调用拿不到真实头像昵称;另外wx.login的code只能换会话,不含用户资料,两者被混为一谈。
解决:登录拆成两步——wx.login换 token 解决身份,头像昵称用平台提供的头像昵称填写能力(按钮选头像、输入框收昵称)让用户主动提交,再传给后端。别指望静默拿到真实资料,这条路已经走不通了。同时注意wx.login的code一次性,别缓存复用。
5.5 主包超限、页面栈溢出
现象:上传时报主包体积超限;或者深层跳转后navigateTo失败,报页面栈层数超限。
原因:主包塞了太多图片和未拆分的模块;跳转一律用navigateTo,页面栈越堆越深,超过上限就跳不动了。
解决:静态资源上 CDN,低频模块拆分包,公共依赖留主包;跳转按场景选 API——平级切换用redirectTo,回首页用switchTab或reLaunch,只有需要返回上一页时才用navigateTo。下单流程这种多步表单,能合并成一个页面用状态切换,就别开五层页面栈。
6. 二次开发:把通用骨架改成自己业务的三步改造法
拿到这套代码,别急着在pages/index上改,那是最容易改成一锅粥的路径。我的习惯是分三步替换,每步都能独立验证。
第一步,换数据层。保留utils/request.js,把config/env.js指向自己的后端,然后逐个重写api/下的文件。这一步做完,用工具里的“清缓存 → 重新编译”跑一遍,确认每个接口都能返回真实结构。验证方法很土但有效:在每个api函数里临时打一行日志,看参数和返回值对不对,全绿了再往下走。
第二步,换页面骨架。把pages/下的示例页面复制成自己的业务页面,先保留 WXML 结构和样式,只改数据字段名。等页面能渲染出真实数据了,再动样式。顺序反过来——先调样式后接数据——最后一定分不清是样式写错还是字段没对上。
第三步,抽公共能力。业务跑通之后回头看,哪些交互重复出现了三次以上,就抽成components/;哪些格式化逻辑散在各页面,就沉到utils/。这一步别提前做,过早抽象出来的组件往往和真实需求对不上,改起来比不抽还累。
几个改造时的取舍,可以对照着看:
| 改造点 | 省事做法 | 长期做法 | 建议 |
|---|---|---|---|
| 接口域名 | 直接写死在 request.js | 抽到 config/env.js | 项目超过两周就抽 |
| 登录态 | 每个页面自己判断 | 统一ensureLogin | 一开始就统一 |
| 列表分页 | 每个页面复制一份 | 抽useList类工具函数 | 出现第三个列表页时抽 |
| 组件通信 | 页面直接改组件内部 data | props 下行 + 事件上行 | 不要妥协 |
改完怎么验证?我会准备一份“冒烟清单”:冷启动首屏、登录、列表下拉与触底、详情跳转返回、退出登录,五条走一遍。这五条过了,基本盘就稳了。剩下的边界情况,等真实用户来教。
有个教训值得说:早些年我改这类模板项目,图快直接在示例页面上动刀,结果示例代码和业务代码缠在一起,两周后自己都分不清哪行是原来的、哪行是我加的。从那以后我每次改造都强制走一遍“先换数据层、再换页面、最后抽组件”的顺序,中间每步都提交一次,出问题能立刻回退。希望这套顺序帮到你。
本文还有配套的精品资源,点击获取