Vite 设计与实现
第10章 CSS 处理引擎
开篇引言
packages/vite/src/node/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的路径重写策略
10.1 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.2 cssPlugin:预处理与 PostCSS
初始化与 Worker 池
cssPlugin 在 buildStart 钩子中初始化预处理器的 Worker 池:
// packages/vite/src/node/plugins/css.ts:296(节选)
export function cssPlugin(config: ResolvedConfig): Plugin {
let moduleCache: Map<string, Record<string, string>>
// ...
let preprocessorWorkerController: PreprocessorWorkerController | undefined
// ...
return {
name: 'vite:css',
buildStart() {
moduleCache = new Map<string, Record<string, string>>()
cssModulesCache.set(config, moduleCache)
// ...(省略 removedPureCssFilesCache 的初始化)
preprocessorWorkerController = createPreprocessorWorkerController(
normalizeMaxWorkers(config.css.preprocessorMaxWorkers),
)
// ...(省略写入 preprocessorWorkerControllerCache)
},
buildEnd() {
preprocessorWorkerController?.close()
},
// ...
}
}
preprocessorMaxWorkers 默认值为 true(packages/vite/src/node/plugins/css.ts:200,类型见 packages/vite/src/node/plugins/css.ts:148),表示使用 CPU 核心数减 1 个 Worker(官方文档 docs/config/shared-options.md 的 css.preprocessorMaxWorkers 一节写明 "true means up to the number of CPUs minus 1";实现上 normalizeMaxWorkers 把 true 归一成 undefined 交给 worker 池取默认值,见 packages/vite/src/node/plugins/css.ts:3163-3167)。这对大型项目中大量 Less/Stylus 文件的并行编译至关重要——预处理器通常是 CPU 密集型的操作。
不过这个选项的实际作用面比名字暗示的窄,有两处要当心。其一,Sass 根本不读它:makeScssWorker 的形参写成 _maxWorkers 且函数体内从未使用,源码里那两行注释写明 "we use the compiler api provided by sass" / "instead of creating a worker pool on our own"——它返回的 worker 只是一个带 run 方法的普通对象,实际并发交给 sass.initAsyncCompiler() 自己管(packages/vite/src/node/plugins/css.ts:2490-2520)。只有 makeLessWorker 和 makeStylWorker 真的把它作为 max 传给 artichokie 的 WorkerWithFallback(packages/vite/src/node/plugins/css.ts:2896、packages/vite/src/node/plugins/css.ts:3019)。其二,即使是 Less 和 Stylus,也有静默回退到主线程的分支:Less 在 preprocessorOptions.less.plugins 非空时回退(shouldUseFake 判定:插件是函数、不可结构化克隆,packages/vite/src/node/plugins/css.ts:2891-2895),Stylus 在 define 里出现函数值或设了 Evaluator 时回退(packages/vite/src/node/plugins/css.ts:3007-3018)。两处都没有任何日志。
官方文档 css.preprocessorMaxWorkers 条目(vite 仓库 docs/config/shared-options.md)末尾那句 "Vite may run the preprocessors on the main thread even if this option is not set to 0" 说的就是它们。所以「配了 preprocessorMaxWorkers 却没变快」时,先确认自己用的是哪种预处理器、有没有传函数型选项。
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
// packages/vite/src/node/plugins/css.ts:1418(简化)
async function compileCSS(
environment: PartialEnvironment,
id: string,
code: string,
workerController: PreprocessorWorkerController,
urlResolver?: CssUrlResolver,
) {
const { config } = environment
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))
} else if (lang === 'sss' && config.css.transformer === 'lightningcss') {
// SugarSS 在 lightningcss 模式下先经 transformSugarSS 转成标准 CSS
// ...
}
// 第二阶段:CSS 转换器
const transformResult = await (
config.css.transformer === 'lightningcss'
? compileLightningCSS(environment, id, code, deps, ...)
: compilePostCSS(environment, id, code, deps, lang, ...)
)
// compilePostCSS 判定无需处理时返回 undefined:直接交回预处理结果
if (!transformResult) {
return {
code,
map: config.css.devSourcemap ? preprocessorMap : { mappings: '' },
deps,
}
}
// 合并 source map
return {
...transformResult,
map: config.css.devSourcemap
? combineSourcemapsIfExists(cleanUrl(id),
transformResult.map, preprocessorMap)
: { mappings: '' },
deps,
}
}
预处理器集成
预处理器的调用通过 compileCSSPreprocessors 函数封装。每种预处理器有独立的解析器配置:
// packages/vite/src/node/plugins/css.ts:1348
async function compileCSSPreprocessors(
environment: PartialEnvironment,
id: string,
lang: PreprocessLang,
code: string,
workerController: PreprocessorWorkerController,
) {
const { config } = environment
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 }
}
@import 解析器
每种 CSS 方言有专属的路径解析器,配置了不同的文件扩展名和 package.json 条件:
// packages/vite/src/node/plugins/css.ts:1286(简化:省略了三个 getter 的 ??= 缓存
// 与 sass 解析器外包的 file:// 转换 wrapper,见 §10.13.4)
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 的插件链。它以用户配置的插件为基底,按需 unshift/push 三类内置插件——最终顺序是 postcss-modules → postcss-import → 用户插件 → vite-url-rewrite(注意 URL 重写插件的条件是调用方传入了 urlResolver 且「有任何 postcss 插件 / 是 CSS Module / 代码里含 url()」三者居其一——后半段放宽是因为其他插件可能注入新的 url();preprocessCSS 这类公开 API 调 compileCSS 时不传第 5 个参数,于是这个插件不会加。见 compilePostCSS 里 push UrlRewritePostcssPlugin 前的 hasUrl 等条件,packages/vite/src/node/plugins/css.ts:1598-1605):
flowchart TD
Start[compilePostCSS] --> Check{需要 PostCSS 处理?}
Check -->|"无 @import、无 url()、<br/>非 CSS Module、无 postcss 配置"| Skip[返回 undefined]
Check -->|需要| Build[构建插件链]
Build --> User["以用户 postcss 插件为基底"]
User --> Import{"包含 @import?"}
Import -->|是| P1["unshift postcss-import<br/>内联 @import"]
Import -->|否| Next1[跳过]
P1 --> URL{"有插件 / CSS Module / 含 url()?"}
Next1 --> URL
URL -->|是| P2["push vite-url-rewrite<br/>重写 url() 路径(排在最后)"]
URL -->|否| Next2[跳过]
P2 --> Module{"CSS Module?"}
Next2 --> Module
Module -->|是| P3["unshift postcss-modules<br/>生成作用域类名(排在最前)"]
Module -->|否| Next3[跳过]
P3 --> Run["执行 postcss(plugins).process()"]
Next3 --> Run
style P1 fill:#e8f4fd,stroke:#1890ff
style P2 fill:#fff7e6,stroke:#fa8c16
style P3 fill:#f6ffed,stroke:#52c41a
一个关键的优化:当文件不是 SugarSS(.sss)、不包含 @import、不包含 url()/image-set()、不是 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)
// (源码此处对非绝对路径的 id 打印 "Unable to resolve `@import ...`" 错误日志,略)
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() 引用:
// packages/vite/src/node/plugins/css.ts:2007-2067(**节选**:略去 :2012-2014 的
// opts 判空、:2022-2029 里 `from` 缺失时的 warnOnce,以及
// :2040-2059 里 url() 与 image-set() 同时出现时串联两个 rewriter 的那一支)
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 importer = declaration.source?.input.file // :2021
const isCssUrl = cssUrlRE.test(declaration.value)
const isCssImageSet = cssImageSetRE.test(declaration.value)
if (isCssUrl || isCssImageSet) {
// :2033-2039——解析出新 URL 的同时把 resolvedId 记进 opts.deps
const replacerForDeclaration = async (rawUrl: string) => {
const [newUrl, resolvedId] = await opts.resolver(rawUrl, importer)
if (resolvedId) {
opts.deps.add(resolvedId)
}
return newUrl
}
const rewriterToUse = isCssImageSet ? rewriteCssImageSet : rewriteCssUrls
promises.push(
rewriterToUse(declaration.value, replacerForDeclaration)
.then((url) => { declaration.value = url })
)
}
})
if (promises.length) return Promise.all(promises)
},
}
}
URL 重写的逻辑需要处理多种边界情况:外部 URL 和 data URL 需要跳过,已经是 Vite 占位符的 URL(__VITE_ASSET__)也需要跳过。对于带引号和不带引号的 URL,替换逻辑需要正确处理转义:
// packages/vite/src/node/plugins/css.ts:2135(签名在源码里是多行带类型)
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)
if (newUrl === false) return matched
// 新 URL 可能需要引号包裹(包含空格或特殊字符)
if (wrap === '' && (newUrl !== encodeURI(newUrl) || newUrl.includes(')'))) {
wrap = '"'
}
// ...(省略:单引号包裹而新 URL 含单引号时改用双引号;双引号包裹时转义 URL 里的双引号)
return `${funcName}(${wrap}${newUrl}${wrap})`
}
10.3 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 导出代码。
10.4 cssPostPlugin:输出阶段
cssPostPlugin 是 CSS 处理的"分叉点"——开发模式和构建模式在这里走上完全不同的路径。
开发模式:CSS 转 JS
在开发模式下,CSS 被转换为注入样式的 JavaScript 模块。这是 Vite "一切皆模块"理念的体现:
// packages/vite/src/node/plugins/css.ts:569 起
if (config.command === 'serve') {
// :570-583——devSourcemap 打开时把 combined sourcemap 注回内容,否则原样返回
const getContentWithSourcemap = async (content: string) => {
if (config.css.devSourcemap) {
const sourcemap = this.getCombinedSourcemap()
if (sourcemap.mappings) {
await injectSourcesContent(sourcemap, cleanUrl(id), config.logger)
}
return getCodeWithSourcemap('css', content, sourcemap)
}
return content
}
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 = [
config.isBundled
? `const { updateStyle: __vite__updateStyle, removeStyle: __vite__removeStyle } = import.meta.hot._internal`
: `import { updateStyle as __vite__updateStyle, removeStyle as __vite__removeStyle } from ${JSON.stringify(
path.posix.join(config.base, CLIENT_PUBLIC_PATH),
)}`,
`const __vite__id = ${JSON.stringify(id)}`,
`const __vite__css = ${JSON.stringify(cssContent)}`,
`__vite__updateStyle(__vite__id, __vite__css)`,
// css modules exports change on edit so it can't self accept
`${modulesCode || 'import.meta.hot.accept()'}`,
`import.meta.hot.prune(() => __vite__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 在 transform 中算出 isSelfAccepting: false,再写回模块图(有 _addedImports 时经 updateModuleInfo,否则直接赋给 thisModule.isSelfAccepting):
const isSelfAccepting =
!cssModulesCache.get(config)?.get(id) &&
!inlineRE.test(id) &&
!htmlProxyRE.test(id)
构建模式:代码分割与提取
构建模式下的 CSS 处理复杂得多。cssPostPlugin 需要处理多个环节:
flowchart TD
subgraph "构建模式 CSS 流水线"
T["transform 钩子"] --> Record["记录 CSS 到 styles Map"]
Record --> RC["renderChunk 钩子"]
RC --> Collect["收集 chunk 关联的 CSS"]
Collect --> Resolve["解析资源 URL 占位符"]
Resolve --> Minify["CSS 压缩"]
Minify --> Emit["emitFile 输出 CSS 资源"]
Emit --> GB["generateBundle 钩子"]
GB --> Pure["清理纯 CSS chunk"]
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
// ...(省略 renderedModules 代理的构造)
// 只含 CSS import 的动态入口 chunk,渲染出的 JS 为空
const isJsChunkEmpty = code === '' && !chunk.isEntry
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)
} else if (!isJsChunkEmpty) {
// 含有非 CSS 模块,这个 chunk 就不是纯 CSS chunk
isPureCssChunk = false
}
}
// ...
}
如果启用了 cssCodeSplit(默认),每个 chunk 对应的 CSS 会被单独输出为一个 .css 文件。如果禁用了代码分割,所有 CSS 会合并为一个文件。
纯 CSS Chunk 的清理
当一个 JavaScript chunk 只包含 CSS 导入(没有 JS 导出)时,它被标记为"纯 CSS chunk"。generateBundle 阶段会清理这些空的 JS 文件:
// packages/vite/src/node/plugins/css.ts:1052 起
if (pureCssChunks.size) {
// :1056-1068——rendered chunk 的 fileName 里还带 !~{XXX}~ 占位符,得先用
// preliminaryFileName 映射回 bundle 里真正的文件名;watch 模式下 bundle 可能
// 只装了一种输出格式,filter(Boolean) 把对不上的名字丢掉
const prelimaryNameToChunkMap = Object.fromEntries(
Object.values(bundle)
.filter((chunk): chunk is OutputChunk => chunk.type === 'chunk')
.map((chunk) => [chunk.preliminaryFileName, chunk.fileName]),
)
const pureCssChunkNames = [...pureCssChunks]
.map((pureCssChunk) => prelimaryNameToChunkMap[pureCssChunk.fileName])
.filter(Boolean)
const replaceEmptyChunk = getEmptyChunkReplacer(
pureCssChunkNames, opts.format,
)
for (const file in bundle) {
const chunk = bundle[file]
if (chunk.type === 'chunk') {
let chunkImportsPureCssChunk = false
// 从其他 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))
// ...(importedAssets 同样转移,略)
chunkImportsPureCssChunk = true
return false
}
return true
})
if (chunkImportsPureCssChunk) {
chunk.code = replaceEmptyChunk(chunk.code)
}
}
}
// 从 bundle 中删除纯 CSS chunk
pureCssChunkNames.forEach((fileName) => {
// ...(省略:入口 chunk 把元数据移交给真正的 CSS 入口;
// 记入 removedPureCssFilesCache)
delete bundle[fileName]
delete bundle[`${fileName}.map`]
})
}
getEmptyChunkReplacer 函数根据输出格式生成不同的替换正则:
// packages/vite/src/node/plugins/css.ts:1242
export function getEmptyChunkReplacer(
pureCssChunkNames: string[],
outputFormat: InternalModuleFormat,
): (code: string) => string {
const emptyChunkFiles = pureCssChunkNames
.map((file) => escapeRegex(path.basename(file)))
.join('|')
// 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})["'];`
: `(\\b|,\\s*)require\\(\\s*["'\`][^"'\`]*(?:${emptyChunkFiles})["'\`]\\)(;|,)`,
'g',
)
return (code: string) =>
code.replace(emptyChunkRE, (m, p1, p2) => {
if (outputFormat === 'es') {
return `/* empty css ${''.padEnd(m.length - 15)}*/`
}
if (p2 === ';') {
// 以 `;` 结尾:把分号挪到前面,并去掉前导的 `,`
return `${p2}/* empty css ${''.padEnd(m.length - 16)}*/`
}
// 以 `,` 结尾:去掉它,但保留前导的 `,`(如果有)
return `${p1}/* empty css ${''.padEnd(m.length - 15 - p1.length)}*/`
})
}
替换时保持了与原始导入语句相同的字符长度(通过 padEnd),这样 source map 不会偏移。
10.5 CSS HMR 机制
cssAnalysisPlugin 的角色
cssAnalysisPlugin 在开发模式下跟踪 CSS 文件的 @import 依赖关系。当一个被 @import 的文件发生变化时,导入它的主文件需要重新编译。
// packages/vite/src/node/plugins/css.ts:1174
export function cssAnalysisPlugin(config: ResolvedConfig): Plugin {
return {
name: 'vite:css-analysis',
transform: {
filter: {
id: {
include: CSS_LANGS_RE,
exclude: [commonjsProxyRE, SPECIAL_QUERY_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 unknown as TransformPluginContext)
._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->>HMR: 沿 importer 向上寻找 HMR 边界
HMR->>B: HMR update(带时间戳)
B->>CSS: 带 ?t= 重新请求模块,触发 App.module.scss 重新 transform
CSS->>CSS: Sass 编译 + PostCSS + CSS Modules
CSS-->>B: 新的 JS 模块(内含新 CSS 与类名映射)
B->>B: updateStyle(id, newCSS)
Note over B: CSS Modules: 边界在引用它的模块<br/>普通 CSS: 自身即边界(自接受)
普通 CSS 文件可以自接受 HMR 更新——updateStyle 函数直接替换 <style> 标签的内容即可。但 CSS Modules 不能自接受,因为类名映射的变化需要通知所有使用这些类名的组件重新渲染。这种情况下,HMR 更新会沿着依赖链向上传播。
10.6 CSS 压缩
Vite 支持两种 CSS 压缩引擎:
// packages/vite/src/node/plugins/css.ts:2201(简化:省略 warning 输出与 try/catch 错误包装)
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),
cssModules: undefined,
filename: defaultCssBundleName,
code: Buffer.from(css),
minify: true,
})
return decoder.decode(code) + (inlined ? '' : '\n')
}
inlined 参数影响是否保留末尾换行符——内联 CSS 不需要尾部换行,而独立文件应以换行符结尾。
10.7 Lightning CSS 集成
Lightning CSS 是一个基于 Rust 的高性能 CSS 处理器,可以替代 PostCSS 作为 Vite 的 CSS transformer:
// packages/vite/src/node/plugins/css.ts:214(签名在源码里是多行)
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.8 构建模式与开发模式的差异总结
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 --> B6["URL 占位符解析"]
B6 --> B3["CSS 压缩"]
B3 --> B4["emitFile 输出"]
B4 --> B5["纯 CSS chunk 清理"]
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 环境或资源受限的容器,可以通过设置具体数字来控制并行度。但注意 §10.2「初始化与 Worker 池」里那两条限制:这个数字只对 Less 和 Stylus 生效(Sass 走 sass.initAsyncCompiler() 自己的并发),且 Less/Stylus 在收到函数型选项时会静默退回主线程。
为什么 CSS Modules 不能自接受 HMR
CSS Modules 的输出不仅是样式,还包含类名映射。当类名映射发生变化时(例如添加了新的类名),所有使用这些类名的组件都需要重新渲染。如果 CSS Module 自接受 HMR,只有样式会更新,而引用方仍然使用旧的类名映射,导致样式不一致。
因此 isSelfAccepting 对 CSS Modules 设为 false,让 HMR 更新向上传播到引用方。虽然这可能导致更大范围的更新,但保证了一致性。
为什么不将 ?url CSS 直接当作资源处理
?url 查询的 CSS 文件需要特殊处理。在构建模式下,CSS 内容需要先经过预处理器和 PostCSS 处理(编译 Sass 等、重写 url() 路径),然后才能作为资源输出(?url 不支持 CSS Modules,load 钩子遇到 .module.css?url 会直接抛错)。因此 cssPlugin 的 load 钩子拦截 ?url 请求,注入一个中间层来确保 CSS 处理管线正确执行。
多文件合并时的 @import 与 @charset hoist
当多个 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.9 本章与全专栏体系的呼应
CSS 处理这一章表面独立、实则和 Vite 整个生态紧密咬合。梳理一下接合点:
与第 4 章(插件系统)的依赖:本章讲的 3 个插件(cssPlugin、cssPostPlugin、cssAnalysisPlugin)都是 Vite 插件 API 的标准应用。"三层协作"的架构模式——pre-transform / post-transform / analysis——是 Vite 插件生态的一个范式,用户写自己的 CSS 相关插件时应该考虑是否需要多层拆分。
与第 7 章(HMR)的深度整合:§10.13.7 讲的"三条件判 selfAccepting"决定了 CSS 在 HMR 里走哪条路。普通 CSS 自接受、CSS Modules 冒泡、inline CSS 冒泡——三种行为通过 3 个布尔条件精确区分。第 7 章的 HMR 传播算法在 CSS 场景下走不同路径全靠这 3 个条件。
与第 9 章(JS 转换)的对称:第 9 章讲了 JS 的 transform 管线(Oxc → define → importAnalysis),本章讲了 CSS 的 transform 管线(preprocessors → PostCSS/Lightning CSS → cssPostPlugin)。两条管线几乎对称:同样的 "isBundled 二分 + 预处理 + AST 转换 + HMR 注入" 结构。理解其中一条能快速迁移到另一条——Vite 对不同文件类型应用同一套架构套路是它代码组织的美学。
与第 13 章(Rolldown 构建)的接口:本章 cssPostPlugin 的 renderChunk 钩子、getEmptyChunkReplacer 的清理、codeSplitEmitQueue 的串行化——都是在和 Rollup/Rolldown 的 build 产物精确协作。第 13 章讲过 Rolldown 构建流程与 chunk 元数据(ChunkMetadata);本章展示了 chunk 渲染阶段 CSS 如何被提取、合并、重命名。
与第 17 章(Worker 插件)的对比:第 17 章讲了 Worker 的 WorkerOutputCache 独立构建管线;本章讲了 CSS 的 preprocessorWorkerController Worker 池——两种 Worker 使用模式对比:Worker 插件是"每个 Worker 文件独立构建"、CSS 是"Less、Stylus 各有一个 Worker 池,同一种预处理器的编译任务共享它"。前者按文件并行,后者按任务并行——反映了两种资源的并发特性不同(Worker 文件间独立、预处理器任务间也独立但偏 CPU 密集)。
10.10 本章给插件作者的工程启示
这是 Vite 代码库最大的一个插件——读完本章你能学到的不止是 CSS 处理本身,还有一系列大型插件设计的工程原则:
1、按生命周期分层:cssPlugin(pre)→ 用户插件 → cssPostPlugin(post)→ cssAnalysisPlugin(dev HMR)。如果你在写一个复杂插件(比如针对某个 DSL 的完整处理链),考虑按"pre/post/analysis"拆成 3-4 个子插件让用户能在中间介入。
2、WeakMap 是框架级缓存的默认答案。css.ts 里以 ResolvedConfig 为键的 WeakMap 就有 6 个(§10.13.1 的四个、§10.13.4 的 configToAtImportResolvers,外加 preprocessorWorkerControllerCache),§17.1.9、§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 池是按文件语言分的(less 一个池、stylus 一个池;sass 不建池,交给 sass.initAsyncCompiler())——避免不同预处理器抢同一个 Worker 资源。
5、串行队列保确定性。各 chunk 的 renderChunk 并发执行、CSS 的最终处理谁先完成不确定——用 serial promise queue 让结果按调用顺序返回,emitFile 的顺序因此稳定。这条在任何"构建产物需要确定性"的场景都适用。
6、针对下游工具的精确行为编写代码。§10.13.8 的 require 链式处理、「多文件合并时的 @import 与 @charset hoist」一节的 @import hoisting、§10.13.6 的 HTML 属性转义——都是针对特定下游工具(minifier、浏览器、HTML parser)的精确行为做的针对性处理。这要求插件作者不仅懂自己插件、还懂下游工具链在哪里有什么约束。
这 6 条原则贯穿 Vite 其他复杂插件——你在阅读其他插件源码时能看到同样的模式。学会识别这些 pattern 能明显加快你读源码的速度——不是一行行细读、而是识别出"又是 WeakMap 缓存"、"又是 serial queue"、"又是 pre/post 分层"——直奔特殊点。
10.11 源码定位索引
| 主题 | 源文件 | 关键行号 |
|---|---|---|
| cssPlugin 主体 | packages/vite/src/node/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 v8.0.3(commit a248a41)。
10.12 读完本章能回答的具体问题清单
作为掌握度自测:
- CSS 插件为什么要拆成 3 层(pre、post、analysis)?(§10.1——用户插件需要在 pre 和 post 之间介入)
- cssModulesCache 为什么用 WeakMap?(§10.13.1——config 回收自动清理缓存)
- postcss config 为什么在构造时就预热?(§10.13.2——把 IO 塞进启动阶段空隙、用户请求来了后纯 CPU 路径)
- sass 为什么会把自己报告为自己的依赖?(§10.13.3——sass 实现特性,Vite 用 filter 防循环)
- 普通 CSS 文件为什么能 selfAccept 但 CSS Modules 不行?(§10.13.7——CSS Modules 有 JS value export,值变了要通知使用者)
- pure CSS chunk 清理时怎么处理 minifier 的 require 链?(§10.13.8——正则捕获逗号/分号、保留一个或加分号)
- Lightning CSS 不懂 Sass,Vite 怎么让它们协作?(§10.13.9——用 resolver.read 钩子劫持文件读取、先调 Sass 预处理再返回 CSS)
- HTML style 属性里的 CSS 和 stylesheet 文件走同一段代码吗?(§10.13.10——不同 API,transformStyleAttribute vs bundleAsync)
- cssPostPlugin 为什么要 serial promise queue?(§10.13.5——保证产物文件名确定性、不受并发竞争影响)
- HTML inline style 属性里的 CSS 为什么要转义双引号?(§10.13.6——防止属性闭合被污染、HTML 解析错乱)
10.13 源码核对
本章正文为了讲清主干做过简化。这一节把正文里每一处简化,逐条对回真实源码—— 路径、行号、以及正文没有展开的边界条件。想核对的读者可以照着逐条验证。
10.13.1 四层 WeakMap 各自是什么
打开 packages/vite/src/node/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:ResolvedConfig → (fileName → RenderedChunk),记录 generateBundle 里被删掉的"纯 CSS chunk"。这些 chunk 渲染出的 JS 只是个空壳(只为把 CSS 作为副作用引入),Vite 在 build 时把它们从 bundle 里删掉。但后续步骤需要知道"这些 chunk 的 CSS 去哪了"——importAnalysisBuild 生成预加载依赖、以及关闭 preload 时移除对它们的 import,都要查这个 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 核心代码里以 config 为键的缓存都用 WeakMap 存储,形成一致风格(插件闭包内的状态则另当别论)。
10.13.2 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,那个文件的编译就得先等这段 IO 完成(首次 cold start 时尤其明显)。把 IO 挪到插件构造阶段——此时 Vite 还在初始化其他东西、IO 和 CPU 可以并行——等到真正需要配置时 Promise 通常已经 resolve、await 立刻拿值。
.catch(() => { /* will be handled later */ }) 是必须的——如果 postcss config 有语法错误、预热 catch 吞掉、等真正用 config 时在用户请求第一个 CSS 文件的上下文里再抛——这时错误消息能带上"编译哪个文件时失败了"的上下文,更有诊断价值。错误不是被吞了、是被延后到更合适的时机重现。
这条"预热 + 延后报错"的 pattern 在 Vite 源码里多次出现——比如第 8 章讲过的 depsOptimizer 初始化(依赖扫描在后台并行执行)。Vite 作为高性能 dev server 的关键哲学之一:尽可能把 IO 塞进启动阶段的空隙里、让用户请求来了之后的路径纯 CPU。
10.13.3 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、把它报告的依赖原样登记进依赖图,就得自己处理这种自引用。
Vite 源码注释 "sometimes sass registers the file itself as a dep"——用的是 "sometimes",说明这不是稳定行为。这是一种防御性编程——即使 sass 未来不再这样报告、Vite 的过滤代码照常工作(不过是多一次 filter 而已)。
10.13.4 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 返回的对象本身很轻——三个 getter 都是惰性的,首次访问时才调用 createBackCompatIdResolver 真正构造 resolver:css 和 less 用 ??= 直接存回闭包变量(packages/vite/src/node/plugins/css.ts:1292 与 packages/vite/src/node/plugins/css.ts:1331),sass 则多包一层 wrapper——sass modern API 的 canonicalize 会先传 file:// 形式的已解析 URL,要先 fileURLToPath 还原再交给 resolver(packages/vite/src/node/plugins/css.ts:1301-1327)。贵的是 resolver 内部:它带着 alias、条件导出、package.json 缓存等状态,重复构造既浪费又丢缓存。每编译一个 .scss 文件都要用 atImportResolvers——如果每次都 create 一个新对象,这些惰性 getter 就每次都得重新构造 resolver。
WeakMap 让同一个 config 下只 create 一次——后续每个文件的 CSS 编译都复用同一组 resolver。这是 Vite 源码里"惰性初始化 + WeakMap 缓存"模式的又一例。
10.13.5 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 要按序?源码注释写的是 "to guarantee the files are emitted in a deterministic order"。各 chunk 的 renderChunk 并发执行,finalizeCss(hoist + 压缩)谁先完成并不确定;如果谁先完成谁就先 emitFile,CSS 资源的 emit 顺序每次构建都可能不同。队列让 renderChunk 里 await 之后的 emitFile 按 renderChunk 被调用的顺序执行,emit 顺序不受并发竞争影响。
createSerialPromiseQueue(packages/vite/src/node/utils.ts:1640)的用法:await queue.run(() => doSomething())。注意它并不推迟任务的启动——run 里立刻执行 f(),只是用 Promise.all([previousTask, thisTask]) 让第 N 次调用的 await 等到第 N-1 次也完成才返回:任务照样并发跑,返回顺序被串成调用顺序。看起来只是个简单工具,但它是 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 都干净的状态一般在这个钩子里重置。但也有刻意的例外:同一插件的 styles 在插件创建时初始化一次,源码注释说在 buildStart 初始化会让 watch 模式丢样式(packages/vite/src/node/plugins/css.ts:463)。
10.13.6 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="background: url(./a.png)">)时,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 做转义。
10.13.7 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 通过这三条件精确区分,做到"该自接受就自接受、该冒泡就冒泡"。
10.13.8 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})["'];`
: `(\\b|,\\s*)require\\(\\s*["'\`][^"'\`]*(?:${emptyChunkFiles})["'\`]\\)(;|,)`,
'g',
)
CJS 格式的 chunk 里,minifier(terser、oxc-minify)可能把多个 require() 链成 require("a"), require("b"), require("c");——用逗号操作符串起来。如果 Vite 简单地把 require("b") 替换成空字符串,会产出 require("a"), , require("c"); 这种语法错误代码。
正则用两个捕获组分别抓住 require 前面的 (\\b|,\\s*)(p1)和后面的 (;|,)(p2)。替换回调按 p2 分情况:以 ; 结尾说明是链尾,就把分号挪到注释前面、丢掉前导逗号;以 , 结尾说明后面还有 require,就丢掉这个逗号、但保留前导的逗号。这让清理后的代码语法仍然合法,且注释长度依旧与原文相同。
这条处理的存在反映了 Vite 对下游 minifier 行为的精确感知——不是假设 CJS 代码长什么样,而是知道"terser 会把相邻 require 合并成逗号链"——提前在正则里处理这种合法输出。这是一种"和下游工具链协作"的工程姿态——不是各自独立、而是 Vite 清楚 minifier 会产出什么样的代码。
10.13.9 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。没有这步追踪,CSS 文件 @import 进来的 .scss 所依赖的 partial(比如 _variables.scss)改了也不会触发热更新。
10.13.10 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 处理会把含 url( 或 image-set( 的 style 属性值提取成独立的 CSS 转换请求(findNeedTransformStyleAttribute,packages/vite/src/node/plugins/html.ts:1077-1090,源码注释 only url(...) or image-set(...) in css need to emit file)——id 带 ?html-proxy&inline-css&style-attr 标记。不含这两种函数的 style 属性不会进入这条路径。这让 inline style 里引用的资源也能被正确解析、输出;走 Lightning CSS 时顺带获得按 targets 的语法降级与生产环境的 minify。
这条分支让 Vite 的 CSS 处理覆盖了"stylesheet 文件 + inline style 属性"两个语境——是工具完备性的一个标志。
10.13.11 cssUrlRE 的 lookbehind 正则防 @import 误匹配
§10.2 的「URL 重写」讲了基本机制。真实的 cssUrlRE(css.ts:1997-1998)是一条极其精心设计的正则:
export const cssUrlRE: RegExp =
/(?<!@import\s+)(?<=^|[^\w\-\u0080-\uffff])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\-\u0080-\uffff])——正向后查(positive lookbehind)+ 字符集。确保 "url(" 前面要么是字符串开头、要么是非单词字符。这条阻止匹配用户自定义函数 my-url(...)/custom_url(...)——它们恰好以 url( 结尾、但不是标准 CSS url 函数。
3、捕获组里的双模式:
\s*('[^']+'|"[^"]+")\s*——匹配被单/双引号包裹的路径(?:\\.|[^'")\\])+——匹配无引号的路径(允许转义字符)
两种 CSS url 写法都要支持:url(foo.png) 和 url("foo.png"),都有各自的转义规则。
4、Unicode 字符范围 \u0080-\uffff 涵盖非 ASCII 字符——CSS 标识符里可以出现中文/日文等非 ASCII 字符(源码上方注释指向 CSS Syntax 规范的 identifier code point 定义),正则要把它们识别为"标识符的一部分"——中url( 这样的名字不会被当成 url()。
这条正则的每一处 lookbehind、每一个字符类都对应一种需要排除的误匹配。教科书级的正则设计:不是越短越好、是表达力精准为好。
10.13.12 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 字符
- 按 declaration 遍历——只处理 CSS 属性值里的 url、不处理选择器或 @rule 参数
- 能感知节点来源——
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.13.13 resolvePostcssConfig 的 Promise→Value 自动替换
§10.13.2 讲了 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 对象本身而不是 Promise(resolvePostcssConfig 开头仍会对取到的值 await 一次,所以这谈不上省掉异步开销,作用是让缓存里存放最终值)。错误路径保留 rejected promise——让错误在真实使用位置重现。
10.13.14 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 默认打印导致重复显示。
小结
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 的心智模型。
下一章进入 HTML 处理——HTML 插件要协调本章讲过的 CSS/JS 转换,把它们的产出正确插入到 HTML 中。