☰
免后台测试小程序源码:题库计分、激励视频接入与真机避坑
2026/11/2 13:20:55 网站建设 项目流程

简介:面向想入门抖音小程序开发、测试流量主变现的个人开发者与小型团队,这份源码给出无需独立后台即可运行的小程序示例,对缺少后端经验的读者较友好,侧重前端界面、交互逻辑与平台配置的完整呈现,可用于学习项目结构、页面样式组织、广告位接入和素材管理。压缩包共236个文件,约1.31MB,其中27个js脚本承担业务逻辑,15个json负责配置,11个ttss与10个ttml对应样式和页面模板,另有135个gif、30个png等界面素材,并附docx搭建说明与txt文档,便于对照理解各模块用途。已有1926人学习。资源覆盖页面展示、交互流程、资源配置和素材组织,适合作为抖音小程序练手与二次开发参考,也能帮助读者观察流量主功能在小程序中的落地形态、项目组织方式以及页面结构、配置项和素材调用关系。

1. 免后台的测试小程序源码:先把"不依赖服务器"这件事讲清楚

一份纯前端的测试小程序源码,最值得先确认的不是界面好不好看,而是它到底要不要服务器。这份测试小程序源码的定位很明确:题库、计分、结果文案全部内置在本地 JSON 和 JS 里,没有登录、没有接口、没有数据库,导入开发者工具就能编译出一个完整可点的流程——首页 → 答题 → 结果页 → 分享卡片。它适合三类人:想在一两天内验证小程序变现链路能不能跑通的独立开发者、需要交课程设计但不想折腾后端的同学、以及手上有一批题库想快速做成互动内容的内容运营。免后台的好处是部署成本为零、审核链路短;代价是所有内容和逻辑都暴露在包里,改题库要重新提审,这个边界从第一章就得心里有数。

2. 拆包看结构:四文件工程、数据流与宿主差异收敛

拿到源码先别急着改页面,把目录和调用链摸清楚,后面改题库、换皮肤、加广告位才不至于迷路。这类免后台的小程序工程有一个共同特征:页面数量少但每页职责重,数据全靠页面之间传引用或者缓存中转,所以结构看着简单,实际耦合点比有后端的项目更集中。

2.1 目录树与页面职责划分

常见的工程骨架大致是这样,页面不点名,按职责记就行:

├── app.js # 启动、全局缓存初始化 ├── app.json # 页面注册、窗口配置、分包声明 ├── app.ttss / app.wxss # 全局样式(宿主后缀不同,内容一样) ├── utils/ │ ├── host.js # 宿主 API 探测与收敛 │ ├── score.js # 抽题、计分、结果映射 │ └── storage.js # 本地缓存读写包装 ├── data/ │ └── questions.json # 题库主体,唯一的"内容源" ├── components/ │ └── ad-card/ # 广告卡片组件,含兜底态 └── pages/ ├── index/ # 首页:规则说明 + 开始按钮 ├── quiz/ # 答题页:逐题渲染、进度条 └── result/ # 结果页:分数、文案、广告位、分享

这个划分的关键在于data/questions.json是唯一内容源,utils/score.js是唯一计分源。我见过不少改崩的版本,问题都出在这两处被复制成了两份:答题页存一份抽题逻辑、结果页又存一份计分逻辑,改题库字段名的时候只改了一边,结果页分数永远为 0。血泪经验是,只要涉及"题目"和"分数",一律走 utils。

app.json里要重点核对两处:pages数组的第一项决定启动页,必须放 index;如果题库体量大到要分包,subpackages的root和pages路径要与实际目录严格一致,大小写敏感,这是真机白屏的高频原因。

2.2 没有后台,题库和结果怎么流转

免后台不等于没有数据流,只是数据流从"请求-响应"变成了"读取-计算-缓存"。完整链路是:首页点击开始 → 从题库池按维度抽 N 题 → 逐题记录用户选项下标 → 提交时按scores数组累加得分 → 用得分区间匹配结果文案 → 结果写入本地缓存供分享卡片复用。

