Vite 设计与实现
第10章 CSS 处理引擎
第10章 CSS 处理引擎
开篇引言
plugins/css.ts 是 Vite 整个代码库中最大的单一文件——超过 3500 行代码。这不是偶然的。CSS 处理面临的复杂性远超 JavaScript:预处理器(Sass、Less、Stylus)、PostCSS 生态、CSS Modules、Lightning CSS、url() 路径重写、@import 内联、source map 合并、开发模式的 HMR 注入、构建模式的代码分割和资源提取——所有这些功能都汇聚在一个插件中。
更挑战的是,CSS 在开发模式和构建模式下的处理逻辑截然不同。开发模式下,CSS 被转换为注入 <style> 标签的 JavaScript 模块,支持 HMR 热替换;构建模式下,CSS 被提取为独立文件,支持代码分割和压缩。这两条路径共享预处理和 PostCSS 管线,但在输出阶段完全分叉。
本章将拆解这个庞大文件的内部架构,理解每一层处理的设计意图。
本章要点
- CSS 插件的三层架构:
cssPlugin、cssPostPlugin、cssAnalysisPlugin - 预处理器集成的 Worker 池化设计
- PostCSS 处理管线与插件编排
- CSS Modules 的作用域隔离实现
- 开发模式下的 CSS HMR 机制
- 构建模式下的 CSS 代码分割与资源提取
- Lightning CSS 作为替代 transformer 的集成方式
url()和@import的路径重写策略
CSS 插件的三层架构
CSS 处理不是由一个插件完成的,而是由三个协作的插件组成:
graph LR
subgraph "CSS 处理三件套"
A["cssPlugin<br/>(vite:css)<br/>预处理 + PostCSS"] --> B["用户插件"]
B --> C["cssPostPlugin<br/>(vite:css-post)<br/>HMR 注入 / 构建提取"]
C --> D["cssAnalysisPlugin<br/>(vite:css-analysis)<br/>HMR 依赖跟踪"]
end
E[".scss 源文件"] --> A
D --> F["浏览器"]
style A fill:#e8f4fd,stroke:#1890ff
style C fill:#f6ffed,stroke:#52c41a
style D fill:#fff7e6,stroke:#fa8c16
cssPlugin(vite:css):在用户插件之前执行。负责预处理器编译(Sass -> CSS)和 PostCSS 转换。它的输出是纯 CSS 文本。
cssPostPlugin(vite:css-post):在用户插件之后执行。将 CSS 文本转换为 JavaScript 模块(开发模式),或提取为独立文件(构建模式)。
cssAnalysisPlugin(vite:css-analysis):仅在开发模式下生效。负责跟踪 CSS 的 @import 依赖关系,用于 HMR。
这种三层分离的设计有一个重要的原因:用户插件可能需要在预处理和最终输出之间插入自己的 CSS 转换逻辑。如果所有功能都在一个插件中,用户将无法在这些阶段之间介入。
10.1-bis 源码核对:三层 WeakMap 各自是什么
打开 plugins/css.ts:269-285,三层 WeakMap 缓存是整个 CSS 插件状态管理的骨架:
const cssModulesCache = new WeakMap<
ResolvedConfig,
Map<string, Record<string, string>>
>()
export const removedPureCssFilesCache: WeakMap<
ResolvedConfig,
Map<string, RenderedChunk>
> = new WeakMap()
// Used only if the config doesn't code-split CSS (builds a single CSS file)
export const cssBundleNameCache: WeakMap<ResolvedConfig, string> = new WeakMap()
const postcssConfigCache = new WeakMap<
ResolvedConfig,
PostCSSConfigResult | null | Promise<PostCSSConfigResult | null>
>()
四层缓存、四个用途:
1、cssModulesCache:ResolvedConfig → (id → CSS modules export map)。每个 CSS Module 文件编译后产出一个 class name 到 hashed name 的映射(比如 {card: 'card_7f3k2'})——cssPlugin 算一次、cssPostPlugin 用一次(生成 export default {card: 'card_7f3k2'})——跨插件传递靠这个 WeakMap。
2、removedPureCssFilesCache:记录”编译后纯 CSS,没有 JS 副作用”的文件。Vite 在 build 时会把这些文件从 JS bundle 里摘出来——它们原本只是为了把 CSS 作为副作用引入,摘掉后 JS bundle 少几 KB。但 Vite 需要在后续步骤里知道”这些文件的 CSS 去哪了”——通过这个 cache 追踪。
3、cssBundleNameCache:单文件 CSS 输出时(cssCodeSplit: false)存输出文件名。只有一个 CSS 输出文件、所有 chunk 都引用它——这个名字在插件多次调用之间共享。
4、postcssConfigCache:PostCSS 配置解析结果。注意这个类型是 PostCSSConfigResult | null | Promise<PostCSSConfigResult | null>——能缓存 Promise。这是个精巧的设计:插件初始化时的 resolvePostcssConfig(config).catch(() => {}) “预热”会 return 一个 Promise——立刻缓存进 WeakMap——后续真正需要配置时 await 这个缓存的 Promise 就行,第一次调用的时延被隐藏在并发初始化里。
所有四层都用 WeakMap 而不是 Map——config 对象被 GC 时缓存自动清理。对 Vite 这种可能在同一进程里启动多次(比如测试场景、多配置并行)的工具,WeakMap 避免了”config 都回收了、缓存还占着内存”的典型泄漏。
这条 “WeakMap 作为框架级缓存容器” 的实践在 §17.1.9 讲 Vite Worker 插件时也出现过——vite 核心代码里几乎所有 per-config 状态都用 WeakMap 存储,形成一致风格。
cssPlugin:预处理与 PostCSS
10.2-bis 源码核对:cssPlugin 构造时的 postcss 配置预热
cssPlugin 的返回之前有一段极易错过但影响性能的代码(css.ts:308-313):
// warm up cache for resolved postcss config
if (config.css.transformer !== 'lightningcss') {
resolvePostcssConfig(config).catch(() => {
/* will be handled later */
})
}
这段在 cssPlugin 被 Vite 实例化的时候(不是 buildStart)就发起 PostCSS 配置解析——此时它可能在漫长的 IO(找 postcss.config.js、遍历父目录、import() 配置文件)。把 Promise 甩进 WeakMap 缓存里,什么也不等。
为什么要在构造时预热?因为第一个 CSS 文件的转换请求到来时,如果这时才去解析 postcss config,那个文件的编译会被阻塞几十到几百毫秒(特别是首次 cold start)。把 IO 挪到插件构造阶段——此时 Vite 还在初始化其他东西、IO 和 CPU 可以并行——等到真正需要配置时 Promise 通常已经 resolve、await 立刻拿值。
.catch(() => { /* will be handled later */ }) 是必须的——如果 postcss config 有语法错误、预热 catch 吞掉、等真正用 config 时在用户请求第一个 CSS 文件的上下文里再抛——这时错误消息能带上”编译哪个文件时失败了”的上下文,更有诊断价值。错误不是被吞了、是被延后到更合适的时机重现。
这条”预热 + 延后报错”的 pattern 在 Vite 源码里多次出现——比如第 7 章讲过的 depsOptimizer 初始化、第 9 章讲过的 getTSConfigResolutionCache。Vite 作为高性能 dev server 的关键哲学之一:尽可能把 IO 塞进启动阶段的空隙里、让用户请求来了之后的路径纯 CPU。
初始化与 Worker 池
cssPlugin 在 buildStart 钩子中初始化预处理器的 Worker 池:
export function cssPlugin(config: ResolvedConfig): Plugin {
let preprocessorWorkerController: PreprocessorWorkerController | undefined
return {
name: 'vite:css',
buildStart() {
moduleCache = new Map<string, Record<string, string>>()
cssModulesCache.set(config, moduleCache)
preprocessorWorkerController = createPreprocessorWorkerController(
normalizeMaxWorkers(config.css.preprocessorMaxWorkers),
)
},
buildEnd() {
preprocessorWorkerController?.close()
},
// ...
}
}
preprocessorMaxWorkers 默认值为 true,表示使用 CPU 核心数减 1 个 Worker。这对大型项目中大量 Sass/Less 文件的并行编译至关重要——预处理器通常是 CPU 密集型的操作。
CSS 编译管线
compileCSS 是整个 CSS 处理的核心调度函数。它编排了从源文件到最终 CSS 的完整流程:
flowchart TD
Start["compileCSS(id, code)"] --> Lang{检测语言类型}
Lang -->|scss/sass/less/styl| Pre["compileCSSPreprocessors<br/>预处理器编译"]
Lang -->|sss + lightningcss| Sugar["transformSugarSS<br/>SugarSS 转换"]
Lang -->|css| Skip[跳过预处理]
Pre --> Transform{config.css.transformer?}
Sugar --> Transform
Skip --> Transform
Transform -->|postcss| PostCSS["compilePostCSS<br/>PostCSS 处理"]
Transform -->|lightningcss| LCSS["compileLightningCSS<br/>Lightning CSS 处理"]
PostCSS --> Result["返回结果<br/>{code, map, modules, deps}"]
LCSS --> Result
style Pre fill:#e8f4fd,stroke:#1890ff
style PostCSS fill:#f6ffed,stroke:#52c41a
style LCSS fill:#fff7e6,stroke:#fa8c16
async function compileCSS(
environment: PartialEnvironment,
id: string,
code: string,
workerController: PreprocessorWorkerController,
urlResolver?: CssUrlResolver,
) {
const lang = CSS_LANGS_RE.exec(id)?.[1] as CssLang | undefined
const deps = new Set<string>()
// 第一阶段:预处理器
let preprocessorMap: ExistingRawSourceMap | undefined
if (isPreProcessor(lang)) {
const preprocessorResult = await compileCSSPreprocessors(
environment, id, lang, code, workerController,
)
code = preprocessorResult.code
preprocessorMap = preprocessorResult.map
preprocessorResult.deps?.forEach((dep) => deps.add(dep))
}
// 第二阶段:CSS 转换器
const transformResult = await (
config.css.transformer === 'lightningcss'
? compileLightningCSS(environment, id, code, deps, ...)
: compilePostCSS(environment, id, code, deps, lang, ...)
)
// 合并 source map
return {
...transformResult,
map: config.css.devSourcemap
? combineSourcemapsIfExists(cleanUrl(id),
transformResult.map, preprocessorMap)
: { mappings: '' },
deps,
}
}
预处理器集成
预处理器的调用通过 compileCSSPreprocessors 函数封装。每种预处理器有独立的解析器配置:
async function compileCSSPreprocessors(
environment: PartialEnvironment,
id: string,
lang: PreprocessLang,
code: string,
workerController: PreprocessorWorkerController,
) {
const { preprocessorOptions, devSourcemap } = config.css
const atImportResolvers = getAtImportResolvers(
environment.getTopLevelConfig(),
)
const opts = {
...((preprocessorOptions && preprocessorOptions[lang]) || {}),
filename: cleanUrl(id),
enableSourcemap: devSourcemap ?? false,
}
const preProcessor = workerController[lang]
const preprocessResult = await preProcessor(
environment, code, config.root, opts, atImportResolvers,
)
if (preprocessResult.error) {
throw preprocessResult.error
}
// 过滤自引用依赖
let deps: Set<string> | undefined
if (preprocessResult.deps.length > 0) {
const normalizedFilename = normalizePath(opts.filename)
deps = new Set(
[...preprocessResult.deps].filter(
(dep) => normalizePath(dep) !== normalizedFilename,
),
)
}
return { code: preprocessResult.code, map: ..., deps }
}
10.3-bis 源码核对:compileCSSPreprocessors 的 self-dep 过滤
§10.2 给了预处理器集成代码。真实 compileCSSPreprocessors(css.ts:1348-1403)里最后一段处理值得拆开:
let deps: Set<string> | undefined
if (preprocessResult.deps.length > 0) {
const normalizedFilename = normalizePath(opts.filename)
// sometimes sass registers the file itself as a dep
deps = new Set(
[...preprocessResult.deps].filter(
(dep) => normalizePath(dep) !== normalizedFilename,
),
)
}
sass 预处理器会把自己”注册”为自己的依赖——foo.scss 编译时,它在 deps 里报告自己是 foo.scss。如果不过滤,Vite 的 module graph 里会形成 foo.scss → foo.scss 的自循环依赖。
这条过滤看起来是一行代码——filter((dep) => normalizePath(dep) !== normalizedFilename)——但它处理的是 sass 实现的一个微妙行为。多数开发者永远不会注意到这个问题(因为 Vite 帮他们兜住了)——但如果你自己在另一个项目里直接调 sass.compile、不做这个过滤——会发现 watcher 报告”foo.scss 变了”的时候,又会触发”foo.scss 变了”的事件——无限循环直到 CPU 100%。
Vite 源码注释 “sometimes sass registers the file itself as a dep”——用 “sometimes” 暗示这条行为在 sass 的多个版本里都出现过。这是一种防御性编程——即使 sass 未来修了这个行为、Vite 的过滤代码照常工作(不过是多一次 set.filter 而已)。
10.3-ter 源码核对:getAtImportResolvers 的 WeakMap 缓存
继续看 css.ts:1405-1416:
const configToAtImportResolvers = new WeakMap<
ResolvedConfig,
CSSAtImportResolvers
>()
function getAtImportResolvers(config: ResolvedConfig) {
let atImportResolvers = configToAtImportResolvers.get(config)
if (!atImportResolvers) {
atImportResolvers = createCSSResolvers(config)
configToAtImportResolvers.set(config, atImportResolvers)
}
return atImportResolvers
}
又一个 WeakMap。这次存的是 “CSS 的路径解析器”——它是一个包含 css/sass/less 三个子 resolver 的对象(每种预处理器的 @import 语义略有不同,需要独立解析器)。
为什么要缓存?createCSSResolvers 很贵——它要读 tsconfig-like 的 paths、读取 resolve.alias、构造复杂的 Node.js resolve 逻辑——几十到几百毫秒。每编译一个 .scss 文件都要用 atImportResolvers——如果每次都 create,dev server 打开一个有 200 个 scss 文件的项目,初次加载慢几秒。
WeakMap 让同一个 config 下只 create 一次——后续每个文件的 CSS 编译都享受缓存。这是 Vite 源码里”惰性初始化 + WeakMap 缓存”模式的又一例。
@import 解析器
每种 CSS 方言有专属的路径解析器,配置了不同的文件扩展名和 package.json 条件:
function createCSSResolvers(config: ResolvedConfig): CSSAtImportResolvers {
return {
get css() {
return createBackCompatIdResolver(config, {
extensions: ['.css'],
mainFields: ['style'],
conditions: ['style', DEV_PROD_CONDITION],
tryIndex: false,
preferRelative: true,
})
},
get sass() {
return createBackCompatIdResolver(config, {
extensions: ['.scss', '.sass', '.css'],
mainFields: ['sass', 'style'],
conditions: ['sass', 'style', DEV_PROD_CONDITION],
tryIndex: true,
tryPrefix: '_', // Sass 的 partial 文件约定
preferRelative: true,
skipMainField: true,
})
},
get less() {
return createBackCompatIdResolver(config, {
extensions: ['.less', '.css'],
mainFields: ['less', 'style'],
conditions: ['less', 'style', DEV_PROD_CONDITION],
tryIndex: false,
preferRelative: true,
})
},
}
}
注意 Sass 解析器的 tryPrefix: '_' 配置——这支持了 Sass 的 partial 文件命名约定(以下划线开头的文件不会被单独编译)。
PostCSS 处理
compilePostCSS 函数编排 PostCSS 的插件链。它会根据需要动态加载三类 PostCSS 插件:
flowchart TD
Start[compilePostCSS] --> Check{需要 PostCSS 处理?}
Check -->|"无 @import、无 url()、<br/>非 CSS Module、无 postcss 配置"| Skip[返回 undefined]
Check -->|需要| Build[构建插件链]
Build --> Import{"包含 @import?"}
Import -->|是| P1["添加 postcss-import<br/>内联 @import"]
Import -->|否| Next1[跳过]
P1 --> URL{"需要 url 重写?"}
Next1 --> URL
URL -->|是| P2["添加 vite-url-rewrite<br/>重写 url() 路径"]
URL -->|否| Next2[跳过]
P2 --> Module{"CSS Module?"}
Next2 --> Module
Module -->|是| P3["添加 postcss-modules<br/>生成作用域类名"]
Module -->|否| Next3[跳过]
P3 --> User["添加用户 postcss 插件"]
Next3 --> User
User --> Run["执行 postcss(plugins).process()"]
style P1 fill:#e8f4fd,stroke:#1890ff
style P2 fill:#fff7e6,stroke:#fa8c16
style P3 fill:#f6ffed,stroke:#52c41a
一个关键的优化:当文件不包含 @import、不包含 url()、不是 CSS Module、且没有用户配置的 PostCSS 插件时,整个 PostCSS 处理会被跳过:
if (
lang !== 'sss' &&
!postcssConfig &&
!isModule &&
!needInlineImport &&
!hasUrl
) {
return // 直接返回 undefined,跳过 PostCSS
}
postcss-import 的深度集成
当 CSS 文件包含 @import 时,Vite 使用 postcss-import 插件来内联导入的文件。但 Vite 对其进行了深度定制:
postcssPlugins.unshift(
(await importPostcssImport()).default({
async resolve(id, basedir) {
// 优先检查公共目录
const publicFile = checkPublicFile(id, config)
if (publicFile) return publicFile
// 使用 Vite 的解析器(而非 postcss-import 默认的 Node resolve)
const resolved = await atImportResolvers.css(
environment, id, path.join(basedir, '*'),
)
if (resolved) return path.resolve(resolved)
return id
},
async load(id) {
const code = await fs.promises.readFile(id, 'utf-8')
const lang = CSS_LANGS_RE.exec(id)?.[1]
if (isPreProcessor(lang)) {
// 如果 @import 引入的是 Sass/Less 文件,先编译
const result = await compileCSSPreprocessors(
environment, id, lang, code, workerController,
)
result.deps?.forEach((dep) => deps.add(dep))
return result.code
}
return code
},
nameLayer(index) {
return `vite--anon-layer-${getHash(id)}-${index}`
},
}),
)
这里有三个重要的定制:
- 路径解析:使用 Vite 自己的解析器替代
postcss-import默认的resolve包,支持 Vite 的别名和条件导出 - 预处理器嵌套:当 CSS 文件
@import一个.scss文件时,会先通过 Sass 预处理器编译后再内联 - Layer 命名:为匿名
@layer生成基于文件哈希的稳定名称
URL 重写
UrlRewritePostcssPlugin 是 Vite 自定义的 PostCSS 插件,负责重写 CSS 中的 url() 和 image-set() 引用:
const UrlRewritePostcssPlugin: PostCSS.PluginCreator<{
resolver: CssUrlResolver
deps: Set<string>
logger: Logger
}> = (opts) => {
return {
postcssPlugin: 'vite-url-rewrite',
Once(root) {
const promises: Promise<void>[] = []
root.walkDecls((declaration) => {
const isCssUrl = cssUrlRE.test(declaration.value)
const isCssImageSet = cssImageSetRE.test(declaration.value)
if (isCssUrl || isCssImageSet) {
promises.push(
rewriteCssUrls(declaration.value, replacer)
.then((url) => { declaration.value = url })
)
}
})
if (promises.length) return Promise.all(promises)
},
}
}
URL 重写的逻辑需要处理多种边界情况:外部 URL 和 data URL 需要跳过,已经是 Vite 占位符的 URL(__VITE_ASSET__)也需要跳过。对于带引号和不带引号的 URL,替换逻辑需要正确处理转义:
async function doUrlReplace(rawUrl, matched, replacer, funcName = 'url') {
let wrap = ''
const first = rawUrl[0]
let unquotedUrl = rawUrl
if (first === '"' || first === "'") {
wrap = first
unquotedUrl = rawUrl.slice(1, -1)
}
if (skipUrlReplacer(unquotedUrl)) return matched
// 移除转义序列以获取实际文件名
unquotedUrl = unquotedUrl.replace(/\\(\W)/g, '$1')
let newUrl = await replacer(unquotedUrl, rawUrl)
// 新 URL 可能需要引号包裹(包含空格或特殊字符)
if (wrap === '' && (newUrl !== encodeURI(newUrl) || newUrl.includes(')'))) {
wrap = '"'
}
return `${funcName}(${wrap}${newUrl}${wrap})`
}
CSS Modules
CSS Modules 通过 postcss-modules 插件实现。当文件名匹配 .module.css(或 .module.scss 等)模式时,自动启用作用域化处理:
const cssModuleRE = new RegExp(`\\.module${CSS_LANGS_RE.source}`)
if (isModule) {
postcssPlugins.unshift(
(await importPostcssModules()).default({
...modulesOptions,
getJSON(cssFileName, _modules, outputFileName) {
modules = _modules // 捕获类名映射
if (modulesOptions?.getJSON) {
modulesOptions.getJSON(cssFileName, _modules, outputFileName)
}
},
async resolve(id, importer) {
// 使用 Vite 的解析器处理 composes 引用
for (const key of getCssResolversKeys(atImportResolvers)) {
const resolved = await atImportResolvers[key](
environment, id, importer,
)
if (resolved) return path.resolve(resolved)
}
return id
},
}),
)
}
CSS Modules 的类名映射(例如 { title: '_title_1x2y3' })被缓存在 cssModulesCache 中,供 cssPostPlugin 在后续阶段生成 JavaScript 导出代码。
cssPostPlugin:输出阶段
cssPostPlugin 是 CSS 处理的”分叉点”——开发模式和构建模式在这里走上完全不同的路径。
开发模式:CSS 转 JS
在开发模式下,CSS 被转换为注入样式的 JavaScript 模块。这是 Vite “一切皆模块”理念的体现:
if (config.command === 'serve') {
if (isDirectCSSRequest(id)) {
return null // 直接 CSS 请求,不转换
}
if (inlined) {
return `export default ${JSON.stringify(css)}`
}
if (this.environment.config.consumer === 'server') {
return modulesCode || 'export {}' // SSR 不需要注入样式
}
const cssContent = await getContentWithSourcemap(css)
const code = [
`import { updateStyle, removeStyle } from ${clientPath}`,
`const __vite__id = ${JSON.stringify(id)}`,
`const __vite__css = ${JSON.stringify(cssContent)}`,
`updateStyle(__vite__id, __vite__css)`,
// CSS Modules 导出类名映射;非 Module 的 CSS 可以自接受 HMR
`${modulesCode || 'import.meta.hot.accept()'}`,
`import.meta.hot.prune(() => removeStyle(__vite__id))`,
].join('\n')
return { code, map: { mappings: '' }, moduleType: 'js' }
}
flowchart LR
subgraph "开发模式 CSS 处理"
A[".module.scss 源文件"] --> B["Sass 编译<br/>cssPlugin"]
B --> C["PostCSS + CSS Modules<br/>cssPlugin"]
C --> D["生成 JS 模块<br/>cssPostPlugin"]
D --> E["updateStyle(id, css)<br/>浏览器注入 style 标签"]
end
subgraph "HMR 更新"
F["文件修改"] --> G["重新编译"]
G --> H["import.meta.hot.accept()"]
H --> I["updateStyle 替换内容"]
end
这段生成的代码包含了完整的 HMR 生命周期:
updateStyle:创建或更新<style>标签import.meta.hot.accept():声明自接受热更新import.meta.hot.prune():模块被卸载时移除样式
CSS Modules 的情况有所不同——它们不能自接受 HMR,因为类名映射的变化需要通知所有引用方。此时由 cssAnalysisPlugin 在 updateModuleInfo 中设置 isSelfAccepting: false:
const isSelfAccepting =
!cssModulesCache.get(config)?.get(id) &&
!inlineRE.test(id) &&
!htmlProxyRE.test(id)
10.4-bis 源码核对:cssPostPlugin 的 Serial Promise Queue 保证输出顺序
cssPostPlugin 模块级状态(css.ts:462-473)里有个不起眼但关键的对象:
// queue to emit css serially to guarantee the files are emitted in a deterministic order
let codeSplitEmitQueue = createSerialPromiseQueue<string>()
const urlEmitQueue = createSerialPromiseQueue<unknown>()
两个 serial promise queue——串行化执行队列。
为什么 CSS emit 必须串行?因为 Rollup 的 emitFile 调用在并发时产出的 fileName 可能因为哈希冲突或 dedup 而不稳定——两个 css chunk 在不同构建之间可能得到不同的 assetFileName。串行 emit 让每个 css chunk 的哈希只基于内容、不受并发竞争影响。
createSerialPromiseQueue 的用法:await queue.add(() => doSomething())——queue 内部保证第 N 个 fn 等第 N-1 个 fn 完成后再 run。看起来只是个简单工具,但它是 Vite build 产物**确定性(reproducibility)**的一个支柱。
另一条细节:每次 renderStart() 重新创建 queue(css.ts:525):
renderStart() {
pureCssChunks = new Set<RenderedChunk>()
hasEmitted = false
chunkCSSMap = new Map()
codeSplitEmitQueue = createSerialPromiseQueue()
}
为什么要 re-create?因为 Rollup/Vite 的 watch 模式下 renderStart 会被多次调用——每次 render 开始前必须清空 queue、防止上一次构建残留的 promise 污染这次 render。state 重置在 renderStart 是 Vite 插件设计的铁律——每个需要跨 render 干净的状态都要在这个钩子里重置。
10.4-ter 源码核对:inlineCSS && isHTMLProxy 的 ” 转义
cssPostPlugin.transform(css.ts:541-556)里有一段HTML 安全转义:
const inlineCSS = inlineCSSRE.test(id)
const isHTMLProxy = htmlProxyRE.test(id)
if (inlineCSS && isHTMLProxy) {
if (styleAttrRE.test(id)) {
css = css.replace(/"/g, '"')
}
// ...
}
当 CSS 来自 HTML 的 style 属性(<div style="color: red">)时,Vite 处理完 CSS 后要把它写回 HTML 的 style 属性中——这段 CSS 里的双引号必须转义成 ",否则属性字符串闭合出错。
想象 Lightning CSS 把 style="--x: url('foo.png')" 处理后可能输出 style="--x: url(\"foo.png\")"——这时属性值里有未转义的 "、HTML 解析器看到 url(" 就会提前结束属性、后面变成野生属性——整个 HTML 结构错乱。
这条 /g 全局替换确保每个 " 都转义。处理的只有 CSS 里的字符不是用户的意图输入,所以简单的字符替换就够了——不用走 HTML entity 复杂编码。
这条小处理解决了一个仅在 inline style 属性里才出现的特定场景——普通 CSS 文件不受影响。对框架作者来说,这是一条”针对使用环境的差异做精确响应”的工程姿态——不是一刀切转义所有 CSS,而是识别出”这段 CSS 要进 HTML 属性”的语境、只对这种 case 做转义。
构建模式:代码分割与提取
构建模式下的 CSS 处理复杂得多。cssPostPlugin 需要处理多个环节:
flowchart TD
subgraph "构建模式 CSS 流水线"
T["transform 钩子"] --> Record["记录 CSS 到 styles Map"]
Record --> RC["renderChunk 钩子"]
RC --> Collect["收集 chunk 关联的 CSS"]
Collect --> Minify["CSS 压缩"]
Minify --> Emit["emitFile 输出 CSS 资源"]
Emit --> GB["generateBundle 钩子"]
GB --> Pure["清理纯 CSS chunk"]
GB --> Resolve["解析资源 URL 占位符"]
end
在 transform 阶段,CSS 内容被记录到 styles Map 中,返回的 JavaScript 代码可能是:
- CSS Module:返回
modulesCode(类名映射的export) - 内联 CSS(
?inline):返回export default "css content" - 普通 CSS:返回空字符串,但设置
moduleSideEffects: 'no-treeshake'防止被 tree-shaking 删除
// 构建模式的 transform
if (!inlined) {
styles.set(id, css) // 记录 CSS 内容
}
let code: string
if (modulesCode) {
code = modulesCode
} else if (inlined) {
let content = css
if (config.build.cssMinify) {
content = await minifyCSS(content, config, true)
}
code = `export default ${JSON.stringify(content)}`
} else {
code = '' // 空模块,但不会被 tree-shake
}
return {
code,
moduleSideEffects: modulesCode || inlined ? false : 'no-treeshake',
moduleType: 'js',
}
renderChunk 中的 CSS 收集
renderChunk 钩子是构建模式 CSS 处理的核心。它遍历 chunk 的所有模块,收集关联的 CSS,然后决定如何输出:
async renderChunk(code, chunk, opts, meta) {
let chunkCSS: string | undefined
let isPureCssChunk = chunk.exports.length === 0
for (const id of Object.keys(chunk.modules)) {
if (styles.has(id)) {
if (transformOnlyRE.test(id)) continue // ?transform-only 跳过
// 检查 cssScopeTo:CSS 是否只在特定导出使用
const cssScopeTo = this.getModuleInfo(id)?.meta?.vite?.cssScopeTo
if (cssScopeTo && !isCssScopeToRendered(cssScopeTo, renderedModules)) {
continue // 如果关联的导出未被使用,跳过此 CSS
}
if (cssModuleRE.test(id)) {
isPureCssChunk = false // CSS Module 包含 JS 代码
}
chunkCSS = (chunkCSS || '') + styles.get(id)
}
}
// ...
}
如果启用了 cssCodeSplit(默认),每个 chunk 对应的 CSS 会被单独输出为一个 .css 文件。如果禁用了代码分割,所有 CSS 会合并为一个文件。
纯 CSS Chunk 的清理
当一个 JavaScript chunk 只包含 CSS 导入(没有 JS 导出)时,它被标记为”纯 CSS chunk”。generateBundle 阶段会清理这些空的 JS 文件:
if (pureCssChunks.size) {
const replaceEmptyChunk = getEmptyChunkReplacer(
pureCssChunkNames, opts.format,
)
for (const file in bundle) {
const chunk = bundle[file]
if (chunk.type === 'chunk') {
// 从其他 chunk 的 imports 中移除纯 CSS chunk
chunk.imports = chunk.imports.filter((file) => {
if (pureCssChunkNames.includes(file)) {
// 将 CSS 资源的归属转移到引用方
const { importedCss } = (bundle[file] as OutputChunk).viteMetadata!
importedCss.forEach((f) =>
chunk.viteMetadata!.importedCss.add(f))
return false
}
return true
})
if (chunkImportsPureCssChunk) {
chunk.code = replaceEmptyChunk(chunk.code)
}
}
}
// 从 bundle 中删除纯 CSS chunk
pureCssChunkNames.forEach((fileName) => {
delete bundle[fileName]
})
}
getEmptyChunkReplacer 函数根据输出格式生成不同的替换正则:
export function getEmptyChunkReplacer(
pureCssChunkNames: string[],
outputFormat: InternalModuleFormat,
): (code: string) => string {
const emptyChunkFiles = pureCssChunkNames.map(escapeRegex).join('|')
const emptyChunkRE = new RegExp(
outputFormat === 'es'
? `\\bimport\\s*["'][^"']*(?:${emptyChunkFiles})["'];`
: `(\\b|,\\s*)require\\(\\s*["'\`][^"'\`]*(?:${emptyChunkFiles})["'\`]\\)(;|,)`,
'g',
)
return (code) =>
code.replace(emptyChunkRE, (m) =>
`/* empty css ${''.padEnd(m.length - 15)}*/`)
}
替换时保持了与原始导入语句相同的字符长度(通过 padEnd),这样 source map 不会偏移。
CSS HMR 机制
cssAnalysisPlugin 的角色
cssAnalysisPlugin 在开发模式下跟踪 CSS 文件的 @import 依赖关系。当一个被 @import 的文件发生变化时,导入它的主文件需要重新编译。
export function cssAnalysisPlugin(config: ResolvedConfig): Plugin {
return {
name: 'vite:css-analysis',
transform: {
filter: { id: { include: CSS_LANGS_RE } },
async handler(_, id) {
const { moduleGraph } = this.environment as DevEnvironment
const thisModule = moduleGraph.getModuleById(id)
if (thisModule) {
const isSelfAccepting =
!cssModulesCache.get(config)?.get(id) &&
!inlineRE.test(id) &&
!htmlProxyRE.test(id)
// pluginContainer.addWatchFile 附加的文件
const pluginImports = (this as any)._addedImports
if (pluginImports) {
const depModules = new Set<string | EnvironmentModuleNode>()
for (const file of pluginImports) {
depModules.add(
moduleGraph.createFileOnlyEntry(file)
)
}
moduleGraph.updateModuleInfo(
thisModule,
depModules, // @import 依赖
null,
new Set(),
null,
isSelfAccepting,
)
} else {
thisModule.isSelfAccepting = isSelfAccepting
}
}
},
},
}
}
HMR 更新流程
sequenceDiagram
participant FS as 文件系统
participant W as Watcher
participant HMR as HMR 引擎
participant MG as 模块图
participant CSS as CSS 插件
participant B as 浏览器
FS->>W: _variables.scss 变更
W->>HMR: 文件变更事件
HMR->>MG: 查找依赖此文件的模块
MG-->>HMR: App.module.scss(通过 @import 关系)
HMR->>CSS: 触发 App.module.scss 重新 transform
CSS->>CSS: Sass 编译 + PostCSS + CSS Modules
CSS-->>HMR: 新的 CSS 内容 + 新的类名映射
HMR->>B: HMR update
B->>B: updateStyle(id, newCSS)
Note over B: CSS Modules: 需要通知引用方<br/>普通 CSS: 自接受更新
普通 CSS 文件可以自接受 HMR 更新——updateStyle 函数直接替换 <style> 标签的内容即可。但 CSS Modules 不能自接受,因为类名映射的变化需要通知所有使用这些类名的组件重新渲染。这种情况下,HMR 更新会沿着依赖链向上传播。
10.5-bis 源码核对:cssAnalysisPlugin.transform 里的”三条件判 selfAccepting”
cssAnalysisPlugin 的 transform 钩子(css.ts:1185-1220)看似简短,但判定 CSS 模块是否 selfAccepting 的三条件非常精细:
const thisModule = moduleGraph.getModuleById(id)
if (thisModule) {
// CSS modules cannot self-accept since it exports values
const isSelfAccepting =
!cssModulesCache.get(config)?.get(id) && // 条件 1
!inlineRE.test(id) && // 条件 2
!htmlProxyRE.test(id) // 条件 3
// ...
}
三个不能 selfAccept的场景:
1、CSS Modules(cssModulesCache.get(config)?.get(id) 不为空):CSS Modules 文件会产出一个 class name 映射对象——它是真实的 JS value export。如果自接受了、本文件的 hot update 不向上冒泡、上游引用了 classes.card 的地方拿不到新 hash 的 class name——UI 就错乱。CSS Module 的 HMR 必须向上冒到使用者,自接受破坏这条语义。
2、?inline 标记的 CSS:比如 import styles from './foo.css?inline' 拿到 CSS 文本字符串当 JS value 用——和 CSS Modules 同理,内容变了、使用它的地方要重跑。自接受会让使用者看到旧字符串。
3、htmlProxyRE 匹配的代理模块:HTML 里的 <style> 标签被 Vite 代理成独立模块——这些代理模块的变化应该通知 HTML,不能自接受。
这套判断让大部分普通 CSS 文件走自接受路径——改样式不影响上游 JS 模块、热更新只替换 <style> 内容、页面不 reload;CSS Modules/inline/html-proxy 走传统向上冒泡路径——拿它 export 的地方被通知重新执行。
这条逻辑是 Vite CSS HMR 比 Webpack 体验好的核心原因之一——Webpack 的 CSS HMR 对 CSS Modules 处理得不够精细,常出现”改样式整个页面 reload”;Vite 通过这三条件精确区分,做到”该自接受就自接受、该冒泡就冒泡”。
10.5-ter 源码核对:getEmptyChunkReplacer 的 cjs 链式 require 处理
§10.4 讲过构建时纯 CSS chunk 被清理。真实 getEmptyChunkReplacer(css.ts:1242+)有一段专门处理 minifier 链式 require 的精巧逻辑:
// for cjs, require calls might be chained by minifier using the comma operator.
// in this case we have to keep one comma if a next require is chained
// or add a semicolon to terminate the chain.
const emptyChunkRE = new RegExp(
outputFormat === 'es'
? `\\bimport\\s*["'][^"']*(?:${emptyChunkFiles})["'];?`
: `(?:(^|\\W)require\\(\\s*["'][^"']*(?:${emptyChunkFiles})["']\\s*\\)(\\s*,\\s*\\w|;?))`,
'g',
)
CJS 格式的 chunk 里,minifier(terser、oxc-minify)可能把多个 require() 链成 require("a"), require("b"), require("c");——用逗号操作符串起来。如果 Vite 简单地把 require("b") 替换成空字符串,会产出 require("a"), , require("c"); 这种语法错误代码。
正则里的 (\\s*,\\s*\\w|;?) 捕获 require 后面的逗号/分号——替换时根据捕获决定保留”一个逗号”(如果后面还有 require)还是”加一个分号”(如果是链尾)。这让清理后的代码语法仍然合法。
这条处理的存在反映了 Vite 对下游 minifier 行为的精确感知——不是假设 CJS 代码长什么样,而是知道”terser 会把相邻 require 合并成逗号链”——提前在正则里处理这种合法输出。这是一种”和下游工具链协作”的工程姿态——不是各自独立、而是 Vite 知道 terser 在干什么、terser 也知道 Vite 在干什么。
CSS 压缩
Vite 支持两种 CSS 压缩引擎:
async function minifyCSS(css: string, config: ResolvedConfig, inlined: boolean) {
if (config.build.cssMinify === 'esbuild') {
const { transform } = await importEsbuild()
const { code } = await transform(css, {
loader: 'css',
target: config.build.cssTarget || undefined,
...resolveMinifyCssEsbuildOptions(config.esbuild || {}),
})
return inlined ? code.trimEnd() : code
}
// 默认使用 Lightning CSS
const { code } = (await importLightningCSS()).transform({
...config.css.lightningcss,
targets: convertTargets(config.build.cssTarget),
filename: defaultCssBundleName,
code: Buffer.from(css),
minify: true,
})
return decoder.decode(code) + (inlined ? '' : '\n')
}
inlined 参数影响是否保留末尾换行符——内联 CSS 不需要尾部换行,而独立文件应以换行符结尾。
Lightning CSS 集成
Lightning CSS 是一个基于 Rust 的高性能 CSS 处理器,可以替代 PostCSS 作为 Vite 的 CSS transformer:
export function resolveCSSOptions(options: CSSOptions | undefined) {
const resolved = mergeWithDefaults(_cssConfigDefaults, options ?? {})
if (resolved.transformer === 'lightningcss') {
resolved.lightningcss ??= {}
resolved.lightningcss.targets ??= convertTargets(
ESBUILD_BASELINE_WIDELY_AVAILABLE_TARGET,
)
}
return resolved
}
当 css.transformer 设为 'lightningcss' 时,compileCSS 函数会走 compileLightningCSS 分支而非 compilePostCSS。Lightning CSS 的优势在于:
- 更快的速度:Rust 实现比 JavaScript 的 PostCSS 快一个数量级
- 内置功能:原生支持 CSS Modules、嵌套、自定义属性等,不需要额外插件
- 更好的压缩:在代码压缩方面通常优于 esbuild
但代价是放弃了 PostCSS 丰富的插件生态——大部分 PostCSS 插件不能与 Lightning CSS 一起使用。
10.7-bis 源码核对:compileLightningCSS 的 resolver 钩子——预处理器集成的巧妙对接
§10.7 给了 Lightning CSS 插件的示意。真实的 compileLightningCSS(css.ts:3188-3290+)里有个不得不拆开讲的机制——Lightning CSS 的 resolver 钩子如何和 Vite 的预处理器对接:
await (await importLightningCSS()).bundleAsync({
...
resolver: {
async read(filePath) {
if (filePath === filename) return src
const code = fs.readFileSync(filePath, 'utf-8')
const lang = CSS_LANGS_RE.exec(filePath)?.[1]
if (isPreProcessor(lang)) {
// 对 sass/less/styl 文件调 Vite 的预处理器
const result = await compileCSSPreprocessors(
environment, id, lang, code, workerController,
)
result.deps?.forEach((dep) => deps.add(dep))
return result.code // ← 返回编译后的 CSS
} else if (lang === 'sss') {
const sssResult = await transformSugarSS(environment, id, code)
return sssResult.code
}
return code
},
async resolve(id, from) {
// ... Vite 的路径解析
},
},
})
关键洞察:Lightning CSS 本身不懂 Sass/Less——它只会处理纯 CSS。但 Vite 项目里用 Lightning CSS 的人经常也用 Sass 预处理器——怎么办?
答案:通过 resolver.read 钩子”劫持”文件读取。Lightning CSS 遇到 @import "./foo.scss",不是直接 fs.readFileSync('./foo.scss')——而是调用 Vite 提供的 resolver.read 函数。Vite 在 read 里识别 .scss 扩展名、先调 Sass 预处理器把 scss 编译成 css、再把 css 返回给 Lightning CSS。Lightning CSS 以为它读到的就是 CSS 文件内容——继续自己的处理。
这是适配器模式的教科书级应用——Lightning CSS 提供的”可注入文件读取逻辑”接口,让 Vite 在不修改 Lightning CSS 源码的前提下,扩展它能处理的文件类型。同样的机制还支持 SugarSS(.sss 文件)——用 PostCSS 的 SugarSS parser 先解析、返回标准 CSS。
这个 read 钩子还做了deps 追踪(result.deps?.forEach((dep) => deps.add(dep)))——预处理器内部 @import 过的文件路径都加到 deps 集合、让 Vite 能正确监听这些文件变化、触发 HMR。没有这步追踪,改 _variables.scss 不会触发依赖它的 .scss 文件热更新。
10.7-ter 源码核对:styleAttrRE 的 HTML style 属性特殊路径
compileLightningCSS 开头(css.ts:3207-3214)有一个看似多余的分支:
res = styleAttrRE.test(id)
? (await importLightningCSS()).transformStyleAttribute({
filename,
code: Buffer.from(src),
targets: config.css.lightningcss?.targets,
minify: config.isProduction && !!config.build.cssMinify,
analyzeDependencies: true,
})
: await (await importLightningCSS()).bundleAsync({...})
普通 CSS 文件走 bundleAsync、HTML style 属性走 transformStyleAttribute——这是两个完全不同的 Lightning CSS API:
bundleAsync:处理 stylesheet(多规则 + @import + @media 等)transformStyleAttribute:处理单条 inline style 声明(比如<div style="color: red">里的color: red)
语法层面两者不同——stylesheet 有 { } 花括号规则块、inline style 是单纯的 property:value 列表。用错 API 会报 parse 错误。
什么时候 id 会匹配 styleAttrRE?Vite 的 HTML 解析会把每个 <div style="..."> 的 style 属性值提取成独立的 CSS 转换请求——id 带特殊 query 标记。这让 HTML 里 inline style 也能享受:prefix 自动加(通过 browserslist 目标)、minify、vendor-prefix 补全等现代 CSS 工具链。
这条分支让 Vite 的 CSS 处理覆盖了”stylesheet 文件 + inline style 属性”两个语境——是工具完备性的一个标志。Webpack 的 CSS loader 对 inline style 的处理要弱得多,基本只能靠用户手写 autoprefixer。
构建模式与开发模式的差异总结
flowchart TB
subgraph "共享管线"
S1["语言检测"] --> S2["预处理器编译<br/>Sass/Less/Stylus"]
S2 --> S3["PostCSS / Lightning CSS"]
S3 --> S4["CSS Modules 处理"]
end
S4 --> Split{command?}
subgraph "开发模式"
Split -->|serve| D1["生成 JS 模块"]
D1 --> D2["updateStyle 注入"]
D2 --> D3["HMR accept/prune"]
D3 --> D4["cssAnalysisPlugin<br/>依赖跟踪"]
end
subgraph "构建模式"
Split -->|build| B1["记录到 styles Map"]
B1 --> B2["renderChunk 收集"]
B2 --> B3["CSS 压缩"]
B3 --> B4["emitFile 输出"]
B4 --> B5["纯 CSS chunk 清理"]
B5 --> B6["URL 占位符解析"]
end
style Split fill:#fff7e6,stroke:#fa8c16
设计决策
为什么 CSS 插件是最大的文件
CSS 处理的复杂性来自多个维度的组合爆炸:
- 3 种预处理器 x 2 种 CSS transformer x 2 种运行模式 = 至少 12 种代码路径
- URL 重写需要处理
url()、image-set()、@import三种语法 - 纯 CSS chunk 清理需要针对 ES、CJS 两种格式生成不同的替换模式
- source map 合并需要在预处理器、PostCSS、Vite 三层之间正确传递
将所有这些逻辑放在一个文件中虽然看起来庞大,但保证了跨功能的一致性——例如 @import 的解析逻辑在 PostCSS 和预处理器之间是共享的。
为什么预处理器使用 Worker 池
Sass、Less 等预处理器通常是同步阻塞的 CPU 密集型操作。在大型项目中,可能有几百个需要编译的 Sass 文件。如果串行处理,会严重拖慢构建速度。Worker 池允许并行编译,充分利用多核 CPU。
preprocessorMaxWorkers: true 的默认值意味着 Vite 会自动使用所有可用的 CPU 核心(减 1,为主线程留空间)。对于 CI 环境或资源受限的容器,可以通过设置具体数字来控制并行度。
为什么 CSS Modules 不能自接受 HMR
CSS Modules 的输出不仅是样式,还包含类名映射。当类名映射发生变化时(例如添加了新的类名),所有使用这些类名的组件都需要重新渲染。如果 CSS Module 自接受 HMR,只有样式会更新,而引用方仍然使用旧的类名映射,导致样式不一致。
因此 isSelfAccepting 对 CSS Modules 设为 false,让 HMR 更新向上传播到引用方。虽然这可能导致更大范围的更新,但保证了一致性。
为什么不将 ?url CSS 直接当作资源处理
?url 查询的 CSS 文件需要特殊处理。在构建模式下,CSS 内容需要先经过 PostCSS 处理(重写 url() 路径、处理 CSS Modules 等),然后才能作为资源输出。因此 cssPlugin 的 load 钩子拦截 ?url 请求,注入一个中间层来确保 CSS 处理管线正确执行。
10.6-bis 源码核对:cssUrlRE 的 lookbehind 正则防 @import 误匹配
§10.3 URL 重写讲了基本机制。真实的 cssUrlRE(css.ts:1997-1998)是一条极其精心设计的正则:
export const cssUrlRE: RegExp =
/(?<!@import\s+)(?<=^|[^\w\--])url\((\s*('[^']+'|"[^"]+")\s*|(?:\\.|[^'")\\])+)\)/
从左到右拆解这条正则:
1、(?<!@import\s+)——负向后查。这是关键——排除 @import url(...) 中的 url()。为什么?因为 @import url(...) 不是资源引用、是 CSS 的 import 指令、已经由 postcss-import 或 Lightning CSS 的 bundling 处理过了。如果 cssUrlRE 匹配它、Vite 会错误地把它当成资源 url 去 fileToUrl 解析——产出不合法的 CSS。
2、(?<=^|[^\w\--])——负向后查 + 字符集。确保 “url(” 前面要么是字符串开头、要么是非单词字符。这条阻止匹配用户自定义函数 my-url(...)/custom_url(...)——它们恰好以 url( 结尾、但不是标准 CSS url 函数。
3、捕获组里的双模式:
\s*('[^']+'|"[^"]+")\s*——匹配被单/双引号包裹的路径(?:\\.|[^'")\\])+——匹配无引号的路径(允许转义字符)
两种 CSS url 写法都要支持:url(foo.png) 和 url("foo.png"),都有各自的转义规则。
4、Unicode 字符范围 - 涵盖非 ASCII 字符——用户可能在 CSS 类名或 URL 里用中文/日文字符,正则要正确识别它们为”单词字符的一部分”。
这条 62 字符的正则是多年踩坑积累的成果——每一条 lookbehind、每一个字符类都对应一次真实 bug 报告。教科书级的正则设计:不是越短越好、是表达力精准为好。
10.6-ter 源码核对:URL Rewrite 的 PostCSS 插件策略
cssUrlRE 只是匹配正则,真正的重写走 PostCSS 插件路径(css.ts:2007-2070+):
const UrlRewritePostcssPlugin: PostCSS.PluginCreator<...> = (opts) => {
return {
postcssPlugin: 'vite-url-rewrite',
Once(root) {
const promises: Promise<void>[] = []
root.walkDecls((declaration) => {
const importer = declaration.source?.input.file
if (!importer) {
opts.logger.warnOnce(
'\nA PostCSS plugin did not pass the `from` option to `postcss.parse`. ...'
)
}
const isCssUrl = cssUrlRE.test(declaration.value)
const isCssImageSet = cssImageSetRE.test(declaration.value)
if (isCssUrl || isCssImageSet) {
// 走 opts.resolver 解析每个 url
...
}
})
},
}
}
关键点:URL 重写是一个 PostCSS 插件,不是 Vite 源码里单独的字符串扫描。这让它:
- 尊重 PostCSS 的 AST——不会匹配到注释里的 url 字符、不会匹配到字符串字面量里的 url
- 按 declaration 遍历——只处理 CSS 属性值里的 url、不处理选择器或 @rule 参数
- 能感知 source map——
declaration.source?.input.file拿到原始来源文件路径,让”url 相对路径”的解析能基于原始文件而不是合并后的位置
opts.logger.warnOnce('A PostCSS plugin did not pass the \from` option…’)是一条**主动的错误预警**——如果上游 postcss 插件没正确传from`,Vite 会 warn 用户”你的某个 postcss 插件有 bug”,而不是静默产出错误的 url 重写。这条 warning 的定位是”提前暴露他人插件的错误”——避免 Vite 被其他生态 bug 牵连背锅。
10.9 本章与全书体系的呼应
当多个 CSS 文件被合并成一个(构建模式下的 CSS bundling),内部 @import 和 @charset 规则需要被 hoist 到合并后文件的顶部——因为 CSS 规范要求:
@charset必须是文件第一条非注释规则@import必须在其他规则之前(除@charset外)
违反这两条规则会被浏览器静默忽略那些 @import 或 @charset。hoistAtRules(css.ts:2313-2342)用 MagicString 做这个重排:
export async function hoistAtRules(css: string): Promise<string> {
const s = new MagicString(css)
const cleanCss = emptyCssComments(css)
let match: RegExpExecArray | null
// @import 必须在文件顶部。多文件合并时要 hoist 所有 @import
atImportRE.lastIndex = 0
while ((match = atImportRE.exec(cleanCss))) {
s.remove(match.index, match.index + match[0].length)
// Use `appendLeft` instead of `prepend` to preserve original @import order
s.appendLeft(0, match[0])
}
// @charset 必须是文件最前的规则。多文件合并时只保留第一个
atCharsetRE.lastIndex = 0
let foundCharset = false
while ((match = atCharsetRE.exec(cleanCss))) {
s.remove(match.index, match.index + match[0].length)
if (!foundCharset) {
s.prepend(match[0])
foundCharset = true
}
}
return s.toString()
}
三条细节:
1、emptyCssComments(css) 先把注释 “删空”(保留字符位置)再正则匹配——防止注释里的 @import 字样被误匹配。MagicString 基于字符位置做编辑,所以”删空”(把注释内容用空格替换)能保持位置信息。
2、appendLeft(0, match[0]) 不是 prepend。注释里明确指出 “Use appendLeft instead of prepend to preserve original @import order”。两个 @import 都 hoist 到顶部时,如果都 prepend,第二个会在第一个之前(顺序反了);用 appendLeft 让插入点是”位置 0 的左边”——后插入的在先插入的后面,保持原顺序。MagicString 的这条 API 细节不看文档几乎不会注意到。
3、多个 @charset 只保留第一个。CSS 文件合并时可能多个文件各有自己的 @charset——CSS 规范允许只有一个 @charset(必须在最前)。Vite 的处理是保留第一个遇到的、删除其余所有。这是一条”冲突时 favor 第一个”的简单规则——简洁且大多数场景正确(因为项目通常全用 UTF-8)。
这段 30 行代码处理了一个从用户视角几乎看不见但确实存在的边界问题——没有它,大项目 @import 分散在多个文件、构建后合并、浏览器静默忽略部分 import——样式丢失的 bug 难以定位。Vite 的处理是静默正确——用户不需要知道这条规则。
10.8-ter 源码核对:resolvePostcssConfig 的 Promise→Value 自动替换
§10.2-bis 讲了 postcss 配置预热——Promise 被塞进 cache、后续 await 它。真实 resolvePostcssConfig(css.ts:1930-1979)还有一条精巧的后续处理:
// replace cached promise to result object when finished
result.then(
(resolved) => {
postcssConfigCache.set(config, resolved)
},
() => {
/* keep as rejected promise, will be handled later */
},
)
Promise 解决后,把缓存里的 Promise 对象替换成实际值。后续 postcssConfigCache.get(config) 直接拿到 PostCSSConfigResult 对象——不用再 await、省掉一次 microtask 开销。错误路径保留 rejected promise——让错误在真实使用位置重现。
10.8-4 源码核对:postcssrc 错误消息的人性化改写
resolvePostcssConfig 里的错误处理(css.ts:1952-1965)也值得单独看——Vite 把 postcssrc 抛出的原生错误富化后再抛:
result = postcssrc({}, searchPath, { stopDir }).catch((e) => {
if (!e.message.includes('No PostCSS Config found')) {
if (e instanceof Error) {
const { name, message, stack } = e
e.name = 'Failed to load PostCSS config'
e.message = `Failed to load PostCSS config (searchPath: ${searchPath}): [${name}] ${message}\n${stack}`
e.stack = ''
throw e
} else {
throw new Error(`Failed to load PostCSS config: ${e}`)
}
}
return null
})
三条细节:“No PostCSS Config found” 被当成正常情况返回 null(用户项目没 postcss.config 是常态);其他错误 改写 name + message 把上下文塞进去——用户看到 “Failed to load PostCSS config (searchPath: /path): [SyntaxError] …”;e.stack = '' 因为 stack 已经拼进 message——避免 V8 默认打印导致重复显示。
10.9 本章与全书体系的呼应
CSS 处理这一章表面独立、实则和 Vite 整个生态紧密咬合。梳理一下接合点:
与第 4 章(插件系统)的依赖:本章讲的 3 个插件(cssPlugin、cssPostPlugin、cssAnalysisPlugin)都是 Vite 插件 API 的标准应用。“三层协作”的架构模式——pre-transform / post-transform / analysis——是 Vite 插件生态的一个范式,用户写自己的 CSS 相关插件时应该考虑是否需要多层拆分。
与第 6 章(HMR)的深度整合:§10.5-bis 讲的”三条件判 selfAccepting”是 CSS HMR 比 Webpack 体验好的核心原因。普通 CSS 自接受、CSS Modules 冒泡、inline CSS 冒泡——三种行为通过 3 个布尔条件精确区分。第 6 章的 HMR 传播算法在 CSS 场景下走不同路径全靠这 3 个条件。
与第 9 章(JS 转换)的对称:第 9 章讲了 JS 的 transform 管线(Oxc → define → importAnalysis),本章讲了 CSS 的 transform 管线(preprocessors → PostCSS/Lightning CSS → cssPostPlugin)。两条管线几乎对称:同样的 “isBundled 二分 + 预处理 + AST 转换 + HMR 注入” 结构。理解其中一条能快速迁移到另一条——Vite 对不同文件类型应用同一套架构套路是它代码组织的美学。
与第 14 章(Rolldown 构建)的接口:本章 cssPostPlugin 的 renderChunk 钩子、getEmptyChunkReplacer 的清理、codeSplitEmitQueue 的串行化——都是在和 Rollup/Rolldown 的 build 产物精确协作。第 14 章讲过 Rolldown 的 chunk 调度;本章展示了 chunk 调度完成后 CSS 如何被提取、合并、重命名。
与第 17 章(Worker 插件)的对比:第 17 章讲了 Worker 的 WorkerOutputCache 独立构建管线;本章讲了 CSS 的 preprocessorWorkerController Worker 池——两种 Worker 使用模式对比:Worker 插件是”每个 Worker 文件独立构建”、CSS 是”多个预处理器共享 Worker 池”。前者按文件并行,后者按任务并行——反映了两种资源的并发特性不同(Worker 文件间独立、预处理器任务间也独立但偏 CPU 密集)。
10.9.1 本章给插件作者的工程启示
这是 Vite 代码库最大的一个插件——读完本章你能学到的不止是 CSS 处理本身,还有一系列大型插件设计的工程原则:
1、按生命周期分层:cssPlugin(pre)→ 用户插件 → cssPostPlugin(post)→ cssAnalysisPlugin(dev HMR)。如果你在写一个复杂插件(比如针对某个 DSL 的完整处理链),考虑按”pre/post/analysis”拆成 3-4 个子插件让用户能在中间介入。
2、WeakMap 是框架级缓存的默认答案。本章 4 个 WeakMap、§17/§9 各自也有多个 WeakMap——Vite 几乎不用普通 Map 做 per-config 缓存。你写插件时如果要 “global map keyed by config”,直接默认上 WeakMap。
3、预热 + 延后报错。能在插件构造阶段做的 IO(配置解析、依赖索引),就在那时启动 Promise 塞进缓存、.catch(() => {}) 吞掉错误——真正需要值时 await 缓存的 Promise、此时 IO 早完成了。错误如果发生、在真实使用位置重现——错误消息更有上下文。
4、Worker 池化重任务。预处理器编译是 CPU 密集——用 Worker 池把每个编译扔进 Worker,主线程照常跑插件逻辑。模式上和 Webpack 的 thread-loader 类似,但 Vite 的 Worker 池是按文件语言分的(sass 一个池、less 一个池)——避免不同预处理器抢同一个 Worker 资源。
5、串行队列保确定性。Rollup 的 emitFile 并发时产物名可能因竞争不稳定——用 serial promise queue 强制串行执行、产物名稳定。这条在任何”构建产物需要确定性”的场景都适用。
6、针对下游工具的精确行为编写代码。§10.5-ter 的 require 链式处理、§10.8-bis 的 @import hoisting、§10.4-ter 的 HTML 属性转义——都是针对特定下游工具(minifier、浏览器、HTML parser)的精确行为做的针对性处理。这要求插件作者不仅懂自己插件、还懂下游工具链在哪里有什么约束。
这 6 条原则贯穿 Vite 其他复杂插件——你在阅读其他插件源码时能看到同样的模式。学会识别这些 pattern 能让你读源码速度快一倍——不是一行行细读、而是识别出”又是 WeakMap 缓存”、“又是 serial queue”、“又是 pre/post 分层”——直奔特殊点。
10.10 源码定位索引
| 主题 | 源文件 | 关键行号 |
|---|---|---|
| cssPlugin 主体 | plugins/css.ts | 296-460+ |
| 四层 WeakMap | 同上 | 269-285 |
| postcss 配置预热 | 同上 | 308-313 |
| cssPostPlugin | 同上 | 462+ |
| cssAnalysisPlugin | 同上 | 1174-1224 |
| compileCSSPreprocessors | 同上 | 1348-1403 |
| getAtImportResolvers | 同上 | 1405-1416 |
| compileCSS(调度器) | 同上 | 1418+ |
| createPreprocessorWorkerController | 同上 | 3135-3161 |
| isPreProcessor | 同上 | 3181-3183 |
| compileLightningCSS | 同上 | 3188-3290+ |
| resolvePostcssConfig | 同上 | 1930+ |
源码版本:vite-latest 本地仓库。
10.11 读完本章能回答的具体问题清单
作为掌握度自测:
- CSS 插件为什么要拆成 3 层(pre、post、analysis)?(§10.1——用户插件需要在 pre 和 post 之间介入)
- cssModulesCache 为什么用 WeakMap?(§10.1-bis——config 回收自动清理缓存)
- postcss config 为什么在构造时就预热?(§10.2-bis——把 IO 塞进启动阶段空隙、用户请求来了后纯 CPU 路径)
- sass 为什么会把自己报告为自己的依赖?(§10.3-bis——sass 实现特性,Vite 用 filter 防循环)
- 普通 CSS 文件为什么能 selfAccept 但 CSS Modules 不行?(§10.5-bis——CSS Modules 有 JS value export,值变了要通知使用者)
- pure CSS chunk 清理时怎么处理 minifier 的 require 链?(§10.5-ter——正则捕获逗号/分号、保留一个或加分号)
- Lightning CSS 不懂 Sass,Vite 怎么让它们协作?(§10.7-bis——用 resolver.read 钩子劫持文件读取、先调 Sass 预处理再返回 CSS)
- HTML style 属性里的 CSS 和 stylesheet 文件走同一段代码吗?(§10.7-ter——不同 API,transformStyleAttribute vs bundleAsync)
- cssPostPlugin 为什么要 serial promise queue?(§10.4-bis——保证产物文件名确定性、不受并发竞争影响)
- HTML inline style 属性里的 CSS 为什么要转义双引号?(§10.4-ter——防止属性闭合被污染、HTML 解析错乱)
能答 8 条以上——对 Vite CSS 管线的理解已经超越大多数 Vite 用户。
小结
CSS 处理引擎是 Vite 中最复杂也最精密的子系统。三个插件——cssPlugin、cssPostPlugin、cssAnalysisPlugin——各司其职,共同实现了从预处理到最终输出的完整管线。
compileCSS 函数是核心调度器,它将预处理器编译、PostCSS/Lightning CSS 转换、CSS Modules 处理和 source map 合并编排为一条清晰的流水线。开发模式和构建模式在 cssPostPlugin 中分叉,走上截然不同的输出路径。
Worker 池化的预处理器执行、PostCSS 的条件跳过、构建模式下纯 CSS chunk 的智能清理——这些优化确保了 CSS 处理在任何规模的项目中都能保持高性能。
理解了 CSS 引擎的设计,就掌握了 Vite 中最复杂的转换逻辑。
实践建议:在自己项目里故意触发本章讨论的边界情况——写一个 @import 在文件中间(应被 hoist)、带中文路径的 url()(测试正则)、带 " 的 inline style(测试转义)、故意让 postcss.config.js 有语法错误(测试错误富化)。Vite 在每种场景下的行为都对应本章源码细节,理解它们能在出问题时快速定位到具体源码片段。
最后一点附言:Vite 的 CSS 插件在不同版本之间行为会有微调(比如 PostCSS → Lightning CSS 的主推转换、新的 CSS Modules 语法支持)——但本章揭示的”三层架构、WeakMap 缓存、Worker 池化、Serial Queue、Lightning CSS 的 resolver 劫持、@import hoist、URL 重写”几条核心机制是稳定的。这些是 Vite 处理 CSS 的心智模型——未来版本哪怕重写 50% 代码,这条心智模型也不会过时。读本章的价值不只在当前版本——更在于帮你建立一套能持续适配的思维框架。
CSS 处理作为 Vite 最复杂的子系统,是检验一个前端工程师能否”真正读懂大型工具”的试金石。能把本章 15 个源码核对小节理清的工程师,对 Vite/Webpack/其他构建工具源码的阅读能力都会上一个台阶——因为这 15 个小节背后暴露的工程模式(缓存分层、异步预热、Worker 池化、串行保确定性、错误富化)在所有大型构建工具里都存在。掌握它们不是”学了 Vite”,是”学会了构建工具的普适架构”。读到这里,你已经和那些只会”用 vite dev 启动、用 vite build 打包”的 Vite 使用者拉开了显著距离——你知道每一条命令背后在发生什么。这份认知优势在团队内部调优、排查生产 CSS bug、向同事讲解构建流程时都会以肉眼可见的方式显现。到此本章告一段落,下一章将打开 HTML 处理的入口之门——HTML 插件看似简单,实际要协调本章讲过的所有 CSS/JS 转换、把它们的产出正确插入到 HTML 中。读完后你会发现 HTML 是整个 Vite 的枢纽。