前端知库

Vue 的 scoped CSS 是怎样实现的?:deep()、:slotted()、v-bind() 编译成了什么?

加了 scoped 的 style 会被编译器改写,每个选择器末尾加上 [data-v-哈希] 属性选择器,组件渲染出的元素也带上同一个属性,样式就只命中本组件。:deep() 把属性移到前面的父级,:slotted() 改成 -s 后缀的插槽属性,:global() 去掉属性,CSS 里的 v-bind() 编译成哈希过的自定义属性,由运行时写到组件根元素的行内样式上。

参考答案

简答

在单文件组件里写 <style scoped>,.title { color: red } 编译后会变成 .title[data-v-7ba5bd90],而这个组件渲染出来的元素都会带上 data-v-7ba5bd90 这个属性。哈希每个组件不同,所以这条样式只能命中本组件的元素。

这样做是因为浏览器的 CSS 默认是全局的,两个组件都写 .title 就会互相影响。Vue 不依赖 Shadow DOM,只靠编译期改写选择器和给元素加属性,就做到了组件级隔离,不需要任何 polyfill。

要穿透隔离时有几个伪类::deep(.b) 把属性挪到前面的父级,用来改子组件内部的样式;:slotted() 专门选插槽内容;:global() 让某一条规则不加属性。CSS 里还能写 v-bind(color),编译成一个哈希过的 CSS 自定义属性,运行时写到组件根元素的行内样式上,数据变了样式跟着变。

要注意两点。子组件的根元素会同时带上父组件和子组件的属性,所以父组件的 scoped 样式能命中子组件根元素,这是官方有意的设计,方便父组件做布局。另外 v-html 插入的内容没有属性,scoped 样式管不到,得用 :deep()。

编译器把样式改成了什么

下面这段 scoped 样式用 @vue/compiler-sfc 3.5.44 的 compileStyle 编译,id 传 data-v-7ba5bd90:

CSS
.title { color: red; }
.a :deep(.b) { color: blue; }
:slotted(div) { color: green; }
:global(.dark) { color: white; }
.box { color: v-bind(color); width: v-bind('size.w'); }

实际输出是:

CSS
.title[data-v-7ba5bd90] { color: red; }
.a[data-v-7ba5bd90] .b { color: blue; }
div[data-v-7ba5bd90-s] { color: green; }
.dark { color: white; }
.box[data-v-7ba5bd90] { color: var(--7ba5bd90-color); width: var(--7ba5bd90-size\.w); }

普通选择器的属性加在最右边那一段上,也就是真正被选中的元素。所以 .title 必须同时满足“有这个类”和“是本组件渲染的”才会生效。

:deep(.b) 为什么能改到子组件里面?因为属性被挪到了 .a 上,.b 本身不再要求带本组件的属性。只要 .a 是本组件的元素,它后代里任何 .b 都能命中,包括子组件内部的元素。

:slotted(div) 用的是 -s 后缀的属性。官方文档说,插槽内容默认被看作父组件的东西,子组件的 scoped 样式管不到它;Vue 运行时在渲染插槽内容时,会给它们加上 data-v-哈希-s,:slotted() 就是专门匹配这个属性的。:global(.dark) 则直接去掉属性,相当于写在一个不带 scoped 的 <style> 里。

元素上的属性从哪里来

模板编译时传入 scopeId,运行时给组件渲染的每个元素都加上这个属性。下面是用 Vue 3.5.44 的 renderToString 实际渲染的结果,父组件的 scopeId 是 data-v-parent,子组件是 data-v-child:

HTML
<section data-v-parent>
  <p data-v-parent>text</p>
  <div class="child" data-v-child data-v-parent><span data-v-child>inner</span></div>
  <div data-v-parent><b>html</b></div>
</section>

子组件的根元素 div.child 同时带着两个属性,它里面的 span 只有 data-v-child。这就是官方文档说的:父组件的样式不会漏进子组件内部,但子组件的根元素同时受父子两边的 scoped 样式影响。

最后一个 div 用 innerHTML 插入了 <b>,<b> 上没有属性。v-html 也是这样,所以官方提示 v-html 的内容不受 scoped 样式影响,要用 :deep() 去选。

CSS 里的 v-bind() 怎样变成响应式

v-bind(color) 编译成 var(--7ba5bd90-color) 后,CSS 文件本身还是静态的。值从哪来?同一个组件的 <script setup> 编译结果里会多出一段:

JavaScript
_useCssVars(_ctx => ({
  "7ba5bd90-color": (color.value)
}))

useCssVars 在运行时把 --7ba5bd90-color: red 写到组件根元素的行内样式上,color 变了就重新写一次。自定义属性会继承,所以组件内部的元素都能读到。官方文档的说法是:值被编译成哈希过的 CSS 自定义属性,通过行内样式应用到组件根元素,源数据变化时响应式更新。写 JavaScript 表达式时要加引号,比如 v-bind('theme.color')。

使用时要注意什么

官方给了两条建议。第一,scoped 不能代替类名,p { color: red } 加了 scoped 以后变成元素选择器加属性选择器,官方说这样会慢很多,换成类名或 id 基本就没有这个损耗。第二,递归组件里小心后代选择器,.a .b 中 .a 所在的元素如果包含递归的子组件,子组件里所有 .b 都会被命中,因为它们带着同样的属性。

如果想要不依赖属性、而是改写类名的隔离方式,可以用 <style module>,见《CSS Modules、层叠层和自定义属性怎样帮助维护样式?》。浏览器原生的作用域方案见《CSS @scope 能解决哪些样式隔离问题?》。

常见追问

为什么父组件的 scoped 样式能改到子组件的根元素?

因为子组件根元素同时带着父组件和子组件的 data-v 属性。Vue 官方说这是有意设计的,让父组件能给子组件的根元素设置布局相关的样式,比如外边距、宽度。子组件内部的其他元素只有子组件自己的属性,父组件的样式就碰不到。

同一个组件能同时写 scoped 和非 scoped 的样式吗?

可以,写两个 <style> 块,一个带 scoped、一个不带,不带的就是全局样式。如果只有一两条规则需要全局,也可以在 scoped 块里用 :global() 包起来。