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:
.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'); }
实际输出是:
.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:
<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> 编译结果里会多出一段:
_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()包起来。