Vite 设计与实现

第10章 CSS 处理引擎

作者 杨艺韬 · 11,946 字

第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 插件的三层架构:cssPlugincssPostPlugincssAnalysisPlugin
  • 预处理器集成的 Worker 池化设计
  • PostCSS 处理管线与插件编排
  • CSS Modules 的作用域隔离实现
  • 开发模式下的 CSS HMR 机制
  • 构建模式下的 CSS 代码分割与资源提取
  • Lightning CSS 作为替代 transformer 的集成方式
  • url()@import 的路径重写策略

CSS 插件的三层架构

CSS 处理不是由一个插件完成的,而是由三个协作的插件组成:

graph LR
    subgraph "CSS 处理三件套"
        A["cssPlugin<br/>(vite:css)<br/>预处理 + PostCSS"] --> B["用户插件"]
        B --> C["cssPostPlugin<br/>(vite:css-post)<br/>HMR 注入 / 构建提取"]
        C --> D["cssAnalysisPlugin<br/>(vite:css-analysis)<br/>HMR 依赖跟踪"]
    end

    E[".scss 源文件"] --> A
    D --> F["浏览器"]

    style A fill:#e8f4fd,stroke:#1890ff
    style C fill:#f6ffed,stroke:#52c41a
    style D fill:#fff7e6,stroke:#fa8c16

cssPluginvite:css:在用户插件之前执行。负责预处理器编译(Sass -> CSS)和 PostCSS 转换。它的输出是纯 CSS 文本。

cssPostPluginvite:css-post:在用户插件之后执行。将 CSS 文本转换为 JavaScript 模块(开发模式),或提取为独立文件(构建模式)。

cssAnalysisPluginvite:css-analysis:仅在开发模式下生效。负责跟踪 CSS 的 @import 依赖关系,用于 HMR。

这种三层分离的设计有一个重要的原因:用户插件可能需要在预处理和最终输出之间插入自己的 CSS 转换逻辑。如果所有功能都在一个插件中,用户将无法在这些阶段之间介入。

10.1-bis 源码核对:三层 WeakMap 各自是什么

打开 plugins/css.ts:269-285,三层 WeakMap 缓存是整个 CSS 插件状态管理的骨架:

const cssModulesCache = new WeakMap<
  ResolvedConfig,
  Map<string, Record<string, string>>
>()

export const removedPureCssFilesCache: WeakMap<
  ResolvedConfig,
  Map<string, RenderedChunk>
> = new WeakMap()

// Used only if the config doesn't code-split CSS (builds a single CSS file)
export const cssBundleNameCache: WeakMap<ResolvedConfig, string> = new WeakMap()

const postcssConfigCache = new WeakMap<
  ResolvedConfig,
  PostCSSConfigResult | null | Promise<PostCSSConfigResult | null>
>()

四层缓存、四个用途:

1、cssModulesCacheResolvedConfig → (id → CSS modules export map)。每个 CSS Module 文件编译后产出一个 class name 到 hashed name 的映射(比如 {card: 'card_7f3k2'})——cssPlugin 算一次、cssPostPlugin 用一次(生成 export default {card: 'card_7f3k2'})——跨插件传递靠这个 WeakMap

2、removedPureCssFilesCache:记录”编译后纯 CSS,没有 JS 副作用”的文件。Vite 在 build 时会把这些文件从 JS bundle 里摘出来——它们原本只是为了把 CSS 作为副作用引入,摘掉后 JS bundle 少几 KB。但 Vite 需要在后续步骤里知道”这些文件的 CSS 去哪了”——通过这个 cache 追踪。

3、cssBundleNameCache:单文件 CSS 输出时(cssCodeSplit: false)存输出文件名。只有一个 CSS 输出文件、所有 chunk 都引用它——这个名字在插件多次调用之间共享。

4、postcssConfigCache:PostCSS 配置解析结果。注意这个类型是 PostCSSConfigResult | null | Promise<PostCSSConfigResult | null>——能缓存 Promise。这是个精巧的设计:插件初始化时的 resolvePostcssConfig(config).catch(() => {})预热”会 return 一个 Promise——立刻缓存进 WeakMap——后续真正需要配置时 await 这个缓存的 Promise 就行,第一次调用的时延被隐藏在并发初始化里

所有四层都用 WeakMap 而不是 Map——config 对象被 GC 时缓存自动清理。对 Vite 这种可能在同一进程里启动多次(比如测试场景、多配置并行)的工具,WeakMap 避免了”config 都回收了、缓存还占着内存”的典型泄漏。

这条 “WeakMap 作为框架级缓存容器” 的实践在 §17.1.9 讲 Vite Worker 插件时也出现过——vite 核心代码里几乎所有 per-config 状态都用 WeakMap 存储,形成一致风格。

cssPlugin:预处理与 PostCSS

10.2-bis 源码核对:cssPlugin 构造时的 postcss 配置预热

cssPlugin 的返回之前有一段极易错过但影响性能的代码(css.ts:308-313):

// warm up cache for resolved postcss config
if (config.css.transformer !== 'lightningcss') {
  resolvePostcssConfig(config).catch(() => {
    /* will be handled later */
  })
}

这段在 cssPlugin 被 Vite 实例化的时候不是 buildStart)就发起 PostCSS 配置解析——此时它可能在漫长的 IO(找 postcss.config.js、遍历父目录、import() 配置文件)。把 Promise 甩进 WeakMap 缓存里,什么也不等

为什么要在构造时预热?因为第一个 CSS 文件的转换请求到来时,如果这时才去解析 postcss config,那个文件的编译会被阻塞几十到几百毫秒(特别是首次 cold start)。把 IO 挪到插件构造阶段——此时 Vite 还在初始化其他东西、IO 和 CPU 可以并行——等到真正需要配置时 Promise 通常已经 resolve、await 立刻拿值。

.catch(() => { /* will be handled later */ }) 是必须的——如果 postcss config 有语法错误、预热 catch 吞掉、等真正用 config 时在用户请求第一个 CSS 文件的上下文里再抛——这时错误消息能带上”编译哪个文件时失败了”的上下文,更有诊断价值。错误不是被吞了、是被延后到更合适的时机重现

这条”预热 + 延后报错”的 pattern 在 Vite 源码里多次出现——比如第 7 章讲过的 depsOptimizer 初始化、第 9 章讲过的 getTSConfigResolutionCache。Vite 作为高性能 dev server 的关键哲学之一尽可能把 IO 塞进启动阶段的空隙里、让用户请求来了之后的路径纯 CPU

初始化与 Worker 池

cssPluginbuildStart 钩子中初始化预处理器的 Worker 池:

export function cssPlugin(config: ResolvedConfig): Plugin {
  let preprocessorWorkerController: PreprocessorWorkerController | undefined

  return {
    name: 'vite:css',

    buildStart() {
      moduleCache = new Map<string, Record<string, string>>()
      cssModulesCache.set(config, moduleCache)

      preprocessorWorkerController = createPreprocessorWorkerController(
        normalizeMaxWorkers(config.css.preprocessorMaxWorkers),
      )
    },

    buildEnd() {
      preprocessorWorkerController?.close()
    },
    // ...
  }
}

preprocessorMaxWorkers 默认值为 true,表示使用 CPU 核心数减 1 个 Worker。这对大型项目中大量 Sass/Less 文件的并行编译至关重要——预处理器通常是 CPU 密集型的操作。

CSS 编译管线

compileCSS 是整个 CSS 处理的核心调度函数。它编排了从源文件到最终 CSS 的完整流程:

