普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月24日首页

从零开发一个微信记账小程序,零依赖、附完整源码

2026年3月24日 11:48

本文记录了「简记账」微信小程序的完整开发过程。从需求分析、架构设计到各页面实现,9个技术亮点一一拆解。适合有微信小程序基础的开发者阅读,也适合想找轻量级项目练手的同学。


一、为什么做这个小程序?

市面上的记账 App 动辄要注册账号、开通会员、同步云端——对于只想记个午饭钱的人来说,太重了。

于是我给自己定了一个极简原则:打开即用,不登录,不注册,记一笔只需 3 步

最终做出来的「简记账」是这样的:

  • 首页:余额卡片 + 一键记收入/记支出
  • 统计页:本月收支汇总 + 分类排行
  • 设置页:CSV 数据导出 + 一键清空

零 npm 依赖,纯原生微信小程序 API,包体积极小。

绠€璁拌处鎴浘1.png


二、项目结构

简记账/
├── app.js              # 全局数据服务层(核心)
├── app.json            # 路由 + tabBar 配置
├── app.wxss            # 全局通用样式
├── pages/
│   ├── index/          # 首页(记账 + 流水)
│   ├── stats/          # 统计页
│   └── settings/       # 设置页
└── images/             # tabBar 图标

结构很干净。没有 components 目录,没有 utils 工具库,不引入任何第三方包。


三、架构设计:app.js 作为数据服务层

这是整个项目最关键的设计决策。

微信小程序里各页面之间共享数据,常见做法有两种:

  1. 每个页面自己读写 Storage
  2. 把 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,代码层层嵌套。记账这种轻量场景,数据量小,同步读写完全够用,而且代码清晰很多,不会有回调地狱。


四、首页:记账弹窗的设计细节

绠€璁拌处鎴浘2.png

首页的核心交互是底部弹起的记账面板

弹窗实现

我没用 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日


五、统计页:分类排行的实现

绠€璁拌处鎴浘3.png

统计页的核心是把原始数据转成可展示的排行列表。

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": trueapp.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 的价值所在。


十、总结

这个项目有几个值得借鉴的点:

  1. 全局服务模式app.js 统一管理数据读写,页面解耦
  2. 同步 Storage API:避免异步回调,代码清晰
  3. CSS class 控制弹窗:比 wx:if 性能好,不丢失表单状态
  4. emoji 代替图标库:零依赖,包体积最小
  5. 双钩子刷新onLoad + onShow 保证跨页面数据同步
  6. 剪贴板导出:绕过文件权限限制的轻量方案
  7. 动态分类过滤:一套数据,两种视图
  8. 语义化时间:提升用户体验的小细节
  9. 云开发预留:接口层隔离,未来升级零成本

完整源码已在掘金平台开源,可通过文章开头的链接访问

如果觉得有帮助,点个赞再走~


作者:守(SO) | 2026年3月

昨天以前首页

微信小程序开发02:原始人也能看懂的着色器与视频处理

作者 海石
2026年3月21日 18:41

往期回顾:

微信小程序开发01:XR-FRAME的快速上手

1、背景

还记得01时,3.3.3章节的成果展示吗?

image.png

虽然图片识别成功了,并且视频加载完毕了

但是视频存在大规模的绿色背景,这是业务不期望展示的

期望的效果是抠除绿色背景,仅保留人物主体,如下图

e1ea2feb31f439a3ea638f80d006b27d.jpg

今天我们就来尝试对视频图层做调整

2、温故知新

为了快速实现MVP,我们忽略了很多信息,但这些信息对我们基于MVP二次开发时,比较重要

我们需要了解一下当前demo工程的结构:

mermaid-1774086497015.png

清晰的分层架构 :

  • Pages 层 (如 xr-template-water/index.wxml ):

    • 负责页面配置和展示
    • 定义标题、介绍等元数据
    • 处理页面级别的交互
  • Components 层 (如 xr-template-water/index.wxml ):

    • 包含实际的 XR 场景逻辑
    • 处理 3D 渲染、AR 追踪等核心功能
    • 实现具体的业务逻辑
  • 共享行为机制 share-behavior.js 的设计 :

    • 提供统一的分享功能实现
    • 统一处理 AR 追踪状态初始化
    • 减少重复代码,提高一致性
    • 被所有 template 组件复用

再来看看数据流向:

用户交互
    │
    ▼
Pages 层 (页面配置)
    │
    ▼
xr-demo-viewer (容器组件)
    │
    ├─► 显示 UI (标题、介绍、代码)
    │
    └─► <slot> (主内容)
            │
            ▼
Components/Template (业务组件)
    │
    ├─► share-behavior (共享功能)
    │       │
    │       ├─► 分享初始化
    │       └─► AR 状态管理
    │
    └─► xr-scene (XR 场景)
            │
            ├─► 资源加载
            ├─► 3D 渲染
            └─► AR 追踪

