ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

微信小程序投票系统Demo:云开发实现创建发布与防重复

微信小程序投票系统Demo:云开发实现创建发布与防重复

简介:这是一套面向微信小程序开发者与课程学习者的投票系统实战源码,围绕投票创建、发布和结果展示搭建了可直接运行的 demo,适合用来理解小程序项目组织、页面生命周期与前后端数据交互,也可作为评选、问卷和市场调研类应用的起步模板。压缩包共 90 个文件,以 wxss 样式、wxml 结构、js 逻辑和 json 配置为主,另有 png/jpg 界面图片、php 接口脚本、sql 建表脚本与 md 说明文档,整体约 153KB,体量轻便但模块较完整。目录中可见页面、组件、工具函数、数据模型与模拟数据等分层,配合样式库和上传相关代码,能帮助读者梳理从表单录入到结果呈现的链路,并观察组件复用、请求封装和本地调试数据的组织方式。已有 3647 人学习,适合希望快速读懂一个小程序投票项目并在此基础上扩展用户登录、权限控制与防刷机制的中初级开发者。

1. 三天要上线的评选活动,我从一份投票 Demo 里省下了整个后端

上个月接了个社区活动评选的活,需求一句话:管理员能创建投票、能发布、参与者能投、投完能看结果,三天上线。我第一反应是自己从零撸:小程序端四个页面,加个后端服务,加个数据库,光部署环境就得两天。后来翻到这份微信小程序投票系统创建投票发布 demo 完整源码,导入开发者工具、配一下云开发环境,一个小时首页就出数据了。它把「创建—发布—投票—看结果」这条最容易写重复的链路整个做完了,适合两类人:一类是接到临时活动需求、不想为一次投票搭一套服务器的开发者;另一类是刚从小程序原生入门、想找一份能跑通全链路的完整代码对照着改的新手。后面我按自己的拆解顺序,把技术栈、目录、核心代码和踩过的坑一次讲清。

2. 技术选型与目录结构:原生小程序 + 云开发的三层拆法

2.1 为什么这个 Demo 用原生而不是跨端框架

先看技术栈。这份源码是微信小程序原生写法加云开发(CloudBase)的组合,页面层用 WXML/WXSS/JS,逻辑层的数据存储和业务运算全部落在云函数与云数据库里,没有独立后端服务,也不需要买服务器和域名。很多人搜 HTML mp 这类关键词,本质上是想找一套能直接丢进开发者工具、页面结构和样式都能跑的 mp 端代码,而这份 Demo 正好是纯原生,不存在编译链路,导入即用。

原生 + 云开发这个组合在「一次性活动类需求」上有明显优势。跨端框架(uni-app、Taro 那一类)的价值在于一套代码出多端,但投票这种场景 90% 的流量就在小程序内,多端能力基本用不上,反而多了一层编译产物,调试时要对着编译后的代码找问题,出问题时排查链路更长。云开发的价值更直接:数据库、云函数、存储都在一个控制台里,权限规则改一行就生效,不需要人去配 Nginx、配 HTTPS 证书、配跨域。

代价也要说清楚。云开发的数据库是文档型,没有 JOIN,也没有强事务覆盖所有场景,所以数据模型必须在设计阶段就想好冗余字段,把「一次查询能拿全」作为目标。第 4 章讲投票自增的时候会看到,为了避开并发写冲突,代码里用了原子自增指令而不是读-改-写,这就是文档型数据库逼出来的写法。

另一个容易被忽略的点是云函数的冷启动。投票类业务有明显的波峰——活动开始时所有人涌进来投票,两分钟后又归于平静。云函数在长时间无调用后会进入冷启动状态,第一次调用的响应可能到几百毫秒甚至更久。所以这份 Demo 里凡是能在小程序端直接查数据库的(比如列表页、详情页的只读查询),都不走云函数,只有涉及写入和权限判定的动作才进云函数。这个取舍值得学。

2.2 目录结构与文件职责

导入项目后,根目录的结构大致是这样一份清单,我按自己的理解重排了职责说明:

