上个月在给团队搭企业级 Monorepo 工程化模板的时候,一位同事正好去面某大厂,笔试里出现一道工程化题:在 Monorepo 架构下,如何保证多包之间的样式规范统一?他当场愣住——ESLint 的配置背得滚瓜烂熟,Stylelint 却完全没系统研究过。回来跟我吐槽,说样式代码平时全靠自觉,真要落到工程化就抓瞎了。我笑了笑没说话,因为就在那周,我刚把 Stylelint 从根目录一路配到十几个子包,踩坑踩到怀疑人生,最后沉淀出一套可复用的方法论。这篇就把完整过程整理出来,从方案选型、配置拆解、CI 联动到各种报错排查,不说废话,全部基于可复现的实践。
1. 为什么 Monorepo 模板里 Stylelint 是刚需
1.1 从一次样式事故说起
先讲一个真实事故。两个月前,我们 C 端 React 子包里有位同事在公共样式文件里改了一个全局变量,把某个基础色值从#1677ff调整成了#1668dc。本意是让统一品牌色更耐看,但他改的是公共层级的 SCSS 变量,而这个变量被 B 端中台子包直接引用。结果就是中台系统里所有按钮、链接、选中态的颜色一夜之间全变了,用户在工单系统里截图投诉了三次,最后运维把版本回滚才压住事态。
复盘的时候大家发现,问题根源根本不是“这个颜色该不该改”,而是 Monorepo 模式下样式文件的引用关系太隐蔽了。你在子包 A 里写的变量、mixin、函数,很可能被子包 B、C 以相对路径或 workspace 协议直接引用。代码编译能过、构建能过,但视觉细节悄无声息地崩了。如果当时 Stylelint 已经上线,并且配置了全局变量的只读规则、不允许子包直接引用公共层变量,这种事故在提交阶段就会被拦截。
很多人觉得 lint 是为了“看起来舒服”,但把视角拉高一点,Stylelint 在 Monorepo 里的核心价值是“建立样式代码的边界意识”。它能让一个几千人协作的大仓库重新变得可控:谁改了公共样式、谁引入了非法颜色值、谁写了非标准语法,机器会第一时间告诉你。
1.2 Stylelint 在样式规范体系中的定位
前端领域提“代码规范”时,大家第一反应是 ESLint。JS/TS 的静态检查深入人心,但样式这块长期是被忽视的。ESLint 管的是逻辑、变量、语法范式,而 CSS/SCSS/Less 这些样式代码同样有语法错误、重复属性、无效颜色、未使用的变量、属性顺序混乱等问题,这些靠 code review 根本看不全。
Stylelint 就是样式世界的 ESLint。它的定位很明确:对 CSS 及其预处理器语法做静态分析,发现问题、自动修复、统一风格。放在 Monorepo 工程化模板里,Stylelint 解决的问题可以拆成三层:
第一层,语法与合法性检查。比如color: #fff;后面又写了一个color: #efefef这种重复属性,block-no-empty空规则块,property-no-unknown未知属性,这些是底线问题。
第二层,可维护性约束。比如 hex 颜色是否可以用简写#ffffff写成#fff,选择器嵌套深度是否超过三层,!important是否被禁用,类名单词是否用连字符而不是下划线。
第三层,团队风格统一。比如声明块的属性排序,position永远在display前面,display在flex相关属性前面,这种视觉秩序在小项目里无所谓,但在大 Monorepo 里,几十个人同时改共享样式,没有自动化的顺序管理和风格约束,代码会迅速变成一锅粥。
所以我的结论很直接:Monorepo 工程化模板里,ESLint 和 Stylelint 必须同时存在,缺一个都不算完整的工程化。
2. 从零初始化 Stylelint:版本选型与基础落地
2.1 工具链版本怎么选
搭建 Monorepo 模板的第一步不是写配置,而是选版本。这里我吃过亏,先重点说版本问题。
当前(我写这篇时)Stylelint 已经进入 v16 时代。v14 有一批 deprecated 的规则在 v15 正式移除,v16 又干了一件大事:移除了所有 stylistic 相关的规则,官方理由是“样式风格问题应该交给格式化工具,而不是 lint 工具”。这意味着过去很多人习惯的stylelint-config-prettier已经没有必要,同时你不能再指望用 Stylelint 去检查“缩进应该是 2 空格还是 4 空格”“字符串用单引号还是双引号”这类纯美学的规则。
这个决策我一开始挺反感,觉得官方在“削功能”。但用得久了才理解:stylistic 规则和 Prettier 天然冲突,两个工具互相打架,你改我、我改你,最后只能靠stylelint-config-prettier关掉一堆规则来求和。v16 直接把这个问题从根上解决——Stylelint 专注语义和错误检查,格式问题全权交给 Prettier。这种方式对 Monorepo 特别友好,因为模板里同时存在 Prettier 配置,两套工具的职责边界清晰,新人接手时不用猜。
版本选型上,我建议直接用 v16,不要抱着 v15 的旧项目配置搬家。v16 的 breaking changes 并不多,最核心就是上面说的移除 stylistic 规则、需要 Node 18.12+,以及废弃的配置格式不再兼容。如果你用 Vite 构建,Vite 5+ 已经适配 v16 没有问题。
2.2 最小可用配置长什么样
我先给出一份最基础的.stylelintrc.json,这份配置可以直接放进 Monorepo 根目录,所有子包共享:
{ "extends": [ "stylelint-config-standard" ], "ignoreFiles": [ "**/dist/**", "**/node_modules/**", "**/coverage/**" ], "rules": { "color-no-invalid-hex": true, "block-no-empty": true, "declaration-block-no-duplicate-properties": true, "selector-class-pattern": "^[a-z][a-zA-Z0-9-]*$" } }安装依赖时需要区分清楚,stylelint本身是运行时,stylelint-config-standard是规则集。两个都要装到根目录 devDependencies:
pnpm add -D stylelint stylelint-config-standard这份最小配置能做什么?color-no-invalid-hex拦截#ffGG00这种瞎写的颜色;block-no-empty拦截空样式块;declaration-block-no-duplicate-properties拦截同一声明块里重复的相同属性;selector-class-pattern强制类名用小驼峰或连字符风格。
这里要特别说明一个常见误区:很多人以为“装了 Stylelint 就自动生效”,其实 Stylelint 自带默认规则是关闭的,你必须显式引入规则集或者手动配规则。extends字段加载stylelint-config-standard时,它已经把一百多条规则按官方推荐值打开,你只需要在此基础上覆盖少量个性化项。
2.3 为什么选 pnpm workspace 管理依赖
既然标题是 Monorepo 模板,必然涉及包管理器的选择。我在模板里用的是 pnpm workspace,这不是因为它“最火”,而是依赖管理机制和 Stylelint 天然契合。
pnpm 的 node_modules 结构和 npm/yarn 不一样,它是硬链接+符号链接的组合。对 Stylelint 这种插件生态丰富的工具来说,最难受的问题就是“版本冲突”。npm 传统 node_modules 里,主项目装了一个 Stylelint,某个子包又装了自己的插件和自定义配置,极容易因为 peerDependencies 版本不一致导致插件加载失败。pnpm 的严格依赖隔离虽然偶尔会让人抓狂,但从另一方面逼迫你用 workspace:^ 协议统一版本,反而让 Stylelint 及插件在多个子包之间共享同一套二进制和规则集,避免“每个包都有自己的一份 Stylelint”这种诡异状态。
模板根目录的 pnpm-workspace.yaml 长这样:
packages: - 'packages/*'我建议所有stylelint相关依赖都只装在根目录,子包不要单独安装。这样 CI 安装依赖时只需要跑一次,Stylelint 的规则也被各包共享,配置心智负担最小。
3. Monorepo 多包场景下 Stylelint 配置策略
3.1 共享配置与包级覆盖
这是 Monorepo 和普通单包项目最大的区别。单包项目里,一份.stylelintrc.json放在根部就完事了。Monorepo 里,每个子包有各自的职责和样式选型,比如packages/button用纯 CSS,packages/admin用 SCSS,packages/mobile用 styled-components。你不能用一套规则硬压所有场景,也不能放弃统一放任自流。
我的方案是:根目录放一份基础共享配置,包含所有包必须遵守的底线规则;每个子包通过overrides字段做差异化覆盖。Stylelint 的overrides支持按 glob 匹配文件路径,配合files和子包目录,可以实现精确控制。
比如根目录配置可以这样写:
{ "extends": ["stylelint-config-standard"], "overrides": [ { "files": ["packages/admin/**/*.scss"], "customSyntax": "postcss-scss", "rules": { "max-nesting-depth": 3 } }, { "files": ["packages/mobile/**/*.{ts,tsx}"], "customSyntax": "@stylelint/postcss-css-in-js", "rules": { "color-hex-case": "lower" } } ] }overrides的匹配规则是从配置目录开始算的。如果配置放在根目录,packages/admin/**/*.scss就能命中子包下所有 SCSS 文件。如果某个子包想要它的个性化规则,在子包内放一个.stylelintrc.json也会被自动合并,但有个优先级坑我在后面避坑章节专门说。
对于子包级配置,我推荐一个很实用的技巧:在子包的package.json里加一条快捷脚本:
{ "scripts": { "stylelint": "stylelint \"src/**/*.{css,scss}\" --fix" } }这样每个子包可以自主执行本地 Stylelint 修复,但共享规则仍然是全局统一的那一套。
3.2 与 VS Code 和 CI 的联动
工程化模板的完整度,要看开发体验和自动化链路是否打通。
VS Code 侧需要装 Stylelint 官方插件,然后在项目根目录放一份.vscode/settings.json:
{ "stylelint.validate": ["css", "scss", "less", "vue", "tsx"], "editor.codeActionsOnSave": { "source.fixAll.stylelint": "explicit" } }这里explicit是新版编辑器对 codeActionsOnSave 的值写法,老版本可能要求true,具体看你项目锁定的 VS Code 版本。配置完之后,保存文件时 Stylelint 会自动按规则集修复,ESLint 走 ESLint 的修复,两者互不干扰。
CI 侧推荐用lint-staged配合,实现“提交时只检查暂存的样式文件”。在根目录 package.json 里配置:
{ "lint-staged": { "*.{css,scss,less}": [ "stylelint --fix", "prettier --write" ], "*.{ts,tsx,js,jsx}": [ "eslint --fix", "prettier --write" ] } }再加上 husky 的 pre-commit 钩子,开发者在提交代码时,Stylelint 只对本次变更的样式文件跑检查,速度极快,团队基本不需要关心“执行全局 lint”的过程。
3.3 样式文件该不该放进 ESLint 的检查范围
我见过不少团队的做法是,用 ESLint 附带的eslint-plugin-css去检查 CSS。这个方向我不是很赞同。ESLint 是基于 JS 解析器的,处理 CSS 需要走 custom parser 路线,效率低,规则覆盖也远不如专门做样式检查的 Stylelint 全面。比如 CSS 变量未定义、颜色格式错误、属性值不合法这些,Stylelint 是原生支持,ESLint 则需要靠插件模拟。
在 Monorepo 模板里,工具职责越单一越容易排查问题。JS 归 ESLint,样式归 Stylelint,格式归 Prettier,三者边界清晰。你不需要在 ESLint 配置文件里写一堆“我该怎么处理 .css 文件”的 hack。
4. 企业级样式规则集设计
4.1 规则分级:底线、推荐、个性化
企业级模板不能把所有规则一把梭,不然新项目接入时会被成百上千条报错淹没,开发同学直接原地爆炸。我习惯把 Stylelint 规则分成三级:
底线规则:不管什么项目,不管什么风格,都必须遵守。比如block-no-empty(空块)、color-no-invalid-hex(无效颜色)、property-no-unknown(未知属性)、string-no-newline(字符串禁止换行)。这些规则直接对应“代码写错”的场景,必须开启,没有讨论空间。
推荐规则:默认打开,但允许子包按需覆盖。比如max-nesting-depth(最大嵌套深度)、selector-max-id(限制使用 ID 选择器)、declaration-block-no-duplicate-properties(禁止重复属性)。这类规则在大部分项目都能成立,但总有人有特殊场景,所以保留覆盖通道。
个性化规则:这是企业规范的核心差异点。包括 CSS 属性排序、类命名风格、颜色值书写格式、是否允许!important。这些规则没有绝对的对错,完全取决于团队之前的代码习惯。我建议在模板里先开最小集,等项目跑了一段时间、大家都有体感之后,再通过团队讨论逐步增加。
下面这张表可以直接抄,作为规则集的起点:
| 规则 | 作用 | 级别 |
|---|---|---|
block-no-empty | 禁止空样式块 | 底线 |
color-no-invalid-hex | 禁止非法十六进制颜色 | 底线 |
declaration-block-no-duplicate-properties | 禁止同一声明块重复属性 | 底线 |
max-nesting-depth | 限制 SCSS 嵌套深度 | 推荐 |
selector-max-id | 禁止 ID 选择器 | 推荐 |
color-hex-length | 十六进制颜色长度统一 | 个性 |
order/properties-order | 属性书写顺序 | 个性 |
unit-allowed-list | 限制允许使用的单位 | 个性 |
4.2 CSS 属性顺序与排序插件
属性顺序是样式规范里最容易被忽略、却对阅读体验影响最大的点。一段 CSS 如果写两三百行,position、display、flex、margin、padding、color、font-size穿插出现,任何维护者都得来回滚动才能理清一个元素的空间布局关系。
我使用的方案是stylelint-config-recess-order。它依照一个经典的排序逻辑:先盒模型(位置、尺寸、内边距、边框、外边距),再排版(字体、颜色、背景),最后是其他视觉效果。安装:
pnpm add -D stylelint-config-recess-order在配置里 extended:
{ "extends": [ "stylelint-config-standard", "stylelint-config-recess-order" ] }启用之后,Stylelint 会自动检查每个声明块里的属性顺序,--fix可以自动排序。这块对 Monorepo 的价值在于,多个团队同时维护共享样式时,不再需要人工 review“你这个顺序不对”这种争吵,机器统一了节奏。
4.3 SCSS、Tailwind 和 CSS-in-JS 的共存策略
Monorepo 模板里不可能只有纯 CSS,常见场景是:有的包用 SCSS 写组件库,有的包用 Tailwind 做业务页面,还有的包用 styled-components 或 @emotion 在 TS 文件里写样式。
Stylelint 对这三类场景的接入方式各不相同:
SCSS 场景用postcss-scss作为 customSyntax。装包:
pnpm add -D postcss-scss配置里加:
{ "customSyntax": "postcss-scss" }加了之后,Stylelint 就能解析@mixin、@include、$variable这些 SCSS 特有语法,而不是把它们当非法 CSS 报错。
Tailwind 场景:Tailwind 的@apply、@tailwind指令和 PostCSS 的@config这类自定义 at-rule,直接跑标准 Stylelint 会报at-rule-no-unknown。官方推荐在 rules 里加白名单:
{ "rules": { "at-rule-no-unknown": [true, { "ignoreAtRules": ["tailwind", "apply", "config", "screen"] }] } }CSS-in-JS 场景,比如styled-components,需要在 TS/TSX 文件里检查模板字符串内的样式。这时要用@stylelint/postcss-css-in-js作为 customSyntax,同时需要单独写一个 Stylelint 入口,因为配置文件默认不会去扫 TS 文件。我在模板里的做法是:
{ "customSyntax": "@stylelint/postcss-css-in-js" }然后在lint-staged里增加对.tsx文件的 stylelint 检查。要注意的是,CSS-in-JS 环境下color-no-invalid-hex等规则仍然生效,但selector-class-pattern这类依赖选择器上下文的规则往往要关掉,因为模板字符串里的内容大部分是动态插值。
5. 避坑指南:我踩过的那些 Stylelint 坑
5.1 插件版本冲突导致“配置不生效”
第一个坑来自插件版本。装stylelint-config-recess-order时,如果不注意它内置依赖的 Stylelint 版本,极容易出现“配置加载成功,但规则完全不生效”的诡异情况。
我遇到的现象是:运行stylelint命令没有报错,但属性顺序完全不检查。排查到最后,是stylelint-config-recess-order内部声明依赖stylelint@^14,而我的项目装的是 v16,peerDependency 没满足,导致插件加载时被静默忽略。
解决办法很简单:装完所有 Stylelint 插件后,跑一次stylelint --version确认根目录实际加载的版本,再执行pnpm why stylelint查看依赖树里是否有多个版本混用。如果出现了树形结构里两个不同大版本并存,优先在根 package.json 里用pnpm.overrides强制锁定 Stylelint 版本:
{ "pnpm": { "overrides": { "stylelint": "^16.0.0" } } }5.2overrides与子包配置的优先级陷阱
Monorepo 第二坑是子包配置覆盖根配置时,overrides的匹配顺序和子包配置的合并逻辑很容易搞混。
我最初在packages/admin里放了子包级.stylelintrc.json,希望它只覆盖自己目录下的 SCSS 规则。但实际执行时 Stylelint 会找“离文件最近”的配置文件,如果子包自己有一份完整配置,它不会自动合并根配置的extends,而是整体替换。结果就是子包里的 Stylelint 变成“裸奔”,一百多条标准规则全部失效。
正确的做法是:子包配置里显式继承根配置:
{ "extends": ["../../.stylelintrc.json"], "rules": { "max-nesting-depth": 4 } }如果只是想在某些场景改几条规则,优先用根配置的overrides,而不是分发多份子包配置。
5.3 误伤 CSS Modules 里的:global和变量命名
CSS Modules 在 Monorepo 里很常见,它的.module.scss文件里经常出现:global(.ant-btn)覆盖第三方组件样式的写法,以及$--foobar这类 BEM 风格变量。默认的selector-class-pattern和custom-property-pattern会把这些全部报错。
我处理这类问题的方式是,在overrides里单独为*.module.scss设置宽松规则:
{ "files": ["**/*.module.scss"], "rules": { "selector-class-pattern": null, "custom-property-pattern": null } }这个配置的收益很大:正常源代码用严格规范,第三方样式覆盖场景放行,不会因为 lint 报错强迫开发者写一堆stylelint-disable注释。
5.4 性能问题:大仓库扫描慢
Monorepo 规模上来后,Stylelint 全量扫描几百个 SCSS 文件可能要跑十几秒,这在 CI 里很难接受。
我的优化手段有三板斧:
第一,用ignoreFiles把dist、node_modules、coverage、PNG/SVG 等非目标文件排除在外。
第二,在lint-staged里只检查暂存文件,避免全量扫描。这招效果立竿见影,提交时基本感觉不到 lint 的存在。
第三,给 CI 加缓存。GitHub Actions 或 GitLab CI 里,依赖安装阶段用缓存目录挂载node_modules/.cache/stylelint,Stylelint 自带了基于文件元数据的缓存机制。命令里加--cache --cache-location node_modules/.cache/stylelint/.stylelintcache即可:
stylelint "packages/**/*.{css,scss}" --cache --cache-location node_modules/.cache/stylelint/.stylelintcache5.5 vue 文件里 style 块检查
Vue 单文件组件里<style>块需要额外配置。如果只是把stylelint跑在.vue文件上,默认是解析不了的。我的配置是在overrides里加:
{ "files": ["**/*.vue"], "customSyntax": "postcss-html", "rules": { "no-empty-source": null } }postcss-html可以让 Stylelint 正确识别<script>、<template>、<style>的分隔边界。同理,.astro文件也可以通过自定义postcss-html语法支持。
6. 常见问题速查表与调试技巧
这一节我把实际操作中遇到频率最高的问题整理成速查表,后面谁遇到可以直接照着查。
| 现象 | 可能原因 | 排查/解决 |
|---|---|---|
| 运行 Stylelint 没有任何输出 | 配置里没有打开任何规则 | 检查 extends 是否引入规则集 |
| SCSS @mixin 被报语法错误 | 没有配置 postcss-scss | 安装 postcss-scss 并设置 customSyntax |
| Tailwind @apply 被报 at-rule-no-unknown | 默认规则不认识自定义 at-rule | ignoreAtRules 加 tailwind/apply |
.vue文件的 style 块不检查 | 缺 postcss-html syntax | overrides 配置 customSyntax |
| --fix 之后代码格式被改乱 | 和 Prettier 冲突 | v16 已移除 stylistic 规则,用 Prettier 管格式 |
| 子包配置不生效/规则失效 | 子包配置覆盖了根 extends | 子包 extends 显式继承根配置 |
| CI 里偶现出不来 lint 错误 | 缓存了旧文件状态 | 加 stylelint --cache 后注意清缓存 |
| VS Code 保存时不自动修复 | settings.json 权限或插件未识别 | 确认 stylelint.enable=true 和 validate 配置 |
排查工具方面,我强烈建议用stylelint --debug看配置加载过程,它会输出每个 glob 命中的文件列表和 config 对象内容。这在 Monorepo 多级配置下是救命稻草,能够快速定位“哪个文件被哪份配置接管了”。
还有一个效率技巧:把 Stylelint 命令做成根目录的统一脚本,子包不需要重复写:
"scripts": { "lint:style": "stylelint \"packages/**/*.{css,scss,less}\" --cache --fix" }这样 CICD 和本地执行走同一条命令,我又在脚本里加了--formatter table,报错信息对齐成表格,一眼就能看到文件名、行号、列号和建议,人都不用开编辑器就能判断问题。
7. 把 Stylelint 模板化之后,团队获得了什么
这块算是我个人的实践收尾,不展开长篇大论。
搭建这套 Monorepo Stylelint 模板,前前后后花了两周,其中至少一半时间在踩版本和插件兼容性的坑。但沉淀成模板之后,收益立刻显现。新接入的子包,开发者安装完依赖,.vscode/settings.json和.stylelintrc.json直接用根目录那份,保存文件自动修复,提交代码自动检查,从第一天起写出来的样式就是符合规范的。
我个人最深的一点体会是:样式规范这件事,靠文档约定是维持不住的,必须靠工具强制。文档写一百遍“属性要排序”,不如在 CI 里跑一次 Stylelint 直接让不排序的代码无法合入。尤其是 Monorepo 这种多团队协作场景,公共样式共享面积大,一次低级错误的影响面可能被放大到所有子包。有一层自动化的底层防线,比任何评审流程都可靠。
如果你正在搭自己的 Monorepo 模板,我建议不要一开始追求大而全的规则集,先配置好stylelint-config-standard这一套底线,再根据自己的技术栈把 SCSS、Tailwind、Vue 的 customSyntax 配好,跑通 VS Code 保存修复和 CI 拦截链路。之后有精力了,再逐步加入属性排序、颜色格式、命名约束这类个性化规则。工具先入场,规则慢慢磨合,这样团队阻力最小,规范也最容易落地。
最后再分享一个我常用的“后手”:给样式规范迭代留一个README-STYLELINT.md,每次团队讨论新增或调整规则时,把理由和示例写进去,而不是只改配置。编码规范最怕“规则在,但没人记得为什么”。有历史记录,后来的人维护规则时才不会把它改成另一套风格。