主题
深度选择器(Day8 2/2)
本页对应 day8,共 6 段视频,约 1 小时 53 分。
学习目标
- 能够使用
:deep()或::v-deep在scoped样式中修改 Element Plus 按钮、分页器、SKU 等第三方组件内部元素样式,并能通过提高选择器权重或!important解决样式不生效问题。 - 能够根据商品 ID 完成删除 SKU 的接口封装、表格删除按钮事件绑定、删除成功消息提示,并在删除后重新拉取列表数据。
- 能够说明菜单权限与按钮权限的业务含义,区分超级管理员与普通账号的权限差异,并解释按钮权限为何不能仅用
v-show隐藏。 - 能够使用
el-tree按label/children格式渲染树形数据,通过props配置字段映射并监听节点点击事件;能够按文档使用el-table的展开行或树形数据实现折叠表格展示。 - 能够从登录后获取的用户信息中提取
buttons权限标识并存入 Pinia 用户仓库,在组件中根据标识是否存在于权限数组来控制按钮显示。 - 能够封装全局自定义指令
v-has,在指令挂载时判断指令值是否包含于用户按钮权限数组,并据此保留或移除当前 DOM 元素,同时在入口文件中完成全局注册。
知识点
scoped 到底解决什么问题
为什么需要: 在组件里写样式,如果不加限制,很容易影响整个项目。课堂演示中,先在品牌管理模块把 Element Plus 的 el-button 背景改成红色:
css
.el-button {
background-color: red;
}结果整个项目所有按钮都变红。也就是说,普通样式是全局的,当前模块想要的效果会“污染”到别的模块。
怎么想: 我们只想让当前组件的样式局部生效,就应该给 <style> 加 scoped。笔记里写得很明确:scoped 的作用是局部样式。课堂演示也验证了:加上 scoped 后,当前模块按钮变色,其他模块不受影响。
怎么做:
vue
<style scoped>
.el-button {
background-color: red;
}
</style>易错点: 不要以为加了 scoped 就“什么都能改”。它只保证当前组件模板内的样式局部化,并不等于可以随意穿透子组件内部。
scoped 的底层:data-v-hash 与属性选择器
为什么需要: 只知道 scoped 能局部生效还不够,遇到改不动第三方组件时,必须知道它底层怎么实现,才能判断问题出在哪。
怎么想: 笔记指出:某个组件 <style scoped> 后,当前组件 DOM 元素、以及子组件的根节点,会有相同的 data-v-#hash 数值。scoped 最终通过 data-v-hash 属性选择器实现局部样式。
怎么做: 可以在浏览器审查元素里观察:当前组件元素上会出现类似 data-v-xxxxxx 的属性;编译后的选择器也会带上这个属性限制。课堂演示中,按钮组件根节点和父组件的 hash 一样,所以直接写 .el-button 有时能命中根节点;但按钮内部的 span 没有父组件的 hash,就命中不了。
易错点: 不要把“子组件根节点有 hash”误认为“子组件内部所有元素都有 hash”。内部元素通常只带子组件自己的 hash,这正是后面样式失效的原因。
为什么普通 scoped 改不动第三方组件内部
为什么需要: 实际项目里经常要改 Element Plus 等第三方 UI 组件的颜色、间距、图标颜色。产品经理说按钮不要默认蓝色,要改成粉色、橙色,普通 scoped 往往改不到内部结构。
怎么想: 课堂演示中,先改按钮背景可以生效,因为 el-button 根节点能匹配到当前组件的 hash。但继续改按钮文字颜色时,写成类似:
css
.el-button span {
color: yellowgreen;
}就不生效。审查 DOM 会发现:el-button 根节点有当前组件 hash,里面的 span 没有。也就是说,普通 scoped 选择器被 hash 限制住了,穿不进子组件内部。
怎么做: 这时需要深度选择器。课堂中提到的写法包括 ::v-deep、/deep/、>>>,老版本里也见过 ::v-deep 不加括号的写法;Vue 3 常见写法是 :deep()。例如:
css
::v-deep .el-button span {
color: yellowgreen;
}
/* Vue 3 常见写法 */
:deep(.el-button span) {
color: yellowgreen;
}易错点: 不同 Vue 版本、不同预处理器对 ::v-deep、/deep/、>>> 的支持不一样,不能死记一种写法到处套。先看项目版本和已有代码风格。
深度选择器怎么写、怎么调
为什么需要: 深度选择器不是“写上就灵”。课堂里改分页器按钮高亮时,先写普通选择器不行,因为 .is-active 并不是分页器组件的根节点;换成深度选择器后仍可能不生效,还要继续查权重。
怎么想: 调试顺序应该是:先审查 DOM,确认要改的元素是不是子组件根节点;不是根节点,就考虑深度选择器;用了深度选择器还不生效,就去看组件库自带样式的权重。
怎么做: 以分页器为例,可以写成:
css
::v-deep .el-pagination .is-active {
background-color: skyblue;
}如果还不生效,说明 Element Plus 自带样式权重更高。课堂中的处理是提高权重,最后才考虑 !important:
css
::v-deep .el-pagination .is-active {
background-color: skyblue !important;
}易错点: 不要一上来就 !important。先确认类名、层级、是否根节点、是否被更高权重覆盖。!important 是最后手段,滥用会让后续维护很痛苦。
适用场景与易错点
为什么需要: 深度选择器主要为第三方 UI 组件而生。课堂里反复强调:Element Plus 提供的默认颜色就那几种,但产品需求未必符合,所以需要覆盖成项目需要的样式。
怎么想: 能通过组件库自身属性、类名解决最好;必须改内部结构时,再用深度选择器。它是“穿透 scoped 去匹配子组件内部元素”的工具,不是所有样式的默认写法。
怎么做: 先写 scoped 保证局部;需要改第三方组件内部时,用 :deep() 或项目支持的 ::v-deep 写法;改完立即刷新,对比其他模块是否被污染;不生效就按“DOM 结构 → 类名 → 权重”顺序排查。
易错点: 第一,误以为 scoped 能改所有子组件内部样式;第二,深度选择器写对但权重不够,误判为“没生效”;第三,项目里到处滥用深度选择器,导致样式难维护。记住本节核心链路:scoped → data-v-hash → 属性选择器实现局部样式;要穿透到子组件内部,才使用深度选择器。
课堂画面