路径职责改动频率
app.js/app.json/app.wxss云开发初始化、页面注册、全局样式变量低
project.config.json开发者工具配置,AppID 与编译设置一次性
pages/index/投票列表首页,分页拉取已发布投票中
pages/create/创建投票表单,选项动态增删高
pages/detail/投票详情,选项渲染与提交动作高
pages/result/结果页,票数占比与排行中
pages/mine/我创建的 / 我参与过的低
components/选项卡片、空状态、加载骨架等复用组件中
cloudfunctions/云函数目录,每个子目录一个独立函数高
utils/时间格式化、表单校验、常量枚举中

app.js里的云开发初始化是整个项目的入口,写法很标准:

// app.js App({ globalData: { envId: 'your-env-id', // 云开发环境 ID,控制台可查 openid: '' // 登录后缓存的用户标识 }, onLaunch() { if (!wx.cloud) { console.error('基础库版本过低,请升级到 2.2.3 以上') return } wx.cloud.init({ env: this.globalData.envId, traceUser: true // 在控制台记录访问用户,便于排查 }) } })

env参数是云开发环境 ID,一个账号下可以有多个环境(测试、生产),切换环境就是改这一行。traceUser打开后,云开发控制台的用户管理里能看到访问记录,排查「某个用户提交失败」这类问题时很有用,能确认请求到底有没有打到云函数。

pages目录的划分逻辑是按「一次投票的生命周期」来的:index 是发现,create 是生产,detail 是消费,result 是沉淀,mine 是回溯。这个划分方式比按「列表页/表单页/详情页」这种 UI 类型划分更好改,因为你要加「草稿箱」就挂在 create 旁边,要加「分享海报」就挂在 result 旁边,不用在通用目录里翻。

cloudfunctions/下面每个子目录就是一个独立部署单元,目录名即函数名,改完必须右键「上传并部署」才生效——这一点是新手最容易翻车的地方,本地改了云函数代码、小程序端调用的还是旧版本,会怀疑人生。

2.3 数据模型:三张集合撑起一次投票

投票系统的数据模型其实就三件事:投票主体是什么、选项有哪些、谁投了什么。这份 Demo 用两个核心集合加一个可选的用户集合来承载。

votes集合存投票主体,一条文档就是一次投票:

// votes 集合文档结构(示意) { _id: 'a1b2c3', // 云数据库自动生成 title: '社区最美阳台评选', desc: '每人每天可投一次,截止本周日', type: 'single', // single 单选 | multi 多选 maxSelect: 1, // 多选时最多可选几项 options: [ // 选项内嵌,避免二次查询 { key: 'o1', text: '1 号楼', count: 12 }, { key: 'o2', text: '2 号楼', count: 8 } ], status: 'published', // draft 草稿 | published 已发布 | closed 已结束 anonymous: true, // 是否匿名投票 startTime: 1710000000000, endTime: 1710600000000, creatorOpenid: 'oXXXX', // 创建者,用于「我创建的」过滤 createdAt: 1709900000000 }

选项内嵌在votes文档里,而不是单独建options集合,这是文档型数据库的关键取舍。内嵌的好处是详情页一次查询就能拿到全部选项和票数,不需要JOIN;坏处是选项特别多(比如几百个候选人)时单文档会变大,而且并发自增会集中在同一条文档上。投票场景选项一般在 2 到 20 个,内嵌完全够用。

records集合存投票记录,一条文档就是一次投票动作:

// records 集合文档结构(示意) { _id: 'r9x8y7', voteId: 'a1b2c3', // 关联的投票 ID openid: 'oXXXX', // 投票人,匿名场景下也不对外暴露 selections: ['o1'], // 选中的选项 key 数组,兼容单选多选 createdAt: 1709950000000 }

selections统一用数组,单选就是长度为 1 的数组。这样详情页渲染、结果统计、防重复判断三处的代码结构完全一致,不需要为单选多选写两套分支,是省事又不容易出错的写法。

可选地还会有一个users集合,存昵称头像,用于「谁投的」这类展示。如果业务是匿名投票,这个集合可以完全不要,投票记录里只留 openid 用于判重,展示层永远不显示投票人。

三个集合的关系是:votes一对多records,records通过voteId反查。结果页的票数不靠records聚合计算,而是直接读votes.options[].count,因为聚合查询在数据量上来后很慢,而实时票数要求快速返回。records只在需要「投票明细导出」或者「我参与过的」时才会被查。

