Appearance
第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 处理不是由一个插件完成的,而是由三个协作的插件组成:
cssPlugin(vite:css):在用户插件之前执行。负责预处理器编译(Sass -> CSS)和 PostCSS 转换。它的输出是纯 CSS 文本。
cssPostPlugin(vite:css-post):在用户插件之后执行。将 CSS 文本转换为 JavaScript 模块(开发模式),或提取为独立文件(构建模式)。
cssAnalysisPlugin(vite:css-analysis):仅在开发模式下生效。负责跟踪 CSS 的 @import 依赖关系,用于 HMR。
这种三层分离的设计有一个重要的原因:用户插件可能需要在预处理和最终输出之间插入自己的 CSS 转换逻辑。如果所有功能都在一个插件中,用户将无法在这些阶段之间介入。
cssPlugin:预处理与 PostCSS
初始化与 Worker 池
cssPlugin 在 buildStart 钩子中初始化预处理器的 Worker 池:
typescript
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 的完整流程:
typescript
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 函数封装。每种预处理器有独立的解析器配置:
typescript
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 }
}@import 解析器
每种 CSS 方言有专属的路径解析器,配置了不同的文件扩展名和 package.json 条件:
typescript
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 插件:
一个关键的优化:当文件不包含 @import、不包含 url()、不是 CSS Module、且没有用户配置的 PostCSS 插件时,整个 PostCSS 处理会被跳过:
typescript
if (
lang !== 'sss' &&
!postcssConfig &&
!isModule &&
!needInlineImport &&
!hasUrl
) {
return // 直接返回 undefined,跳过 PostCSS
}postcss-import 的深度集成
当 CSS 文件包含 @import 时,Vite 使用 postcss-import 插件来内联导入的文件。但 Vite 对其进行了深度定制:
typescript
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() 引用:
typescript
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,替换逻辑需要正确处理转义:
typescript
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 等)模式时,自动启用作用域化处理:
typescript
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 "一切皆模块"理念的体现:
typescript
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' }
}这段生成的代码包含了完整的 HMR 生命周期:
updateStyle:创建或更新<style>标签import.meta.hot.accept():声明自接受热更新import.meta.hot.prune():模块被卸载时移除样式
CSS Modules 的情况有所不同——它们不能自接受 HMR,因为类名映射的变化需要通知所有引用方。此时由 cssAnalysisPlugin 在 updateModuleInfo 中设置 isSelfAccepting: false:
typescript
const isSelfAccepting =
!cssModulesCache.get(config)?.get(id) &&
!inlineRE.test(id) &&
!htmlProxyRE.test(id)构建模式:代码分割与提取
构建模式下的 CSS 处理复杂得多。cssPostPlugin 需要处理多个环节:
在 transform 阶段,CSS 内容被记录到 styles Map 中,返回的 JavaScript 代码可能是:
- CSS Module:返回
modulesCode(类名映射的export) - 内联 CSS(
?inline):返回export default "css content" - 普通 CSS:返回空字符串,但设置
moduleSideEffects: 'no-treeshake'防止被 tree-shaking 删除
typescript
// 构建模式的 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,然后决定如何输出:
typescript
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 文件:
typescript
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 函数根据输出格式生成不同的替换正则:
typescript
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 的文件发生变化时,导入它的主文件需要重新编译。
typescript
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 更新流程
普通 CSS 文件可以自接受 HMR 更新——updateStyle 函数直接替换 <style> 标签的内容即可。但 CSS Modules 不能自接受,因为类名映射的变化需要通知所有使用这些类名的组件重新渲染。这种情况下,HMR 更新会沿着依赖链向上传播。
CSS 压缩
Vite 支持两种 CSS 压缩引擎:
typescript
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:
typescript
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 一起使用。
构建模式与开发模式的差异总结
设计决策
为什么 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 处理管线正确执行。
小结
CSS 处理引擎是 Vite 中最复杂也最精密的子系统。三个插件——cssPlugin、cssPostPlugin、cssAnalysisPlugin——各司其职,共同实现了从预处理到最终输出的完整管线。
compileCSS 函数是核心调度器,它将预处理器编译、PostCSS/Lightning CSS 转换、CSS Modules 处理和 source map 合并编排为一条清晰的流水线。开发模式和构建模式在 cssPostPlugin 中分叉,走上截然不同的输出路径。
Worker 池化的预处理器执行、PostCSS 的条件跳过、构建模式下纯 CSS chunk 的智能清理——这些优化确保了 CSS 处理在任何规模的项目中都能保持高性能。
理解了 CSS 引擎的设计,我们就掌握了 Vite 中最复杂的转换逻辑。下一章我们将转向 HTML 处理——Vite 如何解析 HTML 入口文件,提取脚本和样式引用,并在开发与构建模式下正确处理资源路径。