BLOG_POST_JUEJIN.md
Rama main · réplica de lectura del repositorio original.
# 我用 Next.js 做了一个自我反思平台
> **归档说明(2026-08-20):** 本文是 v0.2 产品重置前的历史草稿。文中的“100+ 个测试”和已移除功能只代表旧版本,不是当前公开产品承诺;当前公开范围为 16 条经过审阅的旗舰路线,详见 `README.md`。
> **关键词**:Next.js 16、静态导出、自我探索测试、localStorage、双语架构、代码分割
## 前言
你有没有过这样的时刻——凌晨两点,刷着各种性格测试,想更了解自己一点?
但打开那些测试网站,满屏广告弹窗、强制登录、追踪 Cookie……做个测试像在签卖身契。
我决定自己做一个。
**「认识你自己」** —— 一个提供 16 项中英双语自我反思测评的平台,零广告、零追踪,游客数据保存在浏览器里,登录后可选用跨设备同步。它用于自我反思,不是临床诊断工具。
🔗 **在线体验**:https://knowyourself.cc.cd/
⭐ **GitHub**:https://github.com/11suixing11/know-yourself
---
## 技术栈一览
| 技术 | 版本 | 用途 |
|------|------|------|
| Next.js | 16.2.6 | App Router + 静态导出 |
| React | 19.2.4 | UI 框架 |
| Tailwind CSS | v4 | 样式 |
| Framer Motion | 12.x | 动画 |
| Shadcn UI | 4.x | 组件库 |
| TypeScript | 5.x | 类型安全 |
没有任何状态管理库(没有 Zustand、没有 Redux),没有后端服务,没有数据库。
---
## 核心架构:静态导出 + 动态加载
### 为什么选静态导出?
答案很简单:**隐私**。
当产品没有应用后端时,测试答案和结果就没有可提交的服务端入口。托管层只负责提供静态文件;应用没有数据库、账号系统或产品分析接口,个人记录仍只保存在浏览器中。这不是只靠隐私政策承诺,而是由产品架构限定的。
```ts
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export", // 关键:导出为静态文件
images: {
unoptimized: true, // 静态导出没有运行时图片优化服务
},
trailingSlash: true, // 便于静态文件服务器映射目录路由
};
export default nextConfig;
```
三行配置搞定。`next build` 之后,`out/` 目录里就是完整的静态站点。当前部署把这个目录作为发布产物,由自有 VPS 上的 Caddy 直接托管。
好处:
- **发布内容可审计**:线上内容就是构建生成的 `out/` 目录
- **交付边界明确**:Caddy 负责静态文件路由和 HTTPS
- **应用维护面小**:没有账号、数据库、产品分析或 API 后端服务
### 100+ 个测试的加载策略:动态 import 注册表
100+ 个测试,每个测试都有题目、评分逻辑、双语叙述结果。如果全部打包到一个 JS 文件里,首屏体积会很恐怖。
解决方案:**动态 import 注册表**。
```ts
// src/lib/tests/index.ts
const registry: Record<string, () => Promise<{ default: any }>> = {
"ab-personality": () => import("./ab-personality"),
"animal-personality": () => import("./animal-personality"),
"anxiety": () => import("./anxiety"),
"attachment-style": () => import("./attachment-style"),
"big-five": () => import("./big-five"),
// ... 共 100+ 个条目
"zodiac-match": () => import("./zodiac-match"),
};
export async function loadTestData(type: string): Promise<TestData | null> {
const loader = registry[type];
if (!loader) return null;
try {
const mod = await loader();
return mod.default as TestData;
} catch (e) {
console.error('loadTestData error:', type, e);
return null;
}
}
```
每个测试是一个独立的 webpack chunk。用户访问 `/quiz/mbti/` 时,只加载 MBTI 模块;访问 `/quiz/big-five/` 时,才加载大五人格模块。
性能数据:
| 指标 | 数值 |
|------|------|
| 首屏 JS 体积 | ~180KB (gzipped) |
| 单个测试 chunk | ~5-15KB (gzipped) |
| FCP | <1s |
| TTI | <2s |
---
## 数据模型:每个测试的解剖
### 类型定义
```ts
// src/lib/types.ts
export interface TestQuestion {
id: number;
zh: string; // 中文题目
en: string; // 英文题目
dimension?: string; // 维度标签(如 MBTI 的 EI/SN/TF/JP)
options: {
zh: string[]; // 中文选项
en: string[]; // 英文选项
};
scores: number[]; // 每个选项的得分
}
export interface TestData {
questions: TestQuestion[];
calculate: (answers: number[], questions: TestQuestion[]) => QuizResult;
uiText: {
zh: Record<string, string>;
en: Record<string, string>;
};
narrative?: Record<string, { zh: NarrativeResult; en: NarrativeResult }>;
types?: Record<string, { zh: TypeData; en: TypeData }>;
}
```
### 以 MBTI 为例
```ts
// src/lib/tests/mbti.ts(简化版)
const MBTI_TEST = {
type: 'mbti',
icon: '🧠',
color: '#6C63FF',
questions: [
{
id: 1,
dimension: 'EI',
zh: "在社交场合中,你通常会:",
en: "In social situations, you usually:",
options: {
zh: ["主动与陌生人交谈", "等待别人来找你"],
en: ["Initiate conversations with strangers", "Wait for others to approach you"]
},
scores: [1, 0] // 1 = E, 0 = I
},
// ... 59 道题,覆盖 4 个维度
],
calculate: (answers, questions) => {
// 按维度统计得分,映射到 16 种人格类型
// ...
}
};
```
每个测试模块都遵循这个接口,但评分逻辑各不相同:
- **MBTI**:按 4 个维度(E/I、S/N、T/F、J/P)统计,映射到 16 种类型
- **大五人格**:5 个维度的连续得分
- **九型人格**:9 种原型,取最高分
- **依恋风格**:3 种类型 + 焦虑/回避两个维度
---
## 没有状态管理库的秘诀:React + localStorage
我做了一个有意的决定:**不用 Zustand、不用 Redux、不用任何外部状态管理**。
为什么?因为测试状态本质上是临时的——你做一个测试,看结果,可能以后重做。不需要跨组件的复杂状态共享。
```ts
// 答题引擎的状态
const [currentQuestion, setCurrentQuestion] = useState(0);
const [answers, setAnswers] = useState<(number | null)[]>([]);
const [lang, setLang] = useState<Lang>(() => {
try {
return (localStorage.getItem("quiz-platform-lang") as Lang) || "zh";
} catch {
return "zh";
}
});
// 提交后保存到 localStorage
localStorage.setItem(`quiz-result-${testType}`, JSON.stringify({
result,
answers: numericAnswers,
testName: displayName,
testNameEn: testMeta?.en?.name ?? testData?.en?.name ?? testType,
timestamp: Date.now(),
}));
```
`localStorage` 就是"数据库"。历史页面扫描所有 `quiz-result-*` key 来展示你的测试记录:
```ts
// src/app/history/page.tsx
const loadEntries = useCallback(() => {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith("quiz-result-")) {
const testId = key.replace("quiz-result-", "");
const data = JSON.parse(localStorage.getItem(key) ?? "");
// ... 渲染测试历史
}
}
}, []);
```
---
## 有意思的交互细节
### 1. 手机端滑动导航
答题引擎支持触摸手势——左滑下一题,右滑上一题:
```ts
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
const dx = touch.clientX - touchStartRef.current.x;
const dy = touch.clientY - touchStartRef.current.y;
// 水平滑动必须明显大于垂直(1.5 倍),避免误触
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5) {
if (dx < 0 && currentAnswer !== null) {
isLast ? handleSubmit() : goNext(); // 己滑 → 下一题
} else if (dx > 0 && !isFirst) {
goPrev(); // 右滑 → 上一题
}
}
}, [goNext, goPrev, handleSubmit, currentAnswer, isFirst, isLast]);
```
关键细节:用 `Math.abs(dx) > Math.abs(dy) * 1.5` 来判断用户是想水平滑动还是垂直滚动,比例设为 1.5 是经验值,太大了滑不动,太小了容易误触。
### 2. 里程碑彩纸效果
答题进度到 25%、50%、75% 时,屏幕会飘下 emoji 彩纸:
```ts
useEffect(() => {
const pct = ((currentQuestion + 1) / totalQuestions) * 100;
const milestones = [25, 50, 75];
for (const m of milestones) {
if (pct >= m && !milestonesReachedRef.current.has(m)) {
milestonesReachedRef.current.add(m);
setMilestoneConfetti(true);
break;
}
}
}, [currentQuestion, totalQuestions]);
```
用 `useRef` 记录已触发的里程碑,避免重复触发。彩纸是 14 个 emoji 粒子,Framer Motion 动画,800ms 后自动清除。轻量、不卡顿。
### 3. 键盘快捷键
```ts
function onKeyDown(e: KeyboardEvent) {
if (e.key >= "1" && e.key <= "5") {
const idx = parseInt(e.key) - 1;
selectAnswer(idx); // 数字键选答案
return;
}
if (e.key === "Enter" && currentAnswer !== null) {
isLast ? handleSubmit() : goNext(); // Enter 下一题
return;
}
if (e.key === "Backspace") {
e.preventDefault();
goPrev(); // Backspace 上一题
}
}
```
### 4. 方向感知的动画
```ts
const questionVariants = {
enter: (dir: number) => ({ x: dir > 0 ? 60 : -60, opacity: 0 }),
center: { x: 0, opacity: 1 },
exit: (dir: number) => ({ x: dir > 0 ? -60 : 60, opacity: 0 }),
};
```
用 Framer Motion 的 `custom` prop 传递方向状态,让进出场动画方向一致——下一题从右滑入,上一题从左滑入。
---
## 四个内在世界的组织方式
100+ 个测试不是乱序排列的,而是按 **9 个维度** 组织,再归入 **4 个内在世界**:
```ts
// src/lib/constants.ts
export const WORLDS: WorldDefinition[] = [
{
id: "dreamers",
icon: "🌙",
categories: ["emotion", "mental"],
zh: { title: "梦境感知者", desc: "为那些感受过深、常常在深夜回放一句话的人。" },
en: { title: "Dreamers", desc: "For those who feel too deeply, who replay a single sentence late into the night." },
},
{
id: "analysts",
icon: "🔮",
categories: ["personality", "intelligence"],
zh: { title: "理性建筑师", desc: "为那些在混乱中寻找结构、习惯把世界拆解成系统的人。" },
en: { title: "Analysts", desc: "For those who find structure in chaos." },
},
{
id: "connectors",
icon: "🌊",
categories: ["relationship", "social"],
zh: { title: "关系连接者", desc: "为那些总能感到关系里的温度、沉默和拉扯的人。" },
en: { title: "Connectors", desc: "For those who sense every temperature shift in a relationship." },
},
{
id: "explorers",
icon: "🔥",
categories: ["career", "lifestyle", "fun"],
zh: { title: "行动探索者", desc: "为那些需要走出去,才知道自己是谁的人。" },
en: { title: "Explorers", desc: "For those who need to walk out into the world to discover who they are." },
},
];
```
这不是随便分的——每个世界代表一种自我探索的路径。梦境感知者关注情绪和共情,理性建筑师关注人格和认知,关系连接者关注依恋和边界,行动探索者关注职业和生活方式。
---
## 双语架构:从第一天就支持中英文
所有字符串都有 `zh` 和 `en` 两个版本。这不是后来加的,是写在数据模型里的:
```ts
// 测试注册表条目
{
id: "mbti",
category: "personality",
icon: "🧠",
questions: 60,
time: "12-21",
zh: { name: "MBTI人格测试", description: "MBTI人格,全面了解自己。" },
en: { name: "MBTI Personality Test", description: "Which of the 16 personality types resonates with you most?" }
}
```
语言切换存在 localStorage 里,跨页面保持一致:
```ts
const [lang, setLang] = useState<Lang>(() => {
try {
return (localStorage.getItem("quiz-platform-lang") as Lang) || "zh";
} catch {
return "zh";
}
});
// 切换语言
const toggleLang = () => {
const next = lang === "zh" ? "en" : "zh";
localStorage.setItem("quiz-platform-lang", next);
setLang(next);
};
```
结果页面的叙述文字也是双语的——每种人格类型都有独特的诗意解读。
---
## 雷达图:纯 SVG 实现
结果页面的雷达图是纯 SVG + Framer Motion 实现的,没有引入 Chart.js 或 D3:
```tsx
// src/components/result/radar-chart.tsx
export function RadarChart({ dimensions, accentColor = "#6B5B95", size = 280 }: RadarChartProps) {
const center = size / 2;
const radius = size / 2 - 40;
const count = dimensions.length;
const angleStep = (2 * Math.PI) / count;
const startAngle = -Math.PI / 2;
function getPoint(index: number, value: number) {
const angle = startAngle + index * angleStep;
const r = (value / 100) * radius;
return {
x: center + r * Math.cos(angle),
y: center + r * Math.sin(angle),
};
}
const dataPoints = dimensions.map((d, i) => getPoint(i, d.score));
const dataPath = dataPoints
.map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`))
.join(" ") + " Z";
// 渲染 SVG 多边形 + 网格 + 标签
// ...
}
```
数学很简单:把每个维度的角度等分,用极坐标算出点的位置,连成多边形。
---
## 踩过的坑和学到的教训
### 1. 内容才是王道
100+ 个测试 × 平均 16 道题,意味着大量独立题目、双语文本和评分选项。再加上每种结果类型的叙述文字……
代码可能只占项目的 20%,内容占 80%。
写心理学测试内容很难。每道题需要:
- 中英文都清晰无歧义
- 测量有意义的东西
- 不带评判色彩
- 评分选项校准合理
### 2. 从第一天就做 i18n
如果我先做了纯中文版再加英文,每个组件都要重构。但因为 `zh/en` 模式写在了每个数据结构里、每个 UI 字符串里、每段叙述文字里,语言切换就是一个 `useState` 切换——不需要改路由,不需要改布局。
### 3. 静态站点 + localStorage = 够用的"后端"
整个平台的"后端"就是 localStorage。测试结果、语言偏好、测试历史——全在客户端。
这能 work 是因为场景本身就是单用户、本地的。你不需要应用后端来记住你是个 INFJ。
### 4. Next.js 16 的 App Router 很香
用 `generateStaticParams` 生成所有测试页面的静态路由:
```tsx
// src/app/result/[type]/page.tsx
import { TEST_TYPES } from "@/lib/test-types";
export function generateStaticParams() {
return TEST_TYPES.map((type) => ({ type }));
}
export default async function ResultPage({ params }: { params: Promise<{ type: string }> }) {
const { type } = await params;
return <ResultClient testType={type} />;
}
```
构建时自动生成 `/result/mbti/`、`/result/big-five/` 等所有路由,不需要手动维护路由表。
---
## 数据一览
| 指标 | 数值 |
|------|------|
| 测试总数 | 100+ |
| 测试维度 | 9 |
| 内在世界 | 4 |
| 支持语言 | 2(中文 / English) |
| 题目总数 | ~1,888 |
| 外部状态管理库 | 0 |
| 后端服务 | 0 |
| 收集的用户数据 | 0 |
---
## 试试看
🔗 **https://knowyourself.cc.cd/**
推荐从这几个开始:
- **大五人格测试**:人格科学的金标准
- **MBTI 人格测试**:16 种人格类型
- **九型人格测试**:9 种原型,找到你的深层动机
- **爱的语言测试**:了解你如何给予和接受爱
- **情商测试**:探索你的情绪智力
---
## 求个 Star ⭐
如果你觉得这个项目有意思,或者这篇文章对你有帮助,麻烦去 GitHub 点个 Star。这会帮助更多人发现这个项目,也是我继续更新的动力。
**→ [github.com/11suixing11/know-yourself](https://github.com/11suixing11/know-yourself)**
欢迎贡献:
- **新测试内容**:测试模块结构清晰,加一个新测试主要是内容工作
- **翻译**:支持更多语言
- **Bug 修复 / 功能建议**:提 Issue 或 PR 都行
---
*Made with 💛 by [11suixing11](https://github.com/11suixing11)*