3. 运行与部署:从导入开发者工具到首页出数据

3.1 环境准备与 AppID 配置

跑这个 Demo 的前提是一台装了微信开发者工具的电脑,加上一个能开通云开发的小程序 AppID。测试号(无 AppID 模式)是不能用云开发的,这一点必须先确认,否则导入后会卡在wx.cloud.init报错上。

导入项目的步骤不复杂,但有几个参数要选对:

# 项目导入时的关键配置(在开发者工具「导入项目」对话框里填) 项目目录: 解压后的根目录(含 app.json 的那一层) AppID: 你自己的小程序 AppID 项目名称: 随意,不影响运行 后端服务: 选择「微信云开发」

选错目录层级是最常见的第一个问题。压缩包解压后往往多套一层同名文件夹,如果导入时选到了外层,开发者工具会提示找不到app.json。正确的判断标准是:选中目录后,里面应该直接能看到app.json、app.js、pages这三个东西。

导入完成后打开project.config.json,确认appid字段和你在对话框里填的一致。有些源码包里这个字段是占位值,需要手动改:

{ "appid": "wx0000000000000000", "projectname": "vote-demo", "miniprogramRoot": "miniprogram/", "cloudfunctionRoot": "cloudfunctions/", "setting": { "urlCheck": false, "es6": true, "postcss": true, "minified": false } }

miniprogramRoot和cloudfunctionRoot这两个字段决定了开发者工具去哪里找小程序代码、去哪里找云函数。云开发的模板项目默认会把小程序代码放在miniprogram/子目录里,如果你拿到的源码是在根目录直接放pages,那miniprogramRoot就得改成./,否则右侧模拟器会白屏。这个字段一改,云函数目录也要跟着对,两个路径必须能对上实际文件夹。

urlCheck设为 false 表示不校验合法域名。云开发调用本身不走这个校验,但如果你后续在项目里接了第三方接口或者图片 CDN,不关掉会一直报「不在以下 request 合法域名列表中」。

3.2 云开发初始化与集合权限

AppID 配好之后,点开发者工具顶部的「云开发」按钮,按提示开通环境。开通后会得到一个环境 ID,形如vote-demo-3g8xxxxx,把它填回app.js的envId字段。

接着建集合。云开发的集合不会自动创建,第一次查询不存在的集合会直接报错。需要在云开发控制台的数据库面板里手动新建votes和records两个集合。

权限设置是这一步真正的重点,也是最容易埋雷的地方:

集合推荐权限原因
votes所有用户可读,仅创建者可写列表和详情需要公开读,写操作走云函数
records仅创建者可读写投票记录不能被别人翻到,判重必须走云函数

很多人图省事把两个集合都设成「所有用户可读写」,本地跑没问题,一上线就出事——任何人都能绕过页面直接调用数据库 API 改票数。云开发的权限是数据安全的最后一道闸,页面上的按钮禁用只是交互层面的,不构成防护。

云函数需要在部署时勾选「云端安装依赖」。wx-server-sdk这个包在本地node_modules里有没有都不影响,但云端必须装上,否则函数执行时会报Cannot find module 'wx-server-sdk'。右键云函数目录,选「上传并部署:云端安装依赖」,等控制台提示部署成功再继续。

3.3 跑通验收清单

部署完先别急着改代码,按下面这几步确认基线是通的,后面出问题才能判断是自己改坏的还是本来就没跑通。

第一步,编译后看首页。如果首页是空列表,不一定是错——新环境里votes集合本来就是空的。这时候点「创建投票」,填标题和两个选项,提交。如果提交成功并跳回列表能看到刚创建的这条,说明写入链路通了。

第二步,点进详情页投票。投完之后返回列表,再点进去,票数应该 +1。如果票数没变但没报错,多半是云函数部署了旧版本,重新上传一次。

第三步,换一个账号(开发者工具可以切换测试用户,或者用手机扫码预览)再投一次,验证防重复是否生效。

第四步,在云开发控制台的数据库面板里直接看records集合,应该有两条记录,openid不同。这一步是确认「判重逻辑真的落库了」,而不是只在页面层做了个本地标记。

