写给 Javaer 的 Vue 知识点系列 ------ 揭开
<style scoped>编译产物[data-v-*]的神秘面纱
0 痛点
从后端转全栈来写 Vue,刚写组件就遇到灵异事件:
- A 组件里写了
.title { color: red } - B 组件里的
.title莫名其妙也变红
因为CSS 没有"包"的概念,默认仅全局作用域 。所有 <style> 最终都被打包进同一个浏览器环境,.title 就是 .title,谁先谁后、权重谁高,全看加载顺序和选择器权重------就像 Java 里所有类不加 package 全部平铺在默认包下,重名直接冲突。
Javaer习惯 package com.a.service.UserService 和 com.b.service.UserService 互不干扰,习惯 private 字段外部碰不到。但CSS生无这些机制,任何选择器都是"public static"。
Vue 的 scoped 就是官方解法。
1 scoped 的表面现象
vue
<template>
<div class="container">
<h1 class="title">标题</h1>
</div>
</template>
<style scoped>
.container { border: 1px solid #eee; }
.title { color: red; }
</style>
运行后打开浏览器 DevTools,发现:
DOM 上每个元素都被塞了一个奇怪的属性:
html
<div data-v-7ba5bd90 class="container">
<h1 data-v-7ba5bd90 class="title">标题</h1>
</div>
编译出来的 CSS 选择器"长尾巴"了:
css
.container[data-v-7ba5bd90] { border: 1px solid #eee; }
.title[data-v-7ba5bd90] { color: red; }
这个 data-v-7ba5bd90 就是一把"锁",选择器后面那截 [data-v-7ba5bd90] 就是"验锁"动作。两把钥匙对上了,样式才生效。
2 这不是运行时魔法,是编译期代码增强
很多 Javaer 第一反应"Vue 运行时是不是扫描了 CSS 再做匹配?"
不是。这一切发生在编译期(webpack 的 vue-loader / Vite 的 @vitejs/plugin-vue),浏览器拿到的已经是加工后的产物。
Java 类比:注解处理器 + 字节码增强
用过 Lombok、MapStruct,或写过自定义的 Annotation Processor(APT),这个过程你秒懂:
java
// 你写的源码
public class UserService {
@Getter
private String name;
}
// Lombok 在编译期偷偷改成了
public class UserService {
private String name;
public String getName() { return this.name; } // 编译器生成
}
scoped 干的是同一件事,只是增强对象从 Java 类变成 SFC(单文件组件):
| 步骤 | 你写的 | 编译插件生成的 |
|---|---|---|
| 改模板 | <h1 class="title"> |
<h1 class="title" data-v-7ba5bd90>(给每个元素打标记) |
| 改样式 | .title { ... } |
.title[data-v-7ba5bd90] { ... }(每个选择器加属性过滤) |
属性选择器
[data-v-7ba5bd90] 是 CSS 的属性选择器,含义是"选中带有 data-v-7ba5bd90 这个属性的元素"。它和 .title 连用(.title[data-v-7ba5bd90])就是 AND 条件:类名是 title 且 带这个标记的元素,才命中。
Java类比:带注解的类才能被切面命中
java
// .title[data-v-xxx] ≈ 切点表达式:@annotation(ScopeMarker) && 类名叫 Title
@ScopeMarker("7ba5bd90") // ≈ DOM 上的 data-v-7ba5bd90 属性
public class Title { }
// 切面只拦截"带该注解"的类 ------ scoped 样式只命中"带该属性"的元素
@Around("@annotation(ScopeMarker)")
没有那个"注解"(属性)的元素,样式写得再像也匹配不上------这就是隔离的本质:编译期打标记,选择器层验票。纯靠 CSS 自身机制实现,浏览器无需任何配合。
3 hash是"组件级",非"实例级"
反直觉的:data-v-7ba5bd90 是按组件 (.vue 文件)生成的,不是按组件实例。同一个组件渲染 10 次,10 个实例的 DOM 用的是同一个 hash。
html
<my-card data-v-7ba5bd90></my-card>
<my-card data-v-7ba5bd90></my-card> <!-- 同一个 hash -->
Java 类比:
java
// data-v hash ≈ 类上的注解,而非实例字段
@ScopeMarker("7ba5bd90")
public class MyCard { }
new MyCard(); new MyCard(); // 两个实例,注解(hash)是同一份
所以别指望"给两个实例不同的 hash 让它们样式不同",scoped 解决类与类之间 (组件与组件之间)的隔离,不是实例与实例之间的隔离。实例级差异用动态 class / props 实现。项目里就有现成例子,见 《03-组件化开发》 的 Rate 评分组件:
vue
<!-- Rate.vue:theme 是 props,驱动每个实例自己的颜色 -->
<template>
<div :style="fontstyle">{{ rate }}</div>
</template>
<script setup>
const props = defineProps({
value: Number,
theme: { type: String, default: 'orange' }
})
const themeObj = { red: '#f5222d', green: '#73d13d', blue: '#40a9ff' /* ... */ }
const fontstyle = computed(() => `color:${themeObj[props.theme]};`)
</script>
html
<!-- 父组件里同一组件渲染三个实例,样式各不相同,但它们的 data-v hash 是同一个 -->
<Rate :value="3"></Rate>
<Rate :value="4" theme="red"></Rate>
<Rate :value="1" theme="green"></Rate>
三个实例的 DOM 上都是同一个 data-v-xxx,scoped 样式对它们一视同仁;差异完全由 props 数据驱动(:style / :class 绑定)产生。同样的手法也用在 《21-单元测试:使用TDD开发一个组件》 的 Button 组件里::class="[size ? \el-button--${size}` : '']",props 变、class 变、样式跟着变------Element Plus 的 el-button--primary` 就是这么来的。
4 子组件的"根节点"会被父组件打上标记
父组件的 scoped 样式管不到子组件的内部,但管得到子组件的根元素。
vue
<!-- Parent.vue -->
<template>
<Child class="card-box" />
</template>
<style scoped>
.card-box { margin: 10px; } /* ✅ 生效:Child 的根节点会带父组件的 data-v 标记 */
.card-box .content { padding: 0; } /* ❌ 不生效:.content 是子组件内部元素 */
</style>
编译后,Child 的根节点实际长这样:
html
<div class="card-box" data-v-parenthash data-v-childhash>
<div class="content" data-v-childhash>...</div> <!-- 只有子组件自己的 hash -->
</div>
为啥这么设计?
父组件要负责布局:子组件根元素出现在父组件的模板,父组件须有权控制它的 margin、定位、宽度,否则组件连排版都做不了。
Java 类比:一个对象的"在容器中的位置"由调用方决定(父组件管根元素),但它的"内部字段"是封装的(父组件摸不到子组件内部)------经典的封装边界。
5 深度选择器 :deep(),样式版的"反射"
用 Element Plus 的 <el-button>,想改它内部 .el-button__inner 的样式。内部元素上只有 Element Plus 自己的 data-v 标记,你的 scoped 选择器够不着------就像 private 字段外部访问不到。
Vue给的官方后门是深度选择器:
vue
<style scoped>
.my-wrapper :deep(.el-button__inner) { color: red; }
</style>
编译产物:
css
.my-wrapper[data-v-7ba5bd90] .el-button__inner { color: red; }
/* ↑ 属性只加在 :deep() 前面的部分,后面"裸奔",所以能穿透进去 */
Java 类比:
java
// :deep() ≈ 反射 setAccessible(true),绕过 private 限制
Field f = ElButton.class.getDeclaredField("innerColor");
f.setAccessible(true); // :deep() 做的事
f.set(button, RED);
老写法
老项目里会见到,其实同一件事:
| Vue 2 | Vue 3 |
|---|---|
>>>(原生 CSS 可用) |
:deep() |
/deep/、::v-deep(SCSS/Less 可用) |
:deep()(统一写法) |
配套的还有:
:slotted(.item):只作用于父组件塞进来的插槽内容;:global(.foo):把某条选择器豁免出 scoped,退回全局------相当于临时写了个public static。
6 scoped失效场景
明白了标记是编译期打在模板渲染出的元素上的,失效场景就无需死记:
6.1 v-html的内容
v-html 的字符串是运行时才 innerHTML 进去的,编译插件没机会给它打 data-v 标记,所以 scoped 样式对它一律失效。
Java 类比:APT 只处理编译期存在的源码,运行时反射 newInstance 出来的类它管不着。
css
/* 解法:手动用 :deep() 或全局样式限定范围 */
.content :deep(p) { margin: 1em 0; }
6.2 第三方库运行时创建的 DOM
弹窗挂到 body、富文本编辑器、地图 SDK 动态画的节点------同理,编译期不存在,没有标记就没有命中。
6.3 @keyframes 会被改名
<style scoped> 里的 @keyframes fade { ... } 会被编译器重命名成 fade-7ba5bd90(防冲突),动画名引用会被同步改写。如果你的 keyframes 写在全局样式里、动画用在 scoped 组件里,名字对不上就会"动画不生效"------排查时去 DevTools 看编译产物里 keyframes 是否被改名了。
7 scoped v.s CSS Modules
| 对比项 | <style scoped> |
<style module> |
|---|---|---|
| 隔离手段 | 保留原类名 + 附加属性选择器 | 直接把类名改写成 hash(.container__hash) |
| Java 类比 | 给对象打"作用域注解"再验票 | 类名混淆/强制命名空间前缀 |
| 类名在 DOM 中 | 保持可读(title) |
变成哈希(调试时难读) |
| 隔离强度 | 根元素仍对外暴露(见第 4 节) | 更彻底 |
一般团队约定:业务组件用 scoped,组件库/高复用组件考虑 CSS Modules,改第三方 UI 库用 :deep(),全局基础样式(reset、CSS 变量)单独放全局文件。
8 总结
- CSS 默认全局作用域,没有"包"的概念;
scoped用编译期代码增强 实现样式隔离------给模板元素打data-v-{hash}标记,给每条选择器追加[data-v-{hash}]属性过滤。和 Lombok/APT 一样是编译期改造,浏览器无感知。 - hash 是组件级的(像类注解,不是实例字段),同一组件多实例共享。
- 子组件根元素 会被父组件一并打标(父组件要管布局),但子组件内部是封装的。
:deep()是官方的"反射"后门,专治改不了源的第三方组件内部样式;:global()是豁免通道。- 一切"scoped 失效"的场景(v-html、运行时 DOM、keyframes 改名)都能用一句话解释:编译期没打上标记的元素,属性选择器就不认。
<style scoped> = Vue 版的编译期 APT,data-v-* = 打在 DOM 上的作用域注解,[data-v-*] 选择器 = 验票切面。