资讯动态

纯前端人格测试应用开发实战:架构、计分与分享卡片全解析

发布时间:2026/9/19 13:47:02 来源:尧图企业网站定制
先交代一下背景我一直在做“轻工具”系列的小网页原则是打开即用、用完就走不搞复杂的账号体系。前阵子有位朋友找我做一份团队沟通用的性格测试需求很直接用户答完题拿到一份好看的结果并且愿意把结果分享给同事。我第一反应是拉一个后端服务存每个用户的答题记录再做一个管理后台做统计甚至想好了用哪个云数据库。做到一半我发现不对劲这个项目的核心价值根本不在数据收集而在“答题体验”和“结果呈现”。于是我推倒重来把它做成纯前端项目也就是你现在看到的这个 SBTI 人格测试。全文源码已经同步放到了 GitHub 和 Gitee搜“sbti 纯前端人格测试”就能找到这里先把源码拆开讲一遍。我会从目录结构、状态管理、计分算法、结果页交互、部署扩展五个方面展开。如果你也想做一个类似的心理测试、性格测评或者任何问卷类应用这篇文章里提到的取舍和细节可以直接抄作业。1. 纯前端做心理测试省掉服务器不是降级而是主动选择先回答一个很多人会问的问题心理测试这种应用不是天然需要后端来记录用户数据吗其实要看你的目的。如果你的目标是做专业的心理学量表采集大量样本做信效度分析那确实需要后端和数据库。但如果你的目标只是让用户快速了解自己的性格倾向顺便把这个工具传播出去那么纯前端反而更合适。SBTI 这个项目最终的数据流非常简单用户打开页面题目从静态 JSON 文件里读取答案写到浏览器 localStorage做完以后直接用本地数据计算结果最后生成一张分享图。整个过程不经过任何一台我自己的服务器唯一的外部请求是加载页面本身的静态资源。这对用户来说有一个隐性的心理优势你的答题结果没有上传到某个未知的服务器隐私焦虑会低很多。1.1 我为什么放弃了第一版“带后端”的架构第一版我用的是一个非常标准的开发框架后端提供题目列表接口和答案提交接口前端从接口拉数据答完提交后端算完类型再返回结果。这个架构本身没问题但我很快发现三个让我难受的点。第一个是部署成本。后端代码意味着至少一台云服务器或者容器实例需要考虑 HTTPS 证书、数据库备份、接口万一被刷了怎么办。哪怕用一个很轻量的大服务也要花精力维护。而且我预期这个工具不会有很高的并发却要承担完整的运维负担这让我觉得很不值。第二个是测试成本。没有后端以后我可以在本地直接把整个流程跑完改动任何题目文案都不用重启服务。而有后端时我得同时改前端、改接口、改数据库初始化脚本才能看到一道新题长什么样。对一个以内容为主要产品形态的项目来说这个成本太高了。第三个是结果页的分享闭环。带后端的架构里分享链接通常要带一个用户 ID 或者结果 ID别人打开以后要向后端请求一次才能看到结果。如果后端挂了整条分享链就断了。纯前端项目把类型参数直接放在 URL 里例如?typeESTJ别人打开链接的时候不需要请求任何接口结果页秒开这个体验是带后端方案很难做到的。1.2 纯前端的能力边界和取舍当然纯前端并不是万能药。我在这版里主动放弃了一些功能这些取舍值得你参考。第一个被砍掉的是“按用户维度统计倾向分布”。没有后端我就无法精确知道哪些类型占比最高、用户都在第几题犹豫最久。如果我真的想要一个粗略的分布数据可以通过一个很轻量的事件上报接口来做或者干脆在分享页里加一个投票按钮让用户自己选择结果类型然后把结果发到服务器。但这会引入外部存储我暂时不需要。第二个被砍掉的是“跨设备历史记录”。localStorage 是浏览器本地的用户换台电脑或者换个手机历史结果就没了。对大多数性格测试用户来说这其实可以接受他们很少会在另一个设备上查自己三个月前的结果。第三个被砍掉的是复杂的量表逻辑。心理测量学里有反向计分、随机题序、答题时间记录、测谎题等等。我在第一版里全部做了后来发现投入产出比太低。比如反向计分本质上只是把选项分值反转我完全可以在数据层做一层映射不需要专门设计一套逻辑。测谎题在这个场景里也没必要这个工具的定位是“自我探索和团队破冰”不是员工招聘评估。我保留了一个比较重要的能力给用户一个明确的“答题说明”。真正的心理学测试对施测流程有严格规范但作为轻工具我会在开头写清楚“本测试结果仅供参考不构成任何心理诊断”然后用口语化的引导降低用户的戒备心。这种说明不只是一种免责也是让用户认真答题的必要前提。2. 源码目录拆解数据、状态、视图三层如何协作整个项目我用的技术栈是 Vue 3 Vite Pinia没有引入 UI 组件库。选择 Vue 3 而不是 React纯粹是因为个人习惯模板写法更接近 HTML对后做静态页面的人来说门槛更低。Vite 负责开发和构建最终产出 dist 目录里面全是静态文件。不需要任何运行时环境放到 Nginx、GitHub Pages、Gitee Pages 或者任何一个 CDN 上都行。源码目录大概长这样sbti-personality-test/ ├── index.html ├── package.json ├── vite.config.js └── src/ ├── main.js ├── App.vue ├── data/ │ ├── questions.json │ └── typeDescriptions.json ├── stores/ │ └── testStore.js ├── composables/ │ ├── useScoring.js │ └── useShareImage.js ├── components/ │ ├── WelcomeView.vue │ ├── QuestionView.vue │ └── ResultView.vue └── utils/ ├── storage.js └── shuffle.js这个结构不是一开始就定好的是我写了三四个版本以后慢慢摸索出来的。核心思想只有一句话数据和视图分离状态是唯一的数据源。2.1 数据层把题目和结果文案从代码里彻底剥离开questions.json是题目的唯一数据源每一题的字段设计得非常简单{ id: q01, text: 团队一起讨论新方案时你更倾向于哪种状态, dimension: [EI, SN], options: [ { label: A, text: 话多想到哪说到哪边说边理思路, score: { E: 1, S: 1 } }, { label: B, text: 先听别人讲整理好想法后再发言, score: { I: 1, N: 1 } } ] }这里有个很关键的设计一道题最多影响两个维度每个选项的分值是一个对象里面的 key 是类型维度上的某一端字母value 是增加的分值。例如第一题选项 A 会让「外向 E」和「实感 S」各加 1 分选项 B 会让「内向 I」和「直觉 N」各加 1 分。我不建议让一道题同时影响三个以上维度因为那会让计分逻辑很难解释。用户可能会问为什么我选了某个选项却同时改变了三个分数这在题目内容上很难自圆其说。两道题分别度量两个维度结果呈现会清晰很多。typeDescriptions.json则维护 16 种类型的结果文案。每种类型包含标题、一句话概述、典型行为、适合的合作方式、可能的盲区、适合的例句和给用户的建议。这些文案是整个项目里最花时间的部分我前前后后改了四轮。写文案的时候我会刻意避免一种写法给某个类型贴上绝对的“好人”或“坏人”标签。我希望每个类型既有优势描述也有盲区提醒这样测试结果才显得可信而不是一堆彩虹屁。2.2 状态层用 Pinia 管理答题进度而不是让组件各自为战答题应用最怕的事情是组件之间互相传状态。比如当前在第几题这个值如果放在 QuestionView 里那么用户点“上一题”的时候ResultView 或者 ProgressBar 要同步更新就得靠事件总线或者 props 一层层传非常痛苦。我直接用 Pinia 写了一个testStore所有页面共享同一个状态对象。核心状态就四个字段export const useTestStore defineStore(test, () { const currentIndex ref(0) const answers ref({}) const startedAt ref(null) const finishedAt ref(null) function setAnswer(questionId, score, scrollToNext true) { answers.value[questionId] score if (scrollToNext currentIndex.value questions.length - 1) { currentIndex.value 1 } } function prev() { if (currentIndex.value 0) currentIndex.value - 1 } function reset() { answers.value {} currentIndex.value 0 startedAt.value null finishedAt.value null } return { currentIndex, answers, startedAt, finishedAt, setAnswer, prev, reset } })setAnswer里做了一件非常重要的事把“保存答案”和“跳转下一题”绑定在一起。这看起来好像违反了单一职责原则但在这个场景里其实是刻意的。因为问卷的交互规则就是“选择即答完答完即下一题”把这两个动作拆开反而容易引入中间态比如用户选了答案但没跳到下一题或者跳到了下一题但答案没存上。如果你想支持“先多选再统一提交”的模式那确实需要拆开。但 SBTI 的交互定位是轻快我选择了前者。2.3 视图层QuestionView、ResultView 之间通信的最小路径视图层只有三个大组件WelcomeView、QuestionView、ResultView。App.vue 里通过一个stage计算属性决定当前渲染哪个页面const stage computed(() { if (!store.startedAt) return welcome if (store.currentIndex questions.length) return question return result })也就是说用户只要一开始答题就进入 question 状态答完最后一题currentIndex 等于题目总数自动切换为 result。这个逻辑非常简单没有路由切换没有动画转移但我建议所有问卷类项目都先按这个方式做因为状态机够简单出 bug 的概率最低。QuestionView 里我单独抽了一个QuestionOption子组件。它的 props 只有一个 option 对象和当前是否选中点击事件冒泡到 QuestionView再交给 store 处理。这么拆的原因有两个一是以后想做“图片选项”或者“拖拽排序”时可以直接替换这个子组件而不影响题目容器二是每个选项的选中态样式需要频繁切换独立组件在渲染性能上更可控。ProgressBar 是我从 styled-components 思路里借过来的一个小组件。它不读取题目内容只读取currentIndex / questions.length这个比例用来渲染进度条宽度。把它和 QuestionView 分开最大的好处是接口稳定任何页面想要显示进度直接传一个 0 到 1 的数字就行。3. 计分与类型判定每道题的分数是怎么变成“四个字母”的如果你去看很多开源测试项目会发现计分逻辑通常写在组件里比如在点击选项时直接score.E 1。这在项目很小的时候没问题但一旦你想加“跳过本题”“撤销上一题”“随机打乱题序”这些功能分散的计分逻辑会让你改到怀疑人生。所以这个项目的计分被放在一个独立的 composable 里叫useScoring。SBTI 有四个维度每个维度两端分别是精力倾向E外向/ I内向信息接收S实感/ N直觉决策偏好T思考/ F情感行为方式J计划/ P随性每一道题在设计的时候会落到其中一两个维度上。比如一道关于“临时开会时你的反应”的题目可能同时影响 J/P 和 E/I 两个维度。我不让题目单独落在 T/F 上是因为 T/F 和开会场景的关联度太弱硬设计会让题目显得很刻意。3.1 维度映射一道题为什么要影响两个维度有人会困惑为什么不直接把题目标注为测量某个单一维度答案是在行为场景里几乎不存在只影响一个维度的选择。你选择“提前安排行程”还是“到了再说”表面上看是 J/P 这个维度但背后可能也反映出你是更享受计划带来的安全感还是更享受临时变化的刺激感这就又和 E/I 或 S/N 沾边了。但我不建议让一道题同时影响三个维度。原因前面说过产品文案上很难解释。所以我给每个选项的score字段最多两个 key这在数据上是允许的但产品设计朝尽可能固定为两个 key 去靠。这样计分逻辑可以很简单就是一个累加过程。3.2 计分过程和边界处理平局、缺答、中途退出useScoring.js的核心代码不长export function useScoring() { function computeResult(answers, questions) { const raw { E: 0, I: 0, S: 0, N: 0, T: 0, F: 0, J: 0, P: 0 } questions.forEach((q) { const selected answers[q.id] if (!selected) return Object.keys(selected).forEach((key) { raw[key] selected[key] }) }) const dimensions [ { left: E, right: I }, { left: S, right: N }, { left: T, right: F }, { left: J, right: P } ] const letters dimensions.map((d) { const leftScore raw[d.left] const rightScore raw[d.right] if (leftScore rightScore) { return { letter: d.left, diff: 0 } } return leftScore rightScore ? { letter: d.left, diff: leftScore - rightScore } : { letter: d.right, diff: rightScore - leftScore } }) return { raw, letters } } return { computeResult } }所有维度分数累加完成以后每个维度比较左右两边的总分数谁高取谁。这里有两个边界情况需要单独处理。第一个边界是平局。如果某个维度上左右分数完全一样我默认取左边字母同时把diff设为 0。在结果页我会隐藏这个维度的类型字母而是显示一个“在该维度上倾向不明显”的提示。这么做是因为如果一个用户在很多题上都犹豫不定导致分数严重接近硬给他一个类型反而会显得不专业。第二个边界是缺答。正常情况下用户答完所有题才会进入结果页但 URL 里如果有人直接手动拼接了一个?typeENTJ参数或者用浏览器控制台手动调用了结果页组件就可能导致部分答案缺失。我在computeResult里对每一题都做了if (!selected) return保证缺答不会导致代码崩溃只是那几个维度的分数会偏低。3.3 输出不强求“完全匹配”而是给出倾向强度我在第一版里只输出四个字母比如“ESTJ”然后配一段对应的描述。后来朋友试用反馈说“我看不懂 ESTJ 和 ENTJ 有什么区别而且我感觉自己每个维度只有一点点偏向为什么结果说得那么绝对。”这个反馈让我意识到普通用户需要的不是类型标签而是“我大概偏向哪边”。所以在结果页里四个维度不再只是字母而是用一个双端进度条来展示左右分数比例。比如 E 68 分、I 32 分进度条就会明显偏向 E 端旁边写“你更依赖外部互动来获取能量”。如果两边是 51 比 49进度条几乎居中我就不强推某个字母而是提示“你在这一维度上比较灵活具体表现取决于场景”。这里有一个产品设计上的小心思百分比条是纯 CSS 渲染的我只需要算出两个分数分别占总分的比例就行完全不需要图表库。很多前端开发者一听到“可视化”就想着引入 ECharts但这个场景用三个 div 加一个 CSS flex 就能解决性能和加载体积都更优。4. 结果页和分享卡片流量回流都藏在这些交互细节里结果页是整个项目里最容易出彩也最容易做砸的地方。用户辛辛苦苦答完 40 道题等的就是这一眼。如果结果页做得像一份平淡的报告他大概率不会转发如果做成一份“懂他”的卡片他转发到群里的概率会高很多。4.1 结果页的信息层次我定义的结果页信息结构是四层第一屏类型字母 一句话身份标签。例如“ESTJ · 组织者”下面一句“你习惯先定目标再想路径擅长把混乱变成秩序”。这层要足够短让用户截图时能截到重点。第二屏四个维度的倾向强度条。让用户看到量化的偏向避免“绝对化”的感觉。第三屏该类型的优势、适合的协作方式、可能的盲区。这层是给愿意往下滚的人看的也是真正能引发“对我就是这样”认同感的内容。第四屏一个“生成分享卡片”按钮和一个“重新测试”按钮。为什么要按这个顺序因为从传播角度用户第一眼看到的是“我被定义成了什么”如果这个定义足够精准他才会愿意往下看细节才会愿意分享。如果把一堆长篇大论放在第一屏反而稀释了冲击力。我在文案里还会刻意控制每段描述不超过三行。超过三行读者基本不会看完而且截图分享到群里以后过长的文字很容易被折叠。这是一个反直觉但是真实存在的传播规律让卡片上的字越少它被传播的概率越高。4.2 Canvas 分享卡片实现及中文字体踩坑“生成分享卡片”这个功能第一版我用的是 HTML 转图片方案也就是把 DOM 节点复制到 canvas 上然后调用canvas.toDataURL()。这个方案在 PC 端 Chrome 上表现很好但在移动端兼容性很糟糕尤其是 iPhone 的 Safari 对 canvas 内容绘制有各种诡异的限制最后我改成了纯 Canvas 绘制方案。核心思路是在页面上放一个尺寸为 1200×630 的 canvas把所有需要展示的内容直接画上去。这样生成图片时不需要依赖 DOM 解析兼容性最稳。代码大致是这样const canvas document.createElement(canvas) canvas.width 1200 canvas.height 630 const ctx canvas.getContext(2d) ctx.fillStyle #ffffff ctx.fillRect(0, 0, 1200, 630) ctx.fillStyle #2b2b2b ctx.font bold 64px PingFang SC, Microsoft YaHei, sans-serif ctx.textAlign center ctx.fillText(result.letters.map(l l.letter).join(), 600, 220) ctx.font 32px PingFang SC, Microsoft YaHei, sans-serif ctx.fillStyle #666666 ctx.fillText(result.title, 600, 300) ctx.fillStyle #999999 ctx.font 26px PingFang SC, Microsoft YaHei, sans-serif ctx.fillText(长按保存或分享给朋友一起来测, 600, 520)这里面最大的坑是字体。Canvas 绘制中文字体时如果用户设备上找不到你指定的字体名称就会直接回退到默认字体导致卡片上的字变成奇怪的宋体或者系统默认字体。我的解决方案是写多个字体栈并优先使用系统自带的中文字体比如 iOS 用 PingFang SC、Windows 用 Microsoft YaHei、其他设备用 sans-serif。因为没有加载远程字体所以不会出现跨域字体污染 canvas 导致toDataURL()失败的情况。还有一个细节Canvas 里的换行需要手动计算文本宽度。fillText不会自动换行如果标题太长就会溢出画布。我写了一个简单的wrapText函数根据配置的最大宽度把字符串拆成多行。4.3 用 URL Scheme 让分享链接直达结果分享卡片有两种形态一种是完整的图片另一种是图片底部带一个二维码或者 URL。我选择在图片上叠一个短链接让用户可以用“复制链接”的方式直接分享文本链接。这个链接就是https://你的域名/?typeESTJ。我在 App.vue 的初始化逻辑里做了判断const params new URLSearchParams(window.location.search) const sharedType params.get(type) if (sharedType) { store.finishedAt Date.now() store.forcedResult sharedType }这样别人打开链接后不需要重新答题直接看到 ESTJ 的结果页。而在结果页里他会看到一个很明显的“我也要测”按钮点击后清空forcedResult让用户进入正常的答题流。这就形成了一个回流闭环老用户分享 - 新用户打开结果页 - 新用户开始答题 - 新用户分享。有人会担心直接把类型写在 URL 里会不会被别人伪造。我的答案是不会。这个产品的定位是轻工具不是严肃测评如果有人想手动改 URL 让自己变成某个类型那是他自己的选择对产品没有坏处。而且为了减少参数污染我在生成分享链接的时候只带类型缩写不带任何答案这样分享链接不会暴露用户的答题过程。5. 从源码到上线部署、扩展和一个最容易踩的坑这部分我写清楚两个问题怎么把源码跑起来怎么把它改造成你自己的测试应用。如果你对 Vue 不熟前几段可以直接跳到你需要的段落。5.1 本地跑起来只需要三步假设你已经拉到了源码本地需要 Node.js 16 以上。在项目根目录依次执行npm install npm run devVite 会启动一个开发服务器默认端口是 5173浏览器打开就能看到欢迎页。开发模式下题目和结果的修改都是热更新你改完questions.json里的一道题页面会立刻刷新不需要重启服务这个体验非常适合反复调题目文案。要发布到线上npm run build构建完成后dist目录里就是所有静态文件。你把它上传到任何一个静态托管服务就行。如果是 Nginx配置根目录指向 dist 即可如果是 GitHub Pages把 dist 内容推到仓库的 gh-pages 分支即可如果是 Gitee Pages 也一样只是国内访问速度可能更好一点。这个过程我一共用了不到十分钟比配置服务器快太多。5.2 把 SBTI 换成任意量表的改造方法很多朋友拿到源码以后最想做的事应该是把题目换成自己的。这个项目里你只需要替换两个文件src/data/questions.json换成你自己的题目注意保留id、text、dimension、options这四个字段结构。src/data/typeDescriptions.json换成你自己的结果文案注意每个类型 ID 要和计分得出的四个字母对应。如果你要改的题目不是四个维度而是三个维度或者五个维度你需要同步改useScoring.js里的dimensions数组以及ResultView.vue里渲染维度条的部分。如果量表选项不再是文字而是图片或者音频你需要扩展QuestionOption组件让它能根据type字段渲染不同形态的选项。这个改动成本很小因为数据层和视图层已经完全分离了。我当初就是刻意把它做成一个“可以换内容”的壳而不是一个写死的 SBTI 应用。这也是为什么文件结构里没有把题目写死在组件里的原因。5.3 localStorage 不可用、hash 路由与资源加载三个部署细节部署上线以后有三个细节特别容易踩坑。第一个是 localStorage 不可用。用户在隐私模式或者设置了禁止网站存储数据时localStorage.setItem可能会直接抛异常。如果不处理用户一点“开始测试”就会白屏。我的解决方式是在utils/storage.js里包一层 try/catch失败时回退到内存模式也就是把数据放在一个普通对象里。这样至少保证当前会话内能正常使用只是刷新后会丢失进度。第二个是路由模式。这是一个纯前端项目我建议用 hash 路由而不是 history 路由。如果你在 GitHub Pages 或 Gitee Pages 上部署history 模式刷新二级页面时经常出现 404因为静态服务器没有配置重写规则。hash 模式不存在这个问题而且对于这种单页工具URL 里带#并没有任何副作用。第三个是第三方统计脚本的加载。我不想在这个项目里引入重量级分析平台因为它们会阻塞页面渲染。后来我选择在index.html里用异步加载方式引入统计脚本并且设置了defer。如果你想统计访问量可以这样做script defer srchttps://your-analytics.example.com/script.js/script这样页面主体渲染完成之后再加载脚本不会因为统计服务挂掉而拖慢首屏。还有一个容易被忽略的问题资源路径。Vite 构建默认会生成绝对路径资源也就是/assets/xxx.js。如果你的网站不是部署在域名根路径而是部署在某个子路径下比如https://yourname.github.io/repo/那就需要在vite.config.js里设置base: ./这样构建出来的资源会变成相对路径部署到任何子目录都不会崩。我之前吃过一次这个亏发布到 GitHub Pages 后页面一片空白控制台报一堆 404原因就是这个。最后说一点我自己的体会。做完这个纯前端测试项目以后我对“轻工具”的理解更具体了不是所有东西都需要一套完整的中后台不是所有用户行为都必须被记录不是所有结果都必须存到数据库。有时候把产品边界刻意划小一点反而能让用户在使用时感到更轻松也让开发者能睡个安稳觉。如果你对这个源码有兴趣建议先跑起来然后把questions.json换成你自己的题目试试。哪怕你最后不想用 SBTI这套“数据驱动题目渲染 本地状态管理 Canvas 分享卡片”的套路也可以平移到其他问卷、测评甚至打卡类工具里。祝你能用最小的成本做出一个自己喜欢的小产品。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价