4. 核心链路拆解:创建投票到发布上墙的完整实现

4.1 创建页:选项动态增删与表单状态

创建页是整个 Demo 里交互最复杂的一页,难点不在表单本身,而在「选项数组」的状态管理。小程序的数据绑定是单向的,setData更新整个数组最直观,但选项多的时候会有性能问题,而且输入框光标容易跳。

这份源码用的是「数组 + 索引路径」的更新方式:

// pages/create/create.js Page({ data: { form: { title: '', desc: '', type: 'single', // 单选 / 多选 maxSelect: 1, anonymous: true, endTime: '' }, options: ['', ''], // 至少两个空位,避免用户面对空页面 submitting: false }, // 选项输入:用索引路径精确更新,避免整个数组重渲染导致光标跳动 onOptionInput(e) { const { index } = e.currentTarget.dataset this.setData({ [`options[${index}]`]: e.detail.value }) }, // 新增一个选项,上限 20 个,防止单文档过大 onAddOption() { const { options } = this.data if (options.length >= 20) { wx.showToast({ title: '最多 20 个选项', icon: 'none' }) return } this.setData({ options: [...options, ''] }) }, // 删除选项,保留至少两个 onRemoveOption(e) { const { index } = e.currentTarget.dataset const { options } = this.data if (options.length <= 2) { wx.showToast({ title: '至少保留两个选项', icon: 'none' }) return } options.splice(index, 1) this.setData({ options }) } })

setData里的options[${index}]是小程序支持的路径写法,只更新数组里指定的那一项,不会触发整个列表的 diff。用整数组替换的写法在这里会出问题:每输入一个字符就重建整个数组,输入框会失焦,用户打字打到一半光标就没了,这是我在别的项目里实打实翻过的车。

onRemoveOption里用了splice原地修改再整体setData。删除操作不像输入那样高频,整体替换可以接受,而且删完之后索引会重排,用路径更新反而容易错位。

表单校验放在提交前统一做,而不是每个字段实时校验。投票创建是个低频动作,实时校验带来的红字提示反而干扰填写:

// 提交前的集中校验 validate() { const { form, options } = this.data const title = form.title.trim() if (title.length < 2 || title.length > 30) { return '标题需要 2 到 30 个字' } const valid = options.map(o => o.trim()).filter(Boolean) if (valid.length < 2) { return '至少填写两个有效选项' } if (new Set(valid).size !== valid.length) { return '选项内容不能重复' } if (form.type === 'multi' && form.maxSelect > valid.length) { return '最多可选数不能超过选项数量' } if (form.endTime && form.endTime <= Date.now()) { return '截止时间必须晚于当前时间' } return '' }

new Set(valid).size !== valid.length这句是去重判断的常用写法,比双重循环清爽。maxSelect和选项数量的关系校验容易被漏掉——多选场景下如果允许「最多选 5 项」但只有 3 个选项,用户操作时会很困惑,虽然不致命,但属于体验上的硬伤。

4.2 提交写入:校验、草稿与发布状态机

创建页填完之后,数据不是直接写数据库,而是先调云函数。中间加这一层的原因有两个:一是要把creatorOpenid从服务端上下文里取,不能信客户端传的值;二是要生成选项的key,保证每个选项有稳定的标识。

// cloudfunctions/createVote/index.js const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db = cloud.database() exports.main = async (event) => { const { OPENID } = cloud.getWXContext() const { title, desc, type, maxSelect, anonymous, endTime, options, status } = event // 服务端二次校验:客户端的校验只是体验,不能作为信任边界 if (!title || title.trim().length < 2) { return { code: 400, msg: '标题不合法' } } const texts = (options || []).map(o => String(o).trim()).filter(Boolean) if (texts.length < 2) { return { code: 400, msg: '选项不足' } } const now = Date.now() const doc = { title: title.trim(), desc: (desc || '').trim(), type: type === 'multi' ? 'multi' : 'single', maxSelect: type === 'multi' ? Math.min(Math.max(maxSelect, 1), texts.length) : 1, anonymous: !!anonymous, // 选项 key 用序号生成,保证同一次投票内唯一且稳定 options: texts.map((text, i) => ({ key: `o${i + 1}`, text, count: 0 })), status: status === 'draft' ? 'draft' : 'published', startTime: now, endTime: endTime || 0, creatorOpenid: OPENID, createdAt: now } const res = await db.collection('votes').add({ data: doc }) return { code: 0, msg: 'ok', id: res._id } }

