Skip to content

深度选择器(Day8 2/2) ​

本页对应 day8,共 6 段视频,约 1 小时 53 分。

学习目标 ​

  1. 能够使用 :deep() 或 ::v-deep 在 scoped 样式中修改 Element Plus 按钮、分页器、SKU 等第三方组件内部元素样式,并能通过提高选择器权重或 !important 解决样式不生效问题。
  2. 能够根据商品 ID 完成删除 SKU 的接口封装、表格删除按钮事件绑定、删除成功消息提示,并在删除后重新拉取列表数据。
  3. 能够说明菜单权限与按钮权限的业务含义,区分超级管理员与普通账号的权限差异,并解释按钮权限为何不能仅用 v-show 隐藏。
  4. 能够使用 el-tree 按 label/children 格式渲染树形数据,通过 props 配置字段映射并监听节点点击事件;能够按文档使用 el-table 的展开行或树形数据实现折叠表格展示。
  5. 能够从登录后获取的用户信息中提取 buttons 权限标识并存入 Pinia 用户仓库,在组件中根据标识是否存在于权限数组来控制按钮显示。
  6. 能够封装全局自定义指令 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

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

示例 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 嵌套,只是展示成“点行展开”而不是“点箭头展开”。

常见错误 ​

  1. 现象:在 scoped 样式里直接写 .el-button { ... },目标按钮没变,其他按钮反而被改了。
    原因:选择器没穿透到子组件内部,或未加 scoped 导致全局污染。
    修法:限定父级作用域后使用 :deep(.el-button) / ::v-deep(.el-button) / >>>,并确认编译环境支持写法。

  2. 现象:已经写了深度选择器,类名也对,样式仍不生效。
    原因:第三方组件原样式权重更高。
    修法:补齐父级选择器提升权重;仍不够时再谨慎使用 !important,不要一开始就堆 !important。

  3. 现象:按钮权限用 v-show 控制,禁用的按钮仍能被调出来。
    原因:v-show 只是 display:none,DOM 仍存在,存在越权风险。
    修法:改用 v-if 或全局自定义指令在挂载时移除无权限元素;权限标识从用户信息中获取并存入仓库共享。

  4. 现象:自定义指令里判断按钮权限时报错或拿不到用户仓库数据。
    原因:在非组件环境直接使用组件内写法获取 Pinia,或指令挂载时机/参数使用不当。
    修法:在指令模块中显式引入用户仓库获取函数;在 mounted 中读取 binding.value,无权限时通过父节点 removeChild 移除当前元素。

  5. 现象:树形控件/可展开表格直接使用后端数据,节点不显示或字段不识别。
    原因:组件要求 label、children 等固定字段,后端返回字段名不一致。
    修法:使用组件 props 配置映射字段,或在前端转换数据结构;不要硬改组件源码。

  6. 现象:删除 SKU 后列表未更新,或删除后页码/数据异常。
    原因:删除成功后未重新拉取列表,或未处理当前页状态。
    修法:删除接口成功后给出消息提示,再按当前分页参数重新请求列表;必要时处理当前页边界。

练习 ​

  1. 在不污染全局的前提下,把某个页面内 Element Plus 按钮背景改成粉色,并说明为什么不能直接写 .el-button。
  2. 给品牌管理页面的“添加/编辑/删除”按钮接入按钮权限:分别使用 v-if 方案和全局自定义指令方案各实现一版,并比较差异。
  3. 模拟后端返回字段为 name、sub 的树形数据,使用 Tree 控件展示,并通过 props 映射为组件需要的字段。
  4. 完成 SKU 删除业务:调用删除接口、成功提示、重新请求当前页列表;补充删除最后一条数据时的处理思路。
  5. 用深度选择器修改分页器当前页按钮背景色,若首次不生效,写出排查权重问题的步骤。

原视频对照 ​

#视频时长
1深度选择器修改第三方UI组件库的样式14:11
2完成删除SKU的业务04:13
3权限业务的介绍29:52
4tree控件与table折叠组件查看10:54
5按钮权限业务-129:48
6按钮权限业务-自定义指令24:43

小结 ​

  1. scoped 解决的是组件样式隔离,不等于能随意修改第三方组件内部结构。
  2. 修改第三方 UI 组件样式时,优先“限定父级 + 深度选择器”,权重不够再逐级补强。
  3. 按钮权限不能用 v-show 做安全控制,必须让无权限元素不渲染或被移除。
  4. 权限标识应以后端随用户信息返回的数据为准,并放入全局仓库供各页面/指令共享。
  5. 全局自定义指令适合统一处理按钮权限,能减少每个组件重复引入仓库和写判断的成本。
  6. Tree、可展开 Table 等组件要先满足其数据格式要求,字段不一致时优先用 props 映射。
  7. 删除类业务不止是调接口,还要处理提示、列表刷新和分页边界。