Vue 3 核心源码深度剖析系列(v3.5.44)-第 10 章 compiler-sfc(二)

Vue 3 核心源码深度剖析系列(v3.5.44)-第 10 章 compiler-sfc(二):compileTemplate / compileStyle 与 scoped / CSS 变量闭环

本章对应 vuejs/core 3.5.44,主讲 packages/compiler-sfc/src/compileTemplate.ts、compileStyle.ts、style/pluginScoped.ts、style/cssVars.ts、style/pluginTrim.ts、style/preprocessors.ts,并交叉引用 compiler-core 的 codegen.ts、compiler-dom/src/index.ts、compiler-ssr 的 ssrInjectCssVars.ts 与运行时侧的 runtime-core/src/componentRenderContext.ts、runtime-dom/src/helpers/useCssVars.ts。

第 09 章讲清了 SFC 的 parse / compileScript------即 <script> / <script setup> 如何被解析、分析绑定、改写成组件模块 。但一个 SFC 还剩两块没有被覆盖:<template> 被编译成什么 ,以及 <style>(尤其 scoped、v-bind()、CSS Modules)被编译成什么 。这两块的入口分别是 compileTemplate() 与 compileStyle()。本章把它们拆到函数级、选项级甚至 PostCSS 插件级;更要紧的是,本章会证明一件事:scoped 是一个跨 template / style / script / runtime 四层的完整闭环,任何单独看其中一层的理解都是残缺的。


导语:为什么 compileTemplate / compileStyle 值得单独用一章

前面几章(05~08)讲的 parse / transform / codegen 都是编译器的通用内核 :它们面对的是一段"裸模板字符串"或一棵 AST,产出一个 render 函数字符串。compileTemplate() 本身并不重写任何编译逻辑------它做的是编排(orchestration):

  1. 决定用哪个编译器(compiler-dom 还是 compiler-ssr);
  2. 把 SFC 上下文的选项(id → scopeId、scoped → longId、slotted、ssrCssVars、isProd → hmr)翻译成 compiler-core 的 CompilerOptions;
  3. 注入 SFC 专属的 nodeTransform(transformAssetUrl / transformSrcset)和复用 <template> 块的 AST;
  4. 把 codegen 产出的 map 与 SFC parse 阶段的 inMap 做行映射合并 ,把 errors 的 offset 校正回"原始文件行号";
  5. 把 warnings 转成带代码框(code frame)的 tips。

而 compileStyle() 背后的东西则完全是另一套技术栈:它不是 AST 变换,而是 PostCSS 插件链 。Vue 把 scoped、v-bind()、语言预处理全部实现为 PostCSS 插件 + 预处理器适配层。这套设计的收益是巨大的:复用 PostCSS 成熟的 CSS 解析/序列化、source map 生成、嵌套规则处理能力,而不必自己写一个 CSS 解析器。

但收益背后是复杂度。pluginScoped.ts 单文件 397 行,是整个 compiler-sfc 里最"算法密集"的文件:它要处理 :deep / ::v-deep / :slotted / :global 四种伪类、>>> 与 /deep/ 遗产语法、keyframes 的名字改写、:is / :where / :has / :not 里嵌套 :deep 的分支拆分 、以及 CSS Nesting(& 嵌套)带来的声明提升。这些逻辑缺乏外部文档,只能一行行读。

本章的路线是:

  • 10.1~10.4 吃透 compileTemplate 的选项映射、内联模式、source map、错误;
  • 10.5 建立 scoped 的四层闭环心智模型(这是本章的"骨架");
  • 10.6~10.7 深入 compileStyle 的 PostCSS 管线与 pluginScoped 算法;
  • 10.8~10.9 拆解 v-bind() → CSS 自定义属性 → useCssVars 运行时注入的链路;
  • 10.10~10.11 讲清 options 与产物在 SFC 模块里的拼装顺序;
  • 10.12 用一个同时含 scoped / :deep / v-bind() 的 SFC 做端到端实战;
  • 10.13 收束。

术语约定:

术语 含义
longId data-v-<shortId>,最终写到 DOM 属性上的完整 id,如 data-v-7ba5bd90
shortId 去掉 data-v- 前缀的部分,如 7ba5bd90;CSS 变量名、scoped 插件内部都用它
scopeId CompilerOptions.scopeId,即 longId;同时是组件选项 __scopeId 的值
slotScopeId data-v-<shortId>-s,:slotted() 专用后缀

10.1 compileTemplate 总览:从选项到产物的编排

10.1.1 入口签名与预处理分流

compileTemplate() 位于 packages/compiler-sfc/src/compileTemplate.ts:105-158,返回 SFCTemplateCompileResults。它只做一件事:判断模板是否需要预处理 ,然后调用 doCompileTemplate()。

ts 复制代码
export function compileTemplate(
  options: SFCTemplateCompileOptions,
): SFCTemplateCompileResults {
  const { preprocessLang, preprocessCustomRequire } = options

  if (
    (__ESM_BROWSER__ || __GLOBAL__) &&
    preprocessLang &&
    !preprocessCustomRequire
  ) {
    throw new Error(
      `[@vue/compiler-sfc] Template preprocessing in the browser build must ` +
        `provide the \`preprocessCustomRequire\` option to return the in-browser ` +
        `version of the preprocessor in the shape of { render(): string }.`,
    )
  }

  const preprocessor = preprocessLang
    ? preprocessCustomRequire
      ? preprocessCustomRequire(preprocessLang)
      : __ESM_BROWSER__
        ? undefined
        : consolidate[preprocessLang as keyof typeof consolidate]
    : false
  if (preprocessor) {
    try {
      return doCompileTemplate({
        ...options,
        source: preprocess(options, preprocessor),
        ast: undefined, // invalidate AST if template goes through preprocessor
      })
    } catch (e: any) {
      return {
        code: `export default function render() {}`,
        source: options.source,
        tips: [],
        errors: [e],
      }
    }
  } else if (preprocessLang) {
    /* ... 返回 tips + errors 提示用户安装预处理器 ... */
  } else {
    return doCompileTemplate(options)
  }
}

(compileTemplate.ts:105-158,上面为节选,else if 分支体略。)

这里有几个边界与易错点值得单独指出:

  1. 浏览器构建必须自带预处理器 。@vue/compiler-sfc 有 Node 版与浏览器版(__ESM_BROWSER__ / __GLOBAL__)。浏览器构建没法 require('pug'),因此模板预处理必须通过 preprocessCustomRequire 由宿主(如 @vue/repl)注入一个 { render(): string } 形状的对象,否则直接抛错。注意错误信息里说的形状是 { render(): string },而 PreProcessor 接口(compileTemplate.ts:73-79)要求的 render 签名是 (source, options, cb) => void------这是刻意保持与 @vue/consolidate 一致的回调式 API。
  2. 为什么回调式仍能同步返回 ?preprocess()(compileTemplate.ts:81-103)的注释给出了答案:consolidate 对多数模板引擎其实是同步回调 的,而 compiler-sfc 需要一个同步 API ,因为 Jest 的 transform 是通过 Node require hook 同步应用的。所以 preprocess 直接调用 preprocessor.render(...),在回调里把 res/err 写入局部变量,回调返回后再 throw/return。
  3. 预处理后必须作废 AST :ast: undefined(compileTemplate.ts:134)。原因很朴素------lang="pug" 的源文本与 <template> 块文本不同,parse 阶段缓存的 AST 对应的是 pug 源码,不能复用。下方 doCompileTemplate 里还有另一处作废 AST 的逻辑(自定义 compiler 时),见 10.1.3。
  4. 缺预处理器时不是 throw 而是"软失败" :返回一个空 render 占位函数 + 一条 tips + 一条 errors 字符串(compileTemplate.ts:144-154)。这种"降级到空组件"的策略,让构建工具能把错误作为诊断信息展示,而不是直接炸掉整条管线。

10.1.2 SFCTemplateCompileOptions 字段全表

SFCTemplateCompileOptions(compileTemplate.ts:45-71):