像我们在第一期做的改造,得益于此demo工程的优秀设计,当我们想要新增功能时,只要做4步操作:

  • 创建 pages/template/xr-template-newFeature
  • 创建 components/template/xr-template-newFeature
  • 使用 xr-demo-viewer 包裹
  • 引入 share-behavior 获得共享功能

3、透明视频

ok,接下来我们进入正题,如何让绿幕视频可以扣除绿幕,实现一些付费AR软件提供的功能?

有两条路:

  1. 直接导入微信小程序支持的透明视频
  2. 通过自定义着色器计算每个像素颜色与绿色背景的距离,使用 smoothstep 函数根据距离动态调整透明度,使绿色背景变为透明而其他内容保持不透明。

第一条路需要使用AE等视频处理软件,导出成果,对素材的质量要求较高,也就是对上游有依赖

因此,不想被上游依赖,我们便选择第二条路,自己实现视频扣除纯色背景的功能

况且,XR FRAME本就支持着色器

// XR-Frame 提供的 API
wx.getXrFrameSystem().registerEffect("chroma-key", createChromaKeyEffect);

scene.createEffect({
  "name": "chroma-key",
  "shaders": [vertexShader, fragmentShader]  // 支持 GLSL 着色器
})

ok,写到这里,大家应该还是困惑,着色器和视频有什么关系?

着色器就像一个超级快的修图师,把视频的每一帧图片都检查一遍,把绿色的像素变成透明,然后把处理好的图片贴在3D模型这块"布料"上,纹理材质就是这块布料和修图师的组合

大白话说完,我们来看看处理的过程

我们创建一个新的资源,让它被包裹在xr-assets下,这个新的资源就是我们刚刚提到的“修图师”,它在小程序里的体现就是“材质”,即xr-asset-material

  <xr-assets>
    <xr-asset-load type="video-texture" asset-id="ayuan-video" src="https:/xxxx.mp4" options="autoPlay:true,loop:true" />
    <xr-asset-material asset-id="chroma-key-mat" effect="chroma-key" />
  <xr-assets>

asset-id我们很熟悉了,对应于材质的名字,就和视频的asset-id一样

effect是效果,即材质的模板

通过对effect的设置,我们可以调整光照模式,等等

image.png

"chroma-key"是我们通过scene.createEffect方法创造出来的一种自定义效果

部分源码如下:

function createChromaKeyEffect(scene) {
  return scene.createEffect({
    "name": "chroma-key",  // 给这个修图师起个名字叫"绿幕扣除"
    
    // 定义要用的工具:视频图片
    "images": [{
      "key": "u_baseColorMap",  // 视频纹理的代号
      "default": "white",
      "macro": "WX_USE_BASECOLORMAP"
    }],
    
    // 定义修图规则(着色器代码)
    "shaders": [
      // 第一个着色器:负责把3D模型放到屏幕上
      `顶点着色器...`,
      
      // 第二个着色器:负责给每个像素上色(这里是关键!)
      `片元着色器...
        vec4 color = texture2D(u_baseColorMap, vTextureCoord);  // 取出视频的像素颜色
        
        vec3 greenKey = vec3(0.055, 0.816, 0.294);  // 绿幕的颜色
        float dist = distance(color.rgb, greenKey);  // 算一下这个像素离绿色有多远
        
        float threshold = 0.40;  // 设定一个距离标准
        float alpha = smoothstep(threshold - 0.005, threshold + 0.005, dist);
        // 如果离绿色很近,透明度就变成0(看不见)
        // 如果离绿色很远,透明度就保持1(看得见)
        
        color.a *= alpha;  // 把算好的透明度应用到像素上
      `
    ]
  })
}

写完之后,别忘了在系统中注册,这样之后到处都可以使用

// 在组件加载时执行
lifetimes: {
  async attached() {
    const xrFrameSystem = wx.getXrFrameSystem();
    
    // 把这个修图师注册到系统里,以后可以随时用
    xrFrameSystem.registerEffect("chroma-key", createChromaKeyEffect);
  }
}

然后我们就要把之前的视频,和我们刚刚创建的材质,组合起来

  • 创建一个3D平面模型
  • 给这个模型穿上"chroma-key-mat"这件衣服
  • 把视频"ayuan-video"贴在衣服上
  • 把模型放到场景里
handleARReady: async function ({ detail }) {
  // 创建一个3D平面(就像一块板子)
  const videoPlane = this.scene.createElement(xr.XRMesh, {
    geometry: 'plane',           // 形状:平面
    material: 'chroma-key-mat',  // 材质:用刚才创建的“布料”
    uniforms: 'u_baseColorMap: video-ayuan-video',  // 把视频贴在布料上
    position: '0 0.5 0',      // 位置
    scale: '0.8 0.45 1',      // 大小
  });
  
  // 把这个平面添加到场景中
  lockItemEle.addChild(videoPlane);
}