cloud.getWXContext()返回的OPENID是云函数运行环境注入的用户标识,客户端伪造不了。所有跟「谁」相关的字段都必须从这里取,绝不能从event里读客户端传的 openid,这是云开发场景下最基本的安全习惯。

status字段支持草稿和已发布两种状态,草稿不会出现在列表页。这个设计的价值在于「先存着,等活动时间到了再发」,比一次性填完就必须发出去灵活。列表页的查询条件里带上status: 'published'就自动过滤了草稿。

选项key用o1、o2这种序号生成,而不是用文本内容做标识。用文本做标识的问题是用户可能改选项文字(如果以后加了编辑功能),一改历史投票记录就对不上了。序号 key 一旦生成就不再变,投票记录只存 key,展示时再去votes.options里查对应文本。

4.3 列表与详情:分页、聚合与实时票数

列表页用的是云数据库的分页查询,没有走云函数。只读查询直连数据库可以省掉云函数的冷启动开销,首屏更快:

// pages/index/index.js const PAGE_SIZE = 10 Page({ data: { list: [], page: 0, loading: false, hasMore: true }, onLoad() { this.loadList(true) }, async loadList(reset = false) { if (this.data.loading) return if (!reset && !this.data.hasMore) return this.setData({ loading: true }) const page = reset ? 0 : this.data.page const db = wx.cloud.database() try { const res = await db.collection('votes') .where({ status: 'published' }) .orderBy('createdAt', 'desc') .skip(page * PAGE_SIZE) .limit(PAGE_SIZE) // 列表页不需要完整选项,只取前两个做预览 .field({ title: true, desc: true, options: true, endTime: true, createdAt: true }) .get() this.setData({ list: reset ? res.data : this.data.list.concat(res.data), page: page + 1, hasMore: res.data.length === PAGE_SIZE, loading: false }) } catch (err) { this.setData({ loading: false }) wx.showToast({ title: '加载失败', icon: 'none' }) console.error('[loadList]', err) } }, onReachBottom() { this.loadList(false) }, onPullDownRefresh() { this.loadList(true).then(() => wx.stopPullDownRefresh()) } })

.field()指定返回字段是分页查询里容易被忽略的优化。不加这个,每条记录会把options里所有选项、creatorOpenid、desc全带回来,列表 10 条就是 10 份完整文档,首屏体积能差好几倍。列表页只需要标题、描述、前两个选项做预览,其余字段没必要传。

.skip(page * PAGE_SIZE)这种偏移分页在数据量小的时候没问题,数据上千条之后深分页会变慢,因为数据库要先跳过前面所有记录。如果要做得更稳,可以用createdAt做游标:where({ createdAt: _.lt(lastCreatedAt) }),每次从上一页最后一条的时间往前取。这个改造留到第 6 章说。

详情页的票数不单独请求,直接复用列表传来的_id再查一次完整文档。投票成功后不做整页刷新,而是局部更新对应选项的票数:

// 投票成功后的局部更新,避免整页重渲染 const idx = this.data.vote.options.findIndex(o => o.key === selectedKey) this.setData({ [`vote.options[${idx}].count`]: this.data.vote.options[idx].count + 1, 'vote.mySelection': selections })

同样是路径更新的思路。整页setData会把所有选项重新渲染一遍,视觉上会有一次明显的闪动,用户刚点完的选项卡片会「跳」一下,体验很差。

4.4 投票动作:防重复与并发自增

投票是整条链路里唯一需要保证数据一致性的地方。要同时做到两件事:同一个人对同一次投票不能重复投;票数自增不能因为并发丢票。

