Vue作用域CSS与深度选择器:修改子组件样式为何要用:deep()
如果你写过一段时间的 Vue,大概率踩过这个坑:父组件里明明写了样式,子组件却"纹丝不动";加上 `scoped` 之后更玄学——给子组件根元素写样式能生效,往里再深入一层就失效了。于是你在网上搜到答案:加 `:deep()`。然后问题解决了,但你未必真的搞清楚它为什么必须存在。
这篇就来把这件事从头讲清楚。理解了 `scoped` 的编译机制,`:deep()` 就不再是一条"魔法咒语",而是一个逻辑上必然的产物。
scoped 到底做了什么
很多人对 `scoped` 的理解停留在"让样式只在当前组件生效",这个说法不够精确。它的实现方式其实非常"笨":靠属性选择器硬匹配。
比如你写了这样一段:
<template>
<div class="box">
<Child />
</div>
</template>
<style scoped>
.box { color: red; }
</style>
经过 SFC 编译器处理后,大致会变成:
<div class="box" data-v-7ba5bd90>...</div>
.box[data-v-7ba5bd90] { color: red; }
也就是说,编译器做了两件事:
1. 给当前组件模板里的每一个元素打上一个唯一的 `data-v-xxxxxx` 属性;
2. 给 `scoped` 里的每一条选择器的最后一段补上对应的属性选择器。
样式"只在本组件生效",本质上是"只有带这个属性标记的元素才匹配得上"。它不是什么运行时隔离,纯粹是编译期的字符串改写。
为什么子组件内部的元素匹配不上
关键在于:`data-v-xxxxxx` 是跟着组件模板走的。
父组件模板里出现的元素,带的是父组件的 `data-v-parent`;子组件模板里的元素,带的是子组件的 `data-v-child`。它们是两套独立的标记。
那为什么给子组件根元素写样式有时候能生效?因为 Vue 做了一个刻意的设计:子组件的根节点会同时继承父组件的 scopeId。目的是让父组件能方便地调整子组件的定位、外边距这类"摆放"相关的样式。
所以:
/* 生效:子组件根节点上有 data-v-parent */
.child-root { margin-top: 12px; }
/* 失效:子组件内部元素只有 data-v-child */
.child-inner { color: blue; }
第二条编译后是 `.child-inner[data-v-parent]`,而子组件内部的那个 `<span>` 身上只有 `data-v-child`,属性对不上,自然匹配失败。
:deep() 做的事情就是"挪个位置"
既然问题出在属性选择器被附加到了选择器链的末尾,那解决办法就很直观了:把它挪到前面去。
:deep(.child-inner) { color: blue; }
编译结果变成:
[data-v-parent] .child-inner { color: blue; }
属性选择器现在挂在了祖先节点上——只要这个祖先在父组件的模板里,它就带着 `data-v-parent`。而后面的 `.child-inner` 是纯粹的后代选择器,不再要求目标元素带标记,于是就能一路穿透到子组件内部任何层级。
换句话说,`:deep()` 并不是"开启穿透模式",它只是改变了编译器插入 scopeId 的位置:默认插在最后,用了 `:deep()` 就插到前面的那段选择器上。理解这一点,很多"为什么这样写不行"的疑惑就迎刃而解了。
那些年写过的 >>>、/deep/ 和 ::v-deep
如果你是从 Vue 2 时代过来的,可能记得另外几种写法。
最早的方案是 `>>>`:
.parent >>> .child-inner { }
问题是 `>>>` 不是合法的 CSS 语法,在 Sass、Less 里会直接编译报错。于是有了 `/deep/`,但它其实是 Chrome 的一个非标准 Shadow DOM 伪类,后来被废弃移除。
再后来是 `::v-deep`,在 Vue 2 + vue-loader 15 时代以组合器形式使用:`::v-deep .child-inner`。到了 Vue 3,为了让语法更规范,改成了伪类函数的形式 `:deep(.child-inner)`,同时配套的还有 `:slotted()`(作用于插槽内容)和 `:global()`(声明全局样式)。
所以今天在 Vue 3 项目里,请统一用 `:deep()`,老写法要么报错要么会被警告。
几个容易踩的坑
第一,`lang="scss"` 下的嵌套写法。 `:deep()` 放在嵌套里时,要写在选择器链的中间层,而不是自己单独成一层,否则编译出来的结果可能不是你想要的。
第二,穿透不了 Teleport。 `:deep()` 依赖的是 DOM 祖先关系。如果子组件用 `<Teleport to="body">` 把内容扔到了 body 下,父组件那个带 scopeId 的祖先就不在它的祖先链上了,再怎么 deep 也白搭。
第三,穿透不了全局挂载的样式。 第三方组件库如果你是按需在全局引入 CSS 的,那部分样式本来就带全局性质,`:deep()` 只能影响组件模板内的元素结构,管不到它内部的全局 class 规则冲突。
穿透之前,先想想有没有更优雅的方案
`:deep()` 是逃逸舱,不是首选方案。它能用,但用多了意味着父子组件之间形成了隐式的样式耦合——子组件只要改个内部 class 名,父组件就悄悄崩了,而且 TypeScript 和 linter 都不会提醒你。
更稳妥的做法通常是这几种:
- CSS 变量穿透:子组件内部样式写成 `color: var(--child-color, #333)`,父组件只要设置 `--child-color` 就行,双方约定的是一个变量名,耦合面小得多。
- 传 props 或 class:Vue 3 的属性透传会把父组件写在子组件标签上的 `class` 自动合并到根元素,很多布局需求根本不需要 `:deep()`。
- 让子组件自己暴露定制点:比如提供 `size`、`variant` 之类的 prop,把样式的决策权留在组件内部。
只有当面对的是第三方组件库、确实没法改源码时,`:deep()` 才是合理选择。而且建议始终带上父级限定,比如 `.my-form :deep(.el-input__inner)`,而不是直接 `:deep(.el-input__inner)`,避免样式溢出到你没预料到的地方。
小结
`scoped` 的本质是编译期给选择器末尾追加属性选择器,`:deep()` 的本质是把属性选择器挪到选择器链的前面去。它之所以必要,不是因为 Vue 的隔离"太严格",而是因为属性标记天然跟组件模板绑定,父子两套标记匹配不上。理解了这一点,你就能判断什么时候该用、什么时候不该用——穿透是把钥匙,但门本来就不该总是锁着。
转载请注明出处,版权归原作者所有。
管理员
黑卡会员