代码示例
示例 1:scoped 的 data-v-hash 原理与 :deep() 深度选择器
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>scoped 与深度选择器 :deep() 原理</title>
<style>
/* ========== 1. 第三方组件库(el-button)自带的样式 ========== */
.el-button {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 16px; border: none; border-radius: 4px;
background: #409eff; color: #fff; cursor: pointer;
}
.el-button span { font-size: 14px; }
/* ========== 2. 不加 scoped:全局污染 ==========
源码:.el-button { background: red; }
结果:项目里所有按钮全变红,别的模块也遭殃 */
.global-demo .el-button { background: red; }
/* ========== 3. 加 scoped 后的编译结果 ==========
源码:.el-button { background: hotpink; }
编译:.el-button[data-v-7ba5bd90] { background: hotpink; }
子组件的「根节点」身上才有 hash,所以只影响当前组件的按钮根节点 */
.scoped-demo .el-button[data-v-7ba5bd90] { background: hotpink; }
/* 源码:.el-button span { color: yellowgreen; }
编译:.el-button span[data-v-aaaaaaa] { ... }
❌ span 是组件内部节点,身上没有 hash → 永远匹配不到,样式失效 */
.deep-demo [data-v-aaaaaaa] .el-button span[data-v-aaaaaaa] { color: yellowgreen; }
/* 源码::deep(.el-button span) { color: yellowgreen; }
编译:[data-v-bbbbbbb] .el-button span { color: yellowgreen; }
✅ hash 落在祖先元素上,内部 span 不再要求带 hash → 生效 */
.deep-demo [data-v-bbbbbbb] .el-button span { color: yellowgreen; }
</style>
</head>
<body>
<h1>scoped / :deep() 深度选择器</h1>
<section class="global-demo">
<h2>① 不加 scoped —— 全局污染</h2>
<button class="el-button"><i>+</i><span>添加品牌</span></button>
<button class="el-button"><i>✎</i><span>编辑品牌</span></button>
</section>
<section class="scoped-demo">
<h2>② 加 scoped —— 只命中根节点</h2>
<div data-v-7ba5bd90>
<button class="el-button"><i>+</i><span>添加品牌</span></button>
</div>
</section>
<section class="deep-demo">
<h2>③ 深度选择器 :deep()</h2>
<p>错误写法 .el-button span[data-v-xxx]:span 没有 hash,颜色不变</p>
<div data-v-aaaaaaa>
<button class="el-button"><i>+</i><span>添加品牌</span></button>
</div>
<p>正确写法 [data-v-bbbbbbb] .el-button span:文字变黄绿</p>
<div data-v-bbbbbbb>
<button class="el-button"><i>+</i><span>添加品牌</span></button>
</div>
</section>
</body>
</html>关键行与易错点
scoped的本质:Vue 编译时给当前组件的 DOM(含子组件根节点)打上data-v-xxxx属性,再把选择器末尾补上[data-v-xxxx],用属性选择器实现“局部样式”。- 所以
.el-button能改,是因为el-button是子组件的根节点,身上带 hash;而.el-button span里的span是组件内部节点,没有 hash,直接写必然失效。 :deep(.el-button span)编译成[data-v-xxxx] .el-button span,hash 落在祖先上,后代不再要求带 hash,这才是改第三方组件内部样式的正确姿势。::v-deep、>>>是老写法,原理相同。- 改了类名、也加了
:deep()还是不变?十有八九是权重不够(比如el-pagination .is-active前面还挂着别的选择器),补全选择器或加!important。 - 深度选择器只用于第三方 UI 组件(Element Plus / Ant Design 等),自己写的组件直接加 class 就行,别滥用。
示例 2:按钮权限 v-has 自定义指令(removeChild 版)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>按钮权限 v-has 自定义指令</title>
<style>
.el-button { padding: 8px 16px; margin-right: 8px; border: none;
border-radius: 4px; background: #409eff; color: #fff; cursor: pointer; }
</style>
</head>
<body>
<h1>按钮权限 v-has</h1>
<p>当前账号:<b id="who">未登录</b></p>
<p>
<button onclick="login('admin')">以超级管理员登录</button>
<button onclick="login('shanghai')">以上海0320登录</button>
</p>
<div id="toolbar"></div>
<script>
/* ================= 1. 模拟 pinia 中的用户仓库 ================= */
/* 真实项目:const userStore = useUserInfoStore() */
const userStore = {
buttons: [], // 当前用户拥有的按钮标识
setButtons(arr) { this.buttons = arr; }
};
/* ================= 2. 模拟登录后 /api/user/info 的返回 ================= */
const mockUserInfo = {
// 超级管理员(老板):所有按钮都有
admin: { name: 'admin', buttons: [
'BTN-品牌管理-ADD', 'BTN-品牌管理-UPDATE', 'BTN-品牌管理-REMOVE'
]},
// 平台管理员:老板只下发了「添加」和「删除」
shanghai: { name: '上海0320', buttons: [
'BTN-品牌管理-ADD', 'BTN-品牌管理-REMOVE'
]}
};
/* ================= 3. v-has 指令的核心实现 ================= */
/* Vue 中的写法:
export const has = {
mounted(el, binding) { ... }
}
app.directive('has', has)
*/
function hasMounted(el, binding) {
const need = binding.value; // v-has="'BTN-品牌管理-ADD'"
if (!userStore.buttons.includes(need)) { // 没权限
el.parentNode && el.parentNode.removeChild(el); // 直接从 DOM 树摘掉
}
}
/* ================= 4. 渲染工具栏 ================= */
const BUTTONS = [
{ text: '添加品牌', code: 'BTN-品牌管理-ADD' },
{ text: '编辑品牌', code: 'BTN-品牌管理-UPDATE' },
{ text: '删除品牌', code: 'BTN-品牌管理-REMOVE' }
];
function render() {
const toolbar = document.getElementById('toolbar');
toolbar.innerHTML = '';
BUTTONS.forEach(btn => {
const el = document.createElement('button');
el.className = 'el-button';
el.textContent = btn.text;
toolbar.appendChild(el);
// 等价于模板中:<button class="el-button" v-has="'BTN-品牌管理-ADD'">添加品牌</button>
hasMounted(el, { value: btn.code });
});
}
function login(key) {
const info = mockUserInfo[key];
userStore.setButtons(info.buttons);
document.getElementById('who').textContent = info.name;
render();
}
login('admin'); // 默认以老板身份进入
</script>
</body>
</html>关键行与易错点
- 权限标识的格式要和后端约定死:
BTN-模块名-ADD / UPDATE / REMOVE,前端只负责用includes判断,不在前端拼规则。 - 指令里拿到的
binding.value是v-has右侧的值;模板里要写成v-has="'BTN-品牌管理-ADD'"(字符串要加引号,否则会被当成变量)。 - 一定要用
mounted钩子,此时元素已经挂载到 DOM,el.parentNode才存在,removeChild才有效。 - 绝对不能用
v-show做按钮权限:它只是display: none,改一下 DOM 就能调出来,等于没做权限。要用removeChild(等价于v-if),元素压根不在 DOM 里。 buttons必须存在**仓库(pinia)**里而不是某个组件里:所有路由组件、所有按钮都要拿它做判断,组件通信(props/emit)根本传不过来。- 赋值时机:登录拿到 token 后请求用户信息,拿到
result.buttons立刻store.buttons = result.buttons,否则刷新页面权限就丢了。
示例 3:树形控件 label / children 数据格式与后台字段映射
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>树形控件数据格式映射</title>
<style>
ul { list-style: none; padding-left: 20px; }
li { line-height: 2; cursor: pointer; user-select: none; }
.arrow { display: inline-block; width: 16px; color: #909399; }
li.collapsed > ul { display: none; }
li.active > .node-label { color: #409eff; font-weight: bold; }
</style>
</head>
<body>
<h1>树形控件:后台字段 → label / children</h1>
<div id="tree"></div>
<p>点击节点:<b id="log">-</b></p>
<script>
/* ============ 1. 后台真实返回的数据(字段叫 name / id / subList)============ */
const backendData = [
{ id: 1, name: '一级 1', subList: [
{ id: 11, name: '二级 1-1', subList: [
{ id: 111, name: '三级 1-1-1' }
]}
]},
{ id: 2, name: '一级 2', subList: [
{ id: 21, name: '二级 2-1' },
{ id: 22, name: '二级 2-2' }
]},
{ id: 3, name: '一级 3' }
];
/* ============ 2. 递归转换:后台字段 → 组件要求的 label / children ============ */
function toTreeData(list) {
return list.map(item => ({
id: item.id,
label: item.name, // name → label
children: item.subList && item.subList.length
? toTreeData(item.subList) // subList → children
: undefined // 空数组要给 undefined
}));
}
const treeData = toTreeData(backendData);
/* ============ 3. 渲染 + 折叠 + 点击节点 ============ */
function renderTree(list, parent) {
const ul = document.createElement('ul');
list.forEach(node => {
const li = document.createElement('li');
const hasChild = Array.isArray(node.children) && node.children.length > 0;
const label = document.createElement('span');
label.className = 'node-label';
label.innerHTML = (hasChild ? '<span class="arrow">▾</span>' : '<span class="arrow"></span>')
+ node.label;
li.appendChild(label);
// 点击节点(对应 el-tree 的 @node-click)
label.addEventListener('click', () => {
if (hasChild) li.classList.toggle('collapsed'); // 展开 / 折叠
document.querySelectorAll('#tree li').forEach(x => x.classList.remove('active'));
li.classList.add('active');
document.getElementById('log').textContent = `${node.label}(id=${node.id})`;
});
if (hasChild) ul.appendChild(li), renderTree(node.children, li);
else ul.appendChild(li);
});
parent.appendChild(ul);
}
renderTree(treeData, document.getElementById('tree'));
</script>
</body>
</html>关键行与易错点
- 树形控件的
data必须是数组,且默认字段名就是label(节点名)和children(子节点),格式不对组件就不渲染。 - 后台字段名通常不叫
label,两种处理方式:① 用组件的props配置映射,<el-tree :data="data" :props="{ label: 'name', children: 'subList' }" />;② 前端递归转换(上面这种)。能改 props 就别手写递归,省事。 - 递归转换最易错的点:
children是空数组[]时要给undefined,否则每个叶子节点都会冒出一个空的展开箭头。 - 点击节点用的是组件的
@node-click事件(对应上面的addEventListener),不要自己给每个span绑一堆重复逻辑。 - 同类思路还有
el-table的type="expand"折叠行,数据格式同样是children嵌套,只是展示成“点行展开”而不是“点箭头展开”。
常见错误
现象:在
scoped样式里直接写.el-button { ... },目标按钮没变,其他按钮反而被改了。
原因:选择器没穿透到子组件内部,或未加scoped导致全局污染。
修法:限定父级作用域后使用:deep(.el-button)/::v-deep(.el-button)/>>>,并确认编译环境支持写法。现象:已经写了深度选择器,类名也对,样式仍不生效。
原因:第三方组件原样式权重更高。
修法:补齐父级选择器提升权重;仍不够时再谨慎使用!important,不要一开始就堆!important。现象:按钮权限用
v-show控制,禁用的按钮仍能被调出来。
原因:v-show只是display:none,DOM 仍存在,存在越权风险。
修法:改用v-if或全局自定义指令在挂载时移除无权限元素;权限标识从用户信息中获取并存入仓库共享。现象:自定义指令里判断按钮权限时报错或拿不到用户仓库数据。
原因:在非组件环境直接使用组件内写法获取 Pinia,或指令挂载时机/参数使用不当。
修法:在指令模块中显式引入用户仓库获取函数;在mounted中读取binding.value,无权限时通过父节点removeChild移除当前元素。现象:树形控件/可展开表格直接使用后端数据,节点不显示或字段不识别。
原因:组件要求label、children等固定字段,后端返回字段名不一致。
修法:使用组件props配置映射字段,或在前端转换数据结构;不要硬改组件源码。现象:删除 SKU 后列表未更新,或删除后页码/数据异常。
原因:删除成功后未重新拉取列表,或未处理当前页状态。
修法:删除接口成功后给出消息提示,再按当前分页参数重新请求列表;必要时处理当前页边界。
练习
- 在不污染全局的前提下,把某个页面内 Element Plus 按钮背景改成粉色,并说明为什么不能直接写
.el-button。 - 给品牌管理页面的“添加/编辑/删除”按钮接入按钮权限:分别使用
v-if方案和全局自定义指令方案各实现一版,并比较差异。 - 模拟后端返回字段为
name、sub的树形数据,使用 Tree 控件展示,并通过props映射为组件需要的字段。 - 完成 SKU 删除业务:调用删除接口、成功提示、重新请求当前页列表;补充删除最后一条数据时的处理思路。
- 用深度选择器修改分页器当前页按钮背景色,若首次不生效,写出排查权重问题的步骤。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 深度选择器修改第三方UI组件库的样式 | 14:11 |
| 2 | 完成删除SKU的业务 | 04:13 |
| 3 | 权限业务的介绍 | 29:52 |
| 4 | tree控件与table折叠组件查看 | 10:54 |
| 5 | 按钮权限业务-1 | 29:48 |
| 6 | 按钮权限业务-自定义指令 | 24:43 |
小结
scoped解决的是组件样式隔离,不等于能随意修改第三方组件内部结构。- 修改第三方 UI 组件样式时,优先“限定父级 + 深度选择器”,权重不够再逐级补强。
- 按钮权限不能用
v-show做安全控制,必须让无权限元素不渲染或被移除。 - 权限标识应以后端随用户信息返回的数据为准,并放入全局仓库供各页面/指令共享。
- 全局自定义指令适合统一处理按钮权限,能减少每个组件重复引入仓库和写判断的成本。
- Tree、可展开 Table 等组件要先满足其数据格式要求,字段不一致时优先用
props映射。 - 删除类业务不止是调接口,还要处理提示、列表刷新和分页边界。