先看抽题,这里有个流传很广的写法需要避掉:

// utils/score.js —— 抽题与洗牌 function pickQuestions(pool, n = 8) { if (!Array.isArray(pool) || pool.length === 0) return []; // 不用 sort(() => Math.random() - 0.5),各宿主引擎下分布不均匀 // Fisher-Yates 是原地洗牌,每次都从剩余元素里随机选一个换到前面 const arr = pool.slice(); for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const t = arr[i]; arr[i] = arr[j]; arr[j] = t; } return arr.slice(0, Math.min(n, arr.length)); } module.exports = { pickQuestions };

逻辑说明:pool.slice()先拷贝,避免洗牌污染原始题库导致下次抽题顺序固定;循环从末尾往前,每次在[0, i]里取随机下标与i交换,保证每种排列概率相等;Math.min(n, arr.length)是防呆,题库不足 N 题时不返回稀疏数组,否则答题页渲染到undefined会直接报错。

参数说明:n是单次答题的题量,这个值和结果区间的划分必须联动——题库有多少题、每题满分多少、总分区间怎么切,三者要一起算。假设 8 题、每题最高 3 分,总分区间是 0 到 24,切成 3 段就是 0-8、9-16、17-24,别出现区间空洞或者重叠,用户落在空洞里就拿到 undefined 文案,页面一片空白。

再看计分:

// utils/score.js —— 累加得分 function calcScore(questions, answers) { // questions 与 answers 下标一一对应 // answers[i] 是用户在第 i 题选的选项下标,未作答记 -1 let total = 0; for (let i = 0; i < questions.length; i++) { const q = questions[i]; const idx = answers[i]; if (idx === undefined || idx < 0) continue; // 跳过未作答 let s; if (Array.isArray(q.scores)) { s = q.scores[idx]; // 多维度:每个选项对应一个分数 } else { s = idx === q.answer ? (q.weight || 1) : 0; // 单选:答对给权重分 } total += Number(s) || 0; // 字段缺失时兜 0,不让 NaN 传染整个总分 } return total; }

这段同时兼容了两种题库形态:一种是一题一分、对错二值,适合知识测试;一种是每个选项权重不同,适合性格或倾向类测评。Number(s) || 0那一行是防守型写法,题库是人工维护的 JSON,出现字符串数字、空值、少写字段都算常见,一个 NaN 进去,结果页就会显示NaN 分,用户第一反应就是这程序坏了。

2.3 把宿主差异压到一个文件里

多端小程序最容易翻车的地方,是业务代码里散落着宿主专属的 API 调用。这份源码如果只在一个宿主上跑,短时间没问题;一旦想搬到另一个生态,改到怀疑人生。稳妥做法是加一层探测层:

// utils/host.js —— 探测当前宿主的全局对象 const CANDIDATES = ['tt', 'wx', 'my', 'swan']; function detect() { for (let i = 0; i < CANDIDATES.length; i++) { const key = CANDIDATES[i]; // 某些宿主在 JS 引擎里直接访问未声明的全局变量会抛错 // 用 typeof 判断是安全的,typeof 对未声明标识符不抛异常 if (typeof globalThis[key] !== 'undefined' && globalThis[key]) { return globalThis[key]; } } return null; } const host = detect(); function getHost() { return host; } // 统一样式后缀,模板里做条件编译时用得上 function getStyleExt() { return host && host.getSystemInfoSync ? '.ttss' : '.wxss'; } module.exports = { getHost, getStyleExt };

逻辑说明:typeof globalThis[key]而不是globalThis[key]直接取值,是因为部分宿主的 JS 引擎里访问未声明标识符会抛 ReferenceError,一旦抛出,后面的探测全断。CANDIDATES的顺序即优先级,如果你的工程确定只跑某一个生态,可以只留一项,减少无用判断。

参数说明:这层封装里通常还要收进去几样东西——本地缓存setStorageSync/getStorageSync、激励视频创建、分享配置、canvas 节点查询。每多收一样,业务页面就少一处宿主专属代码。代价是这层文件会逐渐变厚,控制在一两百行以内比较合适,超过就说明该拆成host.storage.js、host.ad.js了。