一句话总结 :代码先创建了一个"绿幕扣除"的修图方案,然后创建一块用这个方案的布料,最后把视频贴在这块布料上,视频的每一帧都会自动被修图师处理,绿色背景就变透明了!

mermaid-1774089305244.png

附录

架构图

mermaid-1774084518187.png

【uniapp】小程序支持分包引用分包 node_modules 依赖产物打包到分包中

2026年3月19日 15:31

前言

5.04 版本之前的 uniapp 和 uniappx,小程序端不支持分包引用的 node_modules 依赖打包到分包中,这对于很多备受小程序主包体积超出困扰的开发者来说,显然不是一个好消息。为了解决这一问题,5.04 版本开始,hx项目或者 cli 项目支持分包引用的 node_modules 依赖打包到分包中。下面介绍下具体的操作步骤,示例项目请点击 ask.dcloud.net.cn/article/424…

分包优化

首先,需要在 mainfest.json 指定小程序节点下添加如下配置,例如:

{
  "mp-weixin": {
         "optimization": {
            "subPackages": true
          }
   }
}

筛选分包用的依赖

这一步尤为重要,要先梳理出哪些依赖是分包用到的,哪些是主包用到的,以及你期望的主包分包产物引用关系。

我们举一个简单的例子,主包用到了 lodash-esaddsubtract 函数,分包 sub 用到了 lodash-esmultiply 函数,这种分包用到的内容主包没用,就可以考虑使用这种策略,把 分包 sub 用到的 lodash-esmultiply 函数打包到 分包 sub 下,我们来看下 5.04 版本之前的效果

首先是项目结构

project.jpg

打包的产物体积

before.jpg

可以看到,用到的 lodash-es 的三个函数都被打包到了主包的 vendor.js 文件中。下面我们看下 5.04 如何解决这种问题

首先进入到分包的根目录,创建一个 package.json 文件,这里写分包需要用到的依赖,然后安装依赖

sub_node_modules.jpg

然后重新打包即可。

可以看到 分包 sub 根目录下面多了 vendor.js 文件,里面就是 lodash-esmultiply 函数

sub_vendor.jpg

after.jpg

注意事项

  • 该优化只对 vue3 项目生效
  • 支持 uniapp 和 uniappx 的小程序项目
  • 支持 hx 项目和 cli 项目,测试项目是 hx 项目,cli 项目同理
  • 仅支持 node_modules 中的 js 相关文件,不支持其他文件
  • 测试项目为附件六
  • 5.04 是指 hx 的版本号,uniapp 对应的依赖版本为 3.0.0-5000420260318001

小程序-下拉刷新不走回调函数

作者 喂_balabala
2026年3月18日 15:15

下拉刷新

配置与回调

  • .json 文件中添加配置开启下拉刷新
{
  "enablePullDownRefresh": true,//开启下拉刷新
  "backgroundTextStyle": "dark" //配置颜色
}
  • onPullDownRefresh 是下拉刷新的回调函数
/**
* 页面相关事件处理函数--监听用户下拉动作
*/
onPullDownRefresh() {
    wx.showNavigationBarLoading();
},
  • stopPullDownRefresh 是自己写的停止下拉动效函数
stopPullDownRefresh() {
    wx.stopPullDownRefresh();
    wx.hideNavigationBarLoading();
},

Question

Q1: 下拉动效出来了,但是没有触发回调函数
原因: 页面问题:页面高度 = 屏幕高度,没有任何可滚动空间
  • 代码里的布局逻辑(必然是有这种结构):
page { height: 100%; }
.container { height: 100vh; }
.full_screen_container { height: 100%; }
  • 这种写法会导致:

  • 页面高度 = 手机屏幕高度 → 页面无法滚动 → 系统认为 “没有下拉动作” → 不触发 onPullDownRefresh 回调

  • 但!系统依然会播放下拉动画(因为配置开着)。

  • 下拉刷新动画是 【系统全局自动触发】 的,只要配置了 enablePullDownRefresh:true,不管页面能不能滚动、不管回调写没写,动画都会出现!

  • onPullDownRefresh ()回调函数是业务逻辑触发,必须满足页面存在可滚动区域 + 页面真的发生了下拉滚动行为才会执行!

解决方案
方案一:
  • 把根容器改成这样
/* 必须去掉固定 100% 高度!!! */
page {
  height: auto; /* 关键 */
  min-height: 100%;
}

.container {
  min-height: 100vh; /* 不能写死 height */
  overflow: visible;
}
方案二:
/* 给页面加一个看不见的高度,强制让页面可滚动 */
page::after {
  content: '';
  display: block;
  height: 1rpx;
}
❌
❌