// cloudfunctions/submitVote/index.js const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db = cloud.database() const _ = db.command exports.main = async (event) => { const { OPENID } = cloud.getWXContext() const { voteId, selections } = event if (!voteId || !Array.isArray(selections) || selections.length === 0) { return { code: 400, msg: '参数不合法' } } // 1. 读投票主体,校验状态与选项合法性 const voteRes = await db.collection('votes').doc(voteId).get() const vote = voteRes.data if (!vote) return { code: 404, msg: '投票不存在' } if (vote.status !== 'published') return { code: 403, msg: '投票未开始或已结束' } if (vote.endTime && Date.now() > vote.endTime) return { code: 403, msg: '投票已截止' } const validKeys = vote.options.map(o => o.key) if (!selections.every(k => validKeys.includes(k))) { return { code: 400, msg: '选项不存在' } } if (vote.type === 'single' && selections.length !== 1) { return { code: 400, msg: '单选只能选一项' } } if (vote.type === 'multi' && selections.length > vote.maxSelect) { return { code: 400, msg: `最多选 ${vote.maxSelect} 项` } } // 2. 判重:同一 openid + voteId 只允许一条记录 const dup = await db.collection('records') .where({ voteId, openid: OPENID }) .count() if (dup.total > 0) { return { code: 409, msg: '你已经投过票了' } } // 3. 先落记录,再自增票数 await db.collection('records').add({ data: { voteId, openid: OPENID, selections, createdAt: Date.now() } }) // 用 inc 原子自增,避免读-改-写在并发下丢票 const incData = {} selections.forEach(key => { const idx = vote.options.findIndex(o => o.key === key) incData[`options.${idx}.count`] = _.inc(1) }) await db.collection('votes').doc(voteId).update({ data: incData }) return { code: 0, msg: 'ok' } }

_.inc(1)是云数据库的原子自增指令,它把加法交给数据库执行,不经过「读出来、加一、写回去」这个过程。如果用读-改-写,两个人同时投同一个选项时,两次读到的都是 8,各自加一写回 9,最终票数是 9 而不是 10,丢了一票。这种错误在测试阶段几乎发现不了,要等真实并发才暴露,属于典型的黑匣子问题。

incData里的options.${idx}.count是嵌套路径更新语法,一次update可以同时更新多个选项的票数,多选场景下一次请求只写一次数据库。

判重用的是「先查 count 再插入」,严格来说这两步之间存在极小的并发窗口——同一个人用两个设备同时点提交,理论上可能都通过 count 检查。要彻底杜绝,得给records加唯一索引,或者在云函数里用事务。云开发的数据库支持事务,写法是把判重和插入包在db.startTransaction()里。Demo 里没这么做是因为单人重复点击的概率极低,页面层也做了按钮置灰。如果你的场景是「抢票」级别的并发,这里必须补上事务。

顺序上先落记录再自增票数,是有意为之。如果先自增票数、后写记录,写记录失败时票数已经加上去了,结果就是票数虚高且无法回滚。反过来,先写记录、自增失败,最坏情况是这个人投了票但票数没加,用户重试会被判重拦住——虽然也难受,但比票数虚高好排查,至少records里能查到记录,能人工补。

5. 避坑与排查:五个让我返工的真实问题

5.1 云函数改了没生效,调到的是旧版本

现象:本地改了submitVote的返回结构,小程序端拿到的还是旧字段,控制台也没报错。

原因:云函数是独立部署单元,保存文件不等于部署。开发者工具只会把云函数代码同步到本地,云端运行的还是上次上传的版本。

解决:每次改完云函数,右键该函数目录,选「上传并部署:云端安装依赖」,等提示成功再测。判断是否真的部署成功,去云开发控制台的云函数列表看「更新时间」,时间不对就是没传上去。我现在的习惯是改完云函数先看一眼更新时间,再动小程序端。

5.2 集合权限设成「仅创建者可读」,列表页空数据

现象:数据库里明明有数据,列表页一条都查不到,也没有报错。

原因:votes集合的权限如果是「仅创建者可读写」,那么查询只会返回当前用户自己创建的记录。列表页要展示所有人的投票,这个权限下必然查不到别人的数据。

解决:votes集合改成「所有用户可读,仅创建者可写」。改完不用重新编译,权限是即时生效的。注意这个改动只影响读,写操作仍然受限制,是安全的。

5.3 数据库查询报「collection not exists」

现象:第一次运行就报错,提示集合不存在。