3. 跑通首屏链路:导入、配置与答题流程验证

结构和数据流清楚了,接下来是把它真的跑起来。这一步的目标不是"能编译",而是"从首页点到结果页再到分享,每一步的状态都对得上",尤其要确认广告不可用时用户还能不能走完全程。

3.1 导入工程与必改的三处配置

新建工程后把源码目录整体拷进项目根目录,先别动页面代码,只改配置,改完立刻编译一次,确认没有立即报错再继续。必改项集中在三处:

配置位置字段改什么不改的后果
工程配置appid换成你自己的小程序 appid广告位无法绑定,预览只能走游客态
app.jsonpages[0]必须是首页路径冷启动落到答题页,用户一脸茫然
页面 jsadUnitId换成后台创建的广告位 ID激励视频一直 reject,点击无反应

appid 这一项要特别注意:很多人在开发者工具里用测试号跑通后,直接把包传给另一个账号,忘了改 appid,结果广告全挂。广告位是和 appid 绑定的,换号必须换位。

另外project.config.json里的编译设置建议开着"ES6 转 ES5"和"增强编译",这两项关掉之后,部分机型上的解构赋值和可选链会直接报语法错误——不是所有宿主的基础库都跟得上最新语法,这个坑在低端安卓机上尤其明显。

3.2 首页到结果页的完整点击链路

导入后按这条路径走一遍,每一步都观察控制台,不要跳到结果再回头排查:

第一步,冷启动进首页,看规则说明的文案有没有被截断,看"开始"按钮的点击区域是不是被自定义导航栏遮挡。自定义导航栏在部分机型上安全区高度不同,按钮贴在顶部就容易点不到。

第二步,点击开始,观察答题页是否正确渲染第一题,进度条是不是1/N。如果题目文字出现但选项空白,八成是题库字段名和模板里绑定的字段不一致,比如 JSON 里叫options,模板里写成了option。

第三步,逐题作答到最后一题,重点看自动跳转和手动提交是不是重复触发了结果页。有些版本在最后一题的选项回调里既做了redirectTo又监听了页面卸载再跳一次,结果页会压两层,用户点返回直接退出小程序。

// pages/quiz/quiz.js —— 作答与跳转的关键片段 const { calcScore } = require('../../utils/score'); Page({ data: { idx: 0, questions: [], answers: [] }, onSelect(e) { const pick = Number(e.currentTarget.dataset.index); // 选项下标 const answers = this.data.answers.slice(); answers[this.data.idx] = pick; const next = this.data.idx + 1; if (next < this.data.questions.length) { // 还有题,只更新下标,不跳页 this.setData({ answers, idx: next }); return; } // 最后一题,先算分再跳,避免结果页拿不到数据 const total = calcScore(this.data.questions, answers); this.setData({ answers }); // redirectTo 而不是 navigateTo,防止用户返回重复答题 getHost().redirectTo({ url: `/pages/result/result?score=${total}` }); } });

逻辑说明:answers.slice()而不是直接 push,是为了保证setData时数据是新的引用,否则部分宿主会对同一引用做浅比较判定"无变化"而不触发渲染,表现为选项高亮不切换。redirectTo替换navigateTo是这类单向流程的标准做法,答题完成后回退到答题页没有业务意义,还会让用户重复提交。

参数说明:分数通过 URL query 传给结果页,长度可控、调试直观。但如果结果页还需要题目明细(比如做答题回顾),query 传对象会超长,这时改用本地缓存中转更合适——storage.setSync('last_result', {total, detail}),结果页onLoad里读,读完立即removeSync,避免下次冷启动读到脏数据。

第四步,进入结果页,看分数和文案是否匹配。这一步最容易暴露区间划分问题:如果题库被换过但区间没重算,用户拿了 20 分却显示"你比较均衡",说服力直接归零。

第五步,点击分享,观察分享卡片的标题和封面。标题不带分数、不带"测测你是"这类诱导词,封面图不要用带文字的截图。

