从零开发一个微信记账小程序,零依赖、附完整源码
本文记录了「简记账」微信小程序的完整开发过程。从需求分析、架构设计到各页面实现,9个技术亮点一一拆解。适合有微信小程序基础的开发者阅读,也适合想找轻量级项目练手的同学。
一、为什么做这个小程序?
市面上的记账 App 动辄要注册账号、开通会员、同步云端——对于只想记个午饭钱的人来说,太重了。
于是我给自己定了一个极简原则:打开即用,不登录,不注册,记一笔只需 3 步。
最终做出来的「简记账」是这样的:
- 首页:余额卡片 + 一键记收入/记支出
- 统计页:本月收支汇总 + 分类排行
- 设置页:CSV 数据导出 + 一键清空
零 npm 依赖,纯原生微信小程序 API,包体积极小。
![]()
二、项目结构
简记账/
├── app.js # 全局数据服务层(核心)
├── app.json # 路由 + tabBar 配置
├── app.wxss # 全局通用样式
├── pages/
│ ├── index/ # 首页(记账 + 流水)
│ ├── stats/ # 统计页
│ └── settings/ # 设置页
└── images/ # tabBar 图标
结构很干净。没有 components 目录,没有 utils 工具库,不引入任何第三方包。
三、架构设计:app.js 作为数据服务层
这是整个项目最关键的设计决策。
微信小程序里各页面之间共享数据,常见做法有两种:
- 每个页面自己读写 Storage
- 把 Storage 操作统一封装在
app.js,页面通过getApp()调用
我选了第二种。好处是:页面完全不感知存储细节,未来如果从本地存储升级到云数据库,只改 app.js 就够了,页面代码零改动。
数据结构
每一条记账记录长这样:
{
id: Date.now(), // 时间戳作唯一 ID,够用
type: 'income' | 'expense',
amount: 58.5, // 数字,不是字符串
note: '午餐', // 用户输入,默认为分类名
category: 'food', // 分类 key
icon: '🍜', // emoji 图标
categoryIcon: 'food', // CSS 类名(用于背景色)
date: '2026-03-23T10:30:00Z' // ISO 8601,方便计算
}
五个核心方法
// app.js
App({
onLaunch() {
this.checkLocalStorage()
},
// 初始化:确保 key 存在
checkLocalStorage() {
const transactions = wx.getStorageSync('transactions')
if (!transactions) {
wx.setStorageSync('transactions', [])
}
},
// 新记录插到数组头部,保证最新在前
saveTransaction(transaction) {
let transactions = wx.getStorageSync('transactions') || []
transactions.unshift(transaction)
wx.setStorageSync('transactions', transactions)
return true
},
getTransactions() {
return wx.getStorageSync('transactions') || []
},
// 按 id 过滤,重写全量数组
deleteTransaction(id) {
let transactions = wx.getStorageSync('transactions') || []
transactions = transactions.filter(t => t.id !== id)
wx.setStorageSync('transactions', transactions)
},
// 月度统计:按年月筛选后累加
getMonthlyStats() {
const transactions = this.getTransactions()
const now = new Date()
let income = 0, expense = 0
transactions.forEach(t => {
const date = new Date(t.date)
if (date.getMonth() === now.getMonth() &&
date.getFullYear() === now.getFullYear()) {
if (t.type === 'income') income += t.amount
else expense += t.amount
}
})
return { income, expense, balance: income - expense }
},
// 分类汇总
getStatsByCategory(type) {
const transactions = this.getTransactions()
const now = new Date()
const stats = {}
transactions.forEach(t => {
if (t.type !== type) return
const date = new Date(t.date)
if (date.getMonth() !== now.getMonth()) return
stats[t.category] = (stats[t.category] || 0) + t.amount
})
return stats
}
})
为什么用同步 API(Sync 系列)?
异步 API 需要写回调或 Promise,代码层层嵌套。记账这种轻量场景,数据量小,同步读写完全够用,而且代码清晰很多,不会有回调地狱。
四、首页:记账弹窗的设计细节
![]()
首页的核心交互是底部弹起的记账面板。
弹窗实现
我没用 wx:if 控制显隐,而是用 CSS class 切换:
/* 默认隐藏 */
.modal {
display: none;
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
justify-content: center;
align-items: flex-end; /* 关键:内容贴底部 */
}
/* 激活时显示 */
.modal.active {
display: flex;
}
/* 弹窗面板:只有上方是圆角 */
.modal-content {
background: white;
width: 100%;
border-radius: 48rpx 48rpx 0 0;
padding: 48rpx;
max-height: 80vh;
overflow-y: auto;
}
WXML 里通过三元表达式动态切换 class:
<view class="modal {{showModal ? 'active' : ''}}" bindtap="closeAddModal">
<view class="modal-content" catchtap="stopPropagation">
<!-- 内容 -->
</view>
</view>
注意 catchtap="stopPropagation" 这里——点击面板内容时,阻止事件冒泡到背景层,否则一碰面板就会关闭弹窗。
为什么不用 wx:if?
wx:if 是条件渲染,每次显示/隐藏都会销毁/重建 DOM。用 CSS 切换只是修改 display 属性,性能更好,也不会丢失输入框里已填的内容。
动态分类过滤
记收入和记支出要显示不同的分类选项,我把所有分类存在一个数组里,根据类型实时过滤:
openAddModal(e) {
const type = e.currentTarget.dataset.type // 'income' 或 'expense'
const incomeCategories = ['salary', 'bonus', 'investment', 'other_income']
const filtered = allCategories.filter(c =>
type === 'income'
? incomeCategories.includes(c.value)
: !incomeCategories.includes(c.value)
)
this.setData({
showModal: true,
modalType: type,
categories: filtered,
selectedCategory: filtered[0].value
})
}
一套数据,两种视图,不用维护两个独立数组。
智能时间显示
交易列表里的时间,我做了语义化处理,比"2026-03-23 10:30"更有温度:
formatDate(isoString) {
const date = new Date(isoString)
const now = new Date()
const diff = now - date
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) {
const minutes = Math.floor(diff / (1000 * 60))
if (minutes === 0) return '刚刚'
const hours = Math.floor(diff / (1000 * 60 * 60))
if (hours === 0) return `${minutes}分钟前`
return `今天 ${this.formatTime(date)}`
}
if (days === 1) return '昨天'
if (days < 7) return `${days}天前`
return `${date.getMonth() + 1}月${date.getDate()}日`
}
输出效果:刚刚 / 5分钟前 / 今天 09:30 / 昨天 / 3天前 / 3月15日
五、统计页:分类排行的实现
![]()
统计页的核心是把原始数据转成可展示的排行列表。
formatCategories(rawStats, type) {
const categoryMap = {
food: { name: '餐饮', icon: '🍜' },
transport: { name: '交通', icon: '🚇' },
shopping: { name: '购物', icon: '🛒' },
// ...其他分类
}
return Object.entries(rawStats)
.map(([key, value]) => ({
key,
name: categoryMap[key]?.name || key,
icon: categoryMap[key]?.icon || '📦',
amount: value.toFixed(2)
}))
.sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount)) // 按金额降序
}
Object.entries() 把 { food: 120, transport: 30 } 这样的对象转成数组,再 map + sort,链式操作很清晰。
结余颜色动态判断:
<view class="stat-value {{balance >= 0 ? 'income' : 'expense'}}">
¥{{balance}}
</view>
收支相抵为正显示绿色,亏损显示红色,简单直观。
六、设置页:用剪贴板实现数据导出
微信小程序的文件系统权限比较复杂,直接生成并保存 Excel 文件需要申请额外权限。
我的解法是:生成 CSV 文本,复制到剪贴板,让用户自己粘贴到 Excel。
exportData() {
const transactions = app.getTransactions()
if (transactions.length === 0) {
wx.showToast({ title: '暂无数据可导出', icon: 'none' })
return
}
let csv = '类型,金额,备注,分类,日期\n'
transactions.forEach(t => {
const date = new Date(t.date)
const dateStr = `${date.getFullYear()}-${date.getMonth()+1}-${date.getDate()}`
const type = t.type === 'income' ? '收入' : '支出'
csv += `${type},${t.amount},${t.note},${t.category},${dateStr}\n`
})
wx.setClipboardData({
data: csv,
success: () => {
wx.showModal({
title: '导出成功',
content: '数据已复制到剪贴板,请粘贴到Excel中保存',
showCancel: false
})
}
})
}
这个方案绕开了文件权限的麻烦,对普通用户来说操作也不复杂:复制 → 打开 Excel → 粘贴。
七、UI 设计:用 emoji 代替图标库
整个项目没有引入任何图标字体或 SVG 图标库,全部用 Unicode emoji。
好处:
- 零包体积增加
- 天然跨平台兼容
- 色彩丰富,视觉效果好
每个分类有独立的背景色标:
.transaction-icon.food { background: #fef3c7; } /* 暖黄 */
.transaction-icon.transport { background: #dbeafe; } /* 浅蓝 */
.transaction-icon.shopping { background: #fce7f3; } /* 粉色 */
.transaction-icon.salary { background: #dcfce7; } /* 浅绿 */
.transaction-icon.entertainment { background: #e0e7ff; } /* 淡紫 */
.transaction-icon.medical { background: #fee2e2; } /* 浅红 */
emoji + 分类色块,不需要设计稿,纯代码实现就有不错的视觉层次。
主色用紫蓝渐变:
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
顶部卡片加了毛玻璃效果:
.balance-info {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(20rpx);
}
八、数据刷新策略
所有页面都实现了 onLoad + onShow 双钩子刷新:
onLoad() { this.loadData() }
onShow() { this.loadData() }
onLoad 是页面第一次加载时触发,onShow 是每次切换到该页面时触发。
如果只有 onLoad,从统计页切回首页时,余额不会更新。加上 onShow 就解决了多页面数据同步的问题。这是微信小程序开发的标准实践,值得记住。
九、云开发迁移路径
虽然目前用的是本地存储,但项目已经为云开发预留了迁移空间:
app.json 已设置 "cloud": true,app.js 中有注释掉的初始化代码:
// wx.cloud.init({ env: 'your-env-id', traceUser: true })
迁移时只需修改 app.js 里的五个方法:
| 当前实现 | 云开发替换 |
|---|---|
wx.setStorageSync('transactions', data) |
db.collection('transactions').add({ data }) |
wx.getStorageSync('transactions') |
db.collection('transactions').get() |
transactions.filter(t => t.id !== id) + setStorageSync
|
db.collection('transactions').doc(id).remove() |
页面代码一行不用改。这就是把数据层抽象到 app.js 的价值所在。
十、总结
这个项目有几个值得借鉴的点:
-
全局服务模式:
app.js统一管理数据读写,页面解耦 - 同步 Storage API:避免异步回调,代码清晰
-
CSS class 控制弹窗:比
wx:if性能好,不丢失表单状态 - emoji 代替图标库:零依赖,包体积最小
-
双钩子刷新:
onLoad+onShow保证跨页面数据同步 - 剪贴板导出:绕过文件权限限制的轻量方案
- 动态分类过滤:一套数据,两种视图
- 语义化时间:提升用户体验的小细节
- 云开发预留:接口层隔离,未来升级零成本
完整源码已在掘金平台开源,可通过文章开头的链接访问
如果觉得有帮助,点个赞再走~
作者:守(SO) | 2026年3月