flowchart TD
    Start["compileCSS(id, code)"] --> Lang{检测语言类型}

    Lang -->|scss/sass/less/styl| Pre["compileCSSPreprocessors<br/>预处理器编译"]
    Lang -->|sss + lightningcss| Sugar["transformSugarSS<br/>SugarSS 转换"]
    Lang -->|css| Skip[跳过预处理]

    Pre --> Transform{config.css.transformer?}
    Sugar --> Transform
    Skip --> Transform

    Transform -->|postcss| PostCSS["compilePostCSS<br/>PostCSS 处理"]
    Transform -->|lightningcss| LCSS["compileLightningCSS<br/>Lightning CSS 处理"]

    PostCSS --> Result["返回结果<br/>{code, map, modules, deps}"]
    LCSS --> Result

    style Pre fill:#e8f4fd,stroke:#1890ff
    style PostCSS fill:#f6ffed,stroke:#52c41a
    style LCSS fill:#fff7e6,stroke:#fa8c16
async function compileCSS(
  environment: PartialEnvironment,
  id: string,
  code: string,
  workerController: PreprocessorWorkerController,
  urlResolver?: CssUrlResolver,
) {
  const lang = CSS_LANGS_RE.exec(id)?.[1] as CssLang | undefined
  const deps = new Set<string>()

  // 第一阶段:预处理器
  let preprocessorMap: ExistingRawSourceMap | undefined
  if (isPreProcessor(lang)) {
    const preprocessorResult = await compileCSSPreprocessors(
      environment, id, lang, code, workerController,
    )
    code = preprocessorResult.code
    preprocessorMap = preprocessorResult.map
    preprocessorResult.deps?.forEach((dep) => deps.add(dep))
  }

  // 第二阶段:CSS 转换器
  const transformResult = await (
    config.css.transformer === 'lightningcss'
      ? compileLightningCSS(environment, id, code, deps, ...)
      : compilePostCSS(environment, id, code, deps, lang, ...)
  )

  // 合并 source map
  return {
    ...transformResult,
    map: config.css.devSourcemap
      ? combineSourcemapsIfExists(cleanUrl(id),
          transformResult.map, preprocessorMap)
      : { mappings: '' },
    deps,
  }
}

预处理器集成

预处理器的调用通过 compileCSSPreprocessors 函数封装。每种预处理器有独立的解析器配置:

async function compileCSSPreprocessors(
  environment: PartialEnvironment,
  id: string,
  lang: PreprocessLang,
  code: string,
  workerController: PreprocessorWorkerController,
) {
  const { preprocessorOptions, devSourcemap } = config.css
  const atImportResolvers = getAtImportResolvers(
    environment.getTopLevelConfig(),
  )
  const opts = {
    ...((preprocessorOptions && preprocessorOptions[lang]) || {}),
    filename: cleanUrl(id),
    enableSourcemap: devSourcemap ?? false,
  }

  const preProcessor = workerController[lang]
  const preprocessResult = await preProcessor(
    environment, code, config.root, opts, atImportResolvers,
  )

  if (preprocessResult.error) {
    throw preprocessResult.error
  }

  // 过滤自引用依赖
  let deps: Set<string> | undefined
  if (preprocessResult.deps.length > 0) {
    const normalizedFilename = normalizePath(opts.filename)
    deps = new Set(
      [...preprocessResult.deps].filter(
        (dep) => normalizePath(dep) !== normalizedFilename,
      ),
    )
  }

  return { code: preprocessResult.code, map: ..., deps }
}

10.3-bis 源码核对:compileCSSPreprocessors 的 self-dep 过滤

§10.2 给了预处理器集成代码。真实 compileCSSPreprocessors(css.ts:1348-1403)里最后一段处理值得拆开:

let deps: Set<string> | undefined
if (preprocessResult.deps.length > 0) {
  const normalizedFilename = normalizePath(opts.filename)
  // sometimes sass registers the file itself as a dep
  deps = new Set(
    [...preprocessResult.deps].filter(
      (dep) => normalizePath(dep) !== normalizedFilename,
    ),
  )
}

sass 预处理器会把自己”注册”为自己的依赖——foo.scss 编译时,它在 deps 里报告自己是 foo.scss。如果不过滤,Vite 的 module graph 里会形成 foo.scss → foo.scss 的自循环依赖。

这条过滤看起来是一行代码——filter((dep) => normalizePath(dep) !== normalizedFilename)——但它处理的是 sass 实现的一个微妙行为。多数开发者永远不会注意到这个问题(因为 Vite 帮他们兜住了)——但如果你自己在另一个项目里直接调 sass.compile、不做这个过滤——会发现 watcher 报告”foo.scss 变了”的时候,又会触发”foo.scss 变了”的事件——无限循环直到 CPU 100%。

Vite 源码注释 “sometimes sass registers the file itself as a dep”——用 “sometimes” 暗示这条行为在 sass 的多个版本里都出现过。这是一种防御性编程——即使 sass 未来修了这个行为、Vite 的过滤代码照常工作(不过是多一次 set.filter 而已)。

10.3-ter 源码核对:getAtImportResolvers 的 WeakMap 缓存

继续看 css.ts:1405-1416:

const configToAtImportResolvers = new WeakMap<
  ResolvedConfig,
  CSSAtImportResolvers
>()
function getAtImportResolvers(config: ResolvedConfig) {
  let atImportResolvers = configToAtImportResolvers.get(config)
  if (!atImportResolvers) {
    atImportResolvers = createCSSResolvers(config)
    configToAtImportResolvers.set(config, atImportResolvers)
  }
  return atImportResolvers
}

又一个 WeakMap。这次存的是 “CSS 的路径解析器”——它是一个包含 css/sass/less 三个子 resolver 的对象(每种预处理器的 @import 语义略有不同,需要独立解析器)。

为什么要缓存?createCSSResolvers 很贵——它要读 tsconfig-like 的 paths、读取 resolve.alias、构造复杂的 Node.js resolve 逻辑——几十到几百毫秒。每编译一个 .scss 文件都要用 atImportResolvers——如果每次都 create,dev server 打开一个有 200 个 scss 文件的项目,初次加载慢几秒。

WeakMap 让同一个 config 下只 create 一次——后续每个文件的 CSS 编译都享受缓存。这是 Vite 源码里”惰性初始化 + WeakMap 缓存”模式的又一例。

@import 解析器

每种 CSS 方言有专属的路径解析器,配置了不同的文件扩展名和 package.json 条件:

function createCSSResolvers(config: ResolvedConfig): CSSAtImportResolvers {
  return {
    get css() {
      return createBackCompatIdResolver(config, {
        extensions: ['.css'],
        mainFields: ['style'],
        conditions: ['style', DEV_PROD_CONDITION],
        tryIndex: false,
        preferRelative: true,
      })
    },

    get sass() {
      return createBackCompatIdResolver(config, {
        extensions: ['.scss', '.sass', '.css'],
        mainFields: ['sass', 'style'],
        conditions: ['sass', 'style', DEV_PROD_CONDITION],
        tryIndex: true,
        tryPrefix: '_',        // Sass 的 partial 文件约定
        preferRelative: true,
        skipMainField: true,
      })
    },

    get less() {
      return createBackCompatIdResolver(config, {
        extensions: ['.less', '.css'],
        mainFields: ['less', 'style'],
        conditions: ['less', 'style', DEV_PROD_CONDITION],
        tryIndex: false,
        preferRelative: true,
      })
    },
  }
}

注意 Sass 解析器的 tryPrefix: '_' 配置——这支持了 Sass 的 partial 文件命名约定(以下划线开头的文件不会被单独编译)。

PostCSS 处理

compilePostCSS 函数编排 PostCSS 的插件链。它会根据需要动态加载三类 PostCSS 插件:

flowchart TD
    Start[compilePostCSS] --> Check{需要 PostCSS 处理?}

    Check -->|"无 @import、无 url()、<br/>非 CSS Module、无 postcss 配置"| Skip[返回 undefined]
    Check -->|需要| Build[构建插件链]

    Build --> Import{"包含 @import?"}
    Import -->|是| P1["添加 postcss-import<br/>内联 @import"]
    Import -->|否| Next1[跳过]

    P1 --> URL{"需要 url 重写?"}
    Next1 --> URL
    URL -->|是| P2["添加 vite-url-rewrite<br/>重写 url() 路径"]
    URL -->|否| Next2[跳过]

    P2 --> Module{"CSS Module?"}
    Next2 --> Module
    Module -->|是| P3["添加 postcss-modules<br/>生成作用域类名"]
    Module -->|否| Next3[跳过]

    P3 --> User["添加用户 postcss 插件"]
    Next3 --> User

    User --> Run["执行 postcss(plugins).process()"]

    style P1 fill:#e8f4fd,stroke:#1890ff
    style P2 fill:#fff7e6,stroke:#fa8c16
    style P3 fill:#f6ffed,stroke:#52c41a