3.3 题库结构设计与扩题方法

题库是这份源码里唯一需要长期维护的东西,字段设计要一次性想清楚,不然后面加维度会很痛苦。一个够用的结构:

{ "version": 3, "questions": [ { "id": "q001", "q": "题干文案,控制在一行半以内", "options": ["选项一", "选项二", "选项三", "选项四"], "scores": [3, 2, 1, 0], "tag": "dim-a" } ] }

字段说明:version是给题库自己用的,改题之后手动加一,方便你确认线上跑的是哪一版;q是题干,超过两行在答题页会挤压选项区域;options长度建议固定为 3 到 4,长度不一时模板里的等高布局要额外处理;scores与options严格等长,这是最容易写错的地方,少写一个就是 NaN;tag用于多维度统计算法,单维度测评可以省掉。

扩题的实操顺序是:先改 JSON,再跑一次JSON.parse校验,最后重算区间。

// 题库自检脚本,放 node 里跑,不进小程序包 const fs = require('fs'); const raw = fs.readFileSync('./data/questions.json', 'utf8'); let data; try { data = JSON.parse(raw); } catch (e) { console.error('JSON 解析失败,通常是尾逗号或中文引号:', e.message); process.exit(1); } data.questions.forEach((q, i) => { if (!q.q || !q.q.trim()) console.warn(`第 ${i} 题题干为空`); if (!Array.isArray(q.options) || q.options.length < 2) console.warn(`${q.id} 选项不足`); if (Array.isArray(q.scores) && q.scores.length !== q.options.length) { console.warn(`${q.id} scores 与 options 长度不一致`); } }); console.log('题量:', data.questions.length);

这段自检脚本不算复杂,但能省下大量"改完题库真机跑一遍才发现问题"的时间。JSON 解析失败最常见的原因是复制粘贴带进了中文全角引号和尾逗号,编辑器不报,宿主解析时直接白屏,这属于典型的玄学现场。

4. 广告位接入:激励视频的时序、兜底与频次控制

变现是这份源码的核心卖点之一,但广告接得糙比不接还伤。翻车的形态通常是两种:点了没反应,或者看完了没奖励。前者是加载时序问题,后者是回调注册问题,都能在代码层面解决。

4.1 广告位 ID 的注入位置与前置条件

先说前提:广告组件需要在宿主平台侧开通相应权限,广告位是在后台先创建、审核通过、拿到adUnitId之后才能用的。源码里预留的位置通常是结果页的解锁按钮和答题间隙的插屏,具体能不能跑起来,取决于你的账号状态,不是改几行代码的事。

注入位置建议集中到一个配置对象里,别散落在各个 page 的 data 中:

// config/ad.js —— 广告位集中配置 module.exports = { // 激励视频:用于结果页解锁完整报告 rewardUnitId: 'adunit-0000000000000000', // 插屏:用于答题页切题间隙,注意频次 interstitialUnitId: 'adunit-1111111111111111', // 广告不可用时的兜底策略:true 表示直接放行,不阻断主流程 fallbackUnlock: true };

参数说明:rewardUnitId和interstitialUnitId必须与当前 appid 同一主体下创建,跨主体无效;fallbackUnlock是这份配置里最值钱的一个开关,它在广告拉取失败、无网络、被频控时决定用户能否继续——对于测试类内容,直接放行几乎总是比卡死体验更好,广告收益本来就是增量而不是主流程。

4.2 激励视频的发放与双保险

激励视频最常见的实现错误是一次性只写show():广告没预加载完,show()直接 reject,用户点第二下才出来;另一种错误是onClose在页面onShow里反复注册,导致一次观看触发两次奖励发放,如果奖励涉及额度,很容易被刷。

