一、为什么要做增量 Lint?
在多数现代前端项目里,我们都会配上:husky + lint-staged + eslint + stylelint + prettier。
理想情况下,一切顺畅。但在“历史欠账型”老项目中会出现典型痛点:
| 问题场景 | 结果 | 开发者常用“应对” |
|---|---|---|
| 项目中途才引入校验规则 | 旧文件从未整体修复 | 新修改触发旧文件海量错误 |
改动一个上千行的 .vue文件 | 改动文件旧代码大量报错 | 错误淹没真正的业务修改 |
| 大量格式/顺序类问题(可自动修) | 提交被卡 | 直接 --no-verify 跳过 |
| 团队成员习惯绕过钩子 | 规范形同虚设 | 代码质量继续滑坡 |
于是出现“伪规范”阶段:本地 git commit -m "xxx" --no-verify 司空见惯,导致编辑器可视化 git 提交、合并失败,还得回到命令行,体验愈发糟糕。
痛点归结:需要一个 “不依赖开发者自觉、能在合并关口统一兜底” 的机制。
解决思路:在 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 codereview、summarization 等。
四、从 0 到 1:配置 .gitlab-ci.yml
最简结构示例(阶段层次):
stages:
- install
- lint
- test
- notify
写 rules 条件时:
-
同一条 rules 里 if 和 changes 是并且关系:两者都满足才算命中该 rule。
-
rules 从上到下匹配,命中第一条就停止,采用这条里的 when。
-
when 的含义是“这个 job 在整条流水线里何时被调度/是否被创建”,针对的是前面“已完成的所有上游 stage”的结果,而不是“同一 stage 的上一个 job”。
- when: on_success(默认): 仅当所有上游阶段成功时创建/执行该 job。
- when: on_failure: 仅当任一上游阶段有失败时才创建/执行该 job。
- when: always: 无论上游成功或失败都创建/执行。
- when: manual/delayed: 分别为手动触发/延迟执行。
-
若没有任何 rule 命中,job 不会被创建。
run_install:
stage: install
timeout: 20m
script:
- pnpm install
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^(dev_(210|215)|stage|master)$/'
changes: #只有依赖变化,才重新安装。
- package.json
when: on_success
- when: never
run_lint:
stage: lint
interruptible: true
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: on_success
changes: #只有变动包含指定文件类型,才会创建执行对应 job。
- "**/*.{js,cjs,mjs,ts,tsx,vue,css,scss,sass,less}"
script:
- node scripts/lint_changed.js
allow_failure: false
注意执行 lint 阶段之前,如果每次都重新 install 依赖,会很慢,一般项目的依赖也不会常变。
gitlab 默认是会删除 node_modules 的,如果想缓存住,需要在 gitlab CICD 的 Variables 添加如下设置。
这样设置后,如果依赖有变化的话,需要在服务器上重新 install 一下。在 runner 服务器操作前,需要 sudo su 切一下用户。
variables:
GIT_CLEAN_FLAGS: -fdx -e node_modules/
如何获取 MR 变动文件
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(分批控制 & 退出码)
核心思路:将变动文件按批次切片,逐批运行,记录最终退出码。
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、匹配扩展集。
主执行流程(调度 & 汇总)
/* ===================== 主执行流程(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调用。
pipeline 失败,可强制合并,提示如下:
在gitlab仓库 merge request 可勾选强制要求必须通过:
特别注意
- gitlab 支持使用 workflow
- when: never让这次 MR 根本不生成 pipeline。按照预期,rules 不符合时应该是直接跳过流水线的,但实际上 GitLab 的 MR 页面 UI 一直保持 “Checking…” 状态——应该 GitLab 的已知体验缺陷(gitlab.com/gitlab-org/…
- 不知道最新版是否可以,所以 rules 条件还是需要写在 stage 里面。都不符合时可以直接跳过!
#workflow:
# rules:
# - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^(master|stage)$/'
# when: always
# - when: never # 其它情况整个 pipeline skipped
- 在 GitLab 中,创建一个 “把分支 a 合并到分支 b 的 Merge Request”,MR 页面上看到的那条(或多条)预合并流水线,默认使用的是“源分支 a 当前提交里的 .gitlab-ci.yml”配置,而不是目标分支 b 的。
总结
通过 GitLab Runner + MR 增量 Lint 脚本,我们达到了:
- 避免开发者随意
--no-verify。 - 不把历史债务一次性压给当前修改者。
- 让“质量门”前移到代码合并前,而不是线上事故后。
这套方案很容易落地:一个脚本 + 一段配置 → 即刻把“软规范”变为“硬门槛”。
同理单元测试和钉钉通知等功能实现也是类似,但没这么复杂,只需逐步引入。
另外如果想接入AI codereview ,可以像上面那样,先把变更文件拿到,处理成 json,然后结合 Prompt 一起发给大模型,接下来获取结果就可以。
可以根据实际需要,发挥一下想象,等配置稳定了,再补充一下!