一个关键的优化:当文件不包含 @import、不包含 url()、不是 CSS Module、且没有用户配置的 PostCSS 插件时,整个 PostCSS 处理会被跳过:

if (
  lang !== 'sss' &&
  !postcssConfig &&
  !isModule &&
  !needInlineImport &&
  !hasUrl
) {
  return  // 直接返回 undefined,跳过 PostCSS
}

postcss-import 的深度集成

当 CSS 文件包含 @import 时,Vite 使用 postcss-import 插件来内联导入的文件。但 Vite 对其进行了深度定制:

postcssPlugins.unshift(
  (await importPostcssImport()).default({
    async resolve(id, basedir) {
      // 优先检查公共目录
      const publicFile = checkPublicFile(id, config)
      if (publicFile) return publicFile

      // 使用 Vite 的解析器(而非 postcss-import 默认的 Node resolve)
      const resolved = await atImportResolvers.css(
        environment, id, path.join(basedir, '*'),
      )
      if (resolved) return path.resolve(resolved)

      return id
    },

    async load(id) {
      const code = await fs.promises.readFile(id, 'utf-8')
      const lang = CSS_LANGS_RE.exec(id)?.[1]
      if (isPreProcessor(lang)) {
        // 如果 @import 引入的是 Sass/Less 文件,先编译
        const result = await compileCSSPreprocessors(
          environment, id, lang, code, workerController,
        )
        result.deps?.forEach((dep) => deps.add(dep))
        return result.code
      }
      return code
    },

    nameLayer(index) {
      return `vite--anon-layer-${getHash(id)}-${index}`
    },
  }),
)

这里有三个重要的定制:

  1. 路径解析:使用 Vite 自己的解析器替代 postcss-import 默认的 resolve 包,支持 Vite 的别名和条件导出
  2. 预处理器嵌套:当 CSS 文件 @import 一个 .scss 文件时,会先通过 Sass 预处理器编译后再内联
  3. Layer 命名:为匿名 @layer 生成基于文件哈希的稳定名称

URL 重写

UrlRewritePostcssPlugin 是 Vite 自定义的 PostCSS 插件,负责重写 CSS 中的 url()image-set() 引用:

const UrlRewritePostcssPlugin: PostCSS.PluginCreator<{
  resolver: CssUrlResolver
  deps: Set<string>
  logger: Logger
}> = (opts) => {
  return {
    postcssPlugin: 'vite-url-rewrite',
    Once(root) {
      const promises: Promise<void>[] = []
      root.walkDecls((declaration) => {
        const isCssUrl = cssUrlRE.test(declaration.value)
        const isCssImageSet = cssImageSetRE.test(declaration.value)
        if (isCssUrl || isCssImageSet) {
          promises.push(
            rewriteCssUrls(declaration.value, replacer)
              .then((url) => { declaration.value = url })
          )
        }
      })
      if (promises.length) return Promise.all(promises)
    },
  }
}

URL 重写的逻辑需要处理多种边界情况:外部 URL 和 data URL 需要跳过,已经是 Vite 占位符的 URL(_​_VITE_ASSET_​_)也需要跳过。对于带引号和不带引号的 URL,替换逻辑需要正确处理转义:

async function doUrlReplace(rawUrl, matched, replacer, funcName = 'url') {
  let wrap = ''
  const first = rawUrl[0]
  let unquotedUrl = rawUrl
  if (first === '"' || first === "'") {
    wrap = first
    unquotedUrl = rawUrl.slice(1, -1)
  }
  if (skipUrlReplacer(unquotedUrl)) return matched

  // 移除转义序列以获取实际文件名
  unquotedUrl = unquotedUrl.replace(/\\(\W)/g, '$1')

  let newUrl = await replacer(unquotedUrl, rawUrl)

  // 新 URL 可能需要引号包裹(包含空格或特殊字符)
  if (wrap === '' && (newUrl !== encodeURI(newUrl) || newUrl.includes(')'))) {
    wrap = '"'
  }
  return `${funcName}(${wrap}${newUrl}${wrap})`
}

CSS Modules

CSS Modules 通过 postcss-modules 插件实现。当文件名匹配 .module.css(或 .module.scss 等)模式时,自动启用作用域化处理:

const cssModuleRE = new RegExp(`\\.module${CSS_LANGS_RE.source}`)

if (isModule) {
  postcssPlugins.unshift(
    (await importPostcssModules()).default({
      ...modulesOptions,
      getJSON(cssFileName, _modules, outputFileName) {
        modules = _modules  // 捕获类名映射
        if (modulesOptions?.getJSON) {
          modulesOptions.getJSON(cssFileName, _modules, outputFileName)
        }
      },
      async resolve(id, importer) {
        // 使用 Vite 的解析器处理 composes 引用
        for (const key of getCssResolversKeys(atImportResolvers)) {
          const resolved = await atImportResolvers[key](
            environment, id, importer,
          )
          if (resolved) return path.resolve(resolved)
        }
        return id
      },
    }),
  )
}

CSS Modules 的类名映射(例如 { title: '_title_1x2y3' })被缓存在 cssModulesCache 中,供 cssPostPlugin 在后续阶段生成 JavaScript 导出代码。

cssPostPlugin:输出阶段

cssPostPlugin 是 CSS 处理的”分叉点”——开发模式和构建模式在这里走上完全不同的路径。

开发模式:CSS 转 JS

在开发模式下,CSS 被转换为注入样式的 JavaScript 模块。这是 Vite “一切皆模块”理念的体现:

if (config.command === 'serve') {
  if (isDirectCSSRequest(id)) {
    return null  // 直接 CSS 请求,不转换
  }
  if (inlined) {
    return `export default ${JSON.stringify(css)}`
  }
  if (this.environment.config.consumer === 'server') {
    return modulesCode || 'export {}'  // SSR 不需要注入样式
  }

  const cssContent = await getContentWithSourcemap(css)
  const code = [
    `import { updateStyle, removeStyle } from ${clientPath}`,
    `const __vite__id = ${JSON.stringify(id)}`,
    `const __vite__css = ${JSON.stringify(cssContent)}`,
    `updateStyle(__vite__id, __vite__css)`,
    // CSS Modules 导出类名映射;非 Module 的 CSS 可以自接受 HMR
    `${modulesCode || 'import.meta.hot.accept()'}`,
    `import.meta.hot.prune(() => removeStyle(__vite__id))`,
  ].join('\n')
  return { code, map: { mappings: '' }, moduleType: 'js' }
}
flowchart LR
    subgraph "开发模式 CSS 处理"
        A[".module.scss 源文件"] --> B["Sass 编译<br/>cssPlugin"]
        B --> C["PostCSS + CSS Modules<br/>cssPlugin"]
        C --> D["生成 JS 模块<br/>cssPostPlugin"]
        D --> E["updateStyle(id, css)<br/>浏览器注入 style 标签"]
    end

    subgraph "HMR 更新"
        F["文件修改"] --> G["重新编译"]
        G --> H["import.meta.hot.accept()"]
        H --> I["updateStyle 替换内容"]
    end

这段生成的代码包含了完整的 HMR 生命周期:

  • updateStyle:创建或更新 <style> 标签
  • import.meta.hot.accept():声明自接受热更新
  • import.meta.hot.prune():模块被卸载时移除样式

