代码审查清单模板:先看行为,再看风格
我在 Review 里踩过的坑
带团队三年,我看过太多代码审查陷在风格争论里——缩进用两格还是四格、变量名够不够优雅、这个函数是不是该拆。这些讨论花掉了大量时间,最后线上还是出事故:空指针、越权访问、数据没校验就写库。
问题不在团队成员水平差,而在审查的注意力放错了位置。
Google 在它的工程实践文档里写得很直接:代码审查的首要目标是「让代码库的整体健康度随时间持续改善」,不是追求完美,更不是统一审美。审查者应该把精力花在技术事实上——这段代码是否解决了它该解决的问题,是否引入了新的风险,是否能被下一个接手的人理解。
我把这几年积累下来的审查思路整理成了一份清单。它不长,但覆盖了最容易出问题的地方。
审查的核心原则:行为优先,风格靠后
Google eng-practices 把审查要关注的内容分成几个维度:设计(Design)、功能(Functionality)、复杂度(Complexity)、测试(Tests)、命名(Naming)、注释(Comments)、风格(Style)、一致性(Consistency)。前四个是必须认真看的,后四个更多是锦上添花。
我自己的经验可以浓缩成一句话:先看行为,再看风格。行为包括需求是否符合、边界是否处理、数据流是否清晰、安全是否守住。风格问题可以用 lint 工具自动修,行为问题只能靠人脑判断。
| 审查维度 | 优先级 | 典型问题 | 能否自动化 |
|---|---|---|---|
| 行为正确性 | P0 | 逻辑错误、边界遗漏、状态不一致 | 否 |
| 安全与权限 | P0 | 越权、注入、敏感数据泄露 | 部分 |
| 数据流与校验 | P1 | 输入未校验、转换丢失精度、输出未脱敏 | 部分 |
| 错误处理 | P1 | 异常吞没、错误码混乱、日志缺失 | 否 |
| 测试覆盖 | P1 | 关键路径无测试、边界未覆盖 | 否 |
| 性能 | P2 | N+1 查询、内存泄漏、大对象拷贝 | 部分 |
| 命名与可读性 | P2 | 变量名歧义、函数过长、嵌套过深 | 是 |
| 格式与风格 | P3 | 缩进、空行、引号风格 | 是 |
P3 的事情交给 lint 和 formatter。P0 和 P1 才是审查者真正需要花时间的地方。
三个真实案例
案例一:空列表没处理,详情页直接崩
场景:一个电商后台的商品列表接口,后端返回数据结构是 { items: [...], total: 100 }。前端拿到数据后直接取 items[0].name 渲染详情页。
翻车:测试环境一直有数据,没出问题。上线后某个分类下恰好没有商品,items 返回空数组,页面直接白屏——Cannot read properties of undefined (reading 'name')。
修复思路:在数据消费层加防御性判断,空数据走兜底 UI,而不是让页面崩溃。
// ❌ 坏做法:直接取嵌套属性,不做任何防御
function ProductDetail({ data }: { data: ApiResponse }) {
const name = data.items[0].name
return <h1>{name}</h1>
}
// ✅ 好做法:先判断数据是否存在,空数据给兜底
function ProductDetail({ data }: { data: ApiResponse }) {
const firstItem = data.items?.[0]
if (!firstItem) {
return <EmptyState text="该分类下暂无商品" />
}
return <h1>{firstItem.name}</h1>
}这种问题单靠 TypeScript 的类型检查不一定能拦住,因为 items[0] 的类型在 TS 里不会自动变成 T | undefined(除非开启 noUncheckedIndexedAccess)。审查时看到数组下标访问,就要条件反射地问一句:数组可能为空吗?
案例二:权限校验放在前端,后端裸奔
场景:一个订单管理页面,只有管理员角色能看到「删除订单」按钮。开发者在路由组件里加了角色判断,非管理员看不到按钮。但删除接口本身没有做权限校验。
翻车:有人直接用 Postman 调接口,传了个普通用户的 token,成功删掉了别人的订单。因为后端接口只看「是否登录」,没看「是否有权」。
修复思路:前端权限控制只是为了用户体验,真正的安全边界在后端。每个涉及数据变更的接口都必须独立校验权限。
// ❌ 坏做法:只在路由层做权限判断,接口不校验
// app/admin/orders/[id]/route.ts
export async function DELETE(req: Request) {
const session = await getSession()
if (!session) return Response.json({ error: '未登录' }, { status: 401 })
// 直接删除,没有检查角色和归属
await db.order.delete({ where: { id: params.id } })
return new Response(null, { status: 204 })
}
// ✅ 好做法:接口层独立校验角色和资源归属
export async function DELETE(
req: Request,
{ params }: { params: { id: string } }
) {
const session = await getSession()
if (!session) return Response.json({ error: '未登录' }, { status: 401 })
// 角色校验:只有管理员能操作
if (session.role !== 'admin') {
return Response.json({ error: '无权限' }, { status: 403 })
}
const order = await db.order.findUnique({ where: { id: params.id } })
if (!order) {
return Response.json({ error: '订单不存在' }, { status: 404 })
}
await db.order.delete({ where: { id: params.id } })
return new Response(null, { status: 204 })
}我在审查时遇到只在前端做权限控制的 PR,一律要求补后端校验。前端是展示逻辑,后端才是安全边界。
案例三:用户输入直接拼 SQL,一个注入漏洞
场景:一个内部报表系统,提供搜索功能。后端用字符串拼接构建 SQL 查询:
// ❌ 坏做法:字符串拼接 SQL
async function searchOrders(keyword: string) {
const sql = `SELECT * FROM orders WHERE description LIKE '%${keyword}%'`
return db.$queryRawUnsafe(sql)
}翻车:安全审计时用 '; DROP TABLE orders; -- 作为搜索词,直接把表删了。虽然测试环境有备份,但这类漏洞一旦暴露给外部用户,后果不堪设想。
修复思路:永远使用参数化查询,让数据库驱动处理转义。
// ✅ 好做法:参数化查询,数据库驱动自动转义
async function searchOrders(keyword: string) {
return db.$queryRaw`
SELECT * FROM orders
WHERE description LIKE ${`%${keyword}%`}
`
}这不是什么高级技巧,但每次审查看到字符串拼接 SQL、拼接 shell 命令、拼接 HTML 的地方,都要标红。
审查流程:从 PR 到合并的完整路径
一个结构化的审查流程可以减少遗漏。下面是我在团队里推行的流程:
前四轮是 P0/P1 问题,必须每轮都过。第五轮是 P2/P3,可以快速扫,也可以留给 lint 工具。如果 PR 超过 400 行,建议拆分——研究表明单次审查超过 200-400 行后,缺陷发现率急剧下降。
四类常见问题的审查对比
| 问题类型 | 审查时容易忽略的 | 应该追问的 |
|---|---|---|
| 空值处理 | 「类型写了 string,不会是 null」 | 运行时数据来源可靠吗?API 返回、数据库查询、用户输入都可能为空 |
| 权限校验 | 「前端已经判断了角色」 | 接口层独立校验了吗?资源归属确认了吗? |
| 错误处理 | 「catch 里打了日志」 | 日志包含足够排查信息吗?用户看到了什么?有兜底逻辑吗? |
| 输入校验 | 「前端做了格式校验」 | 后端重复校验了吗?正则够严格吗?长度限制设了吗? |
代码层面的审查对比
对比一:错误处理
// ❌ 坏做法:吞掉异常,外层无法感知失败
async function syncUserData(userId: string) {
try {
const data = await fetchUserFromExternal(userId)
await db.user.update({ data })
} catch (e) {
console.log('同步失败')
// 异常被吞没,调用方不知道失败了
// 日志只有一句话,没有 userId、没有堆栈
}
}
// ✅ 好做法:向上传播错误,日志包含上下文
async function syncUserData(userId: string) {
try {
const data = await fetchUserFromExternal(userId)
await db.user.update({ data })
} catch (error) {
// 日志包含关键上下文,方便排查
logger.error('同步用户数据失败', {
userId,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
// 向上抛出,让调用方决定如何处理
throw new SyncError(`用户 ${userId} 数据同步失败`, { cause: error })
}
}对比二:数据库查询性能
// ❌ 坏做法:N+1 查询,每个订单单独查用户信息
async function getOrderList() {
const orders = await db.order.findMany()
return Promise.all(
orders.map(async (order) => {
const user = await db.user.findUnique({
where: { id: order.userId },
})
return { ...order, userName: user?.name }
})
)
}
// ✅ 好做法:用 include 一次查出关联数据
async function getOrderList() {
const orders = await db.order.findMany({
include: {
user: { select: { name: true } },
},
})
return orders.map((order) => ({
...order,
userName: order.user.name,
}))
}N+1 查询是 Review 里最容易抓到的性能问题。看到循环里有数据库调用或 HTTP 请求,就要警惕。
对比三:敏感数据处理
// ❌ 坏做法:日志里打印完整 token 和密码
function handleLogin(email: string, password: string, token: string) {
logger.info('用户登录', { email, password, token })
// token 和密码都进了日志系统,可能被任何人看到
}
// ✅ 好做法:只记录必要信息,敏感字段脱敏
function handleLogin(email: string, password: string, token: string) {
logger.info('用户登录', {
email: maskEmail(email), // a***@example.com
password: '[REDACTED]',
token: token.slice(0, 8) + '...', // 只保留前8位
})
}对比四:状态管理与副作用
// ❌ 坏做法:在渲染函数里直接修改外部状态
let requestCount = 0
function Dashboard() {
requestCount++
// 每次渲染都会 +1,包括 React StrictMode 下的双重渲染
// 在并发模式下更不可预测
const data = useQuery(['stats'], () => fetchStats(requestCount))
return <div>请求次数:{requestCount}</div>
}
// ✅ 好做法:用 ref 跟踪副作用,不污染渲染
function Dashboard() {
const countRef = useRef(0)
const data = useQuery(['stats'], () => {
countRef.current++
return fetchStats(countRef.current)
})
return <div>请求次数:{countRef.current}</div>
}审查清单
按审查阶段分组,每一项都来自真实踩坑。
提交前(作者自检)
- 改动是否符合需求描述和验收标准?
- 是否手动跑过、验证过核心路径?
- 新增或修改的测试是否通过?
- PR 是否控制在合理大小(建议不超过 400 行)?
- commit message 是否清晰描述了「做了什么」和「为什么」?
第一轮:行为正确性(审查者)
- 代码逻辑是否符合需求?有没有隐含的假设没写出来?
- 边界条件是否处理:空数组、空字符串、null/undefined、零值、超大数?
- 并发场景是否安全:重复提交、竞态条件、幂等性?
- 状态变更是否可追踪:有没有隐藏的全局变量或外部副作用?
- 条件分支是否完整:if/else 覆盖所有情况了吗?switch 有 default 吗?
第二轮:安全与权限
- 接口是否有独立的权限校验(不依赖前端)?
- 用户输入是否经过校验和转义?
- SQL 查询是否使用参数化(禁止字符串拼接)?
- 敏感数据(密码、token、手机号)是否脱敏后才进日志?
- 依赖包是否有已知漏洞(检查 npm audit / Snyk)?
第三轮:数据流与错误处理
- 数据从输入到输出的每一步转换是否清晰?
- API 返回值在使用前是否做了空值防御?
- catch 块是否记录了足够的上下文信息?
- 错误是否向上传播而不是被吞没?
- 用户看到的错误提示是否友好且不泄露内部细节?
第四轮:测试覆盖
- 关键业务路径是否有测试覆盖?
- 边界条件是否有专门的测试用例?
- 修复 bug 时是否补了回归测试?
- 测试是否独立、可重复运行、不依赖外部环境?
第五轮:可读性与维护性
- 函数是否过长(超过 50 行考虑拆分)?
- 命名是否准确反映意图(避免
data、info、temp类模糊命名)? - 是否有重复代码可以提取?
- 注释是否解释了「为什么」而不是复述代码?
- 本次改动是否混入了不相关的变更?
使用边界
这份清单不是要每个 PR 都走完全流程。小改动(改个文案、修个样式)快速扫一眼就行;涉及数据、权限、对外接口的改动,才需要逐项检查。
清单的另一层作用是帮 reviewer 控制讨论边界。如果有人在 PR 里争论缩进风格,可以直接说「这是 P3,交给 lint 处理,我们先看行为」。审查时间有限,应该花在刀刃上。
还有一个容易忽略的点:如果审查过程中发现需求本身不清晰,应该暂停 Review,回到需求澄清。在代码层面修补需求问题,往往越补越乱。
反馈怎么写
Review 反馈的质量直接影响修改效率。几条实操原则:
- 具体到文件和行号:不要说「错误处理不太好」,要说「第 42 行的 catch 块只打了 console.log,建议补上 userId 和错误堆栈,并向上抛出」。
- 说明风险而非只说「不好」:「这里用了字符串拼接 SQL,存在注入风险」比「这样写不安全」有效得多。
- 给建议而非只提问题:如果已经有明确的改法,直接贴代码片段。
- 区分「必须改」和「建议改」:我习惯用前缀标注,比如
Nit:表示可选的小建议,[Must]表示必须修改才能合并。
| 反馈类型 | 标记 | 含义 | 作者预期 |
|---|---|---|---|
| 必须修改 | [Must] | 存在行为错误或安全风险 | 修改后才能合并 |
| 建议优化 | [Suggest] | 有更好写法但不是必须 | 建议采纳,可讨论 |
| 小建议 | Nit: | 风格偏好或微小改进 | 可忽略 |
| 提问 | Q: | 不理解意图,需要解释 | 回复即可 |
工具辅助
清单里有些项可以交给工具自动完成,减轻人工审查的负担:
| 检查项 | 推荐工具 | 说明 |
|---|---|---|
| 代码风格 | ESLint + Prettier / Biome | CI 里跑,不过不让合并 |
| 类型安全 | TypeScript strict mode | 开启 noUncheckedIndexedAccess 和 strictNullChecks |
| 安全漏洞 | npm audit / Snyk / Socket | 依赖扫描,PR 里自动报告 |
| SQL 注入 | 项目 ESLint 插件 / CodeQL | 检测 $queryRawUnsafe 等危险调用 |
| 测试覆盖 | Vitest / Jest coverage | 设置覆盖率阈值,但不盲目追求 100% |
| PR 大小 | PR Size Checker | 超过阈值自动提醒拆分 |
工具能覆盖的就不用人看。人的注意力应该留给工具检查不了的东西:业务逻辑是否正确、需求理解是否有偏差、架构设计是否合理。
小结
代码审查的本质是团队共享代码质量的机制。清单不是约束,而是帮 reviewer 在有限时间里把注意力放在最可能出问题的地方。
我总结的优先级排序很简单:
- 行为是否正确——代码解决了它该解决的问题吗?
- 安全是否守住——权限、输入校验、敏感数据有没有漏洞?
- 错误处理是否到位——出问题时能发现、能排查、能兜底吗?
- 测试是否覆盖——关键路径和边界条件有保护吗?
- 代码是否可读——下一个人能看懂、能改吗?
风格问题排在最后。把精力花对地方,审查效率自然上去。
参考资料
- The Standard of Code Review - Google eng-practices
- What to Look For in a Code Review - Google eng-practices
- Code Review Checklist - CodeReviewChecklist.com
- Code Review Checklist Best Practices - AppSec Master
- Code Review Best Practices - Atlassian
- Software Engineering at Google - Chapter 9: Code Review (O'Reilly)
- 代码审查最佳实践 - 稀土掘金
- Code Review 工程实践之道 - 京东云