如果你已经开始用 Claude Code 处理日常编码任务,大概率遇到过这样的局面:它写得很快,但改完的代码格式化风格跟项目规范完全不是一回事;它偶尔会提出一个看起来很合理的 bash 命令,但你没仔细看就批准了;测试它也提,可更多时候是建议你“自己跑一下试试”。这些问题不是模型能力不够,而是缺少一套强制性的机制,在工具调用链路里卡点。Hooks 就是干这个的。这篇文章我会用 6 个可直接抄走的配置,把代码格式化、安全防护和自动测试这三件事焊死在 Claude Code 的工作流里,涵盖 PreToolUse、PostToolUse、Stop、Notification 等触发时机和完整脚本,适合所有用 Claude Code 写代码、并且希望少一点失控感的人。
先说清楚一件事:hook 不是让你在 prompt 里多写几行“请遵守项目规范”,而是在 Claude 调用工具(Bash、Edit、Write、Read)的前后挂上外部脚本。脚本不满足条件,工具调用就直接被拦下。这种“硬约束”和“软提示”的区别,就是为什么很多人配置完 hooks 之后,代码合入 CI 的一次通过率明显提升。
1. Hooks 到底解决了什么问题:先看三个最常见的失控现场
1.1 失控现场一:格式化规则全凭心情
我用 Claude Code 做过一个小型 TypeScript 项目。模型默认的代码风格跟项目里 prettier 配置不能说一模一样,只能说是各写各的。单引号、双引号混用,对象末尾逗号时有时无,缩进偶尔从两个空格跳成四个。最头疼的是它每次 Edit 只改一小块,格式化问题被分散在十几个文件里,肉眼根本盯不过来。
你可以在系统提示词里写“请始终使用项目 prettier 配置”,但模型记不住每一条规则的细节,更不会在每次写入前主动跑一遍格式化。等 CI 跑完报错再回头修,一个下午就没了。Hooks 的正确姿势是:在文件落盘之后、或者写入之前,由外部脚本强制执行 prettier,不让模型的“个人风格”有机会进入代码库。
1.2 失控现场二:危险命令说跑就跑
Claude Code 的 Bash 工具权限很大。它可能因为你的某句“清理一下项目”就执行rm -rf node_modules,这还算可控;但它也可能在改依赖时顺手执行npm install --unsafe-perm,或者在你没注意的时候往~/.bashrc里追加内容。AI 没有“这个操作影响范围是否超出当前项目”的常识,它只有“用户让我完成目标”的指令。
我见过有人被 Claude 连续执行了git push --force覆盖远端提交,也见过它在排查问题时把.env里的密钥cat到了对话上下文里,这些事后都很不好收拾。Bash 类的 hook 就是最后一道闸门:命令在执行前先过一遍规则,命中风险项直接阻止,并告诉 Claude 为什么不行。
1.3 失控现场三:测试永远“我建议你跑一下”
另一个让我比较无语的行为模式是:Claude 改完代码,它的收尾往往是“测试已更新,建议你运行npm test验证”。如果你不追问,它就当你已经跑过了。偶尔它会主动跑,但改一次跑一次全量测试,几分钟就浪费在等待上。
自动测试类的 hook 能解决两个层面:一是强制,改完代码后自动触发相关测试,没有通过就继续修;二是精准,不是所有变更都跑全量测试,而是根据变更文件反推对应的测试范围。后面我会给出具体的实现思路。
2. 开工前必读:settings.json 和 Hook 触发机制的基础
2.1 配置文件放哪:项目级与用户级
Claude Code 的 hooks 配置写在settings.json里。项目级位置是.claude/settings.json,用户级位置是~/.claude/settings.json。项目级配置随仓库走,适合团队统一约束;用户级配置只对本机生效,适合放个人习惯类的 hook。
我建议大部分自动化规则放项目级,这样团队里任何人用 Claude Code 都会被同一套规则约束。需要说明的是,项目级配置默认情况下对协作者可见,你最好在 README 里写清楚每个 hook 的用途,免得别人 clone 项目后第一次跑被拦截脚本吓一跳。
2.2 六类事件与 matcher 匹配规则
Hooks 的配置结构大致是:
{ "hooks": { "PreToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "node .claude/hooks/check-format.js" } ] } ] } }PreToolUse是事件名,表示“工具执行之前”。官方支持的事件类型主要有这些:
| 事件名 | 触发时机 | 典型用途 |
|---|---|---|
| PreToolUse | 工具调用前 | 拦截危险命令、检查写入内容 |
| PostToolUse | 工具调用后 | 格式化、Lint、自动测试 |
| Notification | Claude 等待用户确认时 | 桌面通知提醒 |
| Stop | Claude 回复生成完成 | 运行完整校验、输出摘要 |
| SessionStart | 会话开始 | 环境检查、项目信息注入 |
| UserPromptSubmit | 用户提交提示词时 | 内容过滤、追加上下文 |
| PreCompact | 上下文压缩前 | 保存任务进度摘要 |
matcher是一段正则表达式,用来限定 hook 作用于哪些工具或哪些调用。比如"Edit|Write"表示匹配编辑文件和新建文件操作,"Bash"表示匹配所有 bash 命令,也可以写成"Bash\\(.*git.*\\)"这类更精确的形态去匹配包含 git 的命令。
2.3 退出码、stdin JSON 与超时:三个决定成败的细节
hook 命令执行时,Claude Code 会通过 stdin 传入一段 JSON,里面至少包含:
{ "session_id": "xxx", "cwd": "/home/user/project", "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": { "command": "rm -rf node_modules" } }脚本要做的就是读取这段 JSON,然后根据tool_input内容决定返回什么退出码。我这里约定:
- 退出码
0:放行,工具继续执行。 - 退出码
2:阻止工具执行,stdout 内容会返回给 Claude,让它知道被拦的原因。 - 其他非 0 退出码:表示 hook 自身出错,Claude Code 会记录 warning,但不会强制阻止工具。所以拦截类逻辑务必用
2。
还有一个容易被忽略的是timeout。hook 命令默认超时时间是 60 秒,超过会被终止。如果你的自动测试脚本可能要跑几分钟,一定要在 hook 配置里显式调大:
{ "type": "command", "command": "node .claude/hooks/run-tests.js", "timeout": 120 }另外,hook 脚本的 stdout 和 stderr 会被 Claude Code 捕获并放进模型上下文。这意味着你可以在脚本里输出给模型看的提示信息,但不要打印一堆无关日志,否则会白白消耗 token,还会干扰模型对工具调用结果的理解。
3. 前两个配置:用 PreToolUse 和 PostToolUse 把格式化和 Lint 焊死在编辑动作上
3.1 配置一:文件落盘后自动格式化,不用再跟模型强调“别用双引号”
这是我在所有项目里第一个配的 hook。先给结论:用 PostToolUse 而不是 PreToolUse 做格式化。
原因在于调用时序。PreToolUse 发生在 Claude 的 Edit/Write 工具真正写入文件之前,这时你拿到的file_path是目标路径,但文件内容还没写入,或者写入的是旧版本。如果你在这个时机去prettier --write,格式化的是旧文件,等 Edit 执行完,新内容覆盖上去,格式化又被冲掉了。
所以正确做法是 PostToolUse。脚本在 Edit/Write 完成后拿到文件路径,立刻执行格式化命令:
#!/usr/bin/env node // .claude/hooks/format-on-write.js const fs = require("fs"); const { execSync } = require("child_process"); let input = ""; process.stdin.on("data", (chunk) => (input += chunk)); process.stdin.on("end", () => { try { const payload = JSON.parse(input); const filePath = payload.tool_input?.file_path; if (!filePath) process.exit(0); const ext = filePath.split(".").pop(); const supported = ["ts", "tsx", "js", "jsx", "json", "css", "md"]; if (!supported.includes(ext)) process.exit(0); // 跳过 node_modules 和生成目录 if (filePath.includes("node_modules") || filePath.includes("dist")) process.exit(0); execSync(`npx prettier --write "${filePath}"`, { cwd: payload.cwd, stdio: "pipe", }); console.log(`[format] ${filePath} 已按项目 prettier 配置格式化`); process.exit(0); } catch (e) { // 格式化失败不阻断工具调用,避免恶性循环 console.error(`[format] 格式化失败: ${e.message}`); process.exit(0); } });配置代码里注册它:
{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "node .claude/hooks/format-on-write.js" } ] } ] } }你可能会担心一个问题:格式化之后的文件内容,和 Claude 在上下文里看到的“刚写入的内容”不一致。实际影响不大,因为 Claude 下次读取文件时,读到的是格式化后的内容,它自然会基于这个版本继续改。
还有一个坑是 Windows 环境。npx prettier --write里的路径如果有特殊字符,引号转义容易出问题。建议所有 hook 脚本都用 Node.js 写,避免直接依赖 bash 语法。后面所有示例我都用 Node。
3.2 配置二:Lint 结果自动回流给模型,从源头减少“改完又错”的来回
格式化解决的是风格,Lint 解决的是“代码有没有明显问题”。我在这个 hook 里跑的是 ESLint,并且只针对 Claude 改过的文件,不做全量扫描。
#!/usr/bin/env node // .claude/hooks/lint-on-write.js const { execSync } = require("child_process"); let input = ""; process.stdin.on("data", (chunk) => (input += chunk)); process.stdin.on("end", () => { try { const payload = JSON.parse(input); const filePath = payload.tool_input?.file_path; if (!filePath || filePath.includes(".test.")) process.exit(0); try { const out = execSync( `npx eslint "${filePath}" --max-warnings=0 --format compact`, { cwd: payload.cwd, stdio: "pipe" } ).toString(); // 无错误则静默退出 process.exit(0); } catch (e) { const output = e.stdout?.toString() || ""; // 只输出错误摘要,最多截取 1500 字符,避免刷爆上下文 const summary = output.split("\n").slice(0, 20).join("\n").slice(0, 1500); console.error(`[lint] ESLint 检测到问题,请修复后再继续: \n${summary}`); process.exit(0); } } catch (e) { process.exit(0); } });注意这里我让 exit code 保持 0,没有用 2 阻止写入。为什么不拦截?因为有些 lint 错误是结构性的,Claude 需要先写入代码、看到报错、再修复,这是一个迭代过程。如果你在写入时就把它拦住,模型会陷入“不知道代码哪里有问题”的困境。更好的做法是把错误信息喂给它,让它自己判断怎么改。
--max-warnings=0这参数很有用。它把 warning 也当作 error 处理,防止项目里积累一大堆“不痛不痒”的警告。对 Claude 这种大模型来说,警告太多会稀释注意力,宁可让 hook 直接暴露出来。
有人会问:eslint 的--fix能不能直接放在 PostToolUse 里自动修?可以,但建议单独跑。因为--fix可能改出模型意料之外的结果,尤其是一些涉及代码结构的规则。我的经验是:格式化可以自动,lint 修复尽量让模型自己来,否则它下次可能重复犯同样的错。
4. 中间两个配置:给 Bash 命令套上安全围栏,给写入内容加上敏感信息闸门
4.1 配置三:危险命令黑名单 + 项目目录白名单,双管齐下
Bash hook 是整个安全体系里最重要的一环,因为 Claude Code 的大多数破坏性操作都是通过 Bash 完成的。我的拦截脚本分两层:
第一层是黑名单,直接命中关键词就阻止。这里列几个我实测下来比较实用的规则:
const BLOCKED_PATTERNS = [ /rm\s+-rf\s+\//, // 删除根目录 /rm\s+-rf\s+~/, // 删除用户目录 /mkfs\./, // 格式化磁盘 /git\s+push\s+.*--force/, // 强推 /curl.*\|\s*(ba)?sh/, // curl 管道执行脚本 /npm\s+install\s+-g\s+.*--unsafe/, /chmod\s+-R\s+777/, /sudo/, ];第二层是白名单思路,这个更重要。对rm、mv、chmod这类具有破坏性的命令,我会校验目标路径是否在项目目录内。路径不在项目内,直接阻止。实现得并不复杂,关键在于用 Node 的path.resolve把相对路径转成绝对路径,再做前缀比较。
// .claude/hooks/guard-bash.js const path = require("path"); let input = ""; process.stdin.on("data", (chunk) => (input += chunk)); process.stdin.on("end", () => { try { const payload = JSON.parse(input); const command = payload.tool_input?.command || ""; const cwd = payload.cwd; for (const pattern of BLOCKED_PATTERNS) { if (pattern.test(command)) { console.error(`[guard] 命令命中危险规则: ${pattern}\n已阻止执行。如果你确实需要执行,请手动在终端操作。`); process.exit(2); } } // 对 rm/mv 做路径范围检查 if (/^(rm|mv|chmod)\b/.test(command)) { const unsafe = command .split(/\s+/) .filter((arg) => arg.startsWith("/") || arg.startsWith("~") || arg.startsWith("../")) .some((arg) => { const abs = path.resolve(cwd, arg); return !abs.startsWith(path.resolve(cwd)); }); if (unsafe) { console.error("[guard] 检测到目标路径超出当前项目目录,已阻止执行。"); process.exit(2); } } process.exit(0); } catch (e) { process.exit(0); } });这个脚本的思路是“默认信任,但有限制”。Claude 在项目里跑npm install、git diff、ls这类命令基本不受影响;一旦碰到影响范围超出项目的操作,就会被拦截。exit 2的关键在于,Claude 能看到 stderr 里的提示,它会自己调整方案。
我见过一个有意思的案例:Claude 想把日志写到/tmp/debug.log,被这个 hook 拦了。它看到提示后,改为写到项目下的.logs/debug.log,还顺手把.logs/加进了.gitignore。这说明给模型一个“为什么不行”的反馈,比单纯阻止更有效。
4.2 配置四:敏感信息检测,防止密钥被写入代码或进入对话上下文
Claude 在写代码时,偶尔会“好心”把真实密钥写进.env文件旁边,或者在测试代码里硬编码一个 API Key。更隐蔽的是,它可能在排查问题时直接cat .env,把密钥读进上下文,然后这些内容就可能出现在日志里。敏感信息 hook 我配在 PreToolUse,分别拦截两类场景:
- Edit/Write:检查
tool_input.content和file_path,如果发现密钥特征,阻止写入。 - Bash:检查命令里是否有读取敏感文件或把敏感信息写入文件的操作。
// .claude/hooks/guard-secrets.js const SENSITIVE_PATTERNS = [ /sk-[A-Za-z0-9]{20,}/, // OpenAI / Anthropic 风格 key /AKIA[0-9A-Z]{16}/, // AWS Access Key /ghp_[A-Za-z0-9]{36,}/, // GitHub Token /BEGIN (RSA|EC|OPENSSH) PRIVATE KEY/, // 私钥块 ]; const SENSITIVE_FILES = [".env", ".env.local", ".pem", "id_rsa", "id_ed25519"]; let input = ""; process.stdin.on("data", (chunk) => (input += chunk)); process.stdin.on("end", () => { try { const payload = JSON.parse(input); const tool = payload.tool_name; const inputData = payload.tool_input || {}; if (tool === "Edit" || tool === "Write") { const content = inputData.content || ""; const filePath = inputData.file_path || ""; const fileBasename = path.basename(filePath); if (SENSITIVE_FILES.includes(fileBasename) && SENSITIVE_PATTERNS.some((p) => p.test(content))) { console.error("[guard] 检测到疑似敏感信息被写入,已阻止。请改用环境变量或 .env.local 维护密钥。"); process.exit(2); } } if (tool === "Bash") { const command = inputData.command || ""; if (SENSITIVE_FILES.some((f) => command.includes(`cat ${f}`) || command.includes(`cat ./${f}`))) { console.error("[guard] 阻止读取敏感文件,避免密钥进入上下文。"); process.exit(2); } } process.exit(0); } catch (e) { process.exit(0); } });这个 hook 的难点在于误报控制。比如.env里本身可以不写密钥,只放配置项名称;项目文档里也可能出现类似sk-xxx的示例占位符。解决方案是:只拦截“文件路径本身很敏感,且内容命中密钥特征”的情况。普通代码文件里出现sk-开头的测试占位符,我选择放行,因为那很可能是 mock 数据。
配好之后,我建议你在测试环境故意触发一次,确认拦截生效、模型能被正确引导。如果发现误报,就调整正则的严谨度,不要因为“宁可不拦也不误判”而把规则关掉,安全这种事儿,宁可多拦几次。
5. 最后两个配置:文件变更后自动跑测试、任务完成时主动提醒
5.1 配置五:PostToolUse 精准触发相关测试,而不是傻等全量执行
自动测试最简单的实现是监听PostToolUse的Edit|Write事件,文件一变就跑npm test。但全量测试在稍大一点的项目里可能要好几分钟,Claude 每改一次文件就触发一次,交互体验会非常差。我采用的策略是根据文件路径缩小范围:
// .claude/hooks/test-on-change.js const { execSync } = require("child_process"); const path = require("path"); let input = ""; process.stdin.on("data", (chunk) => (input += chunk)); process.stdin.on("end", () => { try { const payload = JSON.parse(input); const filePath = payload.tool_input?.file_path || ""; const cwd = payload.cwd; // 只对 src 下的业务代码触发 if (!filePath.startsWith("src/")) process.exit(0); if (filePath.includes(".test.") || filePath.includes(".spec.")) { // 改动的是测试文件,直接跑这个测试 execSync(`npx vitest run "${filePath}" --reporter=dot`, { cwd, stdio: "pipe", timeout: 60000, }); console.error(`[test] ${path.basename(filePath)} 测试通过`); } else { // 改动的是业务代码,跑相关测试 execSync(`npx vitest run --changed --reporter=dot`, { cwd, stdio: "pipe", timeout: 60000, }); console.error(`[test] 相关测试通过`); } process.exit(0); } catch (e) { console.error(`[test] 相关测试失败,请查看上面的报错并修复:\n${e.stdout?.toString().slice(0, 1000)}`); process.exit(0); } });如果你用的测试框架不是 vitest,思路完全一致:Jest 可以用jest -o只跑发生变更的文件相关的测试;其他框架可以通过 git diff 计算变更文件,再传给测试命令。
这里有两个细节值得说。一是timeout我给的是 60 秒,因为测试命令本身要预留执行时间;如果你在 hook 配置里又设了一个更小的 timeout,顶层的会先生效导致命令被提前 kill。所以建议两处都设成一致。
二是失败时不要 exit 2。为什么?因为测试失败不代表代码写入是错误的,Claude 需要先完成这次工具调用、看到测试失败的反馈,然后进行下一轮修复。如果我们把 exit 2 当作“阻止工具执行”,相当于 Claude 写了一个代码,但因为测试没过就不让它写,这会陷入奇怪的状态。测试 hook 的核心价值是“反馈”,不是“阻断”。每次测试失败,Claude 都会在下一轮尝试修复,直到通过。
5.2 配置六:Stop 和 Notification 事件,把等待时间变成可控提醒
最后两个配置解决的是“人机协作时的通知”问题。用 Claude Code 时,它经常会停下来等人批准一个 bash 命令或者问一个问题,这时候如果你切到别的窗口,可能很久都不知道需要你确认。Notification 事件能在这里触发一个桌面通知。
// .claude/hooks/notify-done.js let input = ""; process.stdin.on("data", (chunk) => (input += chunk)); process.stdin.on("end", () => { try { const payload = JSON.parse(input); const event = payload.hook_event_name; const title = event === "Notification" ? "Claude Code 需要确认" : "Claude Code 待办提醒"; // macOS const { execSync } = require("child_process"); try { execSync(`osascript -e 'display notification "请查看 Claude Code" with title "${title}"'`); } catch (e) { // Linux try { execSync(`notify-send "${title}" "请查看 Claude Code"`); } catch (_) {} } process.exit(0); } catch (e) { process.exit(0); } });注册到 Notification 事件即可。至于 Stop 事件,我把它用作“任务收尾检查”。Claude 每次回复完成后,这个 hook 会检查一下 git 状态,如果有未格式化的文件,直接输出提醒,让它主动处理。
// .claude/hooks/stop-check.js const { execSync } = require("child_process"); let input = ""; process.stdin.on("data", (chunk) => (input += chunk)); process.stdin.on("end", () => { try { const payload = JSON.parse(input); const cwd = payload.cwd; const changed = execSync("git status --porcelain", { cwd, stdio: "pipe" }).toString(); const unformatted = changed .split("\n") .filter((line) => /\.(ts|js|tsx|jsx|json|css|md)$/.test(line) && line.startsWith(" M ")); if (unformatted.length > 0) { console.error( `[check] 以下文件有修改但可能未格式化,如果确认已完成所有任务,请运行 prettier --write 处理:\n${unformatted.slice(0, 5).join("\n")}` ); } process.exit(0); } catch (e) { process.exit(0); } });这个 hook 不会阻止任何操作,只是提供一个“事后提醒”。它的价值在于解决 Claude 的“总觉得自己干完了”问题——每次回复完,它都能看到还有哪些代码处于脏状态,从而决定是否继续收拾。
6. 完整配置汇总与排错实录:一份可直接抄走的 settings.json
6.1 六合一配置示例
把上面六个 hook 合并到项目.claude/settings.json里,大概长这样:
{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "node .claude/hooks/format-on-write.js" }, { "type": "command", "command": "node .claude/hooks/lint-on-write.js" }, { "type": "command", "command": "node .claude/hooks/test-on-change.js", "timeout": 60 } ] } ], "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "node .claude/hooks/guard-bash.js" }, { "type": "command", "command": "node .claude/hooks/guard-secrets.js" } ] }, { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "node .claude/hooks/guard-secrets.js" } ] } ], "Notification": [ { "matcher": ".*", "hooks": [ { "type": "command", "command": "node .claude/hooks/notify-done.js" } ] } ], "Stop": [ { "matcher": ".*", "hooks": [ { "type": "command", "command": "node .claude/hooks/stop-check.js" } ] } ] } }请把format-on-write.js、lint-on-write.js、guard-bash.js、guard-secrets.js、test-on-change.js、notify-done.js、stop-check.js这几个脚本放到.claude/hooks/目录下。文件路径按你项目实际情况调整。
6.2 排错实录:我踩过的四个坑
第一个坑是 matcher 写太宽。我一开始把 PreToolUse 的 matcher 写成".*",结果 Claude 每次调用任何工具都要跑一遍 guard 脚本,虽然脚本本身很快,但大量 JSON 解析和正则匹配拖慢了整体交互。后来改成了"Bash"才清爽。
第二个坑是 hook 脚本里忘了读 stdin。Claude Code 通过 stdin 传入 JSON,如果你的脚本不读 stdin 直接开始执行,是拿不到tool_input的。最稳妥的写法就是我上面反复用的那套:process.stdin.on("data")累积后,在end事件里处理。
第三个坑是 exit code 语义混淆。早期我把拦截函数写成process.exit(1),结果工具并没有被阻止,只是 Claude Code 报了个 warning。后来查文档确认,PreToolUse 场景下必须用exit(2)才会真正拦截。所以拦截类逻辑,请务必记住2。
第四个坑是 Windows 下路径和 shell 双引号问题。在 Windows 上用npx prettier --write "${filePath}",如果路径里带空格,Node 的execSync会解析出错。我的解决办法是统一用spawnSync搭配参数数组,避免 shell 转义,或者把路径中的空格做转义处理。最简单的方案是让所有脚本都用 Node 编写,尽量不依赖 shell 特殊语法。
6.3 还能怎么扩展
这 6 个配置是我的基础配置,你可以按需升级。比如:
- 在
SessionStart事件里注入一个“当前项目测试命令”的提示,让 Claude 一开始就知道用什么命令跑测试。 - 在
UserPromptSubmit事件里检查 prompt 里是否包含“忽略所有规则”这类注入尝试,遇到可疑内容直接拦截。 - 在
PreCompact事件里把当前未完成的任务摘要保存到文件,上下文压缩后 Claude 还能记得之前做到哪一步。
我个人在实际操作中的体会是:hooks 配置完成后,最明显的变化不是“代码变好了”,而是“规则冲突变少了”。格式化、安全、测试这三件事从“需要时刻盯着”变成了“系统自动守门”,我只需要在 Claude 被 hook 拦住时看一眼原因,然后决定是调整规则还是让它换个方案。这种“定好规则再放手”的开发方式,才是 Claude Code 这类工具真正让人放心的用法。
最后再分享一个小技巧:所有 hook 脚本里,那两行process.stdin.on("data")的读取逻辑是同一个套路,建议你封装成一个readPayload()公共函数,放到hooks/util.js里,每个脚本都 require 它。这样以后新增 hook 时,代码能少写一大半,也方便统一处理异常。