CSS Modules 的情况有所不同——它们不能自接受 HMR,因为类名映射的变化需要通知所有引用方。此时由 cssAnalysisPluginupdateModuleInfo 中设置 isSelfAccepting: false

const isSelfAccepting =
  !cssModulesCache.get(config)?.get(id) &&
  !inlineRE.test(id) &&
  !htmlProxyRE.test(id)

10.4-bis 源码核对:cssPostPlugin 的 Serial Promise Queue 保证输出顺序

cssPostPlugin 模块级状态(css.ts:462-473)里有个不起眼但关键的对象:

// queue to emit css serially to guarantee the files are emitted in a deterministic order
let codeSplitEmitQueue = createSerialPromiseQueue<string>()
const urlEmitQueue = createSerialPromiseQueue<unknown>()

两个 serial promise queue——串行化执行队列。

为什么 CSS emit 必须串行?因为 Rollup 的 emitFile 调用在并发时产出的 fileName 可能因为哈希冲突或 dedup 而不稳定——两个 css chunk 在不同构建之间可能得到不同的 assetFileName。串行 emit 让每个 css chunk 的哈希只基于内容不受并发竞争影响

createSerialPromiseQueue 的用法:await queue.add(() => doSomething())——queue 内部保证第 N 个 fn 等第 N-1 个 fn 完成后再 run。看起来只是个简单工具,但它是 Vite build 产物**确定性(reproducibility)**的一个支柱。

另一条细节:每次 renderStart() 重新创建 queue(css.ts:525):

renderStart() {
  pureCssChunks = new Set<RenderedChunk>()
  hasEmitted = false
  chunkCSSMap = new Map()
  codeSplitEmitQueue = createSerialPromiseQueue()
}

为什么要 re-create?因为 Rollup/Vite 的 watch 模式下 renderStart 会被多次调用——每次 render 开始前必须清空 queue、防止上一次构建残留的 promise 污染这次 render。state 重置在 renderStart 是 Vite 插件设计的铁律——每个需要跨 render 干净的状态都要在这个钩子里重置。

10.4-ter 源码核对:inlineCSS && isHTMLProxy 的 ” 转义

cssPostPlugin.transform(css.ts:541-556)里有一段HTML 安全转义

const inlineCSS = inlineCSSRE.test(id)
const isHTMLProxy = htmlProxyRE.test(id)
if (inlineCSS && isHTMLProxy) {
  if (styleAttrRE.test(id)) {
    css = css.replace(/"/g, '&quot;')
  }
  // ...
}

当 CSS 来自 HTML 的 style 属性<div style="color: red">)时,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 做转义。

构建模式:代码分割与提取

构建模式下的 CSS 处理复杂得多。cssPostPlugin 需要处理多个环节:

flowchart TD
    subgraph "构建模式 CSS 流水线"
        T["transform 钩子"] --> Record["记录 CSS 到 styles Map"]
        Record --> RC["renderChunk 钩子"]
        RC --> Collect["收集 chunk 关联的 CSS"]
        Collect --> Minify["CSS 压缩"]
        Minify --> Emit["emitFile 输出 CSS 资源"]
        Emit --> GB["generateBundle 钩子"]
        GB --> Pure["清理纯 CSS chunk"]
        GB --> Resolve["解析资源 URL 占位符"]
    end

