
1. lint-staged 到底在解决什么问题很多团队做前端工程化第一个想引入的“门槛工具”往往是 lint-staged。原因很简单它解决的是代码检查效率里最扎心的一个场景——我明明只改了三个文件为什么要等全项目的 ESLint 跑完更别说老项目里那几千条历史 warning一提交就是一堆噪音。lint-staged 的做法很直接把所有检查只钉在 Git 暂存区的文件上你提交什么就检查什么。我第一次接触这个概念的时候其实挺困惑的lint-staged 不过是一个 npm 包它凭什么能插手 Git 的行为后来把原理搞清楚之后才发现它本质上是一个“聪明的命令组织器”——通过读取暂存区文件列表把你要运行的检查命令逐条拼装出来传给这些文件的路径然后在 pre-commit 阶段执行。整个过程不依赖任何魔法只是把工程化里的“范围控制”做对了。1.1 全量检查的疼痛做过前端工程化的人都知道我先说一个特别典型的场景。项目从三五个文件长到三五百个文件团队里任何一次git commit都要先跑一遍全量 ESLint。如果项目是老的 Vue 2 JavaScript 技术栈中途又引入了 TypeScript那情况就更“酸爽”了历史代码里几百条any、no-unused-vars、vue/no-mutating-props这类 warning你根本不敢开--fix因为可能把老祖宗的业务逻辑顺手改坏。全量检查带来的问题不只是慢更致命的是“噪音淹没信号”。新代码本来只有三五个规范问题被历史 warning 一冲开发者在提交信息里看到的全是和自己无关的报错。久而久之团队对代码检查这件事产生了强烈的抗拒心理——反正也跑不完、跑了也是别人的错不如直接跳过。代码规范就这么名存实亡了。还有个容易忽略的成本CI 流水线里的 lint 环节。全量 lint 在大仓库里动辄几分钟起步一旦失败还得回退重跑提交队列直接堵死。我见过最夸张的案例一个中台项目全量跑完 ESLint 加 Stylelint 需要十一分钟结果一半的时间是在处理别人的历史债务。这种状态下lint 已经不是质量保障而是研发效率的杀手。1.2 lint-staged 的核心设计思路lint-staged 把问题重新定义了一下我们真正要保证质量的不是“整个仓库历史上所有代码”而是“这一次提交带来的变更”。于是它把检查范围压缩到 Git 暂存区Staged Area里那些文件上配合 husky 之类的 Git hooks 工具在pre-commit这个时间点把规则“精准打击”。这个思路的优势非常明显。第一是快你改了 5 个文件它就只检查这 5 个100 个项目文件剩下的 95 个完全不碰。第二是噪音少历史 warning 被天然隔离新代码的问题一目了然开发者不会因为海量报错而无所适从。第三是安全检查范围小意味着误判和误修的概率也低--fix这类自动修复命令可以放心配置。当然lint-staged 不是用来替代全量检查的。它更适合做“提交前守门员”而全量 CI 检查依然应当保留作为兜底防线。这个工具的核心价值是在开发者的即时反馈与团队规范之间找到一个性价比最高的平衡点。我经常跟团队里的人说如果只能给提交环节加一个守门工具那一定是 lint-staged因为它把“检查”这个动作对开发效率的干扰降到了最低。2. 先把 Git 暂存区和钩子机制拎清楚要真正理解 lint-staged绕不开 Git 的两个基础概念暂存区stage/index和 hooks。很多人用了几年 Git天天git add、git commit但从来没想过这背后到底是什么在起作用。我习惯用一个“三间房”的类比来讲这事。2.1 工作区、暂存区、仓库之间的“三间房”把你的项目目录看作一间办公室。工作区是办公桌你的源代码文件摊在这里怎么改都行暂存区是放在门边的“待发货纸箱”git add就是把文件从桌面放进纸箱仓库则是楼下的仓库货架只有git commit才会把纸箱里的东西正式入库建档。日常开发中很多人有一个误区以为git commit会把工作区里“所有修改过的东西”一起提交。实际上 Git 只认纸箱里的东西——也就是git add过的内容。你工作区里改了一半的文件如果没有git add提交时是进不去的。lint-staged 盯的正是这个纸箱它通过读取暂存区文件列表确定“这次提交要带走哪些源码文件”然后只对这批文件做检查。理解这个逻辑之后你会明白一件事lint-staged 和“你工作区里有多少未提交的改动”没关系它只看你已经git add的内容。如果你改了 10 个文件但只 add 了 3 个它检查的也只有这 3 个。这既是优势也是不少人踩坑的地方——明明改了文件但忘了git addlint 跑了一通空气。2.2 pre-commit 钩子什么时候触发 lint-stagedGit 本身提供了一套 hooks 机制也就是在特定动作发生时自动执行自定义脚本。.git/hooks/目录下有一堆.sample结尾的模板文件比如pre-commit.sample、post-commit.sample。它的工作逻辑很简单当你执行git commit时Git 会在正式提交前调用pre-commit钩子这个钩子如果退出码非 0提交就会被中断。lint-staged 本身不负责触发它只是被调用的那个“执行者”。我们真正配置的是pre-commit钩子里的那一行npx lint-staged。手动写 hooks 当然也可以但 hooks 脚本不在版本控制里团队成员 Clone 下来之后是缺失的。所以更常见的做法是用 husky 这类工具来管理 hooks 脚本husky 把钩子内容写入.husky/目录并纳入 Git 版本控制团队成员安装依赖时通过prepare脚本自动激活。关于 hooks 有一点要特别注意如果在钩子脚本里改了文件比如eslint --fix或prettier --write这些改动默认不会自动进入本次提交。lint-staged 在 v10 之后的版本会自动帮你把修改后的文件重新git add但如果你只写了原生 pre-commit 脚本就必须手动处理这一步。这也是很多人从“手写 hooks”转向 lint-staged 的直接原因之一。3. 安装配置与环境准备配置 lint-staged 之前先保证你的 Git 基础环境是干净的。这不是废话——我在实际排查问题的时候发现很多“lint-staged 不生效”的案子最后都查到了 Git 版本过旧、Node 版本不匹配、husky 钩子根本没装上这些“前置问题”上。3.1 安装与版本选择在一个标准的 npm 项目里安装 lint-staged 只需要一条命令npm install --save-dev lint-staged husky如果项目用的是 pnpm 或 yarn命令对应的改成pnpm add -D或yarn add -D即可。这里我把 husky 一并装上了因为 lint-staged 本身不是一个常驻进程它需要一个触发时机而 pre-commit 钩子是它最常见的舞台。版本选择上有个小知识点lint-staged 对 Node 版本有要求新版比如 v15通常需要 Node 20 以上老项目如果 Node 还停留在 16 或 18记得安装前看一眼engines字段。另外如果你是从老项目升级上来的注意 v10 前后的配置格式差异很大最明显的变化是命令从“字符串拼接”改成了“数组”并且任务执行后会自动把修改的文件重新git add。这部分我在后面的配置示例里会专门讲到。3.2 配置文件怎么写三种载体lint-staged 的配置可以放在三个位置package.json 里的lint-staged字段、独立的.lintstagedrc文件、以及lint-staged.config.js。三者的优先级和写法略有差别但最终解析出来的数据结构是一样的。以 package.json 为例最常见的长这样{ lint-staged: { *.{js,ts}: eslint --fix, *.{json,md,css,scss}: prettier --write } }这是一个“键为 glob 匹配模式、值为要执行命令”的映射表。这里有个 v10 之后非常重要的细节值推荐写成数组而不是字符串。理由是 lint-staged 把“每个数组元素”当成一条独立的命令来解析如果你写成eslint --fix prettier --write一大串逻辑会被当成单个命令传给 shell跨平台行为不一致且容易出错。推荐的数组写法是{ *.{js,ts}: [eslint --fix, prettier --write] }由于数组里的命令会按顺序执行多条命令之间天然就是“串行”关系完全不需要自己拼。如果你需要更复杂的逻辑——比如根据文件列表动态生成命令也可以把值写成一个函数// lint-staged.config.js export default { *.ts: (filenames) filenames.map((file) tsc --noEmit --pretty false ${file}) }函数接收匹配到的文件路径数组返回一个命令字符串或字符串数组。这种写法比较灵活适合处理tsc --noEmit这类“一次只能处理有限文件”的工具我后面细说。3.3 常用配置项与参数释义除了 glob 映射表lint-staged 本身还提供了一些顶层配置项用来控制它的执行行为。我用得比较多的有这几个配置项类型默认值作用concurrencyboolean/numbertrue是否并发执行多个任务也可指定并发数量shellboolean/stringfalse是否通过 shell 执行命令管道、重定向需要设为 truemaxArgLengthnumber0每个命令分块执行的最大参数长度防止 E2BIGrelativebooleanfalse传给命令的文件路径是否使用相对路径quietbooleanfalse静默模式只输出报错不输出任务信息debugbooleanfalse输出调试日志排查问题时最有用allowEmptybooleanfalse当没有匹配到暂存文件时是否仍然正常退出这些参数可以直接写在 CLI 上比如npx lint-staged --debug也可以放进配置文件里统一管理。注意concurrency默认是 true 不是 1这意味着如果你的多个任务之间有依赖关系比如先 prettier 再 eslint可能会因为并发执行而出问题。不过实际使用中同一个 glob 匹配到的多个命令是按数组顺序执行的并发只发生在“不同 glob 规则”之间这个要理清楚。还有一个隐藏参数--diff它允许你改变 lint-staged 的“检查范围基准”。默认情况下它只看暂存区即git diff --staged的内容但你可以手动指定一个提交区间比如npx lint-staged --diff HEAD~3 HEAD这样就能检查最近三次提交涉及的文件。这个能力在 CI 里做“增量检查”很好用不过日常本地提交基本用不到。4. 核心机制拆解它怎么做到“只查暂存区”lint-staged 的神奇之处不在于它有多复杂的算法而在于它把一个常见需求做得很周全。它的整个处理流程可以概括为三步先拿到暂存区文件列表再用 glob 模式筛选出需要检查的部分最后把匹配到的文件路径作为参数传给对应命令。4.1 获取暂存区文件列表的原理第一步是关键。lint-staged 本质上是在调用 Git 命令获取暂存区文件清单。原理上等价于执行git diff --name-only --cached --diff-filterACMR--cached也就是--staged告诉 Git 只展示已经加入暂存区的文件而不是工作区里所有改动的文件。--diff-filterACMR的含义是只保留类型为 Added新增、Copied复制、Modified修改、Renamed重命名的文件把 Deleted删除的文件过滤掉。这里有一个很多人不知道的细节为什么不直接git diff HEAD因为git diff HEAD比较的是工作区与 HEAD 之间的差异它会把“已经 add 的”和“改完但还没 add 的”混在一起。而 lint-staged 要的是“纸箱里装了什么”所以必须用--cached。另外首次提交时 HEAD 还不存在git diff HEAD会直接报错而git diff --cached在这种情况下会退化成与空目录树比较正常列出所有暂存文件。lint-staged 选择这个命令天然兼容了新仓库的第一个 commit。拿到文件列表之后lint-staged 还会做一次过滤把 Git 不关心的路径、子模块相关的文件等边界情况处理掉。这部分底层逻辑在版本迭代中有过多次调整但对我们使用者来说只需要记住一件事它拿到的列表一定都是“本次提交真正会带走的源码文件”。4.2 glob 匹配规则模式不是正则第二步是 glob 匹配。lint-staged 的配置键遵循 glob 语法底层用的是 micromatch 这个库。glob 的语法和正则有点像但又不完全一样第一次接触的人最容易在这里犯迷糊。一种常见的误解是把*.js理解为“只匹配根目录下的 js 文件”。在 lint-staged 的语义里*.js会匹配仓库内所有层级的.js文件包括src/foo/bar.js和packages/app/index.js。这是它源于 micromatch 的matchBase行为。如果你真的只想匹配根目录下的文件需要写成/*.js。**表示递归匹配任意层级{js,ts,tsx}是花括号展开!开头表示排除。举个例子{ **/*.{js,ts,tsx}: [eslint --fix], !**/*.min.js: [] }上面第一条匹配所有源码目录下的 js/ts/tsx 文件第二条用空数组把*.min.js排除掉空数组表示“匹配到但不执行任何命令”。这种写法在维护遗留项目时特别有用可以精准跳过那些历史生成的压缩文件。另外需要注意glob 匹配是基于“仓库根目录”的相对路径来做的不是基于你执行命令时所在的目录。这点在 monorepo 场景下尤其重要我见过一个团队在子包目录里跑npx lint-staged结果匹配规则全部失效因为路径基准变了。4.3 任务分发与自动暂存拿到匹配的文件列表之后lint-staged 就会开始“干活”了。它会按照配置文件里定义的顺序把文件路径分批传给对应的命令。比如你配置了*.{js,ts}: [eslint --fix, prettier --write]那么假设暂存区里有 10 个 js 文件lint-staged 会执行eslint --fix file1.js file2.js ... file10.js prettier --write file1.js file2.js ... file10.js这里有一个工程上的细节如果文件数量太多命令行参数就会超出系统限制报 E2BIG 错误。lint-staged 的解决办法是使用maxArgLength配置把文件列表拆成多个小块分别执行。默认值 0 表示不限制拆分但如果你遇到 E2BIG 问题把它设置成比如 1000就能自动分块。整个执行过程中还有一个“隐式操作”很容易被忽略自动重新暂存。.js文件经过eslint --fix后内容大概率会变如果 lint-staged 不处理这些修复后的改动就不会被包含进本次提交你又得手动git add。v10 之后的版本默认会在任务全部成功后自动执行git add把被修改的文件重新放回暂存区。这个行为非常关键也是很多老教程里“要在命令里手动拼git add”的做法如今已经不推荐的原因。5. 完整实操搭一条 pre-commit 流水线理论知识聊完了下面进入动手环节。我会从零开始搭一套前端项目常用的 pre-commit 检查流程每一步都给出可以直接复制的命令和配置同时解释每一步背后的选择。5.1 五分钟快速跑通假设你已经有一个 npm 项目并且初始化了 Git 仓库。下面的步骤可以复制粘贴执行# 1. 安装依赖 npm install --save-dev lint-staged husky # 2. 初始化 husky生成 .husky 目录 npx husky init # 3. 在 pre-commit 钩子中写入 lint-staged echo npx lint-staged .husky/pre-commit做完这三步一个最基础的 pre-commit 检查就搭好了。为了验证它真的在起作用我们再往 package.json 里加一段配置{ scripts: { lint: eslint . --ext .js,.ts }, lint-staged: { *.{js,ts}: [eslint --fix] } }然后做一次真实的提交测试# 修改一个 js 文件故意留下一个 no-unused-vars 错误 git add . git commit -m test: lint-staged如果配置正确你会看到 ESLint 报错信息并且 commit 被中断。把错误修掉之后再次git add和git commit提交才能成功。这一步跑通说明你的 pre-commit 流水线已经工作起来了。有一个很容易踩的坑因为eslint --fix可能会改动文件如果刚才那次提交恰好被中断了修复后的文件需要再次git add。lint-staged 会自动暂存它运行时产生的改动但一旦因为报错中断之后的“修复 add”流程还是得你手动来一遍。5.2 配合 husky 的正确姿势husky 的用法在 v5 之后有了很大变化。老版本用的是.huskyrc.json在配置里写{ hooks: { pre-commit: lint-staged } }新版本换成了文件系统的方式钩子脚本直接放在.husky/目录下内容就是普通的 shell 脚本。npx husky init会自动帮你生成.husky/pre-commit里面默认内容一般是npm test之类的占位符我们把它覆盖成npx lint-staged即可。在团队协作时需要注意一个问题.husky/目录必须提交到 Git 仓库否则新成员 clone 下来不会有钩子。由于 husky 的prepare脚本会在npm install时自动执行所以新成员只要正常安装依赖钩子就会自动就位。还有一个关于 “hard reset 之后钩子丢失” 的经典问题如果你或你的同事曾经运行过git reset --hard.husky目录里的可执行权限可能会丢。解决办法是在项目 scripts 里加一个prepare: husky然后在出问题时执行一次npm run prepare重新激活。5.3 多语言多工具混合场景现实中的项目很少只有一种检查工具。前端项目通常是 ESLint Prettier Stylelint 的组合如果用了 TypeScript可能还想在提交时做一次快速的类型检查加上后端部分可能还有 golangci-lint、ruff、gofmt 等工具。lint-staged 最舒服的地方就在这里它不管工具链有多杂只看文件后缀。一个比较典型的前后端混合项目配置长这样{ lint-staged: { *.{js,ts,tsx,vue}: [eslint --fix, prettier --write], *.{css,scss,less,vue}: [stylelint --fix, prettier --write], *.go: [gofmt -w, goimports -w], *.{md,json,yml,yaml}: [prettier --write] } }这个配置把不同类型的文件交给不同的工具链处理每种文件只会被匹配到的规则执行。有个细节值得注意.vue文件同时出现在第一和第二条规则里这是故意的——Vue 单文件组件里既有 JS/TS 逻辑又有 CSS 样式需要 ESLint 和 Stylelint 都跑一遍。关于 TypeScript 类型检查这里必须多说一句。很多团队会这样配{ *.ts: [tsc --noEmit] }但这个配置在大部分场景下会报错。原因很简单tsc的单文件模式和--noEmit一起使用时并不能只检查传入的那几个文件——它会把整个项目的类型依赖关系都拉进来结果要么极其慢要么直接报“Cannot find module”这类错误。正确的做法有两种要么在 pre-commit 里直接跑全量tsc --noEmit适合中小项目要么用 lint-staged 的函数配置把文件列表批量传入一个自定义的tsc -p脚本做个增量检查。我个人更推荐把类型检查放到 CI 里而 pre-commit 只负责语法规范和格式化——毕竟类型的准确性往往需要全项目上下文。6. 常见问题与排查技巧实录lint-staged 出现问题的场景翻来覆去就那么几类。我把这几年在团队里排查过的典型案例整理成了一张速查表方便你一眼定位。6.1 高频报错速查表现象大概率原因解法提交时完全没有检查动作husky 钩子未安装或.husky/pre-commit为空执行npm run prepare确认钩子内容包含npx lint-staged报No staged files match any configured task有暂存文件但没有任何 glob 匹配到检查 glob 写法确认路径基准是仓库根目录命令中用了||或不生效默认不走 shell||是给 shell 的语法配置shell: true或改用数组分开写命令报 E2BIG文件列表过长命令行参数超出系统限制设置maxArgLength: 1000等分块参数eslint --fix修改后未包含进提交用的是老版本 lint-staged 或手动写了 post-checkout升级到 v10不要手动git add新成员 clone 后钩子不生效.husky/未提交或prepare脚本缺失确认.husky/入库package.json 里加prepare: huskyWindows 下命令执行异常路径分隔符或 shell 解析差异设置shell: true或避免在命令里写 shell 语法6.2 我踩过的一些坑第一个坑是“手动 git add 带来的重复暂存”。老版本 lint-staged 配置里为了确保--fix修改后的文件能进提交会这样写{ *.{js,ts}: [eslint --fix, git add] }如果你把这个老写法原封不动搬到新版本会发现git add在 lint-staged 已经自动暂存之后又执行了一遍偶尔会把目录里其他未暂存的文件也带进暂存区。遇到这种情况直接把git add从配置里删掉就行。第二个坑是二进制文件和图片。如果你把*.{png,jpg,webp}也配进了 prettier轻则检查报错重则把图片文件“格式化”成另外的二进制内容。我在一个团队里见过一次事故就是误把图片文件交给 ESLint 检查结果--fix把文件改坏了最后只能从 Git 历史里恢复。遇到二进制资源要么不配置规则要么明确用!排除。第三个坑是eslint --cache与 lint-staged 的搭配。--cache确实能让 lint 更快但缓存文件如果生成在项目目录里很容易被误提交。我建议配置ESLINT_USE_FLAT_CONFIG之外还要把缓存文件路径指到.eslintcache并加入.gitignore。否则 lint-staged 看到暂存区里多了个缓存文件又会跳出来匹配规则把检查流程搅成一团。6.3 一个完整的排查思路如果你遇到“lint-staged 不生效”这种问题我建议按下面的顺序排查不要一上来就怀疑是 lint-staged 坏了。第一步先在命令行手动执行npx lint-staged看它是否正常工作。如果这一步能跑通、能报错说明 lint-staged 本身没问题问题出在触发环节。第二步检查.husky/pre-commit文件内容确认里面有npx lint-staged同时检查项目的prepare脚本是否存在。第三步执行git config --list确认 hooksPath 有没有被全局配置篡改过。如果还不行用npx lint-staged --debug看它到底拿到了哪些文件、匹配了哪些规则。这套排查流程几乎能解决 99% 的 “不生效” 问题。剩下那 1%大概率是同事在自己电脑上全局装了一个老版本 husky 或 lint-staged覆盖了项目内安装的版本。遇到这种“隐身变量”让当事人检查一下全局 npm 包问题往往立刻水落石出。7. 进阶玩法与性能优化lint-staged 用顺手之后很多人会开始琢磨怎么让它更快、更符合团队习惯。这一节是一些相对进阶的玩法属于那种“知道的人少、但效果立竿见影”的技巧。7.1 让检查跑得更快首先是最基础的优化只让 lint-staged 处理必要的文件类型。很多项目的 package.json 里会写一大串规则把*.{js,ts,vue,json,md,yaml,css,scss,html}全部粗暴地丢给eslint --fix——这既不合理也不高效。正确的思路是按工具划分职责ESLint 只看代码逻辑Prettier 管格式Stylelint 管样式各司其职避免一个文件和所有工具都过一遍。其次是用好concurrency和maxArgLength。多个任务之间默认并发执行如果任务之间没有依赖关系没必要强行串行。当碰到大数量文件时合理设置maxArgLength让 lint-staged 分块执行比让它一次扛下所有参数要稳定得多。我之前在一个 monorepo 里配置了 2000 作为阈值几十个文件一次提交只花了不到三秒体验很不错。最后是充分利用--diff参数。这个参数虽然默认只处理暂存区但如果你愿意可以在 CI 里这样用对每次 MR 触发的构建执行npx lint-staged --diff origin/main...HEAD只检查比起分支新增的那些文件。这样既不用给 CI 上全量 lint又能保证每个 MR 的增量代码是干净的速度还快得离谱。7.2 团队落地建议lint-staged 这种东西一个人用是锦上添花全团队用才能真正立住规范。落地时我有几条实操建议。第一锁版本。package.json 里把 lint-staged 和 husky 的版本写死或者用锁文件固定住不要让人天天“顺手升级”。这两个工具的配置格式在不同大版本之间差异不小一旦有人悄悄升级可能导致全团队的配置突然失效。第二把配置模板化。如果团队里有多个前端项目建议把 lint-staged 的配置复制到每个项目里或者抽成一个共享的配置文件比如scope/lint-config通过包引用的方式统一维护。这样新项目开坑时不用再对着旧项目的 package.json 扒拉半天的配置。第三做好失败时的体验。pre-commit 阶段检查失败会打断开发者如果报错信息不友好很容易让人产生抵触情绪。我的建议是在 lint-staged 配置里加上--silent或者通过quiet参数减少输出噪音同时在团队文档里写明“如果 pre-commit 检查挂了第一件事是看报错文件路径而不是直接--no-verify跳过”。其实无论你怎么引导总会有人用--no-verify绕过检查所以 CI 里的全量 lint 永远不能省。这是最后一道防线也是 lint-staged 这种“轻量守门员”无法替代的地方。在团队落地过程中我个人最深的体会是lint-staged 的配置一定不要贪多求全先上 ESLint 和 Prettier跑稳之后再逐步叠加其他工具。没有人会喜欢一个“每次提交要跑四个检查”的仓库但如果是“每次提交只多花两秒钟就能自动帮你修好格式”的流程大家很快就会习惯它。工具存在的终极意义不是给开发流程添堵而是让规范变成一种不打断心流的肌肉记忆——这才是 lint-staged 最值得花时间去调教的地方。