Open Library 的 CSS 工程化指南:BEM 命名、设计令牌与 Vite 构建工作流
Open Library 的 CSS 工程化指南:BEM 命名、设计令牌与 Vite 构建工作流
导读
本文聚焦 Open Library 项目(docs/ai/css.md)中编写 CSS 的完整约定与工作流:样式源码统一存放在 static/css/,经 Vite 编译为 static/build/css/ 产物;命名采用 BEM、选择器保持扁平、垂直间距只用下边距;所有颜色、字体、z-index 与背景色必须使用语义设计令牌而非硬编码值,并由 Stylelint 在提交前强制把关。读完本文,你将掌握在该仓库中新增、修改与审查 CSS 的完整实操路径:如何命名类、如何选择令牌、如何把页面样式挂接到模板、如何规避关键渲染路径的体积限制,以及哪些浏览器特性可以放心使用。
构建工作流:源码与产物
Open Library 的 CSS 源码位于仓库根目录的 static/css/ 下,按 base、components、layout、tokens、lib 等子目录组织。样式不会直接发布,而是通过 Vite 编译到 static/build/css/:
scripts/vite/build.mjs --only css
编译产物以 page-*.css 命名(如 page-home.css、page-book.css)。其中 page-plain.css、page-admin.css 等页面级样式位于关键渲染路径上,有严格的体积上限(见下文「Bundle Size Limits」一节)。因此,开发时修改的是 static/css/ 下的源文件,而运行时页面加载的是 static/build/css/ 下的构建产物——改动样式后必须重新执行构建命令才能在页面上生效。
命名约定:BEM
在模板与全局样式中,类名一律采用 BEM(Block Element Modifier) 三层命名:
/* Block */
.book-card { }
/* Element(Block 的组成部分) */
.book-card__title { }
.book-card__cover { }
/* Modifier(Block 或 Element 的变体) */
.book-card--featured { }
.book-card__title--large { }
需要注意 BEM 的适用边界:Lit Web Components 与 Vue 组件不强制 BEM,因为它们自带 CSS 封装——Lit 使用 Shadow DOM,Vue 使用 <style scoped>——类名不会泄漏到全局,也就不需要通过 BEM 命名来防碰撞。仓库中对应的组件目录见 openlibrary/components/lit/ 与 openlibrary/components/。
选择器规则
三条规则约束选择器的写法规格:
1. 使用显式类,不要用裸元素选择器。 裸元素选择器会影响页面上的每一个同类元素,属于全局副作用:
/* Bad — 影响所有段落 */
p { margin-bottom: 1rem; }
/* Good — 限定到具体上下文 */
.book-description__text { margin-bottom: 1em; }
2. 避免用 ID 做样式。 ID 特异性极高,它应留给 JavaScript 钩子或锚点链接,而不是作为样式选择器使用。
3. 选择器尽量扁平。 过深的嵌套会让选择器难以查找与覆盖。但若必须通过嵌套才能在特异性上胜过遗留 CSS,那也是允许的——只要嵌套不超过必要深度:
/* Avoid — 不必要地深嵌套 */
.book-list .book-card .book-card__title { }
/* Prefer — 能平铺就平铺 */
.book-card__title { }
/* Acceptable — 为覆盖遗留样式而嵌套 */
.book-card .book-card__title { }
仓库用 Stylelint 约束了嵌套与特异性上限:根级 .stylelintrc.json 设置了 selector-max-specificity: "1,4,0",而 static/css/components/.stylelintrc.json 在继承根配置的基础上,对组件目录进一步收紧为 max-nesting-depth: 2 与 selector-max-specificity: "0,3,0"——即组件样式最多两层嵌套、三类特异性(元素/类/伪类层级更少)。
间距与边距
垂直方向上的间距只允许使用下边距(bottom margin),禁止使用上边距(top margin)。单向 margin 能让布局行为可预测,避免 margin 折叠带来的意外高度偏移。若需要元素上方的间距,应当由它上方元素的 bottom margin 承担,而不是给当前元素加 margin-top。
Design Tokens:永远使用语义令牌
Open Library 使用两级设计令牌体系(详见设计令牌指南):第一级是原始令牌(Primitives),如色阶、间距刻度、圆角刻度;第二级是语义令牌(Semantic Tokens),通过引用原始令牌来表达用途而非外观,例如 --color-text、--color-link、--border-radius-card。
规则:永远使用语义令牌,禁止硬编码值。 Stylelint 会直接拒绝裸十六进制颜色、命名颜色,以及 font-family、background-color、z-index、color 这四个属性的硬编码值:
/* Good — 语义令牌 */
.my-card { border-radius: var(--border-radius-card); }
/* Bad — 原始令牌 */
.my-card { border-radius: var(--border-radius-lg); }
/* Bad — 硬编码 */
.my-card { border-radius: 8px; }
如果某种用法没有现成的语义令牌,正确的做法是在 static/css/tokens/ 下的对应文件(colors.css、spacing.css、border-radius.css、typography.css、motion.css、z-index.css 等)中新建一个语义令牌,而不是退回去用原始令牌或写死数值。这一规则适用于你编写或修改的每一行声明,包括历史遗留文件——具体改造边界见 设计指南的 Scope 一节。
z-index 的专项规则
z-index 是令牌体系中最严格的一环,相关定义全部在 static/css/tokens/z-index.css 中:
- 原始层级(primitives):
--z-index-level-negative(-1)、--z-index-level-1(1)……一直到--z-index-level-17(999999)。编号之间的空缺是刻意保留的,不要填充。 - 语义层级带(semantic bands):从低到高为
--z-index-behind、--z-index-base、--z-index-raised、--z-index-sticky、--z-index-fixed、--z-index-dropdown、--z-index-modal、--z-index-overlay、--z-index-toast。页面级部件(sticky 头部、导航栏、工具栏)只使用这些语义带。 - 局部层级:
--z-index-local-1至--z-index-local-5,仅用于在设置了isolation: isolate的组件根内进行内部分层,脱离isolation就失去意义。
三条铁律:第一,不允许出现裸 z-index 数字或 --z-index-level-* 原始令牌——--z-index-level-* 只能在 static/css/tokens/z-index.css 内部被语义带引用,组件与模板中一律使用语义带或 --z-index-local-*;第二,Shadow Root 本身不构成层叠上下文,需要在祖先元素上设置 isolation: isolate;第三,同一层级带内的平局按文档顺序(DOM 先后)决出。当前 Stylelint 对 z-index 使用原始令牌的行为是警告(对应 issue #12363),待遗留消费者迁移完成后将升级为报错——新代码不要新增这类用法。
值得注意的是,现代弹层(dialog、drawer、popover、tooltip、menu)大多走浏览器顶层机制(<dialog>.showModal() 或 Popover API),根本不需要 z-index;只有页面级部件和遗留弹层才依赖上述语义带。
将 CSS 连接到模板
页面级 CSS 文件统一命名为 page-*.css,模板通过 putctx() 声明要加载哪个文件:
$putctx("cssfile", "page-book")
该值会向上传递给站点布局模板,由它在 <head> 中加载对应的样式表。仓库中的真实加载逻辑位于 openlibrary/templates/site/head.html:
$ style = 'build/css/page-%s.css'%ctx.get('cssfile', 'user')
即最终拼出 build/css/page-<cssfile>.css 的路径,未指定时回退到 user。实际模板中的用法如 openlibrary/templates/account/create.html:
$putctx("cssfile", "signup")
以及 openlibrary/templates/admin/index.html 中带默认值的写法:
$ _x = ctx.setdefault('cssfile', 'admin')
两种写法都行:putctx() 无条件设置,setdefault() 仅在未设置时生效。
Bundle Size Limits:关键渲染路径的体积红线
page-*.css 位于关键渲染路径上,有严格的体积上限。构建或 CI 若出现类似输出:
FAIL static/build/page-plain.css: 18.81KB > maxSize 18.8KB (gzip)
说明你的改动超出了该 CSS 的 gzip 后体积预算。两种合规的处理方式:
- 删除未使用的样式,腾出体积空间;
- 把样式移入 JavaScript 入口文件(如
<name>--js.css),通过 JS 加载——JS 入口的 bundlesize 阈值更高,且能将样式移出关键渲染路径。
这类上限定义在 bundlesize.config.json 中,修改样式时需留意对应产物的阈值。
Browser Support:CSS 不做转译
浏览器兼容性总体政策(MediaWiki Grade A,以 package.json 中的 browserslist 为事实来源)见 AI 指南 README 的 Browser Support 一节。对 CSS 而言有一条专门注意事项:
CSS 不会被转译。 Vite 构建时对 CSS 只运行 PostCSS 做 @import 解析与 url() 透传,不进行特性转译——没有 autoprefixer。这意味着你写的任何 CSS 特性都会原样发布到线上。因此在使用较新的 CSS 特性前,必须对照 browserslist 中最低的 Safari 版本在 caniuse 上核实支持情况。JS 会自动转译到最低目标版本,CSS 则不会,这一差异是审查 CSS 代码时的关键检查点。
强制执行:Stylelint 配置解读
上述令牌与选择器规则由根级 .stylelintrc.json 强制执行,要点如下:
- 令牌强制:
stylelint-declaration-strict-value插件对font-family、background-color、z-index、color四个属性要求使用变量(inherit、transparent、none豁免); - 颜色:
color-no-hex: true禁止十六进制颜色,color-named: "never"禁止命名颜色; - z-index:
declaration-property-value-disallowed-list禁止z-index引用--z-index-level-原始令牌(当前为 warning 级); - 其他常见规则:重复属性/长手属性(longhand)检查、重复选择器、空块、长度零值不带单位、
selector-max-specificity、未知属性/伪类/伪元素检查等。
Lit 组件样式的特殊待遇:Stylelint 通过 postcss-lit 语法解析器覆盖 openlibrary/**/*.js 中每一个 css\`字面量(包括 <a href="https://link.gitcode.com/i/cbde7732c2fc72f1ef09e6c7aa8f89e8" target="_blank">openlibrary/components/lit/</a> 下的组件与SearchModal.js这类一次性脚本)。目前对 Lit 内嵌样式只保留 **z-index 令牌规则**,颜色、特异性、重复声明等规则被关闭,直到既有违规清理完毕——**不要在 Lit 中新增这类违规**。没有css``` 字面量的 JS 文件会解析成空样式表、不产生任何报告,因此这个 glob 可以保持宽泛,新出现的内嵌样式无论落在何处都会被覆盖。
**务必不要对含 css\`字面量的文件运行stylelint --fix**:postcss-lit在回写时会重新缩进多行注释,因此lint-fix:css` 与 pre-commit 钩子只对这些文件做检查而不做修复。日常开发时,普通 CSS 文件可以放心交给自动修复,含内嵌样式的 JS 文件则只能手动修改。
小结
Open Library 的 CSS 工程化可以概括为一条清晰的主线:源码在 static/css/,经 Vite 编译到 static/build/css/;类名用 BEM,选择器求扁平;视觉值全部来自语义设计令牌,并由 Stylelint 在提交前强制;页面样式经 putctx("cssfile", …) 挂接,关键路径产物受 bundlesize 约束;浏览器特性按 browserslist 的 Safari 下限人工核实,因为 CSS 不做转译。 遵循这套约定,你的样式既能与既有代码库保持一致,也能在未来的令牌重映射(如深色模式)中自动受益。