transform 阶段,CSS 内容被记录到 styles Map 中,返回的 JavaScript 代码可能是:

  • CSS Module:返回 modulesCode(类名映射的 export
  • 内联 CSS(?inline):返回 export default "css content"
  • 普通 CSS:返回空字符串,但设置 moduleSideEffects: 'no-treeshake' 防止被 tree-shaking 删除
// 构建模式的 transform
if (!inlined) {
  styles.set(id, css)  // 记录 CSS 内容
}

let code: string
if (modulesCode) {
  code = modulesCode
} else if (inlined) {
  let content = css
  if (config.build.cssMinify) {
    content = await minifyCSS(content, config, true)
  }
  code = `export default ${JSON.stringify(content)}`
} else {
  code = ''  // 空模块,但不会被 tree-shake
}

return {
  code,
  moduleSideEffects: modulesCode || inlined ? false : 'no-treeshake',
  moduleType: 'js',
}

renderChunk 中的 CSS 收集

renderChunk 钩子是构建模式 CSS 处理的核心。它遍历 chunk 的所有模块,收集关联的 CSS,然后决定如何输出:

async renderChunk(code, chunk, opts, meta) {
  let chunkCSS: string | undefined
  let isPureCssChunk = chunk.exports.length === 0

  for (const id of Object.keys(chunk.modules)) {
    if (styles.has(id)) {
      if (transformOnlyRE.test(id)) continue  // ?transform-only 跳过

      // 检查 cssScopeTo:CSS 是否只在特定导出使用
      const cssScopeTo = this.getModuleInfo(id)?.meta?.vite?.cssScopeTo
      if (cssScopeTo && !isCssScopeToRendered(cssScopeTo, renderedModules)) {
        continue  // 如果关联的导出未被使用,跳过此 CSS
      }

      if (cssModuleRE.test(id)) {
        isPureCssChunk = false  // CSS Module 包含 JS 代码
      }

      chunkCSS = (chunkCSS || '') + styles.get(id)
    }
  }
  // ...
}

如果启用了 cssCodeSplit(默认),每个 chunk 对应的 CSS 会被单独输出为一个 .css 文件。如果禁用了代码分割,所有 CSS 会合并为一个文件。

纯 CSS Chunk 的清理

当一个 JavaScript chunk 只包含 CSS 导入(没有 JS 导出)时,它被标记为”纯 CSS chunk”。generateBundle 阶段会清理这些空的 JS 文件:

if (pureCssChunks.size) {
  const replaceEmptyChunk = getEmptyChunkReplacer(
    pureCssChunkNames, opts.format,
  )

  for (const file in bundle) {
    const chunk = bundle[file]
    if (chunk.type === 'chunk') {
      // 从其他 chunk 的 imports 中移除纯 CSS chunk
      chunk.imports = chunk.imports.filter((file) => {
        if (pureCssChunkNames.includes(file)) {
          // 将 CSS 资源的归属转移到引用方
          const { importedCss } = (bundle[file] as OutputChunk).viteMetadata!
          importedCss.forEach((f) =>
            chunk.viteMetadata!.importedCss.add(f))
          return false
        }
        return true
      })
      if (chunkImportsPureCssChunk) {
        chunk.code = replaceEmptyChunk(chunk.code)
      }
    }
  }

  // 从 bundle 中删除纯 CSS chunk
  pureCssChunkNames.forEach((fileName) => {
    delete bundle[fileName]
  })
}

getEmptyChunkReplacer 函数根据输出格式生成不同的替换正则:

export function getEmptyChunkReplacer(
  pureCssChunkNames: string[],
  outputFormat: InternalModuleFormat,
): (code: string) => string {
  const emptyChunkFiles = pureCssChunkNames.map(escapeRegex).join('|')

  const emptyChunkRE = new RegExp(
    outputFormat === 'es'
      ? `\\bimport\\s*["'][^"']*(?:${emptyChunkFiles})["'];`
      : `(\\b|,\\s*)require\\(\\s*["'\`][^"'\`]*(?:${emptyChunkFiles})["'\`]\\)(;|,)`,
    'g',
  )

  return (code) =>
    code.replace(emptyChunkRE, (m) =>
      `/* empty css ${''.padEnd(m.length - 15)}*/`)
}

替换时保持了与原始导入语句相同的字符长度(通过 padEnd),这样 source map 不会偏移。

CSS HMR 机制

cssAnalysisPlugin 的角色

cssAnalysisPlugin 在开发模式下跟踪 CSS 文件的 @import 依赖关系。当一个被 @import 的文件发生变化时,导入它的主文件需要重新编译。

export function cssAnalysisPlugin(config: ResolvedConfig): Plugin {
  return {
    name: 'vite:css-analysis',
    transform: {
      filter: { id: { include: CSS_LANGS_RE } },
      async handler(_, id) {
        const { moduleGraph } = this.environment as DevEnvironment
        const thisModule = moduleGraph.getModuleById(id)

        if (thisModule) {
          const isSelfAccepting =
            !cssModulesCache.get(config)?.get(id) &&
            !inlineRE.test(id) &&
            !htmlProxyRE.test(id)

          // pluginContainer.addWatchFile 附加的文件
          const pluginImports = (this as any)._addedImports
          if (pluginImports) {
            const depModules = new Set<string | EnvironmentModuleNode>()
            for (const file of pluginImports) {
              depModules.add(
                moduleGraph.createFileOnlyEntry(file)
              )
            }
            moduleGraph.updateModuleInfo(
              thisModule,
              depModules,        // @import 依赖
              null,
              new Set(),
              null,
              isSelfAccepting,
            )
          } else {
            thisModule.isSelfAccepting = isSelfAccepting
          }
        }
      },
    },
  }
}

HMR 更新流程

sequenceDiagram
    participant FS as 文件系统
    participant W as Watcher
    participant HMR as HMR 引擎
    participant MG as 模块图
    participant CSS as CSS 插件
    participant B as 浏览器

    FS->>W: _variables.scss 变更
    W->>HMR: 文件变更事件
    HMR->>MG: 查找依赖此文件的模块
    MG-->>HMR: App.module.scss(通过 @import 关系)
    HMR->>CSS: 触发 App.module.scss 重新 transform
    CSS->>CSS: Sass 编译 + PostCSS + CSS Modules
    CSS-->>HMR: 新的 CSS 内容 + 新的类名映射
    HMR->>B: HMR update
    B->>B: updateStyle(id, newCSS)

    Note over B: CSS Modules: 需要通知引用方<br/>普通 CSS: 自接受更新

普通 CSS 文件可以自接受 HMR 更新——updateStyle 函数直接替换 <style> 标签的内容即可。但 CSS Modules 不能自接受,因为类名映射的变化需要通知所有使用这些类名的组件重新渲染。这种情况下,HMR 更新会沿着依赖链向上传播。

10.5-bis 源码核对:cssAnalysisPlugin.transform 里的”三条件判 selfAccepting”

cssAnalysisPlugin 的 transform 钩子(css.ts:1185-1220)看似简短,但判定 CSS 模块是否 selfAccepting 的三条件非常精细:

const thisModule = moduleGraph.getModuleById(id)
if (thisModule) {
  // CSS modules cannot self-accept since it exports values
  const isSelfAccepting =
    !cssModulesCache.get(config)?.get(id) &&   // 条件 1
    !inlineRE.test(id) &&                       // 条件 2
    !htmlProxyRE.test(id)                       // 条件 3
  // ...
}

三个不能 selfAccept的场景:

1、CSS Modules(cssModulesCache.get(config)?.get(id) 不为空):CSS Modules 文件会产出一个 class name 映射对象——它是真实的 JS value export。如果自接受了、本文件的 hot update 不向上冒泡、上游引用了 classes.card 的地方拿不到新 hash 的 class name——UI 就错乱。CSS Module 的 HMR 必须向上冒到使用者,自接受破坏这条语义。

2、?inline 标记的 CSS:比如 import styles from './foo.css?inline' 拿到 CSS 文本字符串当 JS value 用——和 CSS Modules 同理,内容变了、使用它的地方要重跑。自接受会让使用者看到旧字符串。

3、htmlProxyRE 匹配的代理模块:HTML 里的 <style> 标签被 Vite 代理成独立模块——这些代理模块的变化应该通知 HTML,不能自接受。

这套判断让大部分普通 CSS 文件走自接受路径——改样式不影响上游 JS 模块、热更新只替换 <style> 内容、页面不 reload;CSS Modules/inline/html-proxy 走传统向上冒泡路径——拿它 export 的地方被通知重新执行。

这条逻辑是 Vite CSS HMR 比 Webpack 体验好的核心原因之一——Webpack 的 CSS HMR 对 CSS Modules 处理得不够精细,常出现”改样式整个页面 reload”;Vite 通过这三条件精确区分,做到”该自接受就自接受、该冒泡就冒泡”。

10.5-ter 源码核对:getEmptyChunkReplacer 的 cjs 链式 require 处理

§10.4 讲过构建时纯 CSS chunk 被清理。真实 getEmptyChunkReplacer(css.ts:1242+)有一段专门处理 minifier 链式 require 的精巧逻辑:

// for cjs, require calls might be chained by minifier using the comma operator.
// in this case we have to keep one comma if a next require is chained
// or add a semicolon to terminate the chain.
const emptyChunkRE = new RegExp(
  outputFormat === 'es'
    ? `\\bimport\\s*["'][^"']*(?:${emptyChunkFiles})["'];?`
    : `(?:(^|\\W)require\\(\\s*["'][^"']*(?:${emptyChunkFiles})["']\\s*\\)(\\s*,\\s*\\w|;?))`,
  'g',
)

CJS 格式的 chunk 里,minifier(terser、oxc-minify)可能把多个 require() 链成 require("a"), require("b"), require("c");——用逗号操作符串起来。如果 Vite 简单地把 require("b") 替换成空字符串,会产出 require("a"), , require("c"); 这种语法错误代码。

正则里的 (\\s*,\\s*\\w|;?) 捕获 require 后面的逗号/分号——替换时根据捕获决定保留”一个逗号”(如果后面还有 require)还是”加一个分号”(如果是链尾)。这让清理后的代码语法仍然合法

这条处理的存在反映了 Vite 对下游 minifier 行为的精确感知——不是假设 CJS 代码长什么样,而是知道”terser 会把相邻 require 合并成逗号链”——提前在正则里处理这种合法输出。这是一种”和下游工具链协作”的工程姿态——不是各自独立、而是 Vite 知道 terser 在干什么、terser 也知道 Vite 在干什么。

CSS 压缩

Vite 支持两种 CSS 压缩引擎:

async function minifyCSS(css: string, config: ResolvedConfig, inlined: boolean) {
  if (config.build.cssMinify === 'esbuild') {
    const { transform } = await importEsbuild()
    const { code } = await transform(css, {
      loader: 'css',
      target: config.build.cssTarget || undefined,
      ...resolveMinifyCssEsbuildOptions(config.esbuild || {}),
    })
    return inlined ? code.trimEnd() : code
  }

  // 默认使用 Lightning CSS
  const { code } = (await importLightningCSS()).transform({
    ...config.css.lightningcss,
    targets: convertTargets(config.build.cssTarget),
    filename: defaultCssBundleName,
    code: Buffer.from(css),
    minify: true,
  })
  return decoder.decode(code) + (inlined ? '' : '\n')
}

inlined 参数影响是否保留末尾换行符——内联 CSS 不需要尾部换行,而独立文件应以换行符结尾。

Lightning CSS 集成

Lightning CSS 是一个基于 Rust 的高性能 CSS 处理器,可以替代 PostCSS 作为 Vite 的 CSS transformer:

export function resolveCSSOptions(options: CSSOptions | undefined) {
  const resolved = mergeWithDefaults(_cssConfigDefaults, options ?? {})
  if (resolved.transformer === 'lightningcss') {
    resolved.lightningcss ??= {}
    resolved.lightningcss.targets ??= convertTargets(
      ESBUILD_BASELINE_WIDELY_AVAILABLE_TARGET,
    )
  }
  return resolved
}

css.transformer 设为 'lightningcss' 时,compileCSS 函数会走 compileLightningCSS 分支而非 compilePostCSS。Lightning CSS 的优势在于:

  1. 更快的速度:Rust 实现比 JavaScript 的 PostCSS 快一个数量级
  2. 内置功能:原生支持 CSS Modules、嵌套、自定义属性等,不需要额外插件
  3. 更好的压缩:在代码压缩方面通常优于 esbuild

但代价是放弃了 PostCSS 丰富的插件生态——大部分 PostCSS 插件不能与 Lightning CSS 一起使用。

10.7-bis 源码核对:compileLightningCSS 的 resolver 钩子——预处理器集成的巧妙对接

§10.7 给了 Lightning CSS 插件的示意。真实的 compileLightningCSS(css.ts:3188-3290+)里有个不得不拆开讲的机制——Lightning CSS 的 resolver 钩子如何和 Vite 的预处理器对接:

await (await importLightningCSS()).bundleAsync({
  ...
  resolver: {
    async read(filePath) {
      if (filePath === filename) return src
      const code = fs.readFileSync(filePath, 'utf-8')
      const lang = CSS_LANGS_RE.exec(filePath)?.[1]
      if (isPreProcessor(lang)) {
        // 对 sass/less/styl 文件调 Vite 的预处理器
        const result = await compileCSSPreprocessors(
          environment, id, lang, code, workerController,
        )
        result.deps?.forEach((dep) => deps.add(dep))
        return result.code   // ← 返回编译后的 CSS
      } else if (lang === 'sss') {
        const sssResult = await transformSugarSS(environment, id, code)
        return sssResult.code
      }
      return code
    },
    async resolve(id, from) {
      // ... Vite 的路径解析
    },
  },
})

关键洞察:Lightning CSS 本身不懂 Sass/Less——它只会处理纯 CSS。但 Vite 项目里用 Lightning CSS 的人经常也用 Sass 预处理器——怎么办?

答案:通过 resolver.read 钩子”劫持”文件读取。Lightning CSS 遇到 @import "./foo.scss",不是直接 fs.readFileSync('./foo.scss')——而是调用 Vite 提供的 resolver.read 函数。Vite 在 read 里识别 .scss 扩展名、先调 Sass 预处理器把 scss 编译成 css、再把 css 返回给 Lightning CSS。Lightning CSS 以为它读到的就是 CSS 文件内容——继续自己的处理。

这是适配器模式的教科书级应用——Lightning CSS 提供的”可注入文件读取逻辑”接口,让 Vite 在不修改 Lightning CSS 源码的前提下,扩展它能处理的文件类型。同样的机制还支持 SugarSS(.sss 文件)——用 PostCSS 的 SugarSS parser 先解析、返回标准 CSS。

这个 read 钩子还做了deps 追踪result.deps?.forEach((dep) => deps.add(dep)))——预处理器内部 @import 过的文件路径都加到 deps 集合、让 Vite 能正确监听这些文件变化、触发 HMR。没有这步追踪,改 _variables.scss 不会触发依赖它的 .scss 文件热更新

