GitLab CI/CD 中实现前端增量 Lint(ESLint & Stylelint)实践

一、为什么要做增量 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

三、整体流程设计

  1. MR 创建或更新 → 触发流水线(merge_request_event)。
  2. CI Job(lint 阶段)运行脚本:
    • 调用 GitLab API:/projects/:id/merge_requests/:iid/changes
    • 过滤掉删除文件,仅保留"新增 / 修改"且扩展名匹配的文件。
    • 拆分:ESLint 文件 & Stylelint 文件。
    • 分批调用 pnpm exec eslint / pnpm exec stylelint
  3. 任一失败 → Job 返回非 0 → MR 状态标红 → 无法合并。
  4. 后续可并行扩展:单测、通知、自动修复建议、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
  • 不把历史债务一次性压给当前修改者。
  • 让"质量门"前移到代码合并前,而不是线上事故后。

这套方案很容易落地:一个脚本 + 一段配置 → 即刻把"软规范"变为"硬门槛"。

同理单元测试和钉钉通知等也是类似,只需逐步引入。

可以根据实际需要,发挥一下想象,等配置稳定了,再补充一下!

相关推荐
柯南二号11 小时前
【开发配置】GitLab CR(Code Review)规则配置清单
gitlab·代码复审
柯南二号11 小时前
【开发配置】云服务器配置Gitlab服务
运维·服务器·gitlab
DevOps_node16 小时前
docker-compose部署gitlab
gitlab·linux基础设施·linux中间件
Incredibuild1 天前
DevSecOps 集成 CI/CD Pipeline:实用指南
c++·ci/cd·devsecops
xiezhr2 天前
Git提交错了,别慌!还有后悔药
git·gitlab·github
GGGGGGGGGGGGGG.2 天前
CI/CD 全链路实践:从 Git 基础到 Jenkins + GitLab 企业级部署
运维·git·ci/cd·云原生·gitlab·jenkins
007php0072 天前
使用 Docker、Jenkins、Harbor 和 GitLab 构建 CI/CD 流水线
数据库·ci/cd·docker·容器·golang·gitlab·jenkins
遇见火星2 天前
如何在 Jenkins 中安装 Master 和 Slave 节点以优化 CI/CD 流程
servlet·ci/cd·jenkins
叫我阿柒啊3 天前
从全栈开发到微服务架构:一次真实的Java面试实录
java·redis·ci/cd·微服务·vue3·springboot·jwt