AI代码重构应先固定现有行为,再改变函数结构。把输入、返回值、顺序、默认值和副作用写清楚,让AI只提取一个职责;随后用相同样本核对前后结果,并查看差异里有没有混入新业务规则。“代码短了”不能代替这一步。
假设通知准备函数同时处理到期判断、收件人清理和默认重试次数,需求只是让这几件事更容易读懂。下面把它缩成一个可独立运行的小例子:只生成待发送记录,不真正发消息。

先圈出通知函数不能改变的规则
Martin Fowler对重构的定义强调保持可观察行为。对这段代码,意味着取消的任务仍被排除,未来任务仍不进入结果,重试次数为0仍表示0,输出仍按输入顺序排列。
可以在墨刀AI客户端中整理这次重构的代码与约束,把待改函数和相关测试限定为本次材料,先得到拆分建议和局部差异。将“修旧问题”“换测试框架”“整理整个目录”等事项排除在这次任务之外,便于判断每一处改动的理由。
新建练习目录,把下面的当前实现保存为before.mjs:
export function prepareNotifications(jobs, now) {
const result = [];
for (const job of jobs) {
if (job.cancelled) continue;
if (job.sendAt > now) continue;
const retries = job.retries ?? 2;
result.push({ id: job.id, recipient: job.recipient.trim(), retries });
}
return result;
}
这个函数接收普通记录组成的数组,now和sendAt使用同一种时间单位。本文练习不用getter、Proxy或执行中被其他代码修改的对象,也不涉及异步发送。若真实函数读写数据库、记录日志或触发回调,还要单独保留这些动作的次数与先后顺序。
| 样本条件 | 当前行为 | 重构时容易误改的地方 |
|---|---|---|
| sendAt等于now | 进入结果 | 把大于写成大于等于 |
| cancelled为true | 排除 | 移动判断时漏掉过滤 |
| retries为0 | 保留0 | 将空值合并换成逻辑或 |
| 未提供retries | 使用2 | 提取参数时改了默认值 |
| recipient前后有空格 | 去除首尾空格 | 顺手改变大小写或格式 |
| 原数组与记录 | 不被改写 | 直接在原记录上赋值 |
要求AI只拆判断与结果组装
这次拆分有两个明确名字:一个函数回答任务是否到期,另一个函数组装通知记录。给AI的任务可以这样写:
重构 before.mjs 中的 prepareNotifications,输出到 after.mjs。
只提取到期判断和结果组装函数,保留导出的函数名与参数。
保持取消判断、时间边界、trim、默认重试2、显式0和结果顺序。
不得修改输入数组或记录,不增加发送消息、日志、异步操作或第三方依赖。
本次输入为普通数据记录数组,不含getter、Proxy或并发修改。
发现已有异常行为时先指出,不把修复混入重构。
请给出逐处差异说明,不用行数减少证明结果正确。
把建议落到after.mjs后,得到下面这份实现:
function isDue(job, now) {
return !job.cancelled && !(job.sendAt > now);
}
function toNotification(job) {
return { id: job.id, recipient: job.recipient.trim(), retries: job.retries ?? 2 };
}
export function prepareNotifications(jobs, now) {
return jobs.filter(job => isDue(job, now)).map(toNotification);
}
这份拆分让筛选和组装各自有名字,但不意味着filter().map()总比循环更好。它会先完成筛选,再组装结果,还会创建中间数组;若函数依赖读取属性的副作用或严格的交错执行顺序,就不能直接照搬。这里没有做性能比较,也不声称拆分后运行更快。
两个看似顺手的修改会越过重构边界
第一个是把job.retries ?? 2改成job.retries || 2。前者仅在值为null或undefined时使用2;后者也会把0变成2。代码依然能运行,却改变了“显式不重试”的规则。
第二个是把!(job.sendAt > now)改成看起来更直白的job.sendAt <= now。对正常数值,它们在本例中给出相同结果;但缺少sendAt时,原实现会保留任务,新写法会排除它。这也许暴露了一个值得修复的旧问题,却不能在“行为不变”的任务里悄悄决定。
同样,若recipient为null,当前实现会抛出TypeError。把它改成空字符串并继续执行,属于容错规则变化。应先确认调用契约,再以单独改动处理,并补上对应测试。
把预期结果与前后对照一起跑
只比较新旧函数还不够:如果两者都违背已确认的规则,对照仍可能通过。因此,先写出独立的预期结果,再检查前后实现。把下面代码保存为同目录的verify.mjs:
import assert from 'node:assert/strict';
import { prepareNotifications as before } from './before.mjs';
import { prepareNotifications as after } from './after.mjs';
const jobs = [
{ id: 'N1', recipient: ' a@example.test ', sendAt: 100, retries: 0 },
{ id: 'N2', recipient: 'b@example.test', sendAt: 101 },
{ id: 'N3', recipient: 'c@example.test', sendAt: 99, cancelled: true },
{ id: 'N4', recipient: 'd@example.test', sendAt: 99 }
];
const snapshot = JSON.stringify(jobs);
const expected = [
{ id: 'N1', recipient: 'a@example.test', retries: 0 },
{ id: 'N4', recipient: 'd@example.test', retries: 2 }
];
assert.deepEqual(before(jobs, 100), expected);
assert.deepEqual(after(jobs, 100), expected);
assert.equal(JSON.stringify(jobs), snapshot);
assert.deepEqual(after([], 100), []);
assert.deepEqual(before([], 100), []);
assert.deepEqual(after(jobs, 0), before(jobs, 0));
assert.deepEqual(after(jobs, 200), before(jobs, 200));
assert.throws(() => before([{ recipient: null, sendAt: 0 }], 100), TypeError);
assert.throws(() => after([{ recipient: null, sendAt: 0 }], 100), TypeError);
assert.deepEqual(
after([{ id: 'N5', recipient: 'x@example.test' }], 100),
before([{ id: 'N5', recipient: 'x@example.test' }], 100)
);
const wrong = { ...expected[0], retries: jobs[0].retries || 2 };
assert.notDeepEqual(wrong, expected[0]);
console.log('通过:到期/未到期/已取消、显式0、缺省2、顺序、输入不变、空数组、异常类型、缺少sendAt保持现状;反例 || 2 被识别');
console.log(JSON.stringify(after(jobs, 100)));
在该目录执行node verify.mjs。练习使用Node.js v24.19.0,以上检查在独立本地运行中通过,返回的两条记录为:
[
{"id":"N1","recipient":"a@example.test","retries":0},
{"id":"N4","recipient":"d@example.test","retries":2}
]
终端实际会把这段JSON打印成一行。测试同时识别了|| 2会改变显式0的反例;这里比较的是返回值、列出的边界和异常类型,不要求重构前后的错误堆栈文本相同。
这些样本验证了本练习的明确规则,不能覆盖所有输入。项目里还应运行原有测试;如果规则清楚却没有相应断言,可参考从业务规则生成能发现问题的单元测试补齐后再改。
提交差异时,把结构变化与规则变化分开
检查差异时,重点找新增的默认值、排序、过滤条件、异常捕获和外部调用。这些地方最容易让“拆函数”变成业务修改。使用AI代码评审时,也应要求它逐处说明行为影响,而不是只点评命名和排版。
这份练习的交接说明可以写成:“提取到期判断与通知组装函数;导出接口不变;到期边界、显式0、缺省2、顺序和输入不变等样本通过。缺少sendAt仍沿用现有行为,另行确认规则。”它让接手者知道改了什么,也留下下一项业务讨论的位置。
当一个长函数还有多个职责时,继续按同样大小拆下一处。每次改动只承担一个可解释的结构调整,发现旧问题则单独记录,团队就更容易判断是哪一步改变了结果。