Vite 设计与实现

第10章 CSS 处理引擎

作者 杨艺韬 · 13,333 字 · 发布于 · 更新于

开篇引言

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}`
    },
  }),
)

这里有三个重要的定制:

  1. 路径解析:使用 Vite 自己的解析器替代 postcss-import 默认的 resolve 包,支持 Vite 的别名和条件导出
  2. 预处理器嵌套:当 CSS 文件 @import 一个 .scss 文件时,会先通过 Sass 预处理器编译后再内联
  3. 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 的优势在于:

  1. 更快的速度:Rust 实现,通常明显快于基于 JavaScript 的 PostCSS
  2. 内置功能:原生支持 CSS Modules、嵌套、自定义属性等,不需要额外插件
  3. 更好的压缩:在代码压缩方面通常优于 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 读完本章能回答的具体问题清单

作为掌握度自测:

  1. CSS 插件为什么要拆成 3 层(pre、post、analysis)?(§10.1——用户插件需要在 pre 和 post 之间介入)
  2. cssModulesCache 为什么用 WeakMap?(§10.13.1——config 回收自动清理缓存)
  3. postcss config 为什么在构造时就预热?(§10.13.2——把 IO 塞进启动阶段空隙、用户请求来了后纯 CPU 路径)
  4. sass 为什么会把自己报告为自己的依赖?(§10.13.3——sass 实现特性,Vite 用 filter 防循环)
  5. 普通 CSS 文件为什么能 selfAccept 但 CSS Modules 不行?(§10.13.7——CSS Modules 有 JS value export,值变了要通知使用者)
  6. pure CSS chunk 清理时怎么处理 minifier 的 require 链?(§10.13.8——正则捕获逗号/分号、保留一个或加分号)
  7. Lightning CSS 不懂 Sass,Vite 怎么让它们协作?(§10.13.9——用 resolver.read 钩子劫持文件读取、先调 Sass 预处理再返回 CSS)
  8. HTML style 属性里的 CSS 和 stylesheet 文件走同一段代码吗?(§10.13.10——不同 API,transformStyleAttribute vs bundleAsync)
  9. cssPostPlugin 为什么要 serial promise queue?(§10.13.5——保证产物文件名确定性、不受并发竞争影响)
  10. 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, '&quot;')
  }
  // ...
}

当 CSS 来自 HTML 的 style 属性(<div style="background: url(./a.png)">)时,Vite 处理完 CSS 后要把它写回 HTML 的 style 属性中——这段 CSS 里的双引号必须转义成 &quot;,否则属性字符串闭合出错。

想象 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 源码里单独的字符串扫描。这让它:

  1. 尊重 PostCSS 的 AST——不会匹配到注释里的 url 字符
  2. 按 declaration 遍历——只处理 CSS 属性值里的 url、不处理选择器或 @rule 参数
  3. 能感知节点来源——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 中。