Vue 3 核心源码深度剖析系列(v3.5.44)-第 10 章 compiler-sfc(二):compileTemplate / compileStyle 与 scoped / CSS 变量闭环
本章对应
vuejs/core3.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):
- 决定用哪个编译器(
compiler-dom还是compiler-ssr); - 把 SFC 上下文的选项(
id→scopeId、scoped→longId、slotted、ssrCssVars、isProd→hmr)翻译成compiler-core的CompilerOptions; - 注入 SFC 专属的
nodeTransform(transformAssetUrl/transformSrcset)和复用<template>块的 AST; - 把
codegen产出的map与 SFCparse阶段的inMap做行映射合并 ,把errors的 offset 校正回"原始文件行号"; - 把
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 分支体略。)
这里有几个边界与易错点值得单独指出:
- 浏览器构建必须自带预处理器 。
@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。 - 为什么回调式仍能同步返回 ?
preprocess()(compileTemplate.ts:81-103)的注释给出了答案:consolidate对多数模板引擎其实是同步回调 的,而compiler-sfc需要一个同步 API ,因为 Jest 的 transform 是通过 Noderequirehook 同步应用的。所以preprocess直接调用preprocessor.render(...),在回调里把res/err写入局部变量,回调返回后再throw/return。 - 预处理后必须作废 AST :
ast: undefined(compileTemplate.ts:134)。原因很朴素------lang="pug"的源文本与<template>块文本不同,parse 阶段缓存的 AST 对应的是 pug 源码,不能复用。下方doCompileTemplate里还有另一处作废 AST 的逻辑(自定义 compiler 时),见 10.1.3。 - 缺预处理器时不是
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}) {`)
}
要点:
- 子上下文
preambleContext:inline 模式下 preamble 用一个全新的createCodegenContext生成,因此它有自己的code/map状态。函数末尾return { ..., preamble: isSetupInlined ? preambleContext.code : `` }(codegen.ts:400-405)把它作为独立字符串交回。这就是SFCTemplateCompileResults.preamble的来源。 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),而不是作为一个独立模块导出。- 箭头函数签名 :
(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/gettrap)。
非内联模式做不到这点,因为 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)
}
三个要点:
- 浏览器构建不生成 source map (
!__BROWSER__)。因为source-map-js的体积与运行开销在浏览器场景不划算。 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)。
- 直接传
_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()
}
逐步拆解:
generated坐标直接沿用newMap:生成的 JS 只被改写过一次(本次 codegen),所以"生成侧"坐标无需变化。original坐标追溯到oldMap:newMap的"原始位置"指向 template 块文本;再用oldMapConsumer.originalPositionFor把它查一次,得到.vue文件里的最终位置。column沿用m.originalColumn:注释明确说明------SFCparse产出的oldMap是"line-only"的(每行只映射到整行开头,不区分列),所以列号只能用newMap给的原始列。- 跳过
originalLine == null的映射 :这就是 10.3.1 里那些"无位置"的合成节点(v-if 注释)------它们在newMap里已经没有originalLine,自然无法继续上溯,直接丢弃。 sources/sourcesContent用_sources/sourceContentFor手动搬运 :因为mergedMapGenerator是全新的,需要把 oldMap 的源文件清单与内联内容补齐。sourceRoot/file也从 oldMap 继承。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 的三级链路
如果传入了 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 的执行顺序是:
- 建
errors/warnings两个数组,装配nodeTransforms(资源 URL)(compileTemplate.ts:175-187); ssr && !ssrCssVars→warnOnce;!id→warnOnce且id = ''(compileTemplate.ts:189-198);- 规范化
shortId/longId,选编译器(compileTemplate.ts:200-210); resolveTemplateAST或复用 AST(compileTemplate.ts:212-217);- 组装选项并调用
compiler.compile()(compileTemplate.ts:219-237); inMap && !inAST→mapLines+patchErrors(compileTemplate.ts:242-249);- warnings → tips(带 code frame)(
compileTemplate.ts:251-261); - 返回
{ 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
}
关键点:
currentScopeId取自instance.type.__scopeId------即组件的类型对象上挂着的__scopeId。- 渲染器在渲染组件子树前调用
setCurrentRenderingInstance(instance),于是"当前作用域 id"就绪。 __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)
三个行为规则:
- 本元素写自身
vnode.scopeId(hostSetScopeId在runtime-dom里是el.setAttribute(id, ''))。 slotScopeIds也逐个写上 :这就是:slotted的产物data-v-xxx-s------插槽内容元素会同时带上-s后缀 id(来源见 10.5.6)。- 父组件 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 流转:
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 的两种注入方式:
- 组件自身模板的元素 :
context.scopeId直接拼进开标签。 - 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}`
要点:
trim默认true------SFC 样式块的输出默认做"换行规范化"(pluginTrim),避免因源文件缩进产生无意义 diff。preprocessLang && processors[preprocessLang]:processors表(preprocessors.ts:155-161)只注册less/sass/scss/styl/stylus五种。未注册的语言静默降级 (preprocessor为undefined,直接对原文跑 PostCSS)------这与模板侧"软失败给 tips"的策略不同:样式侧连提示都没有,因为 PostCSS 对未知语法通常会报 parse error,间接暴露问题。shortId/longId再次做规范化,与 template 侧同构(10.1.3)。- 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: [] }
}
}
三个设计点:
- 双 API 兼容 :优先用现代的
compileString(对应 Dart Sass 的compileString),回退到renderSync(node-sass / 旧 dart-sass)。这是历史上链式兼容的产物。 additionalData注入 :getSource()(preprocessors.ts:141-151)支持字符串或(source, filename) => string两种形态,在源文本前 拼接全局变量/@use等------这正是 Vite 的css.preprocessorOptions.scss.additionalData的实现落点。- source map 合并 :
merge(map, sourceMap)来自merge-source-map包,把"SFC 块→原文件"与"SCSS→CSS"两张表合成。这就是 10.6.2 里preProcessedSource.map会被优先用作 PostCSSprev的原因------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 的处理有三重守卫:
if (__GLOBAL__ || __ESM_BROWSER__) throw:浏览器构建不支持(因为postcss-modules依赖 Node);if (!options.isAsync) throw:同步入口用modules直接抛错 ,强制用户改用compileStyleAsync。原因是postcss-modules通过getJSON回调把 class 映射交回,回调是异步触发的,同步流程拿不到;- 仅在通过后
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)
}
要点:
processedRules是WeakSet<Rule>:全局(模块级)记忆已处理的 rule。为什么需要?因为rewriteSelector里存在"嵌套:deep触发拆分为多个选择器、并对新选择器再次调用rewriteSelector"的逻辑(10.7.5),若不加守卫会无限递归 。用WeakSet而非Set是避免内存泄漏(Rule 是临时对象,可被 GC)。- keyframes 内层的 rule 跳过 :
@keyframes color { from {...} to {...} }里的from/to不是选择器 ,不能被注入[data-v-xxx],否则动画帧规则会失效。因此父级是 keyframes atrule 时直接 return。这是必须的例外 ------若误加,from[data-v-test]就成了非法帧选择器。 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]。
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)依次做:
if (splitForNestedDeep) return(10.7.5 的拆分已接管);- CSS Nesting 的声明提升 (
if (rule.nodes.some(node => node.type === 'rule'))); :is/:where锚点的子选择器递归改写;- 空白处理 (
node.spaces.after = ''或selector.first.spaces.before = ''); - 注入属性 (
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)):
- 先给 rule 打
__deep = true(供 10.7.2 的嵌套判断); - 把
:deep(...)内部的节点逐个搬出来 插到:deep位置之后(insertAfter(last, ss)逐个尾插,last指针递进); - 保证
:deep前面有一个空格组合器 :因为后面属性要插到:deep之前的锚点node上,node与展开后的.bar之间必须有后代关系。若前一个节点不是空格,就补一个' 'combinator; selector.removeChild(n)删除:deep本身;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
}
三种情形:
- 前导
*且有后继 (* .foo):删掉*与其后的空格组合器,让属性锚定到.foo。结果* .foo→.foo[data-v-test]。 - 纯
*(* {}):注入一个空 combinator 作为锚点,删掉*。结果* {}→[data-v-test] {}。为什么用空 combinator 而不是直接insertAfter(null)?因为后面通用注入代码用selector.insertAfter(node, attribute),需要一个真实节点占位。 - 中间/末尾
*(.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。)
逐行要点:
- 只挂在
Declaration上 :因为v-bind()只出现在 CSS 值 里(如color: v-bind(color)),不会出现在选择器或 at-rule 参数中。挂在 declaration 上是"精确打击",避免误改选择器。 vBindRE = /v-bind\s*\(/g(cssVars.ts:64) :全局正则,匹配v-bind(且允许中间有空格(v-bind (x))。vBindRE.test(value)后必须vBindRE.lastIndex = 0:因为test()在全局正则上会推进lastIndex,直接进入while (exec)循环会从错误位置开始。这是一个全局正则复用的经典陷阱,Vue 用显式重置规避。lexBinding(value, start)从v-bind(之后的字符开始,用一个状态机 找到匹配的)(10.8.3),从而正确处理v-bind('font.size')、v-bind(foo(bar))这类嵌套/带引号的情形。normalizeExpression(cssVars.ts:53-62)剥掉最外层成对的单/双引号:v-bind('font.size')→font.size。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}))`
}
要点:
- 借用
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)
}))
bindingMetadata: bindings.__isScriptSetup === false ? undefined : bindings:只有<script setup>(__isScriptSetup !== false)才用绑定元数据做前缀优化;普通<script>的导出对象无法提供准确的绑定元数据,退回_ctx代理(_ctx => (_ctx.color))。inline: true:与 10.2 的内联模式一致,让表达式按内联(局部变量)语义前缀化。- 最终包成
_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 inssrRenderStyleto distinguish whether a custom property comes fromssrCssVars. If it does, we need to reset its value toinitialon 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):
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) } }。
-
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) }` 两种输入。 -
_cssVars只在 render 函数开头求值一次 (ssrCodegenTransform.ts:44-46注释:"we do this instead of inlining the expression to ensure the vars are only resolved once per render")------这是性能考量:避免每个元素都重复求值。 -
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())
})
}
逐步拆解:
getter(instance.proxy):proxy是组件公共实例的代理;getter里的_ctx正是这个代理。读取变量表达式即触发响应式追踪。instance.ut(updateTeleports) :把 teleport 内容的更新函数挂到实例上。因为<Teleport>的内容渲染在别的 DOM 位置,无法通过subTree遍历触达,所以用[data-v-owner="${instance.uid}"]选择器直接查 DOM 并设值。setVars:instance.ce存在(自定义元素模式)→ 直接setVarsOnNode(instance.ce, vars);- 否则
setVarsOnVNode(instance.subTree, vars)。
- 更新时机 :
onBeforeUpdate(() => queuePostFlushCb(setVars)):组件更新前排队一个 post-flush 回调(确保 DOM 已更新后再设变量);onMounted:同步 跑一次setVars(首帧就要有值),并建立watch(setVars, NOOP, { flush: 'post' })(响应式变量变化后在 flush 后重设)以及MutationObserver(setVars)(监听子树childList变化------例如条件渲染插入新节点、动画等 DOM 变动导致的变量重设)。onUnmounted断开 observer。
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 的调用顺序
典型顺序(概念层,非本章仓库内容):
parse(source):得到SFCDescriptor(含template/script/styles/cssVars);compileScript(descriptor, { id, inlineTemplate }):产出脚本模块。它内部会:(a) 若 inline,则调用compileTemplate并把产物内联;(b) 生成useCssVars调用(genCssVarsCode);- (非 inline 时)
compileTemplate(...):产出render模块; - 对每个
<style>调compileStyle/compileStyleAsync:产出 CSS 模块(scoped 需先扫描是否含:slotted()以决定slotted选项); - 汇总 :把
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; }
逐段解读:
.wrapper:cssVarsPlugin先把值v-bind(theme)→var(--7ba5bd90-theme);scopedPlugin再把选择器.wrapper→.wrapper[data-v-7ba5bd90]。注意插件顺序:先值后选择器(10.6.3)。.msg:纯 scoped 改写。.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]约束。- 变量闭环 :
var(--7ba5bd90-theme)(CSS 侧)↔"7ba5bd90-theme": (theme.value)(JS 侧)------两侧 shortId 与变量名一致,运行时useCssVars把该自定义属性写到 DOM,样式生效。
10.12.4 运行时时间线
三条链路(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 本章要点回顾
compileTemplate是编排层 (compileTemplate.ts:105-264):选择编译器(dom/ssr)、映射选项(scopeId/ssrCssVars/hmr/nodeTransforms)、复用或重解析 AST、合并 source map、把 warnings 转成带 code frame 的 tips。- 选项覆盖有严格顺序 :
...compilerOptions之前是默认项(用户可覆盖scopeId/slotted/sourceMap),之后是强制项(hmr/nodeTransforms/onError用户不可覆盖)。 - 内联模式有两层开关 :
compileScript.inlineTemplate(决策)+compilerOptions.inline(形状)。收益是免$setup解构、可用bindingMetadata做前缀优化、产物体积更小;代价是模板不能独立 HMR。 - source map 分两段 :codegen 的
addMapping产"code→块文本",parse产"块文本→SFC"的inMap,mapLines链式合成;inMap && !inAST时才合并(复用 AST 时 loc 本就相对原文件)。 - 错误有三种形态 (
CompilerError/Error/string),错误码由ErrorCodes枚举 +DOMErrorMessages合并词典定义;"运行时 helper 汇总"由ast.helpers承担(原名needsRuntimeHelpers不存在)。 scoped是跨四层的闭环 :compileTemplate传scopeId→ 运行时currentScopeId(源自instance.type.__scopeId)→vnode.scopeId→renderer.setScopeId写 DOM 属性 →pluginScoped写[data-v-xxx]选择器。withScopeId/pushScopeId已是兼容残留(3.0.8 起不用)。- SSR 差异 :scopeId 在字符串拼接时注入(
context.scopeId/_scopeId形参),appendScopeId处理继承链;v-bind()用:--前缀标记并在ssrRenderStyle中重置为initial;useCssVars在服务端短路。 compileStyle是 PostCSS 管线 :插件顺序[cssVars, ...用户, trim, scoped, modules];预处理支持scss/sass/less/styl/stylus,additionalData注入、dependencies收集、map 用merge-source-map链式合并;modules强制异步。pluginScoped的核心是"找锚点→注入属性" :锚点选最后一个实体节点;:deep中断并把属性留在其前;:slotted递归注入-s后缀;:global整体替换;:is/:where/:has遇混合 deep 分支时拆分选择器 ;:not不拆分;keyframes 名字加短 id 且两阶段回写 animation;CSS Nesting 声明提升为&子 rule。v-bind()闭环 :cssVarsPlugin(Declaration 访问器)把值改写成var(--name),parseCssVars扫描变量表,genCssVarsCode借processExpression生成_useCssVars(_ctx => ({...})),运行时useCssVars在 mounted/watch/MutationObserver 三种时机把变量写到叶子 DOM;生产模式变量名走 hash,开发模式走<shortId>-<escaped>,两侧必须同源。- 产物合成 :非 inline 拆成 template/script 两个虚拟模块再由插件汇总(追加
__scopeId/__cssModules);inline 合并为单模块。vite-plugin-vue 的调用顺序必须保证slotted检测早于compileTemplate、bindingMetadata早于内联编译。 - 理解
scoped的关键 :它不是"加个属性选择器"这么简单,而是一套编译期确定、运行时静态附着、SSR 字符串对齐 的作用域系统;data-v-xxx属性与[data-v-xxx]选择器是同一枚硬币的两面,缺一不可。