原因:云开发的集合必须在控制台手动创建,代码里db.collection('votes')不会自动建表。这是跟 MySQL 那种「建表语句跑一遍」的习惯不一样的地方。

解决:去云开发控制台 → 数据库 → 新建集合,把votes和records都建上。集合名大小写敏感,代码里写什么就建什么,别写成Votes。

5.4 输入框打字打一半光标消失

现象:在创建页的选项输入框里连续输入,打到第二个字光标就跳走了。

原因:onOptionInput里用了this.setData({ options: newArray })整体替换数组,每次输入都重建列表,输入框组件被重新渲染,焦点丢失。

解决:改成路径更新this.setData({ ['options[' + index + ']']: value })。如果组件是自己封装的,还要注意wx:key不能用 index,要用稳定 id,否则列表 diff 时同样会丢焦点。

5.5 手机预览时云开发调不通,开发者工具里正常

现象:开发者工具里一切正常,扫码到真机上所有云函数调用都失败。

原因:真机预览时小程序会走真实的网络环境,如果app.js里的envId填的是测试环境而真机登录的账号没有该环境权限,或者基础库版本过低不支持wx.cloud,就会全部失败。

解决:先在真机上打开调试面板(vConsole)看具体报错。wx.cloud.init之前那句if (!wx.cloud)的判断要保留,基础库低于 2.2.3 直接给出提示,比让用户面对一堆看不懂的报错强。另外envId建议从app.globalData里取,切换环境只改一处。

6. 二次开发:把 Demo 改成能上线的业务模块

Demo 跑通只是起点,真要用在业务里,有几处必须动。

第一处是判重的可靠性。前面说的「先查 count 再插入」在极端并发下有窗口,改造方式是给records加一个联合唯一键的替代方案:把voteId + openid拼成一个_id直接写入。云数据库的_id天然唯一,重复写入会直接报错,用try/catch捕获这个错误返回「已投过」,比查 count 更稳,还省了一次查询。

// 用确定性 _id 做唯一约束,重复投票会在写入时直接失败 const recordId = `${voteId}_${OPENID}` try { await db.collection('records').add({ data: { _id: recordId, voteId, openid: OPENID, selections, createdAt: Date.now() } }) } catch (err) { if (String(err).includes('duplicate')) { return { code: 409, msg: '你已经投过票了' } } throw err }

第二处是列表的深分页。skip在数据上千后明显变慢,换成时间游标:

// 游标分页:从上一页最后一条的 createdAt 往前取 let query = db.collection('votes').where({ status: 'published' }) if (lastCreatedAt) { query = query.where({ createdAt: _.lt(lastCreatedAt) }) } const res = await query.orderBy('createdAt', 'desc').limit(PAGE_SIZE).get()

_.lt(lastCreatedAt)是「小于」指令,配合orderBy desc就是「取比上一页最后一条更早的记录」。这种分页不会随着页码变深而变慢,代价是不能跳页,只能上一页下一页,投票列表场景完全够用。

第三处是结果导出。运营方基本一定会要「把投票明细导成 Excel」。云开发没有直接的导出接口,常见做法是写一个定时云函数,把records按voteId分批拉出来,生成 CSV 字符串传到云存储,返回一个临时下载链接。数据量大时要分批,一次get最多返回 100 条,用skip或游标翻页拉全。

第四处是匿名投票的展示剥离。records里存 openid 是为了判重,但任何时候都不能把它返回给前端。所有对外接口在返回数据前要过一层字段裁剪,「我参与过的」这类页面只返回voteId和createdAt,投票人信息不出服务端。

改造完之后怎么验证?我的做法是准备三个测试账号,分别走一遍:A 创建投票并发布、B 投一次、B 再投一次(应该被拦)、C 投一次、A 看结果票数是否为 2。这五步能覆盖创建、发布、判重、并发、统计五条主链路。上线前再把records集合权限复查一遍,确认不是「所有用户可读写」。

从那以后我每次接手这类「活动型小程序」,都强制先跑一遍这份 Demo 的五步验收,再决定是在它上面改还是重写。多数时候答案是前者,因为投票这条链路的坑基本都被趟过一遍了,没必要再趟第二遍。希望帮到你。

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

RELATED READING

延伸阅读

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