一、为什么要做增量 Lint?
在多数现代前端项目里,我们都会配上:husky + lint-staged + eslint + stylelint + prettier
。
理想情况下,一切顺畅。但在"历史欠账型"老项目中会出现典型痛点:
问题场景 | 结果 | 开发者常用"应对" |
---|---|---|
项目中途才引入校验规则 | 旧文件从未整体修复 | 新修改触发旧文件海量错误 |
改动一个上千行的 .vue 文件 |
改动文件旧代码大量报错 | 错误淹没真正的业务修改 |
大量格式/顺序类问题(可自动修) | 提交被卡 | 直接 --no-verify 跳过 |
团队成员习惯绕过钩子 | 规范形同虚设 | 代码质量继续滑坡 |
于是出现"伪规范"阶段:本地 git commit -m "xxx" --no-verify
司空见惯,编辑器内置校验失效,还得回到命令行,体验愈发糟糕。
痛点归结:需要一个"不依赖开发者自觉、能在合并关口统一兜底"的机制。
解决思路:在 Merge Request(MR)阶段,通过 GitLab Runner 运行增量 Lint,只检查本次 MR 变动文件;不通过 → 阻止合并。
这样:
- 不阻塞日常开发小步提交(不会全量炸出千行错误)。
- 避免随手
--no-verify
破坏规范。 - 逐步"只清理改动"→ 渐进提升整体质量。
二、核心概念快速梳理
1. GitLab CI/CD vs. Runner
- GitLab CI/CD :仓库内的
.gitlab-ci.yml
定义流水线(Pipeline),包含多个阶段(stages)与任务(jobs)。 - GitLab Runner:驻留在服务器上的执行器,真正拉取代码并执行脚本(可自建,也可共享)。
- 你可以把 Runner 理解为:"代码发生特定事件(如 MR)时的一次'远程命令执行容器'"。
2. 为什么不用 Jenkins?
- Jenkins 也能完成,但如果项目已经托管在 GitLab 上,使用 GitLab 原生 CI 更轻量:配置即代码、快速绑定 MR 生命周期。
- 复杂发布链路可以后续再衔接到 Jenkins。
3. 增量 Lint 的核心要素
要素 | 说明 |
---|---|
触发时机 | 仅 MR(防止对普通 push 触发浪费资源) |
文件范围 | 通过 GitLab MR Changes API 拿到"新增/修改"文件 |
语言与类型 | 区分属于 ESLint / Stylelint 的扩展名集合 |
分批执行 | 防止一次性参数过长 / 内存峰值过高 |
退出码 | 任一工具非 0 → 阻止合并 |
缓存 | 使用 --cache 加速重复任务 |
可扩展性 | 后续可插入单测、覆盖率、通知、邮件、AI Code Review |
三、整体流程设计
- MR 创建或更新 → 触发流水线(
merge_request_event
)。 - CI Job(lint 阶段)运行脚本:
- 调用 GitLab API:
/projects/:id/merge_requests/:iid/changes
- 过滤掉删除文件,仅保留"新增 / 修改"且扩展名匹配的文件。
- 拆分:ESLint 文件 & Stylelint 文件。
- 分批调用
pnpm exec eslint
/pnpm exec stylelint
。
- 调用 GitLab API:
- 任一失败 → Job 返回非 0 → MR 状态标红 → 无法合并。
- 后续可并行扩展:单测、通知、自动修复建议、AI summarization 等。
四、从 0 到 1:配置 .gitlab-ci.yml
最简结构示例(阶段层次):
yaml
stages:
- lint
- test
- notify
示例 Lint Job(仅 MR 触发):
yaml
run_lint:
stage: lint
interruptible: true
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: on_success
script:
- node scripts/lint_changed.js
allow_failure: false
如何获取 MR 变动文件
js
async function fetchChangedFiles() {
// 拼接下面的url,Api_Base 是git地址域名后面拼接一个/api/v4,接下来是项目id,在仓库能看到,这俩是需要硬编码的,最后面的 merge id 在cicd流程中能够获取到
const url = `${API_BASE}/projects/${PROJECT_ID}/merge_requests/${MR_IID}/changes`;
// 调用 GitLab MR changes API
const res = await fetch(url, {
headers: { "PRIVATE-TOKEN": process.env.GitLabToken }, // 这个token不建议硬编码,最后在git lab 上新建一个token,并且加到变量里面
});
if (!res.ok) {
// HTTP 非 2xx
throw new Error(`API 返回 ${res.status} ${res.statusText}`);
}
const data = await res.json();
if (!data.changes) {
throw new Error("响应缺少 changes 字段");
}
// data.changes: [{ old_path, new_path, new_file, renamed_file, deleted_file, ... }]
const list = [];
for (const ch of data.changes) {
if (ch.deleted_file) continue; // 删除文件不 lint
const p = ch.new_path || ch.old_path; // 正常情况使用 new_path;兜底 old_path
if (p && (matchEslintExt(p) || (ENABLE_STYLELINT && matchStyleExt(p))))
list.push(p); // 匹配扩展则加入
}
return [...new Set(list)];
}
增量执行 ESLint(分批控制 & 退出码)
核心思路:将变动文件按批次切片,逐批运行,记录最终退出码。
js
function runEslintOn(files) {
if (!files.length) {
log("ESLint 无匹配文件,跳过。");
return 0;
}
// 分批执行 ESLint
log(`准备 ESLint 校验文件数: ${files.length}`);
const baseArgs = [
// eslint 参数基底
"exec",
"eslint",
"--color",
"--cache",
"--cache-location",
".eslintcache", // 缓存提升速度
];
let exitCode = 0; // 累积最终退出码
for (let i = 0; i < files.length; i += BATCH_SIZE) {
// 分批循环
const batch = files.slice(i, i + BATCH_SIZE); // 当前批次文件
log(
`批次 ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(
files.length / BATCH_SIZE
)} (${batch.length} 文件)`
);
const res = spawnSync("pnpm", [...baseArgs, ...batch], {
stdio: "inherit",
});
if (res.error) {
error(`ESLint 执行失败: ${res.error.message}`);
exitCode = 1;
} else if (res.status !== 0) {
exitCode = res.status;
}
}
return exitCode; // 返回最终状态
}
Stylelint 的逻辑基本一致,只需要换 baseArgs、匹配扩展集。
主执行流程(调度 & 汇总)
js
/* ===================== 主执行流程(IIFE) ===================== */
(async () => {
let files;
try {
files = await fetchChangedFiles(); // 调用 API 获取文件
} catch (e) {
error(`获取改动文件失败: ${e.message}`);
process.exit(1);
}
log(`API 返回改动文件数: ${files.length}`); // 输出文件数量
if (files.length === 0) {
log("无需要 lint 的文件,结束。");
process.exit(0);
}
const eslintFiles = files.filter(matchEslintExt);
const styleFiles = ENABLE_STYLELINT ? files.filter(matchStyleExt) : [];
const eslintCode = runEslintOn(eslintFiles);
const styleCode = runStylelintOn(styleFiles);
const final = eslintCode || styleCode;
if (final === 0) {
log("增量 Lint 全部通过 ✅");
} else {
error("增量 Lint 失败 ❌");
}
process.exit(final);
})().catch((e) => {
// 捕获顶层异常
error(`脚本异常: ${e.stack || e.message}`);
process.exit(1);
})
建议将完整脚本放在
scripts/
目录下,并通过node scripts/lint_changed.js
调用。
总结
通过 GitLab Runner + MR 增量 Lint 脚本,我们达到了:
- 避免开发者随意
--no-verify
。 - 不把历史债务一次性压给当前修改者。
- 让"质量门"前移到代码合并前,而不是线上事故后。
这套方案很容易落地:一个脚本 + 一段配置 → 即刻把"软规范"变为"硬门槛"。
同理单元测试和钉钉通知等也是类似,只需逐步引入。
可以根据实际需要,发挥一下想象,等配置稳定了,再补充一下!