一套 JSON Schema,同时喂饱规则判题和未来的 AI 判题。
小学生也能看懂,前端同学可以放心转给后端。
一、加一道题,暴露了你的架构水平
产品经理走过来:"加一道手写防抖题,今天能上吗?"
1 年经验的小 A :打开判题文件,找到 if (questionId === 'js-debounce'),复制一段,改吧改吧。再加一道,再复制一段。加到第 10 道,文件 800 行,谁都不敢动。
10 年经验的老 B:打开题库 JSON,加一条(只展示关键字段):
json
{
"id": "js-debounce",
"type": "code",
"title": "手写防抖"
}
提交,收工。判题引擎一行没改。
差距在哪?
不是老 B 打字快,也不是他会的框架多。而是他先问了一个问题:
题目,到底是代码,还是数据?
结论先行:
题目是数据,判题是引擎。数据描述"是什么",引擎负责"怎么判"。
图 1 · 加一道题,两条路。
二、你写的不是题库,是一堆 if
把题目写进代码,一开始很爽。
第一道题,一个 if。第二道题,再来一个。第十道题,判题文件变成这样:
js
if (id === 'js-debounce') { ... }
else if (id === 'js-throttle') { ... }
else if (id === 'js-deep-clone') { ... }
// 还有 7 个 else if ...
看起来能跑。但它有三个致命问题:
- 加题要改引擎:每加一道题,判题引擎都要动刀。
- 改引擎要重测所有题:你永远不知道这次改动会不会碰坏老题。
- 知识全散在代码里 :深拷贝为什么用 WeakMap、Promise.all 为什么要按顺序,全写在
if里,AI 想用都用不上。
这不是题库。这是一座"特殊案例博物馆"。
最痛的是第三点。某天你改了一个公共判断,老题集体变红,你从晚上 8 点查到凌晨 1 点,最后发现是新加的 else if 把老题的判断截胡了。那一刻你会明白:这不是在维护题库,是在给历史擦屁股。
图 2 · 题目越多,引擎越像一团毛线。
真正的做法是反过来:
引擎只认识"格式",不认识"每一道题"。
三、把题目变成一张"点菜单"
去饭店点菜,菜单有固定栏目:菜名、价格、辣不辣、主要食材。
题库也一样。一道题必须有固定栏目,引擎才知道怎么处理。
| 栏目 | 作用 | 例子 |
|---|---|---|
id |
身份证,不能重复 | js-deep-clone |
type |
题型:code / algorithm / theory | code |
title |
题面 | 手写深拷贝 |
difficulty |
难度 1~3 | 2 |
hint |
题目要求 | 支持 Date、循环引用 |
functionName |
判题找哪个函数 | deepClone |
testCases |
考卷 | 见第四节 |
keyPoints |
理论题采分点 | 见第六节 |
说明:标题里的"JSON Schema",指的是"用结构约束 JSON 数据"这套思路;代码里实际用的是 zod。
我们用 zod 把这张表写成"带检查的模板":
js
export const QuestionBaseSchema = z.object({
id: z.string(),
type: z.enum(['code', 'algorithm', 'theory']),
title: z.string(),
category: z.enum([...]),
difficulty: z.union([z.literal(1), z.literal(2), z.literal(3)]),
hint: z.string(),
})
export const CodeQuestionSchema = QuestionBaseSchema.extend({
type: z.literal('code'),
starterCode: z.string(),
testCases: z.array(TestCaseSchema),
functionName: z.string().optional(),
})
export const TheoryQuestionSchema = QuestionBaseSchema.extend({
type: z.literal('theory'),
keyPoints: z.array(TheoryKeyPointSchema),
reference: z.string(),
})
三种题型,共用一张基础表,各自再加自己的栏目。
图 3 · 三种题型,一张基础表。
注意一个边界:加一种全新的题型 ,引擎当然要改;但加一道已有题型的题,只加数据。真正值钱的是:
让 99% 的日常加题,不动引擎。
四、两种考卷:直接调用是正门,脚本是后门
判题引擎要考你写的函数,有两种考法。
1. 直接调用型:能简单,就别复杂
json
{
"id": "c1",
"args": [{ "a": 1, "b": [1, 2] }],
"expected": { "a": 1, "b": [1, 2] }
}
引擎找到 deepClone,把 args 传进去,拿返回值跟 expected 比。
优点:一眼看懂、人工好写、AI 好生成、不容易藏坏心思。
能用直接调用,就优先用直接调用。
2. 脚本型:有些题,简单数据真的考不了
比如深拷贝要证明"拷贝出来的 Date 还是 Date":
json
{
"args": [{}],
"expected": [true, true],
"script": "const d = new Date('2026-01-01T00:00:00Z'); const c = lib.deepClone(d); return [c instanceof Date, c.getTime() === d.getTime()];"
}
为什么不能写成 args: [new Date(...)]?
因为 JSON 里没有 Date。Date 一写进 JSON 就变成字符串,instanceof Date 永远是 false。考不出真本事。
再比如 Promise.all 的失败分支:
json
{
"args": [],
"expected": "boom",
"script": "return lib.promiseAll([Promise.resolve(1), Promise.reject('boom')]).catch((e) => e);"
}
这段脚本要在参数里放 Promise、要捕获失败、还要把失败原因当成结果返回。直接调用型做不到------参数会被 JSON 复印,Promise 会变成空对象;失败会被判题器当成"代码报错",没法跟 expected 比。
脚本型的规则是:引擎把用户交出来的整个工具箱 lib 递给脚本,脚本自己决定怎么用,最后 return 一个实际结果。
图 4 · 默认走正门,特殊需求才开后门。
一句话总结取舍:
直接调用是正门,脚本是后门。后门只给真正需要的人开。
每多一段 script,就多一段要写、要审、要维护的代码。能不用,就不用。
五、zod:脏数据别进门
题目变成数据后,新的灾难是:数据写错。
比如:
difficulty写成"很难";type写成"codee";- 两道题用了同一个
id; - 理论题忘了写
keyPoints。
如果这些错误等到用户点"开始判题"才爆炸,排查起来就像在黑屋里找黑猫。
所以 Tigan 在题库门口放了一个检查员:zod。
js
const parsed = z.array(QuestionSchema).safeParse(bank.data)
if (!parsed.success) {
console.error(parsed.error.errors)
throw new Error(`题库 ${bank.name}.json 校验失败`)
}
只要有一项不对,整个题库不许进门,控制台直接告诉你哪个文件、哪一栏错了。
图 5 · 数据不对,就在门口拦住。
后端还有第二道门:发布前检查"能不能判"。
- 理论题至少 1 个
keyPoints; - 代码题至少 1 个测试用例,或者一个
behaviorTest; - 有直接调用型用例,就必须知道函数名。
一句话:
错误要在离源头最近的地方爆炸。
脏数据进了门,后面全是加班。
六、keyPoints:一份采分点,两个老师用
理论题没有函数可跑,怎么判?
Tigan 的答案是:给每道理论题一份 采分点清单 ,叫 keyPoints。
json
"keyPoints": [
{
"point": "闭包 = 函数 + 它能访问的外部词法作用域",
"synonyms": ["函数和其作用域", "闭包的定义"]
},
{
"point": "用途:私有变量、函数柯里化、防抖节流、模块化",
"synonyms": ["私有变量", "保存状态", "模块化"]
}
]
注意:这里没有写"怎么匹配",只写了"一个好答案应该覆盖什么"。
这就是最关键的解耦:
采分点负责说"考什么",引擎负责决定"怎么判断有没有答到"。
同一份 keyPoints,现在有两个老师在用。
规则老师:不聪明,但快、免费、永远在线。它用三层办法判断"答到没有":
- 整句包含;
- 关键词命中一半以上;
- 中文字符二元组重合一半以上。
最后算覆盖率,≥ 50% 算通过。
AI 老师 :更聪明,但要花钱。它拿到的是同一份 keyPoints,被编成编号清单,按固定 JSON 交卷:
json
{
"covered": [0, 2],
"missing": [1, 3],
"score": 50,
"suggestion": "建议补充......",
"confidence": "high"
}
后端还会再批改一遍:不存在的编号删掉;missing 不信 AI,直接用"全集减 covered"重算;分数夹在 0~100;confidence 只允许三个值。
图 6 · 一份采分点,两个老师,同一张成绩单。
AI 老师请假,规则老师顶上,判题不中断。这就是"可降级"的底气。
为什么这件事值钱?
明年换了更强的 AI 模型:只改 AI 那一层。产品说"多考一个点":只在 keyPoints 加一条,规则和 AI 自动都多检查一个点。
一份数据,两个消费者,零复制。
反过来,如果你把采分点抄进规则代码一份、再抄进 AI 提示词一份,迟早会出现"规则说答到了,AI 说没答到"的鬼故事。
别让任何一个引擎,独占题目的知识。
现在 AI 判的是理论题。将来让 AI 参与代码题判题,它读的还是同一份题目数据:题干、函数签名、测试用例、参考答案。数据不变,引擎慢慢长。
七、10 年和 1 年的分水岭
回到开头的小 A 和老 B。
差距不是工龄,是三个习惯。
习惯一:先定数据,再写引擎。
1 年经验先想"这个功能怎么写";10 年经验先想"这个东西由哪些数据组成,谁产生,谁消费,会不会变"。
习惯二:在边界上校验,不让脏数据进门。
题库导入用 zod,接口入口用 zod,发布前再检查"能不能判"。
习惯三:一份数据,服务多个消费者。
规则判题、AI 判题、AI 提示助手,都读同一份 keyPoints。谁负责"怎么判"可以换,"考什么"只有一个答案。
图 7 · 复制数据,还是共享数据。
所以分水岭不是"会不会某个框架",而是:
你能不能让变化只发生在数据层,而不是每次都推倒引擎。
八、五句话带走
- 题目是数据,不是代码:加题加 JSON,引擎不动。
- 三种题型共用基础表 :
type决定还需要哪些栏目。 - 测试用例优先直接调用 :Date、类实例、Promise 参数、多次调用、副作用,才用
script注入lib。 - zod 在门口检查:格式不对、缺栏目、id 重复,不许进门。
- keyPoints 只存一份:规则按它打勾,AI 按它评分,谁都能换,它不用换。
九、这套题库,长在一个真实的小系统里
它是我自己写的前端面试刷题系统,叫 题感 Tigan。
你刚看到的这些,都在里面真实跑着:
- 三种题型的 JSON 题库,zod 导入时体检;
- 代码题在一次性 Web Worker 沙箱里判题,支持直接调用和脚本两种考卷;
- 理论题用
keyPoints规则匹配,登录后再请 AI 补一份点评; - 后台从掘金抓文章,AI 提取题目、对比重复,再人工审核入库。
系统还在上线前的最后准备,上线后会免费开放。