// utils/reward-ad.js —— 单例 + 显式加载 + 结果判定 const { getHost } = require('./host'); const CFG = require('../config/ad'); const host = getHost(); let adInstance = null; function getAd() { if (adInstance) return adInstance; // 模块级单例,避免重复 create if (!host || !host.createRewardedVideoAd) return null; adInstance = host.createRewardedVideoAd({ adUnitId: CFG.rewardUnitId }); // 先注册错误监听,加载失败时才看得到原因 adInstance.onError((err) => { console.warn('[reward-ad] error', err && err.errCode, err && err.errMsg); }); adInstance.onLoad(() => console.log('[reward-ad] loaded')); return adInstance; } function showRewardAd() { return new Promise((resolve, reject) => { const ad = getAd(); if (!ad) return reject(new Error('no-ad-api')); // 用 once 语义手动控制:每次展示只绑定一次关闭回调 const onClose = (res) => { ad.offClose(onClose); // 立刻解绑,防止内存泄漏和重复触发 // isEnded 为 true 表示完整看完,部分宿主低版本没有该字段,按看完处理 resolve(res && res.isEnded !== false); }; ad.onClose(onClose); ad.show() .catch(() => ad.load().then(() => ad.show())) .catch((e) => { ad.offClose(onClose); reject(e); }); }); } module.exports = { showRewardAd, getAd };

逻辑说明:getAd()用模块级变量做单例,模块在同一 JS 上下文里只求值一次,页面来回切换也不会重复创建实例。show()失败后先load()再show()是标准的重试路径,能显著提高首次点击的成片率。onClose里调用offClose是必需的收尾动作,不写的话回调会累积,一次观看把奖励发三次。

参数说明:res.isEnded是判定完整观看的字段,取值可能是undefined(老版本宿主),所以判断写成!== false而不是=== true,宁可多放行不要误判。errCode建议原样打到控制台,排查时先看是 1004(无合适广告)还是 1002(广告位无效),两种的处理路径完全不同——前者是运营侧的填充率问题,改代码没用。

4.3 频次控制与体验红线

广告能不能放、放在哪,比怎么调 API 更影响留存。三条经验:

结果页落地的第一屏不放广告。用户刚答完题、情绪最高,这时候糊一个插屏上去,关闭率极高,还可能引发投诉。

激励视频放在"查看完整报告"这类增值动作上,且必须有明确的按钮文案说明看广告换什么,不能是隐藏触发的。

切题间隙的插屏要做本地频次计数,用缓存记last_interstitial_ts,两次间隔低于一段时间就跳过,具体阈值按你的题量定,题少的时候干脆别开。

频次计数同样走本地缓存,因为这份源码没有后台:

// utils/freq.js —— 本地频次控制 const { getHost } = require('./host'); const KEY = 'ad_interstitial_ts'; const GAP = 5 * 60 * 1000; // 5 分钟内最多一次 function canShowInterstitial() { const host = getHost(); if (!host || !host.getStorageSync) return false; const last = Number(host.getStorageSync(KEY) || 0); return Date.now() - last > GAP; } function markInterstitial() { const host = getHost(); if (host && host.setStorageSync) host.setStorageSync(KEY, Date.now()); } module.exports = { canShowInterstitial, markInterstitial };

逻辑说明:缓存里只存一个时间戳,判断逻辑极轻量,不依赖任何接口。Number(... || 0)兜住首次使用时的空值,Date.now()是本地时间,用户改系统时间可以绕过,但对于测试类内容这个精度足够,不值得为它上服务端。

参数说明:GAP是硬阈值,题量少的产品可以设得更大甚至关掉插屏;如果后续接了自定义上报,可以把真实曝光也记一份,用两端数据对一下差异,差得太多说明本地频次拦掉了大量本可展示的请求。

5. 避坑:五个真实翻车点的现象、原因与处理

这一章全部来自改这类免后台工程时踩过的坑,按"现象 → 原因 → 解决"整理,遇到问题时可以按顺序对号入座。

5.1 开发者工具正常,真机白屏

现象:工具里点得动,扫真机码进去一片白,控制台无报错或只有一行Failed to load。

原因:多数是app.json里注册的页面路径与实际文件不一致,常见于复制目录后大小写变了,或者分包root多写了一层;其次是questions.json里混入了 BOM 头,工具容错而真机解析失败。