字段 类型 作用
source string <template> 块内容(未预处理前的原文)
ast RootNode? parse 阶段产出的、相对于整个 SFC 文件的模板 AST
filename string 用于 source map 的 sources、错误信息、compiler-core 的 filename 选项
id string 组件 id;scoped 时被转成 longId 传给 compilerOptions.scopeId
scoped boolean? 是否启用 scoped;由调用方从 sfc.styles.some(s => s.scoped) 推导
slotted boolean? <style> 中是否出现 :slotted();直接透传给编译器
isProd boolean? 生产模式;决定 hmr 与 CSS 变量名是否哈希
ssr boolean? 是否用 compiler-ssr
ssrCssVars string[]? parseCssVars 收集出的变量名列表,SSR 时注入 _cssVars
inMap RawSourceMap? parse.ts 产出的"仅行级"source map,用于把最终 map/errors 校正回原文件
compiler TemplateCompiler? 自定义编译器(如 vue-tsc 用的 compiler-dom 变体)
compilerOptions CompilerOptions? 透传给编译器的选项,会被 SFC 默认值部分覆盖(见 10.1.3)
preprocessLang string? 模板预处理语言(如 pug)
preprocessOptions any? 传给预处理器的选项
preprocessCustomRequire (id) => any? 自定义 require(浏览器构建 / 链接安装场景)
transformAssetUrls `AssetURLOptions AssetURLTagConfig

其中 slotted 与 scopeId 的联动,是 10.5 闭环的关键:调用方(vite-plugin-vue)必须先扫描 <style scoped> 是否含 :slotted(),才能决定 slotted 传什么;这解释了为什么 slotted 是一个独立于 scoped 的布尔开关。

10.1.3 doCompileTemplate:选项映射的真正发生地

doCompileTemplate()(compileTemplate.ts:160-264)是本节的"心脏"。先看它对 id 的规范化(compileTemplate.ts:200-204):

ts 复制代码
const shortId = id.replace(/^data-v-/, '')
const longId = `data-v-${shortId}`

const defaultCompiler = ssr ? (CompilerSSR as TemplateCompiler) : CompilerDOM
compiler = compiler || defaultCompiler

为什么 id 要先剥 data-v- 再拼回? 因为传入的 id 可能已经是 data-v-xxx(比如从 vite-plugin-vue 的 descriptor.id 来的),也可能是纯 hash(用户直传)。先 replace(/^data-v-/, '') 得到 shortId,再统一拼成 longId,保证幂等 :无论传哪种,结果都是 data-v-<hash>。这也是 pluginScoped.ts:17 与 cssVars.ts:110 里都要做同样剥离的原因------它们都需要 shortId。

接着是自定义编译器的 AST 作废(compileTemplate.ts:206-217):

ts 复制代码
if (compiler !== defaultCompiler) {
  // user using custom compiler, this means we cannot reuse the AST from
  // the descriptor as they might be different.
  inAST = undefined
}

inAST = resolveTemplateAST(inAST, {
  compiler,
  compilerOptions,
  ssr,
  onError: e => errors.push(e),
})

然后是核心:compiler.compile() 的选项映射 (compileTemplate.ts:219-237):

ts 复制代码
let { code, ast, preamble, map } = compiler.compile(inAST || source, {
  mode: 'module',
  prefixIdentifiers: true,
  hoistStatic: true,
  cacheHandlers: true,
  ssrCssVars:
    ssr && ssrCssVars && ssrCssVars.length
      ? genCssVarsFromList(ssrCssVars, shortId, isProd, true)
      : '',
  scopeId: scoped ? longId : undefined,
  slotted,
  sourceMap: true,
  ...compilerOptions,
  hmr: !isProd,
  nodeTransforms: nodeTransforms.concat(compilerOptions.nodeTransforms || []),
  filename,
  onError: e => errors.push(e),
  onWarn: w => warnings.push(w),
})

这段代码里藏着几个必须读懂顺序的细节:

(一)默认值与用户覆盖的优先级。

  • ...compilerOptions 出现在 scopeId / slotted / sourceMap 之后,意味着用户可以覆盖 scopeId、slotted、sourceMap、hoistStatic 等 (这对 @vue/compiler-sfc 的高级用法、vue-tsc、测试都必要)。
  • 但 hmr、nodeTransforms、filename、onError、onWarn 出现在 ...compilerOptions 之后 ,因此用户无法覆盖 这几项。特别是 nodeTransforms:它把 SFC 注入的 transformAssetUrl/transformSrcset 与用户的 compilerOptions.nodeTransforms 拼接 (nodeTransforms.concat(...)),而不是替换。filename/onError/onWarn 被强制接管,是为了让错误与 source map 统一收敛到 SFC 层。

(二)mode: 'module' 是硬性前提。 scopeId 只在 module 模式下生效(见 compiler-core/src/codegen.ts:305 的 genScopeId 条件与 errors.ts:189 的 X_SCOPE_ID_NOT_SUPPORTED)。compileTemplate 永远走 module 模式,所以它产出的 code 是一段ESM 模块代码 (含 export function render)。

(三)ssrCssVars 在调用方就被"表达式化"。 注意 genCssVarsFromList(ssrCssVars, shortId, isProd, true) 的最后一个参数 isSSR = true------它会给每个 key 加 : 前缀(cssVars.ts:29-31)。这直接决定 SSR 产物里 _cssVars = { style: {":--xxx": ...} } 的形状,并在 ssrRenderStyle 中触发 initial 重置语义(见 10.9.3)。

(四)sourceMap: true 无条件开启。 因为 compileTemplate 的返回结构里承诺了 map(SFCTemplateCompileResults.map)。若用户的 compilerOptions.sourceMap 覆盖为 false,最终产物虽 code 正常但 map 变 undefined,此时若 inMap 存在,mapLines 会因 !newMap 走 return oldMap 分支(compileTemplate.ts:267-268)------这是一个被刻意容错的分支,而非 bug。

(五)hoistStatic / cacheHandlers / prefixIdentifiers 默认全开。 这是 SFC 编译相对"裸 Vue.compile"的取向:SFC 场景下你几乎总是希望最大优化。用户仍可通过 compilerOptions 关闭。

(六)onError/onWarn 分流 :errors 与 warnings 是两个数组(compileTemplate.ts:175-176)。compiler-core 在遇到非致命问题时走 onWarn(例如 transformAssetUrl 找不到文件?------实际是浏览器警告类),致命问题走 onError。只有 warnings 被转成 tips,errors 原样返回,二者语义完全不同(10.4 详述)。

10.1.4 resolveTemplateAST:为什么 SFC 要对模板"重解析"

resolveTemplateAST(packages/compiler-sfc/src/template/resolveTemplateAST.ts:15-40)在 inAST 无法直接复用时,会用 整份 SFC 源码 + parseMode: 'sfc' 重新 parse 一次模板:

ts 复制代码
export function resolveTemplateAST(
  inAST: RootNode | undefined,
  options: { compiler?; compilerOptions?; ssr?; onError },
): RootNode | undefined {
  if (!inAST?.transformed) {
    return inAST
  }

  // Parse the full SFC source to preserve template locations relative to it.
  const { compiler = CompilerDOM, compilerOptions, ssr, onError } = options
  const newAST = (ssr ? CompilerDOM : compiler).parse(inAST.source, {
    prefixIdentifiers: true,
    ...compilerOptions,
    parseMode: 'sfc',
    onError,
  })
  const template = newAST.children.find(
    node => node.type === NodeTypes.ELEMENT && node.tag === 'template',
  ) as ElementNode
  return createRoot(template.children, inAST.source)
}

关键条件 if (!inAST?.transformed) return inAST:

  • transformed === true 的 AST 表示它已经在别处被 transform 过 (比如 compileScript 的 inline 模式下去复用模板 AST,或共享缓存被污染)。一旦已 transform,节点上的 codegenNode、helpers 等状态与本次编译的选项可能不匹配,必须丢弃重解析。
  • 重解析时特意用 inAST.source(整份 SFC 源文本 )而非 <template> 块内容,再 parseMode: 'sfc' 解析,最后从 children 里挑出 <template> 元素、取其 children 组成新 root。这样做的目的是:让 loc.offset 仍然是"相对整个 SFC 文件"的偏移 ,从而 source map 与错误定位能落在 .vue 原文件上,而不是块内容上。这也是 10.3 里 inMap 合并能成立的前提。
  • 注意 ssr ? CompilerDOM : compiler:SSR 时解析一律用 DOM 编译器 (因为 SSR 只是 codegen/transform 策略不同,结构解析一致,且 SSR 编译器不导出独立 parse),而 transform 阶段仍用 compiler(compiler-ssr)。

10.1.5 返回结构

SFCTemplateCompileResults(compileTemplate.ts:35-43):

字段 类型 语义
code string 编译出的模块代码;非 inline 时是 export function render(...),inline 时是 arrow function 表达式
ast RootNode? 编译器返回的 AST(供调用方进一步分析,如判断 ast.helpers.has(UNREF))
preamble string? 仅 inline 模式有值:import ... from "vue" + const _hoisted_n = ...
source string 原样回传的 source(注意:预处理后这里已是预处理结果 ,见 10.1.1 的 source: preprocess(...))
tips string[] warnings 转成的、带 code frame 的提示串
errors `(string CompilerError)[]`
map RawSourceMap? source map,可能与 inMap 合并过

preamble 的存在直接服务于 inline 模式:inline 时 render 函数是 setup() 的返回值,import 语句和静态提升必须提到 setup() 外(模块顶层),所以 codegen 把它们拆分到 preambleContext 单独输出(compiler-core/src/codegen.ts:309-315、403)。这引出 10.2。


10.2 内联模式(inline)与 inlineTemplate:把 render 函数缝进 setup

10.2.1 两个"inline"不是同一个东西

初读代码的人极容易混淆两个名字相近的开关:

名字 所在层 含义
compilerOptions.inline 编译器层 (compiler-core codegen) 让 codegen 输出 (_ctx, _cache) => {...} 箭头函数表达式 而非 export function render(...) 声明;preamble 单独返回
options.inlineTemplate SFC 层 (compileScript) 让 compileScript 直接调用 compileTemplate 并把它返回的 code 作为 setup() 的返回值

也就是说:inlineTemplate 是"要不要内联"的决策,inline 是"内联后代码长什么形状"的实现手段。 compileScript 在 inline 分支里显式设置 compilerOptions.inline = true(compileScript.ts:952-959):

ts 复制代码
const { code, ast, preamble, tips, errors, map } = compileTemplate({
  filename,
  ast: sfc.template.ast,
  source: sfc.template.content,
  inMap: sfc.template.map,
  ...options.templateOptions,
  id: scopeId,
  scoped: sfc.styles.some(s => s.scoped),
  isProd: options.isProd,
  ssrCssVars: sfc.cssVars,
  compilerOptions: {
    ...(options.templateOptions && options.templateOptions.compilerOptions),
    inline: true,
    isTS: ctx.isTS,
    bindingMetadata: ctx.bindingMetadata,
  },
})

(compileScript.ts:944-961。)

10.2.2 codegen 侧 inline 做了什么

在 compiler-core/src/codegen.ts:305-335:

ts 复制代码
const genScopeId = !__BROWSER__ && scopeId != null && mode === 'module'
const isSetupInlined = !__BROWSER__ && !!options.inline

// preambles
// in setup() inline mode, the preamble is generated in a sub context
// and returned separately.
const preambleContext = isSetupInlined
  ? createCodegenContext(ast, options)
  : context
if (!__BROWSER__ && mode === 'module') {
  genModulePreamble(ast, preambleContext, genScopeId, isSetupInlined)
}
// ...
if (isSetupInlined) {
  push(`(${signature}) => {`)
} else {
  push(`function ${functionName}(${signature}) {`)
}

要点:

  1. 子上下文 preambleContext :inline 模式下 preamble 用一个全新的 createCodegenContext 生成,因此它有自己的 code/map 状态。函数末尾 return { ..., preamble: isSetupInlined ? preambleContext.code : `` }(codegen.ts:400-405)把它作为独立字符串交回。这就是 SFCTemplateCompileResults.preamble 的来源。
  2. genModulePreamble(..., inline) 决定要不要 export :codegen.ts:532-534 里 if (!inline) push('export ')。inline 时 preamble 是裸的 import/const,因为它会被 ctx.s.prepend(preamble) 插到整个 <script setup> 编译产物最顶部 (compileScript.ts:984-986),而不是作为一个独立模块导出。
  3. 箭头函数签名 :(signature) => {。因为 inline 的 render 要在 setup() 内部引用局部变量(不用 _ctx 前缀),所以它被设计成 setup 返回的箭头函数。注意 prefixIdentifiers: true 时 _ctx 形参仍存在但通常不直接用------变量通过 bindingMetadata 走 $setup/局部绑定(codegen.ts:322-325 只在非 inline 时追加 $props, $setup, $data, $options 参数,inline 下不追加)。

10.2.3 内联模式的编译期优化意义

为什么 inlineTemplate 值得存在?答案有三层:

(一)HMR / 产物体积。 inlineTemplate 的注释直言(compileScript.ts:97-103):"This should only be used in production because it prevents the template from being hot-reloaded separately from component state." 非内联时,template 是一个独立模块 (xxx.vue?vue&type=template),它能被单独 HMR 替换;内联后 render 函数进了 setup 函数体,模板改动只能整组件重载。所以 Vite 开发态默认 inlineTemplate: false,生产态 true。

(二)bindingMetadata 驱动的极致优化。 inline 模式把 bindingMetadata 传进编译器(compileScript.ts:959),配合 prefixIdentifiers: true,编译器能区分 setup-const / setup-ref / setup-reactive-const / props / setup-let 等绑定类型,从而:

  • 对 setup-ref 自动加 .value(或 _unref)而非 _ctx.x;
  • 对 setup-const 直接引用标识符(因为它们不会变,天然可静态提升);
  • 对 props 用 __props.x;
  • 避免 _ctx. 代理查找的运行时开销(has/get trap)。

非内联模式做不到这点,因为 render 函数是独立模块,拿不到 setup 作用域,只能走 _ctx 代理。这是 script setup + 生产构建性能的关键。

(三)减少一层函数与一次 $setup 解构。 非内联模式的渲染是 render(_ctx, _cache, $props, $setup, $data, $options),$setup 由运行时从 setupState 传入并做解构;inline 下 render 闭包直接捕获 setup 局部变量,连 $setup 参数都省了 (codegen.ts:322)。

10.2.4 useInlineTemplate 与条件判定

compileScript 里"是否 inline"的判定(compileScript.ts:897-900):

ts 复制代码
if (
  !options.inlineTemplate ||
  (!sfc.template && ctx.hasDefaultExportRender)
) {
  // non-inline mode, or has manual render in normal <script>
  ...
} else {
  // inline mode
}

即:只有当调用方明确要求 inlineTemplate 且"不存在手写 render 覆盖"时才内联。 hasDefaultExportRender 表示用户在普通 <script> 的 export default { render() {} } 里手写了 render;此时内联会与用户 render 冲突,故退回非内联(让手写 render 生效)。另外内联分支还要求 sfc.template && !sfc.template.src(compileScript.ts:938)------若模板是 src="./x.html" 外部文件,或根本没有 <template>,内联无法进行,产物退化为 () => {}(compileScript.ts:994-996)。

内联产物与 script setup 的拼接方式是:把 preamble 前置 到整个 script 编译产物顶部(ctx.s.prepend(preamble)),把 returned = code(那段箭头函数)作为 setup 的 return 表达式(compileScript.ts:984-993)。于是最终形态为:

js 复制代码
import { ... } from "vue"           // ← preamble 的 import
const _hoisted_1 = { class: "x" }   // ← preamble 的静态提升

export default /*@__PURE__*/_defineComponent({
  setup(__props) {
    const msg = ref('hi')
    // ... 用户 setup 逻辑 ...
    return (_ctx, _cache) => {       // ← compileTemplate 产出的 inline render
      return (_openBlock(), _createElementBlock(...))
    }
  }
})

10.3 source map 生成:从 codegen 的 loc 到最终 .vue 映射

source map 是本章最容易被跳过、也最容易出 bug 的部分。它跨越三个模块:compiler-core 的 codegen 产生"code → template 块文本"的映射;compiler-sfc 的 parse 产生"template 块文本 → 整个 .vue 文件"的仅行级 映射(inMap);compileTemplate 的 mapLines 把两张表链式合成 成"code → .vue 文件"的映射。

10.3.1 codegen 侧:映射的写入时机

compiler-core/src/codegen.ts:272-278 初始化 source map:

ts 复制代码
if (!__BROWSER__ && sourceMap) {
  // lazy require source-map implementation, only in non-browser builds
  context.map =
    new SourceMapGenerator() as unknown as CodegenSourceMapGenerator
  context.map.setSourceContent(filename, context.source)
  context.map._sources.add(filename)
}

三个要点:

  1. 浏览器构建不生成 source map (!__BROWSER__)。因为 source-map-js 的体积与运行开销在浏览器场景不划算。
  2. setSourceContent(filename, context.source) :把模板源文本内联进 map 的 sourcesContent。context.source 是 ast.source(codegen.ts:171)。这解释了测试里的两条断言 :
    • 直接传 source(无 AST)时 map.sourcesContent === [template.content](compileTemplate.spec.ts:153);
    • 复用 descriptor AST 时 map.sourcesContent === [整个 SFC 源文本](compileTemplate.spec.ts:212),因为此时 ast.source 是整份 SFC (resolveTemplateAST 用 inAST.source 重解析,见 10.1.4)。
  3. _sources 是私有字段直写 :SourceMapGenerator 的公共 API 有大量参数校验开销,Vue 直接操作 _sources/_mappings/_names 内部结构以提性能。CodegenSourceMapGenerator 接口(codegen.ts:72-81)专门为此声明。

映射的注入点 是 context.push()(codegen.ts:182-236)。每当 codegen 拼接一段与某个 AST 节点相关的代码,就调用两次 addMapping:

ts 复制代码
push(code, newlineIndex = NewlineType.None, node) {
  context.code += code
  if (!__BROWSER__ && context.map) {
    if (node) {
      let name
      if (node.type === NodeTypes.SIMPLE_EXPRESSION && !node.isStatic) {
        const content = node.content.replace(/^_ctx\./, '')
        if (content !== node.content && isSimpleIdentifier(content)) {
          name = content
        }
      }
      if (node.loc.source) {
        addMapping(node.loc.start, name)
      }
    }
    // ... 更新 line/column/offset ...
    if (node && node.loc !== locStub && node.loc.source) {
      addMapping(node.loc.end)
    }
  }
}

即每个节点写入"起点映射"与"终点映射"两条记录 ,形成一个映射区间。addMapping(codegen.ts:256-270):

ts 复制代码
function addMapping(loc: Position, name: string | null = null) {
  const { _names, _mappings } = context.map!
  if (name !== null && !_names.has(name)) _names.add(name)
  _mappings.add({
    originalLine: loc.line,
    originalColumn: loc.column - 1, // source-map column is 0 based
    generatedLine: context.line,
    generatedColumn: context.column - 1,
    source: filename,
    name,
  })
}

name 的提取规则 是唯一一处"语义增强":只有"非静态的简单表达式"且去掉 _ctx. 前缀后仍是合法标识符,才记录 name。这样 source map 能被浏览器识别为"变量重命名映射"。而静态生成的注释节点(如 v-if 生成的 _createCommentVNode("v-if", true))因为 node.loc === locStub(无真实位置),会被第二条 if 跳过 addMapping(node.loc.end),同时 node.loc.source 为空也不会写起点 ------这正对应测试 compileTemplate.spec.ts:161-188 的断言:v-if 注释位置在 map 里查不到原始位置。

10.3.2 inMap 合并:mapLines 的双层映射

doCompileTemplate 里唯一触发合并的条件(compileTemplate.ts:242-249):

ts 复制代码
if (inMap && !inAST) {
  if (map) {
    map = mapLines(inMap, map)
  }
  if (errors.length) {
    patchErrors(errors, source, inMap)
  }
}

为什么是 && !inAST? 因为当 AST 来自 descriptor(inAST 有值)时,节点的 loc 已经是"相对整个 SFC 文件"的坐标(parseMode: 'sfc' 重解析保证),所以 codegen 直接产出的 map 就已经是最终坐标,不需要再叠一层行映射 。反之 inAST 为空时,编译器接收的是 source(<template> 块内容字符串),其 loc 相对块内容,此时必须与 inMap 合并,把块内行号翻译回 .vue 文件行号。

mapLines 的算法(compileTemplate.ts:266-317)是标准的"两段式 map 链式合成":

ts 复制代码
function mapLines(oldMap: RawSourceMap, newMap: RawSourceMap): RawSourceMap {
  if (!oldMap) return newMap
  if (!newMap) return oldMap

  const oldMapConsumer = new SourceMapConsumer(oldMap)
  const newMapConsumer = new SourceMapConsumer(newMap)
  const mergedMapGenerator = new SourceMapGenerator()

  newMapConsumer.eachMapping(m => {
    if (m.originalLine == null) return
    const origPosInOldMap = oldMapConsumer.originalPositionFor({
      line: m.originalLine,
      column: m.originalColumn!,
    })
    if (origPosInOldMap.source == null) return
    mergedMapGenerator.addMapping({
      generated: { line: m.generatedLine, column: m.generatedColumn },
      original: {
        line: origPosInOldMap.line, // map line
        // use current column, since the oldMap produced by @vue/compiler-sfc
        // does not
        column: m.originalColumn!,
      },
      source: origPosInOldMap.source,
      name: origPosInOldMap.name,
    })
  })

  // ... 复制 sources 与 sourcesContent ...
  const generator = mergedMapGenerator as any
  ;(oldMapConsumer as any).sources.forEach((sourceFile: string) => {
    generator._sources.add(sourceFile)
    const sourceContent = oldMapConsumer.sourceContentFor(sourceFile)
    if (sourceContent != null) {
      mergedMapGenerator.setSourceContent(sourceFile, sourceContent)
    }
  })

  generator._sourceRoot = oldMap.sourceRoot
  generator._file = oldMap.file
  return generator.toJSON()
}

逐步拆解:

  1. generated 坐标直接沿用 newMap:生成的 JS 只被改写过一次(本次 codegen),所以"生成侧"坐标无需变化。
  2. original 坐标追溯到 oldMap :newMap 的"原始位置"指向 template 块文本;再用 oldMapConsumer.originalPositionFor 把它查一次,得到 .vue 文件里的最终位置。
  3. column 沿用 m.originalColumn :注释明确说明------SFC parse 产出的 oldMap 是"line-only"的(每行只映射到整行开头,不区分列),所以列号只能用 newMap 给的原始列。
  4. 跳过 originalLine == null 的映射 :这就是 10.3.1 里那些"无位置"的合成节点(v-if 注释)------它们在 newMap 里已经没有 originalLine,自然无法继续上溯,直接丢弃。
  5. sources/sourcesContent 用 _sources/sourceContentFor 手动搬运 :因为 mergedMapGenerator 是全新的,需要把 oldMap 的源文件清单与内联内容补齐。sourceRoot/file 也从 oldMap 继承。
  6. toJSON() 返回 RawSourceMap。

设计动机总结成一句话:source map 是分两段生成的,mapLines 负责把"段内映射"升华为"文件级映射",而升维的桥梁就是 parse 阶段那份廉价的行级 map。

10.3.3 patchErrors:错误位置的平移

同一条件下(10.3.2),errors 也要做类似的"行平移"(compileTemplate.ts:319-337):

ts 复制代码
function patchErrors(errors: CompilerError[], source: string, inMap: RawSourceMap) {
  const originalSource = inMap.sourcesContent![0]
  const offset = originalSource.indexOf(source)
  const lineOffset = originalSource.slice(0, offset).split(/\r?\n/).length - 1
  errors.forEach(err => {
    if (err.loc) {
      err.loc.start.line += lineOffset
      err.loc.start.offset += offset
      if (err.loc.end !== err.loc.start) {
        err.loc.end.line += lineOffset
        err.loc.end.offset += offset
      }
    }
  })
}

算法极简但有前提假设:

  • offset = originalSource.indexOf(source):用子串查找 定位 template 块内容在整个 .vue 文件中的字节偏移。这要求 block 内容必须是原文件的连续子串(SFC parse 保证成立)。
  • lineOffset:offset 之前有多少个换行 → 块内行号需要整体后移这么多。
  • err.loc.end !== err.loc.start 是一个对象身份比较 ,不是"值比较"。这利用了 compiler-core 里 loc.start 与 loc.end 在某些零宽节点上是同一个对象引用 (例如空标签)。只要两者不同,就同步平移 end。这是一个微妙的易错点:若误写成值比较,零宽节点的 end 会被平移,导致错误范围错位。

10.3.4 一张图看清 map 的三级链路

flowchart LR A[&#34;.vue 原文件<br/>(整份源码)&#34;] -->|&#34;parse.ts<br/>行级 inMap&#34;| B[&#34; 块文本&#34;] B -->|&#34;codegen context.push()<br/>+ addMapping&#34;| C[&#34;render 函数字符串&#34;] C -.->|&#34;newMap: code→块文本&#34;| D[&#34;SourceMapConsumer(newMap)&#34;] B -.->|&#34;oldMap: 块文本→原文件&#34;| E[&#34;SourceMapConsumer(oldMap)&#34;] D -->|&#34;eachMapping 逐条上溯&#34;| F[&#34;mapLines&#34;] E -->|&#34;originalPositionFor&#34;| F F --> G[&#34;最终 map<br/>code→.vue 原文件&#34;]

如果传入了 descriptor AST(inAST 有值),则 A→B 这一跳在 resolveTemplateAST 阶段就已完成 (AST 的 loc 直接相对原文件),mapLines/patchErrors 被跳过,链路退化为二级。


10.4 错误处理与 Tips:errors 的三种形态与 helpers 汇总

10.4.1 CompilerError 结构

compiler-core/src/errors.ts:3-6:

ts 复制代码
export interface CompilerError extends SyntaxError {
  code: number | string
  loc?: SourceLocation
}

因此 compileTemplate 返回的 errors 数组((string | CompilerError)[])里可能有三种形态:

形态 来源 特征
CompilerError(继承 SyntaxError) compiler-core/compiler-dom 的 onError 有 code(数字枚举)与 loc({ start, end })
原始 Error(如预处理器的异常) preprocess() 的 throw 被 catch(compileTemplate.ts:136-142) 无 code,message 为预处理器报错
string 缺预处理器时的软失败(compileTemplate.ts:151-153) 纯文本诊断,如 "uses lang X ... it is not installed."

这就是 compileTemplate 的"错误契约" :调用方必须对三种形态都做类型判断。compileScript 的 inline 分支给了示范(compileScript.ts:966-983):if (typeof err === 'string') throw new Error(err),否则当作 CompilerError 处理,并用 generateCodeFrame(source, err.loc.start.offset, err.loc.end.offset) 在 message 后追加代码框 再 throw。

10.4.2 错误码:ErrorCodes 枚举

compiler-core/src/errors.ts:40-110 定义了 ErrorCodes,按类别分组:

  • HTML parse errors (ABRUPT_CLOSING_OF_EMPTY_COMMENT ~ UNEXPECTED_SOLIDUS_IN_TAG):与 HTML 规范对齐的词法错误。
  • Vue 专属 parse errors (X_INVALID_END_TAG、X_MISSING_END_TAG、X_MISSING_INTERPOLATION_END、X_MISSING_DIRECTIVE_NAME、X_MISSING_DYNAMIC_DIRECTIVE_ARGUMENT_END)。
  • transform errors (X_V_IF_NO_EXPRESSION、X_V_FOR_MALFORMED_EXPRESSION、X_V_MODEL_ON_PROPS 等一大票)。
  • generic errors (X_PREFIX_ID_NOT_SUPPORTED、X_MODULE_MODE_NOT_SUPPORTED、X_CACHE_HANDLER_NOT_SUPPORTED、X_SCOPE_ID_NOT_SUPPORTED、X_VNODE_HOOKS)。

关于 X_SCOPE_ID_NOT_SUPPORTED(errors.ts:189) :"scopeId" option is only supported in module mode. ------ 它由 compiler-core/src/compile.ts:88 在 options.scopeId && !isModuleMode 时抛出。这解释了为什么 10.1.3 里 mode: 'module' 与 scopeId 必须同时出现。

compiler-sfc 还把核心错误词典与 DOM 扩展错误词典合并导出 (packages/compiler-sfc/src/index.ts:16-24):

ts 复制代码
import { DOMErrorMessages, errorMessages as coreErrorMessages } from '@vue/compiler-dom'
export const errorMessages: Record<number, string> = {
  ...coreErrorMessages,
  ...DOMErrorMessages,
}

构建工具把 errors[].code + 数字映射成人类可读文本(vuejs.org/error-reference/#compiler-<code>)时,用的就是这张表。生产构建下(__BROWSER__ 且非 __DEV__),createCompilerError(errors.ts:24-38)不在错误消息里内联文案,而是只给文档 URL,以省体积。

10.4.3 tips:warnings 到 code frame

doCompileTemplate 里 onWarn: w => warnings.push(w)(compileTemplate.ts:236),随后(compileTemplate.ts:251-261):

ts 复制代码
const tips = warnings.map(w => {
  let msg = w.message
  if (w.loc) {
    msg += `\n${generateCodeFrame(
      inAST?.source || source,
      w.loc.start.offset,
      w.loc.end.offset,
    )}`
  }
  return msg
})

代码框取的源文本是 inAST?.source || source ,与 10.3 的 source 选择逻辑一致:有 AST 时用整份 SFC 源(因为 loc 相对它),否则用 block source。warnOnce(packages/compiler-sfc/src/warn.ts:3-10)保证同一条警告只提示一次(Node 生产环境 /__TEST__ 下静默),并按 \x1b[33m(黄色)着色输出。

10.4.4 "needsRuntimeHelpers 汇总"的落点:ast.helpers

任务大纲提到的 needsRuntimeHelpers 在 3.5.44 源码中并不存在同名标识符 (全仓库检索为空)。真正承担"运行时 helper 汇总"职责的是 codegen 阶段的 ast.helpers(Set<symbol>)与 ast.imports。以 compiler-core/src/codegen.ts:302-303 为例:

ts 复制代码
const helpers = Array.from(ast.helpers)
const hasHelpers = helpers.length > 0

随后在 genModulePreamble(codegen.ts:485-513)里把 ast.helpers 序列化成 import { openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue";ast.imports(来自 transformAssetUrl 生成的资源 import)由 genImports 输出(codegen.ts:524-527)。在 compileScript 的 inline 分支里,就利用 ast 做去重 (compileScript.ts:990-992):

ts 复制代码
if (ast && ast.helpers.has(UNREF)) {
  ctx.helperImports.delete('unref')
}

因为 useCssVars 的代码生成(10.9)也会注入 unref,若不删除会与 preamble 重复 import。这是"AST 返回值被下游复用"的一个具体实例 ,也是 compileTemplate 为何要回传 ast 的原因。

10.4.5 汇总:doCompileTemplate 的完整执行序列

把 10.1~10.4 串起来,doCompileTemplate 的执行顺序是:

  1. 建 errors/warnings 两个数组,装配 nodeTransforms(资源 URL)(compileTemplate.ts:175-187);
  2. ssr && !ssrCssVars → warnOnce;!id → warnOnce 且 id = ''(compileTemplate.ts:189-198);
  3. 规范化 shortId/longId,选编译器(compileTemplate.ts:200-210);
  4. resolveTemplateAST 或复用 AST(compileTemplate.ts:212-217);
  5. 组装选项并调用 compiler.compile()(compileTemplate.ts:219-237);
  6. inMap && !inAST → mapLines + patchErrors(compileTemplate.ts:242-249);
  7. warnings → tips(带 code frame)(compileTemplate.ts:251-261);
  8. 返回 { code, ast, preamble, source, errors, tips, map }(compileTemplate.ts:263)。

10.5 scopeId 与 scoped:一个跨四层的完整闭环

这是本章的核心章节。很多文章讲 scoped 只讲"选择器后面加 [data-v-xxx]",这是只见树木 。真正的问题是:模板那边的元素是怎么长出 data-v-xxx 属性的? 答案不在 compileStyle 里,而是散落在 compiler-sfc、runtime-core、runtime-dom(及 server-renderer)四处。下面按"编译期 → 运行时"的顺序把链路接起来。

10.5.1 第 ① 层:编译期,compileTemplate 把 scopeId 交给编译器

回到 10.1.3,compileTemplate 传给编译器的关键一项是:

ts 复制代码
scopeId: scoped ? longId : undefined,
slotted,

即 scoped === true 时,把 data-v-<shortId> 作为 scopeId 传入编译选项 。compiler-core 的 CodegenOptions 定义了这一项(options.ts:284-290):

ts 复制代码
scopeId?: string | null
/**
 * Indicates this SFC template has used :slotted in its styles
 * ... false if no `:slotted` usage is detected in `<style>`
 */
slotted?: boolean

注意 slotted 的类型是 boolean,但在 createTransformContext 里被归一化为 slotted = true(transform.ts:143)------默认值居然是 true !这看似反直觉,实则是为了兼容"旧版工具链不传 slotted"时的行为:默认按"可能有 :slotted"处理,避免 slot 内容缺少 -s 作用域。现代工具链(vite-plugin-vue)会显式传入检测结果(options.ts:288 的注释即要求 false if no :slotted usage is detected)。

10.5.2 第 ① 层续:genScopeId 的历史与现状

compiler-core/src/codegen.ts:305 有这样一个变量:

ts 复制代码
const genScopeId = !__BROWSER__ && scopeId != null && mode === 'module'

它被传给 genModulePreamble(ast, preambleContext, genScopeId, isSetupInlined)(codegen.ts:315)。但如果你逐行读 genModulePreamble(codegen.ts:470-535),会发现 genScopeId 形参从未在函数体内被使用 。这不是疏漏,而是历史遗留:

在 Vue 3.0.8 之前,编译器会为 scoped 组件生成 _withScopeId(() => _createElementVNode(...)) 之类的包装,把 scopeId 通过 pushScopeId/popScopeId 在运行时压栈。3.0.8 之后改为**"scopeId 存在组件类型上、由渲染器统一附着"** 的模型,编译器不再生成包装代码。运行时侧保留了兼容 API(componentRenderContext.ts:52-56):

ts 复制代码
/**
 * Only for backwards compat
 * @private
 */
export const withScopeId = (_id: string): typeof withCtx => withCtx

pushScopeId/popScopeId(componentRenderContext.ts:39-50)也标注为"3.0.8 之后技术上不再需要,仅为兼容旧编译器生成的代码而保留"。所以你在 3.5.44 的任何 SFC 编译产物里都看不到 pushScopeId 调用------它存在于运行时,只为老产物服务。

10.5.3 第 ② 层:运行时,currentScopeId 的设置与 vnode 附着

既然编译器不再生成 pushScopeId,那 data-v-xxx 从哪来?看 runtime-core/src/componentRenderContext.ts:22-33:

ts 复制代码
export function setCurrentRenderingInstance(
  instance: ComponentInternalInstance | null,
): ComponentInternalInstance | null {
  const prev = currentRenderingInstance
  currentRenderingInstance = instance
  currentScopeId = (instance && instance.type.__scopeId) || null
  // v2 pre-compiled components uses _scopeId instead of __scopeId
  if (__COMPAT__ && !currentScopeId) {
    currentScopeId = (instance && (instance.type as any)._scopeId) || null
  }
  return prev
}

关键点:

  1. currentScopeId 取自 instance.type.__scopeId ------即组件的类型对象上挂着的 __scopeId。
  2. 渲染器在渲染组件子树前调用 setCurrentRenderingInstance(instance),于是"当前作用域 id"就绪。
  3. __scopeId 是谁写上去的? 在 runtime-core/src/component.ts:202 里它被声明为可选字段(__scopeId?: string)。而真正赋值的,是 构建工具 :vite-plugin-vue / vue-loader 在合成 SFC 模块时,把 __scopeId: "data-v-xxx" 写进组件选项中(见 10.11)。compiler-sfc 自身不 生成 __scopeId------这是编译产物与工具链职责的边界。

接着,每个 vnode 创建时从 currentScopeId 拷贝一份(runtime-core/src/vnode.ts:453-461):

ts 复制代码
const vnode = {
  __v_isVNode: true,
  __v_skip: true,
  type,
  props,
  key: props && normalizeKey(props),
  ref: props && normalizeRef(props),
  scopeId: currentScopeId,   // ← 关键
  slotScopeIds: null,
  // ...
}

VNode.scopeId 字段(vnode.ts:171)因此记录"创建此 vnode 时所在的组件作用域"。它是快照,不是引用------所以同一组件渲染出的所有元素都带同一个 id。

10.5.4 第 ③ 层:渲染器把 scopeId 变成 DOM 属性

runtime-core/src/renderer.ts:774-814 的 setScopeId 是"落地"的地方:

ts 复制代码
const setScopeId = (
  el: RendererElement,
  vnode: VNode,
  scopeId: string | null,
  slotScopeIds: string[] | null,
  parentComponent: ComponentInternalInstance | null,
) => {
  if (scopeId) {
    hostSetScopeId(el, scopeId)
  }
  if (slotScopeIds) {
    for (let i = 0; i < slotScopeIds.length; i++) {
      hostSetScopeId(el, slotScopeIds[i])
    }
  }
  if (parentComponent) {
    let subTree = parentComponent.subTree
    // ... DEV 下取真实的单根 ...
    if (
      vnode === subTree ||
      (isSuspense(subTree.type) &&
        (subTree.ssContent === vnode || subTree.ssFallback === vnode))
    ) {
      const parentVNode = parentComponent.vnode
      setScopeId(
        el,
        parentVNode,
        parentVNode.scopeId,
        parentVNode.slotScopeIds,
        parentComponent.parent,
      )
    }
  }
}

调用点在 mountElement 里(renderer.ts:713-714):

ts 复制代码
// scopeId
setScopeId(el, vnode, vnode.scopeId, slotScopeIds, parentComponent)

三个行为规则:

  1. 本元素写自身 vnode.scopeId (hostSetScopeId 在 runtime-dom 里是 el.setAttribute(id, ''))。
  2. slotScopeIds 也逐个写上 :这就是 :slotted 的产物 data-v-xxx-s------插槽内容元素会同时带上 -s 后缀 id(来源见 10.5.6)。
  3. 父组件 scopeId 的"下传" :当本 vnode 恰好是父组件的 subTree 根(即"子组件模板的根元素")时,递归地把父组件的 scopeId 也写上去 。这意味着:父组件使用子组件时,子组件模板的根元素会同时获得父、子两个 scopeId 。这是 Vue 3 处理"父组件想通过 scoped 样式命中子组件根元素"的关键机制,也是 ::v-deep 之外 scoped 能"穿透一层"的原因。

易错点 :这条下传只作用于子组件的根元素(单根)。多根 Fragment 的组件,父 scopeId 不会"均匀"下传------这也是 Vue 3 中 scoped 样式对内层子组件通常"打不进去"的根源。

10.5.5 第 ④ 层:compileStyle 把选择器改写成 [data-v-xxx]

模板侧的属性就位后,样式侧还差一步。compileStyle 在 scoped: true 时追加 scopedPlugin(longId)(compileStyle.ts:118-120),把 .foo 改写成 .foo[data-v-test]。这部分规则细节在 10.7,这里先给出闭环示意:

ini 复制代码
         compileTemplate(scoped)                    compileStyle(scoped)
  ┌───────────────────────────────┐        ┌───────────────────────────────┐
  │ <div class="foo">             │        │ .foo { color: red }           │
  │        ↓ compiler-core        │        │        ↓ pluginScoped         │
  │ vnode.scopeId = "data-v-test" │        │ .foo[data-v-test] { color:red}│
  │        ↓ renderer.setScopeId  │        └───────────────────────────────┘
  │ <div class="foo" data-v-test> │                        │
  └───────────────────────────────┘                        │
                    └──────────── 选择器命中 ───────────────┘

10.5.6 :slotted:slot 内容的 id 后缀 -s

:slotted 是 scoped 的一个补丁。问题背景:父组件把内容投进子组件的 <slot>,这段内容的 DOM 元素"出生在父组件作用域"(currentScopeId 是父的),但它被插入到子组件的 DOM 位置 。如果子组件想用 scoped 样式命中"被投入的 slot 内容",普通 [data-v-子] 匹配不到(元素带的是父的 id)。Vue 的解法是引入 data-v-<id>-s 后缀:

  • 样式侧 :pluginScoped 把 :slotted(.foo) 改写为 .foo[data-v-<shortId>-s](pluginScoped.ts:225-245,见 10.7.4)。
  • 模板侧 :<slot> 出口元素在渲染 slot 内容时,给内容元素追加 slotScopeIds。对 SSR,compiler-ssr 的 ssrTransformSlotOutlet 会注入 "${context.scopeId}-s"(compiler-ssr/src/transforms/ssrTransformSlotOutlet.ts:34-36):
ts 复制代码
// inject slot scope id if current template uses :slotted
if (context.scopeId && context.slotted !== false) {
  args.push(`"${context.scopeId}-s"`)
}
  • client 侧 :vnode.slotScopeIds 由 renderSlot / withCtx 链路设置,最终在 setScopeId 的 slotScopeIds 分支里写到 DOM(renderer.ts:784-788)。而 slotted 编译选项的作用,是决定编译器是否为 <slot> 出口生成 -s 相关逻辑 ------slotted: false(<style> 里没用 :slotted)时省掉这段代码体积。

大致的 -s 流转:

flowchart TD SFC[&#34;子组件 <br/> :slotted(.foo)&#34;] --> C1[&#34;compileStyle<br/>.foo[data-v-sub-s]&#34;] SFC --> C2[&#34;compileTemplate slotted=true<br/>slot 出口生成 slotScopeIds&#34;] C2 --> R1[&#34;renderSlot → vnode.slotScopeIds=['data-v-sub-s']&#34;] R1 --> R2[&#34;renderer.setScopeId<br/>hostSetScopeId(el,'data-v-sub-s')&#34;] R1 --> DOM[&#34;<div class="foo">&#34;] C1 --> MATCH[&#34;选择器命中&#34;] DOM --> MATCH

10.5.7 SSR 的差异

SSR 下没有"DOM 属性附着"这一步(HTML 是字符串直接拼),所以 scopeId 必须在字符串拼接时 就注入。compiler-ssr 的做法(compiler-ssr/src/transforms/ssrTransformElement.ts:371-372):

ts 复制代码
if (context.scopeId) {
  openTag.push(` ${context.scopeId}`)
}

即在开标签字符串里直接追加 data-v-xxx。开标签被拼成模板字符串(如 <div${_scopeId}>)或在纯静态时直接是 <div data-v-xxx>。

SSR 里 scopeId 的两种注入方式:

  1. 组件自身模板的元素 :context.scopeId 直接拼进开标签。
  2. slot 函数内部 (_withCtx((_, _push, _parent, _scopeId) => ...)):由于 slot 内容在运行时才渲染,scopeId 无法在编译期写死,因此 compiler-ssr 给 slot 函数加了第 4 个形参 _scopeId(ssrTransformComponent.ts:159),并在插值位置插入 _scopeId 表达式(ssrTransformElement.ts:475-477):
ts 复制代码
// Handle slot scopeId
context.pushStringPart(createSimpleExpression(`_scopeId`, false))

这与 compileTemplate.spec.ts.snap:52-64 的快照完全吻合:

js 复制代码
default: _withCtx((_, _push, _parent, _scopeId) => {
  if (_push) {
    _push(`<picture${_scopeId}><source${_ssrRenderAttr("srcset", _imports_1)}${_scopeId}>...`)

运行时,server-renderer 的 renderComponentSubTree 会算出当前 vnode 的 scopeId 并把它作为 _scopeId 传给 slot 函数(server-renderer/src/render.ts:317-353 的 scopeId 提取与 slotScopeId 拼接逻辑)。SSR 渲染元素时,render.ts:324-348 的 appendScopeId 负责把 scopeId 追加到开标签,并避免与 props 中已有的同名属性重复 ,同时支持"父组件 scopeId 沿 subTree 链下传"(while (curParent && curVnode === curParent.subTree))------逻辑与 client 侧 setScopeId 的递归下传一一对应。

一句话总结 SSR 差异 :client 侧"先建 vnode 再附属性",SSR 侧"拼字符串时直接注入",两者数学等价但实现完全不同,这也是为什么 compileTemplate 要分 ssr 分支选编译器(10.1.3)。

10.5.8 端到端小表

层 文件 关键标识符 产出
编译期 (template) compileTemplate.ts scopeId = longId、slotted 编译器选项
编译内核 compiler-core/codegen.ts / compiler-ssr scopeId、genScopeId(历史) vnode 属性 / 字符串注入
编译期 (style) pluginScoped.ts [data-v-xxx]、-s 改写后的 CSS
运行时 (id 源) component.ts __scopeId 组件类型字段
运行时 (作用域) componentRenderContext.ts currentScopeId 当前渲染作用域
运行时 (附着) vnode.ts / renderer.ts vnode.scopeId、setScopeId DOM data-v-xxx
SSR server-renderer/render.ts appendScopeId、_scopeId HTML 字符串属性

10.5.9 与 Vue 2 scoped 的对比

理解闭环后,回看 Vue 2(vue-loader + vue-template-compiler)能更清晰地看到"为什么 Vue 3 要重构":

维度 Vue 2 Vue 3.5.44
scopeId 传参 _scopeId(兼容字段仍在) __scopeId
运行时附着 编译器生成 _withScopeId,调用 pushScopeId/popScopeId 压栈,渲染时读栈顶 编译器不再生成包装;vnode 直接从 currentScopeId 快照
父 scopeId 下传 靠群体包装/手动逻辑,规则较隐晦 renderer.setScopeId 显式沿 subTree 递归,逻辑集中
:slotted 无(:slotted 是 Vue 3 新增) -s 后缀 + slotScopeIds + slotted 编译开关
::v-deep 支持 >>> / /deep/ / ::v-deep 支持 :deep(),旧语法保留但发 warn 弃用(10.7.3)
CSS 变量 无 v-bind() 到 CSS v-bind() → CSS 自定义属性 + useCssVars(10.8/10.9)
嵌套/分支伪类 基本不支持 :is(:deep()) 分支拆分 pluginScoped 完整支持(10.7.5)

Vue 3 的核心收益是:把"作用域"从"运行时可变的栈"变成"编译期确定、挂在 vnode 上的静态字段" ,从而让 scoped 的运行时开销趋近于零(只多一次 setAttribute),且行为可预测、可 SSR 对齐。


10.6 compileStyle / compileStyleAsync 与 PostCSS 管线

10.6.1 两个导出与一个实现

packages/compiler-sfc/src/compileStyle.ts:70-90:

ts 复制代码
export function compileStyle(
  options: SFCStyleCompileOptions,
): SFCStyleCompileResults {
  return doCompileStyle({ ...options, isAsync: false }) as SFCStyleCompileResults
}

export function compileStyleAsync(
  options: SFCAsyncStyleCompileOptions,
): Promise<SFCStyleCompileResults> {
  return doCompileStyle({ ...options, isAsync: true }) as Promise<SFCStyleCompileResults>
}

export function doCompileStyle(
  options: SFCAsyncStyleCompileOptions,
): SFCStyleCompileResults | Promise<SFCStyleCompileResults> {
  // ...
}

设计模式 :一个 doCompileStyle 通过 isAsync 开关同时服务同步/异步两条路径。同步版返回结果对象;异步版返回 Promise。compileStyleAsync 是CSS Modules 唯一可用的入口(原因见 10.6.5)。

10.6.2 选项解构与预处理

compileStyle.ts:88-112:

ts 复制代码
const {
  filename, id, scoped = false, trim = true, isProd = false,
  modules = false, modulesOptions = {},
  preprocessLang, postcssOptions, postcssPlugins,
} = options
const preprocessor = preprocessLang && processors[preprocessLang]
const preProcessedSource = preprocessor && preprocess(options, preprocessor)
const map = preProcessedSource ? preProcessedSource.map : options.inMap || options.map
const source = preProcessedSource ? preProcessedSource.code : options.source

const shortId = id.replace(/^data-v-/, '')
const longId = `data-v-${shortId}`

要点:

  1. trim 默认 true ------SFC 样式块的输出默认做"换行规范化"(pluginTrim),避免因源文件缩进产生无意义 diff。
  2. preprocessLang && processors[preprocessLang] :processors 表(preprocessors.ts:155-161)只注册 less/sass/scss/styl/stylus 五种。未注册的语言静默降级 (preprocessor 为 undefined,直接对原文跑 PostCSS)------这与模板侧"软失败给 tips"的策略不同:样式侧连提示都没有,因为 PostCSS 对未知语法通常会报 parse error,间接暴露问题。
  3. shortId/longId 再次做规范化,与 template 侧同构(10.1.3)。
  4. map 的选择优先级 :preProcessedSource.map(预处理器产出)> options.inMap > options.map(已废弃字段)。注意 pluginScoped 与 cssVarsPlugin 都需要 map 才能生成正确的最终 source map。

10.6.3 插件数组:顺序就是语义

这是 doCompileStyle 最精妙也最容易被忽视的一段(compileStyle.ts:113-141):

ts 复制代码
const plugins = (postcssPlugins || []).slice()
plugins.unshift(cssVarsPlugin({ id: shortId, isProd }))
if (trim) {
  plugins.push(trimPlugin())
}
if (scoped) {
  plugins.push(scopedPlugin(longId))
}
let cssModules: Record<string, string> | undefined
if (modules) {
  // ... 校验 __GLOBAL__ / __ESM_BROWSER__ / isAsync ...
  plugins.push(postcssModules({
    ...modulesOptions,
    getJSON: (_cssFileName, json) => { cssModules = json },
  }))
}

最终插件顺序(按 PostCSS 的 Visitor 遍历顺序执行):

css 复制代码
[ cssVarsPlugin ]  →  [ ...用户 postcssPlugins ]  →  [ trimPlugin ]  →  [ scopedPlugin ]  →  [ postcss-modules ]

为什么必须是这个顺序?

位置 插件 必须在此时执行的理由
最前 cssVarsPlugin 先把 v-bind(color) 变成 var(--xxx),后续 scoped 看到的就是纯 CSS 值
中间 用户插件 用户的 float/preset 等插件应在 Vue 内建处理之间生效
前中 trimPlugin 在 scoped 改写前先规整空白,避免 raws 里残留的缩进影响选择器拼接
中后 scopedPlugin 需要看到(可能被用户插件/预处理改过的)最终选择器 再注入 [data-v-xxx]
最后 postcss-modules CSS Modules 必须在作用域改写完成后处理 class 名,否则命名哈希会基于已改写的选择器,且 :global/:local 解析要与 scoped 共存

易错点 :plugins.unshift(cssVarsPlugin) 是 unshift(插到最前) ,而 trim/scoped/modules 都是 push(追加到末尾) 。这导致"用户插件夹在 cssVars 与 trim 之间"------即 用户插件能看到 var(--xxx),但看不到 [data-v-xxx] 。如果你写了一个依赖 [data-v-xxx] 的用户 PostCSS 插件,它的 Rule 访问器会在 scoped 插件之前运行,因而选择器里还没有 [data-v-xxx]。

10.6.4 预处理适配层:preprocessors.ts

preprocess()(compileStyle.ts:221-242)先做浏览器构建的守卫(与模板侧同构),再调用 StylePreprocessor。StylePreprocessor 的签名(preprocessors.ts:6-15):

ts 复制代码
export type StylePreprocessor = (
  source: string,
  map: RawSourceMap | undefined,
  options: { additionalData?; filename: string; [key: string]: any },
  customRequire: SFCStyleCompileOptions['preprocessCustomRequire'],
) => StylePreprocessorResults

注意 load = require 的默认参数 ------每个处理器都支持自定义 require 注入,这样在浏览器/链接安装场景下能加载到正确的 sass/less/stylus。这比模板侧的 consolidate 适配更细粒度。

SCSS/Sass 处理器(preprocessors.ts:25-82)
ts 复制代码
const scss: StylePreprocessor = (source, map, options, load = require) => {
  const nodeSass: typeof import('sass') = load('sass')
  const { compileString, renderSync } = nodeSass
  const data = getSource(source, options.filename, options.additionalData)
  try {
    if (compileString) {
      // sass >= 1.45 现代 API
      const { pathToFileURL, fileURLToPath }: typeof import('url') = load('url')
      const result = compileString(data, {
        ...options, url: pathToFileURL(options.filename), sourceMap: !!map,
      })
      css = result.css
      dependencies = result.loadedUrls.map(url => fileURLToPath(url))
      sourceMap = map ? result.sourceMap! : undefined
    } else {
      // legacy renderSync API(dart-sass 旧版 / node-sass)
      const result = renderSync({ ...options, data, file: options.filename,
        outFile: options.filename, sourceMap: !!map })
      css = result.css.toString()
      dependencies = result.stats.includedFiles
      sourceMap = map ? JSON.parse(result.map!.toString()) : undefined
    }
    if (map) return { code: css, errors: [], dependencies, map: merge(map, sourceMap!) }
    return { code: css, errors: [], dependencies }
  } catch (e: any) {
    return { code: '', errors: [e], dependencies: [] }
  }
}

三个设计点:

  1. 双 API 兼容 :优先用现代的 compileString(对应 Dart Sass 的 compileString),回退到 renderSync(node-sass / 旧 dart-sass)。这是历史上链式兼容的产物。
  2. additionalData 注入 :getSource()(preprocessors.ts:141-151)支持字符串或 (source, filename) => string 两种形态,在源文本前 拼接全局变量/@use 等------这正是 Vite 的 css.preprocessorOptions.scss.additionalData 的实现落点。
  3. source map 合并 :merge(map, sourceMap) 来自 merge-source-map 包,把"SFC 块→原文件"与"SCSS→CSS"两张表合成。这就是 10.6.2 里 preProcessedSource.map 会被优先用作 PostCSS prev 的原因------SCSS 的行偏移必须被计入。

sass 处理器只是给 scss 传 indentedSyntax: true(preprocessors.ts:73-82)。

Less / Stylus(preprocessors.ts:85-139)
  • Less :nodeLess.render(source, { ...options, syncImport: true }, cb),靠 syncImport: true 强制同步 import ,以维持 compileStyle 的同步 API。错误通过闭包捕获。
  • Stylus :nodeStylus(source, options),map 存在时 ref.set('sourcemap', { inline: false, comment: false });ref.deps() 收集依赖。注意 ref.render() 是同步的。
依赖收集 dependencies

每个处理器返回 dependencies: string[](被 @import 的文件)。doCompileStyle 把它做成 Set(compileStyle.ts:160-164):

ts 复制代码
const dependencies = new Set(
  preProcessedSource ? preProcessedSource.dependencies : [],
)
// sass has filename self when provided filename option
dependencies.delete(filename)

dependencies.delete(filename) 是为了 Stylus ------stylus 的输出里会包含自身文件名,需去重。构建工具用这个集合做 HMR 依赖追踪(改动被 @import 的 scss 时,即使 .vue 没变也要重编译样式)。

10.6.5 CSS Modules 与异步强制

compileStyle.ts:121-141 对 modules 的处理有三重守卫:

  1. if (__GLOBAL__ || __ESM_BROWSER__) throw:浏览器构建不支持(因为 postcss-modules 依赖 Node);
  2. if (!options.isAsync) throw:同步入口用 modules 直接抛错 ,强制用户改用 compileStyleAsync。原因是 postcss-modules 通过 getJSON 回调把 class 映射交回,回调是异步触发的,同步流程拿不到;
  3. 仅在通过后 push(postcssModules(...))。

CSS Modules 的产物通过闭包变量 cssModules 捕获:getJSON: (_cssFileName, json) => { cssModules = json },最终放进 SFCStyleCompileResults.modules。测试(compileStyle.spec.ts:437-449)验证了 .red/.green 被哈希为 _red_...,而 :global(.blue) 不出现在 modules 中------:global 的 class 不做本地化。

10.6.6 PostCSS 处理与结果汇编

compileStyle.ts:143-218:

ts 复制代码
const postCSSOptions: ProcessOptions = { ...postcssOptions, to: filename, from: filename }
if (map) {
  postCSSOptions.map = { inline: false, annotation: false, prev: map }
}
// ...
try {
  result = postcss(plugins).process(source, postCSSOptions)
  if (options.isAsync) {
    return result.then(result => ({
      code: result.css || '',
      map: result.map && result.map.toJSON(),
      errors,
      modules: cssModules,
      rawResult: result,
      dependencies: recordPlainCssDependencies(result.messages),
    })).catch(error => ({
      code: '', map: undefined, errors: [...errors, error],
      rawResult: undefined, dependencies,
    }))
  }
  recordPlainCssDependencies(result.messages)
  code = result.css   // 强制同步求值(已知只有同步插件)
  outMap = result.map
} catch (e: any) {
  errors.push(e)
}

几个细节:

  • map.prev = map :把上一步(预处理器/SFC parse)的 map 交给 PostCSS,让它链式生成 最终 map。inline: false 表示不作为注释内联,annotation: false 表示不加 sourceMappingURL 注释(由构建工具另行处理)。
  • code = result.css 触发 LazyResult 的同步求值 :PostCSS 的 process() 返回 LazyResult,不加 .then 时访问 .css 会同步 执行插件链。前提是"链上只有同步插件"------这是 compileStyle(同步版)能存在的基础。若用户 postcssPlugins 里混入异步插件,同步版会抛错或结果不完整,此时应改用 compileStyleAsync。
  • recordPlainCssDependencies (compileStyle.ts:171-179):扫描 result.messages 里 type === 'dependency' 的消息(如 postcss-import 产出),加入依赖集合。
  • 错误处理差异 :同步版 catch 后 errors.push(e) 并继续返回(code 可能为空);异步版 catch 后返回带 error 的对象。两者都把错误收集进 errors 而非抛出,让上层决定。

10.6.7 返回结构

SFCStyleCompileResults(compileStyle.ts:61-68):

字段 类型 说明
code string 改写后的 CSS
map `RawSourceMap undefined`
rawResult `Result LazyResult
errors Error[] 错误(含预处理器错误 + PostCSS 错误)
modules Record<string,string>? CSS Modules 的 class→哈希映射
dependencies Set<string> 被引用的外部样式文件路径

10.7 pluginScoped 深度剖析:一个 CSS 选择器重写器

pluginScoped.ts 397 行,是本章算法密度最高的部分。它用 postcss-selector-parser 把选择器解析成语法树后逐节点改写。先记住它的三个访问器入口,再逐层深入。

10.7.1 插件骨架:三个访问器

pluginScoped.ts:15-64:

ts 复制代码
const scopedPlugin: PluginCreator<string> = (id = '') => {
  const keyframes = Object.create(null)
  const shortId = id.replace(/^data-v-/, '')

  return {
    postcssPlugin: 'vue-sfc-scoped',
    Rule(rule) {
      processRule(id, rule)
    },
    AtRule(node) {
      if (keyframesRE.test(node.name) && !node.params.endsWith(`-${shortId}`)) {
        // register keyframes
        keyframes[node.params] = node.params = node.params + '-' + shortId
      }
    },
    OnceExit(root) {
      if (Object.keys(keyframes).length) {
        // ... 逐个 walkDecls 改写 animation / animation-name ...
      }
    },
  }
}

为什么 keyframes 要"两阶段"处理?

  • AtRule 阶段:遇到 @keyframes(含 -webkit-keyframes 等前缀,keyframesRE = /^(?:-\w+-)?keyframes$/,pluginScoped.ts:13)时,把名字 color 改成 color-test,同时把映射记进 keyframes 表。
  • OnceExit 阶段:CSS 遍历结束时,回扫全部声明 ,把 animation / animation-name 里引用到的原名替换成新名。

为什么不能在第一次遇到就顺手改声明? 因为一个组件里 @keyframes color 可能出现在 .anim { animation: color } 之后 ,PostCSS 是前序遍历,遇到 .anim 时 keyframes 表可能还是空的。OnceExit(遍历完成后的钩子)才能保证所有 keyframes 都已登记。这是一个顺序依赖的经典陷阱。

keyframes 用 Object.create(null) 而非 {},是为了避免原型链污染(若用户写了名为 constructor / toString 的 keyframes,{} 上的原型方法会干扰 keyframes[v] 查询)。

!node.params.endsWith('-${shortId}') 的幂等守卫 :防止同一 keyframes 名被二次追加后缀(例如重复运行插件)。测试(compileStyle.spec.ts:364-385)验证:

css 复制代码
animation: color 5s infinite, other 5s;  →  animation: color-test 5s infinite, other 5s;
animation-name: color;                    →  animation-name: color-test;
@keyframes color { ... }                  →  @keyframes color-test { ... }

animation-name 与 animation 简写的处理(pluginScoped.ts:37-60):前者按 , 切分、逐个查表;后者按 , 分组,再每组按空白切分,找到第一个属于 keyframes 名的位置 替换(vals.findIndex(val => keyframes[val]))。注意这里有个已知边界:animation 简写里如果只有一个空白分隔的 token 恰好命中,才替换;否则保留原样。other(未定义 keyframes)会被跳过。

10.7.2 processRule:进入改写的门

pluginScoped.ts:66-92:

ts 复制代码
const processedRules = new WeakSet<Rule>()

function processRule(id: string, rule: Rule) {
  if (
    processedRules.has(rule) ||
    (rule.parent &&
      rule.parent.type === 'atrule' &&
      keyframesRE.test((rule.parent as AtRule).name))
  ) {
    return
  }
  processedRules.add(rule)
  let deep = false
  let parent: Document | Container | undefined = rule.parent
  while (parent && parent.type !== 'root') {
    if ((parent as any).__deep) {
      deep = true
      break
    }
    parent = parent.parent
  }
  rule.selector = selectorParser(selectorRoot => {
    selectorRoot.each(selector => {
      rewriteSelector(id, rule, selector, selectorRoot, deep)
    })
  }).processSync(rule.selector)
}

要点:

  1. processedRules 是 WeakSet<Rule> :全局(模块级)记忆已处理的 rule。为什么需要?因为 rewriteSelector 里存在"嵌套 :deep 触发拆分为多个选择器、并对新选择器再次调用 rewriteSelector"的逻辑(10.7.5),若不加守卫会无限递归 。用 WeakSet 而非 Set 是避免内存泄漏(Rule 是临时对象,可被 GC)。
  2. keyframes 内层的 rule 跳过 :@keyframes color { from {...} to {...} } 里的 from/to 不是选择器 ,不能被注入 [data-v-xxx],否则动画帧规则会失效。因此父级是 keyframes atrule 时直接 return。这是必须的例外 ------若误加,from[data-v-test] 就成了非法帧选择器。
  3. deep 的向上查找 :从当前 rule 沿 parent 链向上,只要发现祖先 rule 带 __deep 标记,就认为当前 rule 处于"deep 上下文"。__deep 是 rewriteSelector 处理 :deep(...) 时打上的(pluginScoped.ts:185)。这解决的是 CSS Nesting 场景:
css 复制代码
:deep(.foo) {
  color: red;
  .bar { color: red; }   /* 这个嵌套 rule 也应视为 deep */
}

外层 :deep(.foo) 被标 __deep,内层 .bar 在 processRule 里上溯发现父 rule __deep === true,于是 deep = true 传入 rewriteSelector,内层 .bar 也不注入 [id] 。测试(compileStyle.spec.ts:189-195)验证产物为 [data-v-test] .foo { color: red; .bar { color: red; } }------注意内层 .bar 确实没有 [data-v-test]。

  1. selectorParser(...).processSync(rule.selector) :用 postcss-selector-parser 解析 + 同步处理 + 序列化回字符串。processSync 是同步 API,符合 compileStyle 同步契约。

10.7.3 rewriteSelector 总览:一条"找锚点 → 注入属性"的主线

rewriteSelector(pluginScoped.ts:94-341)是整个插件的核心。它处理一个 Selector(即逗号分隔的一个分支),最终目的:找到"最合适的节点",把 [data-v-id] 插到它后面。它有一个"状态机"式的遍历:

ts 复制代码
let node: selectorParser.Node | null = null      // 锚点:属性插在它之后
let shouldInject = !deep                          // 是否注入(deep 不注入)
let hasNestedDeep = false                         // 是否遇到嵌套 deep
let splitForNestedDeep = false                    // 是否已因嵌套 deep 拆分
selector.each(n => {
  // ① >>> / /deep/ combinator
  // ② pseudo 分支::deep / :slotted / :global / :is / :where / :has / :not
  // ③ universal(*)分支
  // ④ 普通节点:更新锚点 node
})

遍历结束后(pluginScoped.ts:292-340)依次做:

  1. if (splitForNestedDeep) return(10.7.5 的拆分已接管);
  2. CSS Nesting 的声明提升 (if (rule.nodes.some(node => node.type === 'rule')));
  3. :is/:where 锚点的子选择器递归改写;
  4. 空白处理 (node.spaces.after = '' 或 selector.first.spaces.before = '');
  5. 注入属性 (if (shouldInject) selector.insertAfter(node, attribute({ attribute: idToAdd, value: idToAdd ... })))。

下面逐个拆解四个遍历分支与收尾。

10.7.4 分支 ②:伪类处理

这是最复杂的部分。n.type === 'pseudo' 时依次判断:

(a)>>> 与 /deep/ 组合器(pluginScoped.ts:108-120)
ts 复制代码
if (n.type === 'combinator' && (n.value === '>>>' || n.value === '/deep/')) {
  n.value = ' '
  n.spaces.before = n.spaces.after = ''
  warn(`the >>> and /deep/ combinators have been deprecated. Use :deep() instead.`)
  return false   // ← 停止遍历
}

实际上这段在 selector.each 里,n 是 combinator。把 >>>//deep/ 替换成普通空格组合器(后代选择器),并 return false(each 的 false 表示中断遍历)。测试:

css 复制代码
>>> .foo       → [data-v-test] .foo
/deep/ .foo    → [data-v-test] .foo

注意:此时 node 仍为 null,所以属性会被注入到选择器开头 (selector.insertAfter(null, ...))。

(b):deep / ::v-deep(pluginScoped.ts:184-220)
ts 复制代码
if (value === ':deep' || value === '::v-deep') {
  ;(rule as any).__deep = true
  if (n.nodes.length) {
    // .foo ::v-deep(.bar) -> .foo[xxxxxxx] .bar
    let last: selectorParser.Selector['nodes'][0] = n
    n.nodes[0].each(ss => {
      selector.insertAfter(last, ss)
      last = ss
    })
    // insert a space combinator before if it doesn't already have one
    const prev = selector.at(selector.index(n) - 1)
    if (!prev || !isSpaceCombinator(prev)) {
      selector.insertAfter(n, selectorParser.combinator({ value: ' ' }))
    }
    selector.removeChild(n)
  } else {
    // DEPRECATED: .foo ::v-deep .bar -> .foo[xxxxxxx] .bar
    warn(`${value} usage as a combinator has been deprecated. ...`)
    const prev = selector.at(selector.index(n) - 1)
    if (prev && isSpaceCombinator(prev)) selector.removeChild(prev)
    selector.removeChild(n)
  }
  return false
}

算法(函数式用法 .foo :deep(.bar)):

  1. 先给 rule 打 __deep = true(供 10.7.2 的嵌套判断);
  2. 把 :deep(...) 内部的节点逐个搬出来 插到 :deep 位置之后(insertAfter(last, ss) 逐个尾插,last 指针递进);
  3. 保证 :deep 前面有一个空格组合器 :因为后面属性要插到 :deep 之前的锚点 node 上,node 与展开后的 .bar 之间必须有后代关系。若前一个节点不是空格,就补一个 ' ' combinator;
  4. selector.removeChild(n) 删除 :deep 本身;
  5. return false 中断遍历。

结果 :.foo ::v-deep(.bar) → .foo[data-v-test] .bar。属性插在 :deep 之前 那个 .foo 之后。测试 compileStyle.spec.ts:139-143 验证了 .baz .qux ::v-deep(.foo .bar) → .baz .qux[data-v-test] .foo .bar。

依赖用法(::v-deep .foo) :走 else 分支,弃用警告 + 删掉 :deep 与前导空格。注意此时 node 可能仍是 null(因为 :deep 是最前节点),于是属性插到选择器开头:::v-deep .foo → [data-v-test] .foo。

(c):slotted / ::v-slotted(pluginScoped.ts:222-245)
ts 复制代码
if (value === ':slotted' || value === '::v-slotted') {
  rewriteSelector(
    id, rule, n.nodes[0], selectorRoot,
    deep,
    true /* slotted */,
  )
  let last: selectorParser.Selector['nodes'][0] = n
  n.nodes[0].each(ss => {
    selector.insertAfter(last, ss)
    last = ss
  })
  selector.removeChild(n)
  shouldInject = false
  return false
}

与 :deep 的关键差异 :先对 :slotted(...) 内部递归调用 rewriteSelector(..., slotted = true),带着 slotted=true 标志 去改写内层------于是内层会注入 id + '-s'(见 10.7.6)。然后同样把内层节点搬出来、删除 :slotted。最后 shouldInject = false :因为 -s 属性已经在内层注入了,外层不需要再注入普通 [id]。

结果 ::slotted(.foo) → .foo[data-v-test-s]。注意与 :deep 不同,:slotted 会在内层"就地"注入属性,所以不需要前面的锚点 。测试 compileStyle.spec.ts:198-218 验证:

ruby 复制代码
:slotted(.foo)          → .foo[data-v-test-s]
::v-slotted(.foo .bar)  → .foo .bar[data-v-test-s]
.baz .qux ::v-slotted(.foo .bar) → .baz .qux .foo .bar[data-v-test-s]
(d):global / ::v-global(pluginScoped.ts:247-252)
ts 复制代码
if (value === ':global' || value === '::v-global') {
  selector.replaceWith(n.nodes[0])
  return false
}

最暴力也最简单 :把这个整个选择器替换成 :global(...) 内部的选择器,不做任何作用域注入。测试:

ruby 复制代码
:global(.foo)                 → .foo
.baz .qux ::v-global(.foo .bar) → .foo .bar    ← 前面部分被整体丢弃

注意 .baz .qux ::v-global(...) 的结果丢失了 .baz .qux------因为 selector.replaceWith(n.nodes[0]) 替换的是整个 selector 节点 ,而不是只替换 :global 那一段。这是 :global 语义的一部分("走全局"),但很容易让使用者意外。

10.7.5 容器伪类与嵌套 :deep 的分支拆分

:is / :where / :has / :not 这四个容器伪类的内部可以再含选择器,且可能含 :deep。代码用 isDeepContainerPseudo(pluginScoped.ts:360-370)识别它们,然后分两条路径:

路径 A:可拆分的容器伪类(:is / :where / :has)且满足拆分条件 (pluginScoped.ts:130-152):

ts 复制代码
if (isDeepContainerPseudo(n)) {
  const hasDeepSelectors = n.nodes.some(selector => selector.some(isDeepSelector))
  if (hasDeepSelectors) {
    const hasScopeAnchor = !!node
    const hasMixedSelectors = n.nodes.some(selector => !selector.some(isDeepSelector))
    const hasTrailingNodes = selector.index(n) < selector.length - 1
    if (
      canSplitDeepContainerPseudo(n) && !deep && !hasScopeAnchor &&
      hasMixedSelectors && hasTrailingNodes
    ) {
      splitSelectorForNestedDeep(id, rule, selector, selectorRoot, n, deep, slotted)
      splitForNestedDeep = true
      return false
    }
    // ... :not 的特例 ...
    n.nodes.forEach(selector =>
      rewriteSelector(id, rule, selector, selectorRoot, deep || hasScopeAnchor, slotted)
    )
    if (!hasScopeAnchor) {
      node = n
      shouldInject = false
    }
    hasNestedDeep = true
  }
}

条件解读:

  • hasDeepSelectors:容器内部某个分支含 :deep;
  • hasScopeAnchor = !!node:容器之前已经 有可锚定节点(如 .a :is(...))------此时无需拆分,属性可锚定在 .a 上;
  • hasMixedSelectors:容器内既有 deep 分支、又有 非 deep 分支(如 :is(:deep(.foo), .bar));
  • hasTrailingNodes:容器后面还有节点(如 :is(...) .baz)------否则拆分没有意义。

四者全部 满足时(且 !deep、canSplitDeepContainerPseudo),调用 splitSelectorForNestedDeep 做分支级拆分。

为什么要拆分? 因为 :is(:deep(.foo), .bar) .baz 里,"deep 分支"与"非 deep 分支"需要不同的作用域处理 :deep 分支不应注入 [id],非 deep 分支应注入。用一个选择器无法表达两种行为,所以必须复制选择器成多份 ,各自改写。测试(compileStyle.spec.ts:164-168)验证:

scss 复制代码
:is(:deep(.foo), .bar) .baz
→  :is([data-v-test] .foo) .baz, :is(.bar) .baz[data-v-test]

splitSelectorForNestedDeep(pluginScoped.ts:378-406)的实现:

ts 复制代码
function splitSelectorForNestedDeep(id, rule, selector, selectorRoot, pseudo, deep, slotted) {
  const pseudoIndex = selector.index(pseudo)
  const selectors = pseudo.nodes.map((branch, index) => {
    const branchSelector = selector.clone()
    if (branchSelector.first) {
      branchSelector.first.spaces.before = index === 0 ? selector.first.spaces.before : ' '
    }
    const branchPseudo = branchSelector.at(pseudoIndex) as selectorParser.Pseudo
    const branchClone = branch.clone()
    if (branchClone.first) branchClone.first.spaces.before = ''
    branchPseudo.removeAll()
    branchPseudo.append(branchClone)
    rewriteSelector(id, rule, branchSelector, selectorRoot, deep, slotted)
    return branchSelector
  })
  selector.replaceWith(...selectors)
}
  • 对容器内每个分支克隆一份整条选择器;
  • 把克隆里对应位置的伪类替换为那个分支;
  • 首分支沿用原选择器的前导空格,后续分支前导空格设为一个空格(保证拆开后仍是独立规则且排版正确);
  • 对每个拆分后的选择器递归调用 rewriteSelector (此时 node 未锚定,deep 分支会走 shouldInject = false 路径);
  • 最后 selector.replaceWith(...selectors) 一条选择器变多条。

路径 B:不可拆分 / 不满足条件的容器 (含 :not,pluginScoped.ts:154-179):

对每个内部分支递归 rewriteSelector,若容器前无锚点则把 node 设为容器本身、且 shouldInject = false(因为属性已在各分支内处理),并标记 hasNestedDeep = true。

:not 有一个特例分支 (pluginScoped.ts:154-162):

ts 复制代码
if (value === ':not' && !deep && !hasScopeAnchor && hasMixedSelectors && hasTrailingNodes) {
  return
}

:not 不能像 :is/:where 那样拆分(拆分 :not(.a, .b) 会改变语义),所以这里选择 return(放弃对当前节点的处理 ),让后面的节点逻辑接手。测试(compileStyle.spec.ts:174-183)验证 :not(:deep(.foo)) .bar → :not([data-v-test] .foo) .bar(注意 :not 不拆分,deep 属性注入到内部)。

:has 则可以被拆分 (canSplitDeepContainerPseudo 返回 :has 为 true,pluginScoped.ts:372-376),测试 compileStyle.spec.ts:184-188:

scss 复制代码
:has(:deep(.foo), .bar) .baz
→  :has([data-v-test] .foo) .baz, :has(.bar) .baz[data-v-test]

说明:canSplitDeepContainerPseudo 只对 :is/:where/:has 返回 true,:not 返回 false。

10.7.6 分支 ③:universal 选择器 *(pluginScoped.ts:255-279)

ts 复制代码
if (n.type === 'universal') {
  const prev = selector.at(selector.index(n) - 1)
  const next = selector.at(selector.index(n) + 1)
  // * ... {}
  if (!prev) {
    // * .foo {} -> .foo[xxxxxxx] {}
    if (next) {
      if (next.type === 'combinator' && next.value === ' ') selector.removeChild(next)
      selector.removeChild(n)
      return
    } else {
      // * {} -> [xxxxxxx] {}
      node = selectorParser.combinator({ value: '' })
      selector.insertBefore(n, node)
      selector.removeChild(n)
      return false
    }
  }
  // .foo * -> .foo[xxxxxxx] *
  if (node) return
}

三种情形:

  1. 前导 * 且有后继 (* .foo):删掉 * 与其后的空格组合器,让属性锚定到 .foo。结果 * .foo → .foo[data-v-test]。
  2. 纯 * (* {}):注入一个空 combinator 作为锚点,删掉 *。结果 * {} → [data-v-test] {}。为什么用空 combinator 而不是直接 insertAfter(null)?因为后面通用注入代码用 selector.insertAfter(node, attribute),需要一个真实节点占位。
  3. 中间/末尾 * (.foo *):这里 if (node) return(中断遍历),保留 *,属性锚定在已找到的 node(.foo)上。结果 .foo * → .foo[data-v-test] *。

易错点 :* 的处理只针对单个 *;如果选择器是 .a * .b,遍历到 * 时 node 已存在(.a),return 中断,属性插到 .a 后------结果 .a[data-v-test] * .b,符合"作用域锚定在最外层"的直觉。

测试里看到的 CSS Nesting 产物(compileStyle.spec.ts:44-59):

css 复制代码
main {
  width: 100%;
  > * { max-width: 200px; }
}
↓
main {
&[data-v-test] { width: 100%; }
> *[data-v-test] { max-width: 200px; }
}

注意 > * 被处理为 *[data-v-test](> combinator 保留,* 在中间有 node = > 组合器?------实际这里 node 在 * 前是 > 组合器之后,属于 if (node) return 分支的变体)。

10.7.7 分支 ④:锚点的选择规则(pluginScoped.ts:281-289)

ts 复制代码
if (
  !hasNestedDeep &&
  ((n.type !== 'pseudo' && n.type !== 'combinator') ||
    (n.type === 'pseudo' &&
      (n.value === ':is' || n.value === ':where') &&
      !node))
) {
  node = n
}

规则是:每次遇到"非伪类、非组合器"的节点,就把锚点更新为它 (因此最终锚点是选择器里最后一个非伪类/非组合器节点)。例外:

  • hasNestedDeep 为 true 时不再更新锚点(因为嵌套 deep 已决定注入策略);
  • 单独的 :is / :where(且当前还没有锚点)可以作为锚点。

为什么锚点要选"最后一个实体节点"? 考虑 h1 .foo:遍历到 h1(node=h1),再到空格组合器(跳过),再到 .foo(node=.foo)。最终 node = .foo,属性插到 .foo 之后 → h1 .foo[data-v-test]。这正是"作用域作用于最内层元素"的语义。测试(compileStyle.spec.ts:38-41)验证 h1 .foo → h1 .foo[data-v-test]。

但注意 :deep 遇 return false 中断,所以 :deep 之前 那个锚点得以保留------这就是 10.7.4(b) 里属性插在 :deep 前的原理。

10.7.8 收尾:Nesting 声明提升与属性注入

遍历后的收尾(pluginScoped.ts:292-340)分四段:

(1)CSS Nesting 声明提升 (pluginScoped.ts:296-306):

ts 复制代码
if (rule.nodes.some(node => node.type === 'rule')) {
  const deep = (rule as any).__deep
  if (!deep) {
    extractAndWrapNodes(rule)
    const atruleNodes = rule.nodes.filter(node => node.type === 'atrule')
    for (const atnode of atruleNodes) extractAndWrapNodes(atnode)
  }
  shouldInject = deep
}

当 rule 既有声明又有嵌套 rule 时(原生 CSS Nesting),PostCSS 的 rule 无法同时承载"选择器 + 声明 + 子 rule"。extractAndWrapNodes(pluginScoped.ts:408-423):

ts 复制代码
function extractAndWrapNodes(parentNode: Rule | AtRule) {
  if (!parentNode.nodes) return
  const nodes = parentNode.nodes.filter(
    node => node.type === 'decl' || node.type === 'comment',
  )
  if (nodes.length) {
    for (const node of nodes) parentNode.removeChild(node)
    const wrappedRule = new Rule({ nodes, selector: '&' })
    parentNode.prepend(wrappedRule)
  }
}

把声明与注释 从父 rule 里"抽"出来,包进一个 selector: '&' 的子 rule。于是:

css 复制代码
h1 { color: red; .foo { color: red; } }

被重写成:

css 复制代码
h1 { &[data-v-test] { color: red; } .foo[data-v-test] { color: red; } }

& 是 CSS Nesting 的父选择器引用,它自身经过 rewriteSelector 会被加入 [data-v-test](测试 compileStyle.spec.ts:63-65 验证)。atrule(如嵌套 @media)内部也做同样提升(测试 compileStyle.spec.ts:82-97)。

关键 :shouldInject = deep------如果当前 rule 是 deep,就不给这个"容器 rule"注入 (&[id] 只应出现在提升出来的子 rule 上,容器本身不需要)。

(2):is/:where 锚点的子选择器递归 (pluginScoped.ts:308-316):

ts 复制代码
if (node && !hasNestedDeep) {
  const { type, value } = node
  if (type === 'pseudo' && (value === ':is' || value === ':where')) {
    node.nodes.forEach(value => rewriteSelector(id, rule, value, selectorRoot, deep, slotted))
    shouldInject = false
  }
}

若锚点本身是 :is/:where(如 :is(.foo) 单独出现),则对内部各选择器递归注入 (因为属性应注入到内部实体),并 shouldInject = false。测试(compileStyle.spec.ts:246-254)验证 :is(.foo) → :is(.foo[data-v-test])、:where(.foo, .bar) → :where(.foo[data-v-test], .bar[data-v-test])。

但复合选择器 .div:where(.foo:hover) 有特殊处理(pluginScoped.ts:281-289 里 !node 条件)→ 锚点选 .div 而非 :where,于是 .div:where(.foo:hover) → .div[data-v-test]:where(.foo:hover)(测试 compileStyle.spec.ts:263-302)。这就是为什么 10.7.7 的锚点规则要区分"单独 :is/:where"与"复合选择器里的 :is/:where"。

(3)空白归一 (pluginScoped.ts:318-325):

ts 复制代码
if (node) {
  (node as selectorParser.Node).spaces.after = ''
} else {
  // For deep selectors & standalone pseudo selectors,
  // the attribute selectors are prepended rather than appended.
  selector.first.spaces.before = ''
}

锚点存在时,清空锚点后的空格(因为马上要插属性)。锚点为 null(属性要前置)时,清空第一个节点的前导空格 ,避免产出 [data-v-test]::selection 这种带前导空格的选择器。测试 compileStyle.spec.ts:118-122 验证 .abc, ::selection → .abc[data-v-test], [data-v-test]::selection。

(4)真正注入 (pluginScoped.ts:327-340):

ts 复制代码
if (shouldInject) {
  const idToAdd = slotted ? id + '-s' : id
  selector.insertAfter(
    node as any,   // node 为 null 时 insertAfter 会插到开头
    selectorParser.attribute({
      attribute: idToAdd,
      value: idToAdd,
      raws: {},
      quoteMark: `"`,
    }),
  )
}

idToAdd 根据 slotted 选择 id 或 id + '-s'(10.7.4(c) 递归传入)。insertAfter(null, ...) 的语义是"插到选择器最前"。quoteMark: '"' 保证输出 [data-v-test] 带引号(与 postcss-selector-parser 的默认输出风格一致)。

10.7.9 选择器改写规则全表

把上面所有分支汇总成一张可供查阅的规则表(id = data-v-test):

输入选择器 输出 触发分支
h1 h1[data-v-test] 普通节点锚点
.foo .bar .foo .bar[data-v-test] 锚点=最后一个实体
.foo, .bar .foo[data-v-test], .bar[data-v-test] 逐 selector 独立
.foo::after .foo[data-v-test]::after 伪元素在锚点后
::selection [data-v-test]::selection 无锚点→前置
* [data-v-test] universal 独支
* .foo .foo[data-v-test] universal 前导删除
.foo * .foo[data-v-test] * universal 保留
:deep(.foo) [data-v-test] .foo :deep+前置空格
.a ::v-deep(.b) .a[data-v-test] .b :deep 函数式
::v-deep .b [data-v-test] .b :deep 组合器(弃用警告)
>>> .b / /deep/ .b [data-v-test] .b 组合器(弃用警告)
:slotted(.foo) .foo[data-v-test-s] :slotted 递归
.x :slotted(.foo) .x .foo[data-v-test-s] :slotted 递归
:global(.foo) .foo :global 替换
.x ::v-global(.y) .y :global 整体替换
:is(.foo) :is(.foo[data-v-test]) :is 递归
:is(.foo, .bar) div :is(.foo, .bar) div[data-v-test] 复合锚点
:is(:deep(.foo), .bar) .baz :is([data-v-test] .foo) .baz, :is(.bar) .baz[data-v-test] 分支拆分
:where(:deep(.foo)) .bar :where([data-v-test] .foo) .bar 容器递归
:not(:deep(.foo)) .bar :not([data-v-test] .foo) .bar :not 不拆分
:has(:deep(.foo), .bar) .baz :has([data-v-test] .foo) .baz, :has(.bar) .baz[data-v-test] :has 可拆分
.div:where(:hover) .div[data-v-test]:where(:hover) 复合选择器锚点
@keyframes color @keyframes color-test(并改写引用) AtRule+OnceExit
@media print { .foo } @media print { .foo[data-v-test] } atrule 内 rule 正常处理
h1 { color:red; .foo {} } h1 { &[data-v-test]{} .foo[data-v-test]{} } Nesting 提升
@keyframes x { from {} } 不变 keyframes 内跳过

10.8 pluginCssVars:把 v-bind(foo) 编译为 CSS 自定义属性

源码结构提示 :任务大纲中提到的 style/pluginCssVars.ts 在 3.5.44 中不存在 。实际的 v-bind() → CSS 自定义属性插件 cssVarsPlugin 定义在 packages/compiler-sfc/src/style/cssVars.ts:273-301,与变量名生成、useCssVars 代码生成同处一个文件。下文按源码为准。

10.8.1 cssVarsPlugin:一个 Declaration 访问器

ts 复制代码
export const cssVarsPlugin: PluginCreator<CssVarsPluginOptions> = opts => {
  const { id, isProd } = opts!
  return {
    postcssPlugin: 'vue-sfc-vars',
    Declaration(decl) {
      // rewrite CSS variables
      const value = decl.value
      if (vBindRE.test(value)) {
        vBindRE.lastIndex = 0
        let transformed = ''
        let lastIndex = 0
        let match
        while ((match = vBindRE.exec(value))) {
          const start = match.index + match[0].length
          const end = lexBinding(value, start)
          if (end !== null) {
            const variable = normalizeExpression(value.slice(start, end))
            transformed +=
              value.slice(lastIndex, match.index) +
              `var(--${genVarName(id, variable, isProd)})`
            lastIndex = end + 1
          }
        }
        decl.value = transformed + value.slice(lastIndex)
      }
    },
  }
}

(cssVars.ts:273-301。)

逐行要点:

  1. 只挂在 Declaration 上 :因为 v-bind() 只出现在 CSS 值 里(如 color: v-bind(color)),不会出现在选择器或 at-rule 参数中。挂在 declaration 上是"精确打击",避免误改选择器。
  2. vBindRE = /v-bind\s*\(/g(cssVars.ts:64) :全局正则,匹配 v-bind( 且允许中间有空格(v-bind (x))。
  3. vBindRE.test(value) 后必须 vBindRE.lastIndex = 0 :因为 test() 在全局正则上会推进 lastIndex,直接进入 while (exec) 循环会从错误位置开始。这是一个全局正则复用的经典陷阱,Vue 用显式重置规避。
  4. lexBinding(value, start) 从 v-bind( 之后的字符开始,用一个状态机 找到匹配的 )(10.8.3),从而正确处理 v-bind('font.size')、v-bind(foo(bar)) 这类嵌套/带引号的情形。
  5. normalizeExpression (cssVars.ts:53-62)剥掉最外层成对的单/双引号:v-bind('font.size') → font.size。
  6. transformed += value.slice(lastIndex, match.index) + 'var(--xxx)' :把"上一次结束到本次匹配"之间的原文 + 替换后的 var(--xxx) 追加;lastIndex = end + 1(跳过 ))。这是一个"边扫描边拼接"的替换器,而非 replace 回调 ------因为要支持同一行多个 v-bind()。

注意一个易错点 :如果 lexBinding 返回 null(未闭合的 v-bind(),该匹配被跳过,transformed 不追加、lastIndex 不推进。但 while 循环的 exec 仍会继续------因为 lastIndex 已被 exec 推进,不会死循环。这保证了畸形输入下插件不至于卡死。

10.8.2 变量名生成 genVarName

cssVars.ts:36-51:

ts 复制代码
function genVarName(id: string, raw: string, isProd: boolean, isSSR = false): string {
  if (isProd) {
    // hash must not start with a digit to comply with CSS custom property naming rules
    return hash(id + raw).replace(/^\d/, r => `v${r}`)
  } else {
    // escape ASCII Punctuation & Symbols
    // #7823 need to double-escape in SSR because the attributes are rendered
    // into an HTML string
    return `${id}-${getEscapedCssVarName(raw, isSSR)}`
  }
}

两条路径:

  • 生产模式 :hash(id + raw)(hash-sum 实现)→ 8 位十六进制串;若首字符是数字,替换为 v<digit>(CSS 自定义属性名不允许以数字开头 )。测试(cssVars.spec.ts:102-114)验证 color → v4003f1a6、font.size → v41b6490a。生产模式哈希的好处:体积小 + 避免表达式原文泄露到 CSS 里 (例如 v-bind(foo.bar.baz) 的生产产物不含 foo.bar.baz)。
  • 开发模式 :${id}-${escaped(raw)},例如 test-color、test-font\.size。注意 . 被转义成 \.,因为 CSS 自定义属性名里的 . 需要转义(测试 cssVars.spec.ts:90-99 验证 font-size: var(--test-font\\.size))。

id 已经在 cssVarsPlugin 调用处是 shortId (compileStyle.ts:114 传 id: shortId)。所以开发模式变量名是 <shortId>-<变量名>,与 10.5 的 shortId 一致。

getEscapedCssVarName 来自 @vue/shared,负责转义 ASCII 标点符号与 #7823 里提到的"SSR 需要双重转义"(因为属性值被渲染进 HTML 字符串,\ 需要再转义一次)。测试(cssVars.spec.ts:83-86)验证了 _φ、1-字号、フォント 等 Unicode 变量名的转义(_φ、1-字号 里 ASCII 部分被转义但中文/希腊字母保留)。

10.8.3 lexBinding 状态机

cssVars.ts:224-265:

ts 复制代码
enum LexerState { inParens, inSingleQuoteString, inDoubleQuoteString }

function lexBinding(content: string, start: number): number | null {
  let state: LexerState = LexerState.inParens
  let parenDepth = 0
  for (let i = start; i < content.length; i++) {
    const char = content.charAt(i)
    switch (state) {
      case LexerState.inParens:
        if (char === `'`) state = LexerState.inSingleQuoteString
        else if (char === `"`) state = LexerState.inDoubleQuoteString
        else if (char === `(`) parenDepth++
        else if (char === `)`) {
          if (parenDepth > 0) parenDepth--
          else return i
        }
        break
      case LexerState.inSingleQuoteString:
        if (char === `'`) state = LexerState.inParens
        break
      case LexerState.inDoubleQuoteString:
        if (char === `"`) state = LexerState.inParens
        break
    }
  }
  return null
}

算法 :从 v-bind( 后一字符开始,逐字符推进,维护:

  • parenDepth:嵌套括号深度(应对 v-bind(querySelector('a:not(.b)')) 这类含括号的表达式);
  • 三态字符串状态(应对 v-bind("a'b") 这类引号内含引号)。

遇到 深度为 0 的 ) 时返回其下标;扫描完仍未闭合返回 null。注意字符串状态里不处理转义 ------这是一个已知的简化:v-bind('a\'b') 这类带转义引号的表达式会解析错误。但对 CSS 变量这一用途,绝大多数表达式是 simple.path 或 'quoted.path',简化是可接受的取舍。

10.8.4 parseCssVars:编译期扫描出全部变量

模板/脚本侧也需要知道"这个 SFC 用了哪些 CSS 变量",以便生成 useCssVars 的 getter。parseCssVars(cssVars.ts:66-83):

ts 复制代码
export function parseCssVars(sfc: SFCDescriptor): string[] {
  const vars: string[] = []
  sfc.styles.forEach(style => {
    let match
    const content = stripComments(style.content)
    while ((match = vBindRE.exec(content))) {
      const start = match.index + match[0].length
      const end = lexBinding(content, start)
      if (end !== null) {
        const variable = normalizeExpression(content.slice(start, end))
        if (!vars.includes(variable)) vars.push(variable)
      }
    }
  })
  return vars
}

它与 cssVarsPlugin 用的是同一套 vBindRE + lexBinding + normalizeExpression,保证"扫描出的变量"与"样式里替换的变量"完全一致。区别在于:

  • parseCssVars 遍历所有 <style> 块 (不限 scoped),去重收集变量名;
  • 调用的位置是 parse.ts(产出 SFCDescriptor.cssVars),供 compileScript 在生成 useCssVars 时使用,以及 compileTemplate 在 SSR 时作为 ssrCssVars。

为什么要 stripComments? 因为注释里的 v-bind(...) 不应被当作真实变量。stripComments(cssVars.ts:117-152)是一个手写的 CSS 词法扫描器:它跳过 /* */ 块注释、// 行注释(Less/Sass/Stylus 支持),但保留字符串与 url() 里的内容 (因为它们可能合法地包含注释分隔符),并正确处理转义。配套的 skipString/isUrlFunction/skipUrl/isIdentChar 等函数(cssVars.ts:156-222)共同构成一个"足够局部"的 CSS 注释剥离器。这是 v-bind() 解析正确性的重要保障------如果注释里的 v-bind(x) 被算进变量表,运行时会尝试读取一个不存在的绑定。

10.8.5 从 v-bind(x) 到 var(--xxx):闭环

CSS 侧插件把 v-bind(color) 变成 var(--test-color)(开发)或 var(--v4003f1a6)(生产)。但 var(--xxx) 需要有人在运行时给元素设置这个自定义属性 。这一步由 useCssVars 完成(10.9),而它的 getter 由 genCssVarsCode 生成,变量名同样走 genVarName------两侧必须用同一个 genVarName,否则 var() 找不到值 。这是 cssVars.ts 把插件、扫描、代码生成三者放在同一个文件的原因:共享 genVarName 与常量 CSS_VARS_HELPER,从结构上保证一致。


10.9 style/cssVars.ts:useCssVars 运行时注入与 SSR 差异

10.9.1 genCssVarsFromList:把变量列表变成对象表达式

cssVars.ts:17-34:

ts 复制代码
export function genCssVarsFromList(
  vars: string[],
  id: string,
  isProd: boolean,
  isSSR = false,
): string {
  return `{\n  ${vars
    .map(
      key =>
        // The `:` prefix here is used in `ssrRenderStyle` to distinguish whether
        // a custom property comes from `ssrCssVars`. ...
        `"${isSSR ? `:--` : ``}${genVarName(id, key, isProd, isSSR)}": (${key})`,
    )
    .join(',\n  ')}\n}`
}

输出形如:

js 复制代码
{
  "test-color": (color),
  "test-font\\.size": (font.size)
}

注意 isSSR 会给 key 加 :-- 前缀 (":--test-color"),这是 10.9.3 要讲的 SSR 专属机制。

10.9.2 genCssVarsCode:生成 getter 并做表达式前缀化

cssVars.ts:303-329:

ts 复制代码
export function genCssVarsCode(
  vars: string[],
  bindings: BindingMetadata,
  id: string,
  isProd: boolean,
) {
  const varsExp = genCssVarsFromList(vars, id, isProd)
  const exp = createSimpleExpression(varsExp, false)
  const context = createTransformContext(createRoot([]), {
    prefixIdentifiers: true,
    inline: true,
    bindingMetadata: bindings.__isScriptSetup === false ? undefined : bindings,
  })
  const transformed = processExpression(exp, context)
  const transformedString = /* ... 拼成字符串 ... */
  return `_${CSS_VARS_HELPER}(_ctx => (${transformedString}))`
}

要点:

  1. 借用 compiler-dom 的 processExpression :把变量值表达式(如 color、font.size)通过 compiler-core 的表达式前缀化机制改写。这就是为什么 <script setup> 里 color 会变成 color、size(ref)会变成 size.value、foo(prop)会变成 __props.foo 。测试 cssVars.spec.ts:44-75 验证:
js 复制代码
_useCssVars(_ctx => ({
  "${mockId}-color": (color),
  "${mockId}-size": (size.value),
  "${mockId}-foo": (__props.foo)
}))
  1. bindingMetadata: bindings.__isScriptSetup === false ? undefined : bindings :只有 <script setup>(__isScriptSetup !== false)才用绑定元数据做前缀优化;普通 <script> 的导出对象无法提供准确的绑定元数据,退回 _ctx 代理(_ctx => (_ctx.color))。
  2. inline: true:与 10.2 的内联模式一致,让表达式按内联(局部变量)语义前缀化。
  3. 最终包成 _useCssVars(_ctx => ({...})) :CSS_VARS_HELPER = 'useCssVars',_ 前缀是 helper 的惯例。

10.9.3 SSR 差异:: 前缀与 initial 重置

SSR 时 genCssVarsFromList(..., isSSR = true) 会把 key 变成 ":--xxx"。这个前导冒号不是给 CSS 的,是给 ssrRenderStyle 的标记 (cssVars.ts:24-28 注释):

The : prefix here is used in ssrRenderStyle to distinguish whether a custom property comes from ssrCssVars. If it does, we need to reset its value to initial on the component instance to avoid unintentionally inheriting the same property value from a different instance of the same component in the outer scope.

对应实现 server-renderer/src/helpers/ssrRenderAttrs.ts:104-129:

ts 复制代码
export function ssrRenderStyle(raw: unknown): string {
  if (!raw) return ''
  if (isString(raw)) return escapeHtml(raw)
  const styles = normalizeStyle(ssrResetCssVars(raw))
  return escapeHtml(stringifyStyle(styles))
}

function ssrResetCssVars(raw: unknown) {
  if (!isArray(raw) && isObject(raw)) {
    const res: Record<string, unknown> = {}
    for (const key in raw) {
      // `:` prefixed keys are coming from `ssrCssVars`
      if (key.startsWith(':--')) {
        res[key.slice(1)] = normalizeCssVarValue(raw[key])
      } else {
        res[key] = raw[key]
      }
    }
    return res
  }
  return raw
}

问题背景 :SSR 是"一次性字符串渲染",同一个组件的多个实例可能渲染在同一页面上。如果某个实例没有为 --xxx 赋值(或值来自父作用域),CSS 自定义属性的继承性 会导致子实例"意外继承"外层同名变量的值。用 :-- 前缀标记"这是 SSR CSS 变量",ssrResetCssVars 在序列化时把它转成真正的 --xxx,并在适当时机重置为 initial,切断继承链。

SSR 侧的注入链路(compiler-ssr):

  1. ssrCodegenTransform(compiler-ssr/src/ssrCodegenTransform.ts:44-59)在 render 函数体开头插入:
ts 复制代码
if (options.ssrCssVars) {
  const cssContext = createTransformContext(createRoot([]), options)
  const varsExp = processExpression(createSimpleExpression(options.ssrCssVars, false), cssContext)
  context.body.push(createCompoundExpression([`const _cssVars = { style: `, varsExp, `}`]))
  Array.from(cssContext.helpers.keys()).forEach(helper => ast.helpers.add(helper))
}

即 const _cssVars = { style: { ":--test-color": (color) } }。

  1. ssrInjectCssVars(compiler-ssr/src/transforms/ssrInjectCssVars.ts:12-67)给每个根级元素注入 v-bind="_cssVars" ,于是 ssrRenderAttrs 会把它合并进 style 属性。测试 compiler-ssr/__tests__/ssrInjectCssVars.spec.ts:8-120 验证了 ssrCssVars: \{ color }`与{ "--hash": (dynamic) }` 两种输入。

  2. _cssVars 只在 render 函数开头求值一次 (ssrCodegenTransform.ts:44-46 注释:"we do this instead of inlining the expression to ensure the vars are only resolved once per render")------这是性能考量:避免每个元素都重复求值。

  3. SSR 下 useCssVars 不执行 :useCssVars 第一行 if (!__BROWSER__ && !__TEST__) return(useCssVars.ts:23)------服务端直接短路。这解释了 10.1.3 里 compileTemplate 的 ssr && ssrCssVars 分支单独处理 SSR 变量的原因 :SSR 不能依赖 useCssVars,必须在模板 codegen 阶段注入。

10.9.4 useCssVars 运行时注入完整链路

packages/runtime-dom/src/helpers/useCssVars.ts:20-67:

ts 复制代码
export function useCssVars(getter: (ctx: any) => Record<string, unknown>): void {
  if (!__BROWSER__ && !__TEST__) return

  const instance = getCurrentInstance()
  if (!instance) {
    __DEV__ && warn(`useCssVars is called without current active component instance.`)
    return
  }

  const updateTeleports = (instance.ut = (vars = getter(instance.proxy)) => {
    Array.from(document.querySelectorAll(`[data-v-owner="${instance.uid}"]`)).forEach(node =>
      setVarsOnNode(node, vars))
  })

  if (__DEV__) instance.getCssVars = () => getter(instance.proxy)

  const setVars = () => {
    const vars = getter(instance.proxy)
    if (instance.ce) {
      setVarsOnNode(instance.ce as any, vars)
    } else {
      setVarsOnVNode(instance.subTree, vars)
    }
    updateTeleports(vars)
  }

  // handle cases where child component root is affected and triggers reflow in onMounted
  onBeforeUpdate(() => { queuePostFlushCb(setVars) })

  onMounted(() => {
    // run setVars synchronously here, but run as post-effect on changes
    watch(setVars, NOOP, { flush: 'post' })
    const ob = new MutationObserver(setVars)
    ob.observe(instance.subTree.el!.parentNode, { childList: true })
    onUnmounted(() => ob.disconnect())
  })
}

逐步拆解:

  1. getter(instance.proxy) :proxy 是组件公共实例的代理;getter 里的 _ctx 正是这个代理。读取变量表达式即触发响应式追踪。
  2. instance.ut(updateTeleports) :把 teleport 内容的更新函数挂到实例上。因为 <Teleport> 的内容渲染在别的 DOM 位置,无法通过 subTree 遍历触达,所以用 [data-v-owner="${instance.uid}"] 选择器直接查 DOM 并设值。
  3. setVars :
    • instance.ce 存在(自定义元素模式)→ 直接 setVarsOnNode(instance.ce, vars);
    • 否则 setVarsOnVNode(instance.subTree, vars)。
  4. 更新时机 :
    • onBeforeUpdate(() => queuePostFlushCb(setVars)):组件更新前排队一个 post-flush 回调(确保 DOM 已更新后再设变量);
    • onMounted:同步 跑一次 setVars(首帧就要有值),并建立 watch(setVars, NOOP, { flush: 'post' })(响应式变量变化后在 flush 后重设)以及 MutationObserver(setVars)(监听子树 childList 变化------例如条件渲染插入新节点、动画等 DOM 变动导致的变量重设)。onUnmounted 断开 observer。
  5. instance.getCssVars(DEV only):给 devtools 提供只读快照。

为什么需要 MutationObserver? 因为 CSS 变量必须写在真实的叶子 DOM 节点 上,而 vnode 树可能因 v-if/v-for/Fragment 而动态变化。仅靠响应式 watch 无法感知"纯 DOM 结构变化(vnode 未变但元素被替换)"。MutationObserver 兜底保证新插入的节点也能拿到变量。

setVarsOnVNode:向下钻取到叶子元素

useCssVars.ts:69-97:

ts 复制代码
function setVarsOnVNode(vnode: VNode, vars: Record<string, unknown>) {
  if (__FEATURE_SUSPENSE__ && vnode.shapeFlag & ShapeFlags.SUSPENSE) {
    const suspense = vnode.suspense!
    vnode = suspense.activeBranch!
    if (suspense.pendingBranch && !suspense.isHydrating) {
      suspense.effects.push(() => setVarsOnVNode(suspense.activeBranch!, vars))
    }
  }

  // drill down HOCs until it's a non-component vnode
  while (vnode.component) {
    vnode = vnode.component.subTree
  }

  if (vnode.shapeFlag & ShapeFlags.ELEMENT && vnode.el) {
    setVarsOnNode(vnode.el as Node, vars)
  } else if (vnode.type === Fragment) {
    ;(vnode.children as VNode[]).forEach(c => setVarsOnVNode(c, vars))
  } else if (vnode.type === Static) {
    let { el, anchor } = vnode
    while (el) {
      setVarsOnNode(el as Node, vars)
      if (el === anchor) break
      el = el.nextSibling
    }
  }
}

四种情形:

  • Suspense :取 activeBranch(当前展示的分支);若还在 pending,把"重设"挂进 suspense.effects 队列(等 resolve 后执行)。
  • 组件 vnode :while (vnode.component) vnode = vnode.component.subTree------一直钻到子组件的实际 DOM 根 (HOC/包装组件的情况),直到遇到非组件 vnode。这也说明 useCssVars 注入的变量会落到组件子树的真实根元素上。
  • 元素 vnode :直接 setVarsOnNode(vnode.el, vars)。
  • Fragment:递归每个 child。
  • Static(静态提升节点) :Static 类型代表被提升/字符串化的一整段 DOM,其 el 到 anchor 之间的所有兄弟节点 都要设值------于是用 while (el) { setVarsOnNode(el); if (el === anchor) break; el = el.nextSibling } 遍历。
setVarsOnNode:写入内联样式

useCssVars.ts:99-109:

ts 复制代码
function setVarsOnNode(el: Node, vars: Record<string, unknown>) {
  if (el.nodeType === 1) {
    const style = (el as HTMLElement).style
    let cssText = ''
    for (const key in vars) {
      const value = normalizeCssVarValue(vars[key])
      style.setProperty(`--${key}`, value)
      cssText += `--${key}: ${value};`
    }
    ;(style as any)[CSS_VAR_TEXT] = cssText
  }
}
  • el.nodeType === 1:只对元素节点设值(跳过文本/注释)。
  • normalizeCssVarValue (@vue/shared):把 null/undefined 等归一化成 initial 之类的合法 CSS 值,避免写入非法内容。
  • style.setProperty('--key', value) :用 setProperty 而非属性赋值,因为自定义属性名含 - 前缀无法用属性语法。
  • (style as any)[CSS_VAR_TEXT] = cssText :把一个符号键(CSS_VAR_TEXT)挂到 style 对象上,缓存本次写入的 CSS 文本 。这个符号被 runtime-dom 的 style patch 逻辑用于diff 时跳过重复设置(避免每次更新都重设全部变量)。

10.9.5 变量名哈希与两侧一致性

综合 10.8.2 与 10.9,变量名生成有两条必须同步的路径:

侧 调用 参数
CSS 侧(cssVarsPlugin) genVarName(id, variable, isProd) id = shortId
JS 侧(genCssVarsFromList) genVarName(id, key, isProd, isSSR) id = shortId

二者用的 id 都是 shortId (compileStyle.ts:114 传 shortId;compileScript 生成时用 scopeId------即剥掉 data-v- 的部分,见 compileScript.ts:182)。生产模式都走 hash(id + raw),开发模式都走 ${id}-${escaped}。只要一处不一致(如一个用 longId、一个用 shortId),var(--xxx) 就会指向一个从未被赋值的自定义属性,样式静默失效 ------这是排查 v-bind() 不生效时首先要检查的点。


10.10 compileStyle 的 options 全表

SFCStyleCompileOptions(compileStyle.ts:20-37)与 SFCAsyncStyleCompileOptions(compileStyle.ts:53-59):

选项 类型 / 默认 作用
source string <style> 块内容
filename string PostCSS to/from、错误定位、source map
id string 组件 id;内部剥成 shortId,用于 [data-v-<shortId>] 与变量名
scoped boolean = false 是否追加 scopedPlugin(longId)
trim boolean = true 是否追加 trimPlugin,规整 raws.before/after 换行
isProd boolean = false 传给 cssVarsPlugin,决定变量名是否哈希
inMap RawSourceMap? 上游 map(优先于 map),作为 PostCSS map.prev
preprocessLang `'less' 'sass'
preprocessOptions any? 传预处理器的选项(含 additionalData)
preprocessCustomRequire (id)=>any? 自定义 require(浏览器/链接安装)
postcssOptions any? 与内建合并的 PostCSS ProcessOptions
postcssPlugins any[]? 用户插件(夹在内建 cssVars 与 trim 之间)
map RawSourceMap? 已废弃 ,请用 inMap
modules boolean = false 异步专用:启用 CSS Modules
modulesOptions CSSModulesOptions? 透传 postcss-modules(scopeBehaviour/generateScopedName/hashPrefix/localsConvention 等)

CSS Modules 支持路径 :modules: true → 强制 compileStyleAsync → postcss-modules 的 getJSON 回调把 { 本地类名: 哈希 } 写入 results.modules → 构建工具把它作为默认导出或 useCssModule() 的输入。compileStyle(同步)遇到 modules 直接抛错(compileStyle.ts:128-132)。


10.11 产物与集成:三块产物如何合成一个组件模块

10.11.1 三部分产物

一个 SFC 经 compiler-sfc 会产出三块:

块 入口 产物
<script> / <script setup> compileScript 组件选项对象({ setup, __name, props, ... })+ __scopeId/__cssModules 等运行时元数据
<template> compileTemplate render 函数(非 inline)或内联进 setup 的箭头函数(inline)
<style> compileStyle 改写后的 CSS 字符串(+ modules 映射)

10.11.2 非 inline:三个独立模块 + 汇总模块

vite-plugin-vue / vue-loader 的惯用做法是把三块拆成三个虚拟模块,再用一个"主模块"汇总:

js 复制代码
// App.vue(主模块,由插件生成)
import { render } from './App.vue?vue&type=template'
import script from './App.vue?vue&type=script'
script.render = render
script.__scopeId = 'data-v-7ba5bd90'        // ← 10.5 的 __scopeId 在此写入
script.__cssModules = styles                 // 若用了 CSS Modules
export default script

关键点 :__scopeId 不是 compiler-sfc 产出的,而是插件在汇总阶段手动挂到组件选项上的(10.5.3)。这样拆分能让 template 独立 HMR(改模板不动脚本),代价是多一次模块拼接。

10.11.3 inline:单一模块

生产构建用 compileScript({ inlineTemplate: true }):compileTemplate 的 code 作为 setup() 返回值、preamble 前置到模块顶部,__scopeId 仍由插件追加。产物体积更小、少一层函数调用(10.2.3),代价是模板不能独立 HMR(这也是它只在生产启用)。

10.11.4 vite-plugin-vue 的调用顺序

典型顺序(概念层,非本章仓库内容):

  1. parse(source) :得到 SFCDescriptor(含 template/script/styles/cssVars);
  2. compileScript(descriptor, { id, inlineTemplate }) :产出脚本模块。它内部会:(a) 若 inline,则调用 compileTemplate 并把产物内联;(b) 生成 useCssVars 调用(genCssVarsCode);
  3. (非 inline 时)compileTemplate(...) :产出 render 模块;
  4. 对每个 <style> 调 compileStyle / compileStyleAsync :产出 CSS 模块(scoped 需先扫描是否含 :slotted() 以决定 slotted 选项);
  5. 汇总 :把 render、__scopeId、__cssModules 挂到脚本默认导出上。

顺序上的依赖 :compileScript 必须先于内联 compileTemplate(要拿到 bindingMetadata 做前缀优化);slotted 的检测必须先于 compileTemplate(它要写进编译选项);__scopeId 的写入必须在 compileScript 之后(它由插件在 descriptor 已知 scoped 后才决定)。

10.11.5 resolveTemplateAST 与缓存

parse 有 parseCache(index.ts:13 导出)。compileTemplate 复用 descriptor AST 时走 resolveTemplateAST 的"已 transform 就重解析"分支(10.1.4),避免复用被污染的状态------这是缓存友好 与正确性之间的权衡。


10.12 实战:一个含 scoped / :deep / v-bind() 的 SFC

给定 SFC(id = 7ba5bd90):

vue 复制代码
<template>
  <div class="wrapper">
    <p class="msg">{{ msg }}</p>
    <div class="child-wrap"><ChildComp /></div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
const msg = 'hi'
const theme = ref('red')
</script>

<style scoped>
.wrapper { color: v-bind(theme); }
.msg { font-weight: bold; }
.child-wrap :deep(.inner) { color: blue; }
</style>

10.12.1 template 编译产物(节选,非 inline)

js 复制代码
import { openBlock as _openBlock, createElementBlock as _createElementBlock,
  toDisplayString as _toDisplayString, createVNode as _createVNode,
  resolveComponent as _resolveComponent, createElementVNode as _createElementVNode } from "vue"

export function render(_ctx, _cache) {
  const _component_ChildComp = _resolveComponent("ChildComp")
  return (_openBlock(), _createElementBlock("div", { class: "wrapper" }, [
    _createElementVNode("p", { class: "msg" }, _toDisplayString(_ctx.msg), 1 /* TEXT */),
    _createElementVNode("div", { class: "child-wrap" }, [
      _createVNode(_component_ChildComp)
    ])
  ]))
}

逐段解读:

  • _resolveComponent("ChildComp"):<ChildComp> 是运行时解析的组件。
  • .wrapper / .msg / .child-wrap 三个元素都带 data-v-7ba5bd90 (DOM 属性),但它们不在 render 代码里 ------因为它们由运行时 setScopeId 附着(vnode.scopeId = currentScopeId)。这是最容易误判的地方 :compileTemplate(scoped) 的产物里看不到 data-v- 字符串,只有 scopeId 作为编译选项影响 currentScopeId 的最终附着。
  • 1 /* TEXT */:patchFlag 表示该 p 是动态文本,需要 diff 文本。

10.12.2 非 inline 的 script 产物(节选)

js 复制代码
import { ref, useCssVars as _useCssVars } from 'vue'
export default {
  setup() {
    const msg = 'hi'
    const theme = ref('red')
    _useCssVars(_ctx => ({ "7ba5bd90-theme": (theme.value) }))
    return { msg, theme }
  }
}

解读 :_useCssVars 的 key 是 <shortId>-theme(开发模式,10.8.2),值是 theme.value(ref 解包,10.9.2)。插件随后追加 __scopeId = 'data-v-7ba5bd90'。

10.12.3 style 编译产物

输入:

css 复制代码
.wrapper { color: v-bind(theme); }
.msg { font-weight: bold; }
.child-wrap :deep(.inner) { color: blue; }

输出(scoped: true,plugins = [cssVars, trim, scoped]):

css 复制代码
.wrapper[data-v-7ba5bd90] { color: var(--7ba5bd90-theme); }
.msg[data-v-7ba5bd90] { font-weight: bold; }
.child-wrap[data-v-7ba5bd90] .inner { color: blue; }

逐段解读:

  1. .wrapper :cssVarsPlugin 先把值 v-bind(theme) → var(--7ba5bd90-theme);scopedPlugin 再把选择器 .wrapper → .wrapper[data-v-7ba5bd90]。注意插件顺序:先值后选择器(10.6.3)。
  2. .msg:纯 scoped 改写。
  3. .child-wrap :deep(.inner) :processRule 遍历到 :deep 伪类 → 打 __deep、把 .inner 搬出、在 :deep 前补空格组合器、删除 :deep、return false 中断(10.7.4b)。锚点停在 .child-wrap,属性插其后 → .child-wrap[data-v-7ba5bd90] .inner。这就是"穿透子组件"的写法:作用域只锚在父元素上,.inner 不受 [data-v] 约束。
  4. 变量闭环 :var(--7ba5bd90-theme)(CSS 侧)↔ "7ba5bd90-theme": (theme.value)(JS 侧)------两侧 shortId 与变量名一致,运行时 useCssVars 把该自定义属性写到 DOM,样式生效。

10.12.4 运行时时间线

sequenceDiagram participant R as render(render函数) participant V as createVNode participant M as mountElement participant C as useCssVars R->>V: 创建 .wrapper vnode V->>V: vnode.scopeId = currentScopeId('data-v-7ba5bd90') V->>M: mountElement M->>M: setScopeId → el.setAttribute('data-v-7ba5bd90','') C->>C: onMounted → setVars → getter(proxy) C->>M: setVarsOnVNode → style.setProperty('--7ba5bd90-theme','red')

三条链路(scopeId、变量、样式)在同一个 DOM 元素上汇聚:.wrapper 同时拥有 data-v-7ba5bd90 属性与 --7ba5bd90-theme 内联自定义属性,于是 .wrapper[data-v-...]{ color: var(--...) } 命中。

10.12.5 常见误配与排查

现象 根因 定位
scoped 样式不生效 模板元素没有 data-v- 检查 __scopeId 是否被插件写入、compileTemplate 是否传了 scoped
:deep 命中过头 .a :deep(.b) 写成 .a :deep .b(弃用组合器) 用函数式 :deep(.b)
v-bind() 无值 两侧变量名不一致(longId/shortId 混用) 对比 CSS var(--x) 与 JS getter 的 key
slot 内容 scoped 不命中 用了 :slotted() 但 slotted 传了 false 检查工具链的 :slotted 扫描
CSS Modules 报错 同步入口用 modules 改用 compileStyleAsync

10.13 本章要点回顾

  1. compileTemplate 是编排层 (compileTemplate.ts:105-264):选择编译器(dom/ssr)、映射选项(scopeId/ssrCssVars/hmr/nodeTransforms)、复用或重解析 AST、合并 source map、把 warnings 转成带 code frame 的 tips。
  2. 选项覆盖有严格顺序 :...compilerOptions 之前是默认项(用户可覆盖 scopeId/slotted/sourceMap),之后是强制项(hmr/nodeTransforms/onError 用户不可覆盖)。
  3. 内联模式有两层开关 :compileScript.inlineTemplate(决策)+ compilerOptions.inline(形状)。收益是免 $setup 解构、可用 bindingMetadata 做前缀优化、产物体积更小;代价是模板不能独立 HMR。
  4. source map 分两段 :codegen 的 addMapping 产"code→块文本",parse 产"块文本→SFC"的 inMap,mapLines 链式合成;inMap && !inAST 时才合并(复用 AST 时 loc 本就相对原文件)。
  5. 错误有三种形态 (CompilerError/Error/string),错误码由 ErrorCodes 枚举 + DOMErrorMessages 合并词典定义;"运行时 helper 汇总"由 ast.helpers 承担(原名 needsRuntimeHelpers 不存在)。
  6. scoped 是跨四层的闭环 :compileTemplate 传 scopeId → 运行时 currentScopeId(源自 instance.type.__scopeId)→ vnode.scopeId → renderer.setScopeId 写 DOM 属性 → pluginScoped 写 [data-v-xxx] 选择器。withScopeId/pushScopeId 已是兼容残留(3.0.8 起不用)。
  7. SSR 差异 :scopeId 在字符串拼接时注入(context.scopeId / _scopeId 形参),appendScopeId 处理继承链;v-bind() 用 :-- 前缀标记并在 ssrRenderStyle 中重置为 initial;useCssVars 在服务端短路。
  8. compileStyle 是 PostCSS 管线 :插件顺序 [cssVars, ...用户, trim, scoped, modules];预处理支持 scss/sass/less/styl/stylus,additionalData 注入、dependencies 收集、map 用 merge-source-map 链式合并;modules 强制异步。
  9. pluginScoped 的核心是"找锚点→注入属性" :锚点选最后一个实体节点;:deep 中断并把属性留在其前;:slotted 递归注入 -s 后缀;:global 整体替换;:is/:where/:has 遇混合 deep 分支时拆分选择器 ;:not 不拆分;keyframes 名字加短 id 且两阶段回写 animation;CSS Nesting 声明提升为 & 子 rule。
  10. v-bind() 闭环 :cssVarsPlugin(Declaration 访问器)把值改写成 var(--name),parseCssVars 扫描变量表,genCssVarsCode 借 processExpression 生成 _useCssVars(_ctx => ({...})),运行时 useCssVars 在 mounted/watch/MutationObserver 三种时机把变量写到叶子 DOM;生产模式变量名走 hash,开发模式走 <shortId>-<escaped>,两侧必须同源。
  11. 产物合成 :非 inline 拆成 template/script 两个虚拟模块再由插件汇总(追加 __scopeId/__cssModules);inline 合并为单模块。vite-plugin-vue 的调用顺序必须保证 slotted 检测早于 compileTemplate、bindingMetadata 早于内联编译。
  12. 理解 scoped 的关键 :它不是"加个属性选择器"这么简单,而是一套编译期确定、运行时静态附着、SSR 字符串对齐 的作用域系统;data-v-xxx 属性与 [data-v-xxx] 选择器是同一枚硬币的两面,缺一不可。
相关推荐
will_net1 小时前
Vue IIS部署 api 访问当前站点 代理跳转真实API 问题
前端·javascript·vue.js
weixin_449173651 小时前
react 的useEffect用法
前端·javascript·react.js
55873 生态系统1 小时前
九层之台,生态为体 —— 论 “55873 全域文明生态系统“ 与第 1 期 2000 多册开源大典
开源·55873全域文明生态体系·55873操作系统·全域文明生态系统·55873 数据开源大典
定栓1 小时前
Webpack迁到Vite第十节:vite.config.js 开发与构建流转全链路
vue.js·webpack·vite
涛涛ing1 小时前
2026年的前端:你引以为傲的框架,正在被浏览器原生能力“架空”
前端
计算机魔术师1 小时前
23项AI技术要管了:大模型API还出口得了吗?
前端
凤城老人1 小时前
ZhyCMS成长日记:一个企业 CMS 从 1.0 到 3.1.3 的折腾史
前端