10.7-ter 源码核对:styleAttrRE 的 HTML style 属性特殊路径

compileLightningCSS 开头(css.ts:3207-3214)有一个看似多余的分支:

res = styleAttrRE.test(id)
  ? (await importLightningCSS()).transformStyleAttribute({
      filename,
      code: Buffer.from(src),
      targets: config.css.lightningcss?.targets,
      minify: config.isProduction && !!config.build.cssMinify,
      analyzeDependencies: true,
    })
  : await (await importLightningCSS()).bundleAsync({...})

普通 CSS 文件走 bundleAsync、HTML style 属性走 transformStyleAttribute——这是两个完全不同的 Lightning CSS API:

  • bundleAsync:处理 stylesheet(多规则 + @import + @media 等)
  • transformStyleAttribute:处理单条 inline style 声明(比如 <div style="color: red"> 里的 color: red

语法层面两者不同——stylesheet 有 { } 花括号规则块、inline style 是单纯的 property:value 列表。用错 API 会报 parse 错误。

什么时候 id 会匹配 styleAttrRE?Vite 的 HTML 解析会把每个 <div style="..."> 的 style 属性值提取成独立的 CSS 转换请求——id 带特殊 query 标记。这让 HTML 里 inline style 也能享受:prefix 自动加(通过 browserslist 目标)、minify、vendor-prefix 补全等现代 CSS 工具链。

这条分支让 Vite 的 CSS 处理覆盖了”stylesheet 文件 + inline style 属性”两个语境——是工具完备性的一个标志。Webpack 的 CSS loader 对 inline style 的处理要弱得多,基本只能靠用户手写 autoprefixer。

构建模式与开发模式的差异总结

flowchart TB
    subgraph "共享管线"
        S1["语言检测"] --> S2["预处理器编译<br/>Sass/Less/Stylus"]
        S2 --> S3["PostCSS / Lightning CSS"]
        S3 --> S4["CSS Modules 处理"]
    end

    S4 --> Split{command?}

    subgraph "开发模式"
        Split -->|serve| D1["生成 JS 模块"]
        D1 --> D2["updateStyle 注入"]
        D2 --> D3["HMR accept/prune"]
        D3 --> D4["cssAnalysisPlugin<br/>依赖跟踪"]
    end

    subgraph "构建模式"
        Split -->|build| B1["记录到 styles Map"]
        B1 --> B2["renderChunk 收集"]
        B2 --> B3["CSS 压缩"]
        B3 --> B4["emitFile 输出"]
        B4 --> B5["纯 CSS chunk 清理"]
        B5 --> B6["URL 占位符解析"]
    end

    style Split fill:#fff7e6,stroke:#fa8c16

设计决策

为什么 CSS 插件是最大的文件

CSS 处理的复杂性来自多个维度的组合爆炸:

  • 3 种预处理器 x 2 种 CSS transformer x 2 种运行模式 = 至少 12 种代码路径
  • URL 重写需要处理 url()image-set()@import 三种语法
  • 纯 CSS chunk 清理需要针对 ES、CJS 两种格式生成不同的替换模式
  • source map 合并需要在预处理器、PostCSS、Vite 三层之间正确传递

将所有这些逻辑放在一个文件中虽然看起来庞大,但保证了跨功能的一致性——例如 @import 的解析逻辑在 PostCSS 和预处理器之间是共享的。

为什么预处理器使用 Worker 池

Sass、Less 等预处理器通常是同步阻塞的 CPU 密集型操作。在大型项目中,可能有几百个需要编译的 Sass 文件。如果串行处理,会严重拖慢构建速度。Worker 池允许并行编译,充分利用多核 CPU。

preprocessorMaxWorkers: true 的默认值意味着 Vite 会自动使用所有可用的 CPU 核心(减 1,为主线程留空间)。对于 CI 环境或资源受限的容器,可以通过设置具体数字来控制并行度。

为什么 CSS Modules 不能自接受 HMR

CSS Modules 的输出不仅是样式,还包含类名映射。当类名映射发生变化时(例如添加了新的类名),所有使用这些类名的组件都需要重新渲染。如果 CSS Module 自接受 HMR,只有样式会更新,而引用方仍然使用旧的类名映射,导致样式不一致。

因此 isSelfAccepting 对 CSS Modules 设为 false,让 HMR 更新向上传播到引用方。虽然这可能导致更大范围的更新,但保证了一致性。

为什么不将 ?url CSS 直接当作资源处理

?url 查询的 CSS 文件需要特殊处理。在构建模式下,CSS 内容需要先经过 PostCSS 处理(重写 url() 路径、处理 CSS Modules 等),然后才能作为资源输出。因此 cssPluginload 钩子拦截 ?url 请求,注入一个中间层来确保 CSS 处理管线正确执行。

10.6-bis 源码核对:cssUrlRE 的 lookbehind 正则防 @import 误匹配

§10.3 URL 重写讲了基本机制。真实的 cssUrlRE(css.ts:1997-1998)是一条极其精心设计的正则:

export const cssUrlRE: RegExp =
  /(?<!@import\s+)(?<=^|[^\w\-€-￿])url\((\s*('[^']+'|"[^"]+")\s*|(?:\\.|[^'")\\])+)\)/

从左到右拆解这条正则:

1、(?<!@import\s+)——负向后查。这是关键——排除 @import url(...) 中的 url()。为什么?因为 @import url(...) 不是资源引用、是 CSS 的 import 指令、已经由 postcss-import 或 Lightning CSS 的 bundling 处理过了。如果 cssUrlRE 匹配它、Vite 会错误地把它当成资源 url 去 fileToUrl 解析——产出不合法的 CSS。

2、(?<=^|[^\w\-€-￿])——负向后查 + 字符集。确保 “url(” 前面要么是字符串开头、要么是非单词字符。这条阻止匹配用户自定义函数 my-url(...)/custom_url(...)——它们恰好以 url( 结尾、但不是标准 CSS url 函数。

3、捕获组里的双模式

  • \s*('[^']+'|"[^"]+")\s*——匹配被单/双引号包裹的路径
  • (?:\\.|[^'")\\])+——匹配无引号的路径(允许转义字符)

两种 CSS url 写法都要支持:url(foo.png)url("foo.png"),都有各自的转义规则。

4、Unicode 字符范围 €-￿ 涵盖非 ASCII 字符——用户可能在 CSS 类名或 URL 里用中文/日文字符,正则要正确识别它们为”单词字符的一部分”。

这条 62 字符的正则是多年踩坑积累的成果——每一条 lookbehind、每一个字符类都对应一次真实 bug 报告。教科书级的正则设计:不是越短越好、是表达力精准为好

10.6-ter 源码核对:URL Rewrite 的 PostCSS 插件策略

cssUrlRE 只是匹配正则,真正的重写走 PostCSS 插件路径(css.ts:2007-2070+):

const UrlRewritePostcssPlugin: PostCSS.PluginCreator<...> = (opts) => {
  return {
    postcssPlugin: 'vite-url-rewrite',
    Once(root) {
      const promises: Promise<void>[] = []
      root.walkDecls((declaration) => {
        const importer = declaration.source?.input.file
        if (!importer) {
          opts.logger.warnOnce(
            '\nA PostCSS plugin did not pass the `from` option to `postcss.parse`. ...'
          )
        }
        const isCssUrl = cssUrlRE.test(declaration.value)
        const isCssImageSet = cssImageSetRE.test(declaration.value)
        if (isCssUrl || isCssImageSet) {
          // 走 opts.resolver 解析每个 url
          ...
        }
      })
    },
  }
}

关键点:URL 重写是一个 PostCSS 插件,不是 Vite 源码里单独的字符串扫描。这让它:

  1. 尊重 PostCSS 的 AST——不会匹配到注释里的 url 字符、不会匹配到字符串字面量里的 url
  2. 按 declaration 遍历——只处理 CSS 属性值里的 url、不处理选择器或 @rule 参数
  3. 能感知 source map——declaration.source?.input.file 拿到原始来源文件路径,让”url 相对路径”的解析能基于原始文件而不是合并后的位置

opts.logger.warnOnce('A PostCSS plugin did not pass the \from` option…’)是一条**主动的错误预警**——如果上游 postcss 插件没正确传from`,Vite 会 warn 用户”你的某个 postcss 插件有 bug”,而不是静默产出错误的 url 重写。这条 warning 的定位是”提前暴露他人插件的错误”——避免 Vite 被其他生态 bug 牵连背锅。

10.9 本章与全书体系的呼应

当多个 CSS 文件被合并成一个(构建模式下的 CSS bundling),内部 @import@charset 规则需要被 hoist 到合并后文件的顶部——因为 CSS 规范要求:

  • @charset 必须是文件第一条非注释规则
  • @import 必须在其他规则之前(除 @charset 外)

违反这两条规则会被浏览器静默忽略那些 @import@charsethoistAtRules(css.ts:2313-2342)用 MagicString 做这个重排:

export async function hoistAtRules(css: string): Promise<string> {
  const s = new MagicString(css)
  const cleanCss = emptyCssComments(css)
  let match: RegExpExecArray | null

  // @import 必须在文件顶部。多文件合并时要 hoist 所有 @import
  atImportRE.lastIndex = 0
  while ((match = atImportRE.exec(cleanCss))) {
    s.remove(match.index, match.index + match[0].length)
    // Use `appendLeft` instead of `prepend` to preserve original @import order
    s.appendLeft(0, match[0])
  }

  // @charset 必须是文件最前的规则。多文件合并时只保留第一个
  atCharsetRE.lastIndex = 0
  let foundCharset = false
  while ((match = atCharsetRE.exec(cleanCss))) {
    s.remove(match.index, match.index + match[0].length)
    if (!foundCharset) {
      s.prepend(match[0])
      foundCharset = true
    }
  }

  return s.toString()
}

三条细节:

1、emptyCssComments(css) 先把注释 “删空”(保留字符位置)再正则匹配——防止注释里的 @import 字样被误匹配。MagicString 基于字符位置做编辑,所以”删空”(把注释内容用空格替换)能保持位置信息。

2、appendLeft(0, match[0]) 不是 prepend。注释里明确指出 “Use appendLeft instead of prepend to preserve original @import order”。两个 @import 都 hoist 到顶部时,如果都 prepend,第二个会在第一个之前(顺序反了);用 appendLeft 让插入点是”位置 0 的左边”——后插入的在先插入的后面,保持原顺序。MagicString 的这条 API 细节不看文档几乎不会注意到。

3、多个 @charset 只保留第一个。CSS 文件合并时可能多个文件各有自己的 @charset——CSS 规范允许只有一个 @charset(必须在最前)。Vite 的处理是保留第一个遇到的、删除其余所有。这是一条”冲突时 favor 第一个”的简单规则——简洁且大多数场景正确(因为项目通常全用 UTF-8)。

这段 30 行代码处理了一个从用户视角几乎看不见但确实存在的边界问题——没有它,大项目 @import 分散在多个文件、构建后合并、浏览器静默忽略部分 import——样式丢失的 bug 难以定位。Vite 的处理是静默正确——用户不需要知道这条规则。

10.8-ter 源码核对:resolvePostcssConfig 的 Promise→Value 自动替换

§10.2-bis 讲了 postcss 配置预热——Promise 被塞进 cache、后续 await 它。真实 resolvePostcssConfig(css.ts:1930-1979)还有一条精巧的后续处理:

// replace cached promise to result object when finished
result.then(
  (resolved) => {
    postcssConfigCache.set(config, resolved)
  },
  () => {
    /* keep as rejected promise, will be handled later */
  },
)

Promise 解决后,把缓存里的 Promise 对象替换成实际值。后续 postcssConfigCache.get(config) 直接拿到 PostCSSConfigResult 对象——不用再 await、省掉一次 microtask 开销。错误路径保留 rejected promise——让错误在真实使用位置重现。

10.8-4 源码核对:postcssrc 错误消息的人性化改写

resolvePostcssConfig 里的错误处理(css.ts:1952-1965)也值得单独看——Vite 把 postcssrc 抛出的原生错误富化后再抛:

result = postcssrc({}, searchPath, { stopDir }).catch((e) => {
  if (!e.message.includes('No PostCSS Config found')) {
    if (e instanceof Error) {
      const { name, message, stack } = e
      e.name = 'Failed to load PostCSS config'
      e.message = `Failed to load PostCSS config (searchPath: ${searchPath}): [${name}] ${message}\n${stack}`
      e.stack = ''
      throw e
    } else {
      throw new Error(`Failed to load PostCSS config: ${e}`)
    }
  }
  return null
})

三条细节:“No PostCSS Config found” 被当成正常情况返回 null(用户项目没 postcss.config 是常态);其他错误 改写 name + message 把上下文塞进去——用户看到 “Failed to load PostCSS config (searchPath: /path): [SyntaxError] …”;e.stack = '' 因为 stack 已经拼进 message——避免 V8 默认打印导致重复显示。

10.9 本章与全书体系的呼应

CSS 处理这一章表面独立、实则和 Vite 整个生态紧密咬合。梳理一下接合点:

与第 4 章(插件系统)的依赖:本章讲的 3 个插件(cssPlugin、cssPostPlugin、cssAnalysisPlugin)都是 Vite 插件 API 的标准应用。“三层协作”的架构模式——pre-transform / post-transform / analysis——是 Vite 插件生态的一个范式,用户写自己的 CSS 相关插件时应该考虑是否需要多层拆分。

与第 6 章(HMR)的深度整合:§10.5-bis 讲的”三条件判 selfAccepting”是 CSS HMR 比 Webpack 体验好的核心原因。普通 CSS 自接受、CSS Modules 冒泡、inline CSS 冒泡——三种行为通过 3 个布尔条件精确区分。第 6 章的 HMR 传播算法在 CSS 场景下走不同路径全靠这 3 个条件。

与第 9 章(JS 转换)的对称:第 9 章讲了 JS 的 transform 管线(Oxc → define → importAnalysis),本章讲了 CSS 的 transform 管线(preprocessors → PostCSS/Lightning CSS → cssPostPlugin)。两条管线几乎对称:同样的 “isBundled 二分 + 预处理 + AST 转换 + HMR 注入” 结构。理解其中一条能快速迁移到另一条——Vite 对不同文件类型应用同一套架构套路是它代码组织的美学。

与第 14 章(Rolldown 构建)的接口:本章 cssPostPlugin 的 renderChunk 钩子、getEmptyChunkReplacer 的清理、codeSplitEmitQueue 的串行化——都是在和 Rollup/Rolldown 的 build 产物精确协作。第 14 章讲过 Rolldown 的 chunk 调度;本章展示了 chunk 调度完成后 CSS 如何被提取、合并、重命名。

与第 17 章(Worker 插件)的对比:第 17 章讲了 Worker 的 WorkerOutputCache 独立构建管线;本章讲了 CSS 的 preprocessorWorkerController Worker 池——两种 Worker 使用模式对比:Worker 插件是”每个 Worker 文件独立构建”、CSS 是”多个预处理器共享 Worker 池”。前者按文件并行,后者按任务并行——反映了两种资源的并发特性不同(Worker 文件间独立、预处理器任务间也独立但偏 CPU 密集)。

10.9.1 本章给插件作者的工程启示

这是 Vite 代码库最大的一个插件——读完本章你能学到的不止是 CSS 处理本身,还有一系列大型插件设计的工程原则

1、按生命周期分层:cssPlugin(pre)→ 用户插件 → cssPostPlugin(post)→ cssAnalysisPlugin(dev HMR)。如果你在写一个复杂插件(比如针对某个 DSL 的完整处理链),考虑按”pre/post/analysis”拆成 3-4 个子插件让用户能在中间介入。

2、WeakMap 是框架级缓存的默认答案。本章 4 个 WeakMap、§17/§9 各自也有多个 WeakMap——Vite 几乎不用普通 Map 做 per-config 缓存。你写插件时如果要 “global map keyed by config”,直接默认上 WeakMap。

3、预热 + 延后报错。能在插件构造阶段做的 IO(配置解析、依赖索引),就在那时启动 Promise 塞进缓存、.catch(() => {}) 吞掉错误——真正需要值时 await 缓存的 Promise、此时 IO 早完成了。错误如果发生、在真实使用位置重现——错误消息更有上下文。

4、Worker 池化重任务。预处理器编译是 CPU 密集——用 Worker 池把每个编译扔进 Worker,主线程照常跑插件逻辑。模式上和 Webpack 的 thread-loader 类似,但 Vite 的 Worker 池是按文件语言分的(sass 一个池、less 一个池)——避免不同预处理器抢同一个 Worker 资源。

5、串行队列保确定性。Rollup 的 emitFile 并发时产物名可能因竞争不稳定——用 serial promise queue 强制串行执行、产物名稳定。这条在任何”构建产物需要确定性”的场景都适用。

6、针对下游工具的精确行为编写代码。§10.5-ter 的 require 链式处理、§10.8-bis 的 @import hoisting、§10.4-ter 的 HTML 属性转义——都是针对特定下游工具(minifier、浏览器、HTML parser)的精确行为做的针对性处理。这要求插件作者不仅懂自己插件、还懂下游工具链在哪里有什么约束。

这 6 条原则贯穿 Vite 其他复杂插件——你在阅读其他插件源码时能看到同样的模式。学会识别这些 pattern 能让你读源码速度快一倍——不是一行行细读、而是识别出”又是 WeakMap 缓存”、“又是 serial queue”、“又是 pre/post 分层”——直奔特殊点

10.10 源码定位索引

主题源文件关键行号
cssPlugin 主体plugins/css.ts296-460+
四层 WeakMap同上269-285
postcss 配置预热同上308-313
cssPostPlugin同上462+
cssAnalysisPlugin同上1174-1224
compileCSSPreprocessors同上1348-1403
getAtImportResolvers同上1405-1416
compileCSS(调度器)同上1418+
createPreprocessorWorkerController同上3135-3161
isPreProcessor同上3181-3183
compileLightningCSS同上3188-3290+
resolvePostcssConfig同上1930+

源码版本:vite-latest 本地仓库。

10.11 读完本章能回答的具体问题清单

作为掌握度自测:

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

能答 8 条以上——对 Vite CSS 管线的理解已经超越大多数 Vite 用户。

小结

CSS 处理引擎是 Vite 中最复杂也最精密的子系统。三个插件——cssPlugincssPostPlugincssAnalysisPlugin——各司其职,共同实现了从预处理到最终输出的完整管线。

compileCSS 函数是核心调度器,它将预处理器编译、PostCSS/Lightning CSS 转换、CSS Modules 处理和 source map 合并编排为一条清晰的流水线。开发模式和构建模式在 cssPostPlugin 中分叉,走上截然不同的输出路径。

Worker 池化的预处理器执行、PostCSS 的条件跳过、构建模式下纯 CSS chunk 的智能清理——这些优化确保了 CSS 处理在任何规模的项目中都能保持高性能。

理解了 CSS 引擎的设计,就掌握了 Vite 中最复杂的转换逻辑。

实践建议:在自己项目里故意触发本章讨论的边界情况——写一个 @import 在文件中间(应被 hoist)、带中文路径的 url()(测试正则)、带 " 的 inline style(测试转义)、故意让 postcss.config.js 有语法错误(测试错误富化)。Vite 在每种场景下的行为都对应本章源码细节,理解它们能在出问题时快速定位到具体源码片段。

最后一点附言:Vite 的 CSS 插件在不同版本之间行为会有微调(比如 PostCSS → Lightning CSS 的主推转换、新的 CSS Modules 语法支持)——但本章揭示的”三层架构、WeakMap 缓存、Worker 池化、Serial Queue、Lightning CSS 的 resolver 劫持、@import hoist、URL 重写”几条核心机制是稳定的。这些是 Vite 处理 CSS 的心智模型——未来版本哪怕重写 50% 代码,这条心智模型也不会过时。读本章的价值不只在当前版本——更在于帮你建立一套能持续适配的思维框架。

CSS 处理作为 Vite 最复杂的子系统,是检验一个前端工程师能否”真正读懂大型工具”的试金石。能把本章 15 个源码核对小节理清的工程师,对 Vite/Webpack/其他构建工具源码的阅读能力都会上一个台阶——因为这 15 个小节背后暴露的工程模式(缓存分层、异步预热、Worker 池化、串行保确定性、错误富化)在所有大型构建工具里都存在。掌握它们不是”学了 Vite”,是”学会了构建工具的普适架构”。读到这里,你已经和那些只会”用 vite dev 启动、用 vite build 打包”的 Vite 使用者拉开了显著距离——你知道每一条命令背后在发生什么。这份认知优势在团队内部调优、排查生产 CSS bug、向同事讲解构建流程时都会以肉眼可见的方式显现。到此本章告一段落,下一章将打开 HTML 处理的入口之门——HTML 插件看似简单,实际要协调本章讲过的所有 CSS/JS 转换、把它们的产出正确插入到 HTML 中。读完后你会发现 HTML 是整个 Vite 的枢纽。