解决:把app.json的pages逐条对着目录核一遍,每个路径下四个文件(js / json / 模板 / 样式)齐不齐;题库用前面那段 Node 自检脚本跑一次,BOM 用编辑器另存为 UTF-8 无 BOM 解决。

5.2 激励视频点击无反应,控制台刷 reject

现象:点解锁按钮,什么都不发生,日志里一串show fail。

原因:三种可能——adUnitId与当前 appid 不同主体;广告位还在审核中;当前网络环境拉不到广告。前两种改代码没用。

解决:先看onError打出的errCode,广告位无效的错误码和"无合适广告"完全不同,前者去后台核对主体和审核状态,后者只能等填充或者走fallbackUnlock放行。别用"循环重试"硬顶,越重试越容易被限。

5.3 看完广告奖励发了两次

现象:用户反馈只看了一次,额度加了两次。

原因:onClose在页面onShow里注册,页面每次可见都追加一个回调,回调栈越叠越深,一次关闭触发多次。

解决:把注册收敛到单例模块里,如 4.2 的写法,创建时注册错误和加载监听,展示时临时注册onClose并在回调首行offClose解绑。

5.4 结果页分享卡片是白图

现象:转发出去封面空白,或者只有纯色块。

原因:canvas 绘制时机早于节点就绪,onReady之前拿不到节点;或者绘制用的图片是网络图,画的时候还没下载完;又或者保存到相册时缺相册授权。

解决:绘制放到onReady之后,节点用createSelectorQuery查,图片先getImageInfo下载再drawImage;保存前查一次授权状态,被拒过就引导去设置页重新开,别直接调保存然后失败静默。

5.5 提审被拒,理由指向内容或诱导

现象:功能本身没问题,审核给的理由集中在"内容"或"分享"。

原因:结果页文案里有绝对化表述、测试结论被写成确定性判断;或者解锁完整报告的方式是"分享给好友",这属于明确的诱导分享。

解决:结果文案改成描述倾向而非下结论,加一句"仅供娱乐参考";解锁方式统一走激励视频或直接展示,把强制分享的入口全部摘掉。分享按钮可以留,但不能和任何权益挂钩。

6. 从能跑到能上线:包体、埋点与灰度验证

代码跑通只是起点,真正决定这份免后台源码能不能长期用的是三件事:包体够不够小、有没有数据能判断好坏、上线节奏是不是可控。

包体这块,主包大小是有硬上限的,题库和图片是两大消耗源。题库 JSON 可以做字段压缩,把questions、options这类长键名换成短键名,在读取时映射回来,几百题的量能省下可观体积;图片一律走网络地址,本地只保留一张占位图,别把结果页的背景图 base64 塞进样式文件——那玩意儿看着方便,实际是包体杀手。如果题库确实很大,用分包,把答题页和题库放进同一个分包,首页只留规则和入口,冷启动会明显快一截。

数据观测是免后台方案最薄弱的一环,因为没有服务端日志。可行的替代是用宿主自带的数据分析能力看几个关键指标:页面停留时长、答题完成率、广告曝光与点击、次日回访。其中答题完成率最能反映体验问题——如果大量用户停在第三四题,多半是题目太长或者切题动画卡顿,而不是内容不吸引人。我会在答题页的onUnload里额外记一个本地计数,记录用户在第几题退出,跑几天之后看一眼分布,比拍脑袋改题有效得多。

灰度这件事上,我的习惯是先在极小范围放出去,观察两三天再扩。重点看两个数:广告的填充情况和单次展示收益,以及有没有集中出现的机型相关问题。填充率低说明当前流量还不足以支撑广告请求,这时候把广告入口做得再显眼也没用,不如先把内容做厚。我吃过一次亏,功能一上线就把激励视频挂在最显眼的位置,结果填充率极低,用户点了半天没反应,第二天回访掉了一半——从那以后我每次接广告位,都强制走一遍"断网 + 无填充 + 正常"三种状态的点击路径,确认兜底逻辑真的生效,再谈优化转化。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询