关于 vue scoped 与_deep 底层实现探究

99 阅读5分钟

源自于项目中想要修改 elemenplus 组件库的样式,以符合 UI 设计的要求,但发现自己并不是十分理解,结合源码来梳理相关的内容。

需要提前了解的知识点

属性选择器

CSS 属性选择器是**基于元素的属性和属性值对 dom 元素进行匹配**,从宽泛的角度来说** id 选择器和类选择器也属于属性选择器**。

基础语法如下:

[属性名] {
  /* CSS 规则 */
}
/* 选择具有特定属性值的元素 */
[属性名="属性值"] {
  /* CSS 规则 */
}

选择器的优先级

我们知道 css 中有 id 选择器、类选择器、元素选择器等等,当一个元素同时被多个选择器匹配的时候,元素最终的样式取决于选择器的优先级,优先级的计算是有一套规则的:我们将其划分为0~4共5个等级,其中前4个等级由CSS选择器决定,最后一个等级由书写形式决定
级别选择器类型权重值示例
0级通配选择器、选择符和逻辑组合伪类0*:not():is():where()
1级标签选择器1#foo { color: #999; }
2级类选择器、属性选择器和伪类10.foo { color: #666; } [foo] { color: #666; } :hover { color: #333; }
3级ID 选择器100
4级style 属性内联1000

我们来看几个例子理解优先级权重的计算:

选择器优先级数值计算规则
*{}01个0级通配选择器, 优先级数值为0
dialog{}11个1级标签选择器, 优先级数值为1
a:hover{}111个1级标签选择器, 1个2级伪类, 优先级数值为1+10
#foo{}1001个3级ID选择器, 优先级数值为100
#foo.bar p{}1111个3级ID选择器, 1个2级类选择器, 1个1级标签选择器, 优先级数值为100+10+1

!important 作用的底层机制

通常我们认为CSS的优先级就像一个小世界,设置了`!important`之后,这个CSS属性就可以在CSS世界中“称王称霸”。实际上,`!important`所起的作用不是这样,而是直接将这个CSS属性带到了一个更高级别的级联层级。

CSS的优先级是分层的,就像一层层的建筑,每一层就像一个封闭的小宇宙,与其他层互不关联,其优先级也无法超越。

级联层级的优先级关系:

  1. 设置了!important的浏览器内置样式;
  2. 设置了!important的用户设置的样式;
  3. @layer规则中设置的包含!important的样式;
  4. 开发者设置的包含!important的样式;
  5. 开发者设置的CSS样式;
  6. @layer规则中的CSS样式;
  7. 用户设置的CSS样式;
  8. 浏览器内置的CSS样式。

这个优先级顺序也很自然,给了开发者和用户修改样式的自由。

scoped 实现基础原理

vue 中 `scoped` 的实现的原理是:
  1. 对给style标签添加了 scoped 属性的组件的 dom 元素添加上唯一的 id 标识;
  2. 并给 style 中每一个选择器末尾都添加上这个 id 标识的属性选择器,也因此,使用scopedstyle标签中声明的样式只会影响到该组件,而不会影响其他组件或子组件。

我们来看看这段 vue 简单的代码:

<template>
  <div class="foo">
    <p class="bar">Hello, world!</p>
  </div>
</template>

<style scoped>
.foo {
  background-color: blue;
}

.bar {
  color: red;
}
</style>

vue 的单文件组件编译器(vue-compile-sfc)会把它编译 为类似 下边的代码:

<template>
  <div class="foo">
    <p class="bar">Hello, world!</p>
  </div>
</template>

<style scoped>
  .foo {
    background-color: blue;
  }
  .bar {
    color: red;
  }
</style> 

对子组件的处理

如果父组件和子组件都在`style`中声明了`scoped`,则**子组件的根节点**会包含两个 id 标识符`(data-v-xxxx)`:

:deep 做了什么

:deep 其实是深度选择器,用于将父组件中的样式透传影响到子组件。它的实现也很简单,就是将 **id 标识属性选择器**前置,使得父组件的样式能够应用到子组件。

通常来说,我们经常会有修改第三方 UI 库的组件样式的需求,在我们组件中引入的第三方组件,也相当于子组件;

看以下例子就可以明白,对于下边的这段 css 代码:

:deep(.foo) {
  color: red;
}

.foo :deep(.bar) {
  color: green;
}

vue 编译器会将它转换为:

🧐源码探究

我们来看看 vue 源码中对 scoped 和 :deep 语法是怎么处理的:

源码中 css 处理是通过一个自定义的 postcss 插件:pluginScoped.ts,文件位于compile-sfc包下:

核心处理的代码为:

/**
* 重写选择器,向选择器中注入给定的ID属性。
  * 
  * @param id 要注入的选择器的ID。
  * @param selector 当前正在处理的选择器。
  * @param selectorRoot 选择器的根节点。
  * @param slotted 是否为slotted选择器,默认为false。
  */
  function rewriteSelector(
    id: string,
    selector: selectorParser.Selector,
    selectorRoot: selectorParser.Root,
    slotted = false
  ) {
    selector.each(n => {
      // 其他代码...... 
      if (n.type === 'pseudo') {
        const { value } = n
        // 核心处理逻辑
        // deep: 在 ::v-deep 节点前注入 [id] 属性
        if (value === ':deep' || value === '::v-deep') {
          if (n.nodes.length) {
            // .foo ::v-deep(.bar) -> .foo[xxxxxxx] .bar
            let last: selectorParser.Selector['nodes'][0] = n
            n.nodes[0].each(ss => {
              selector.insertAfter(last, ss)
              last = ss
            })
            // 如果没有空格组合符,则插入一个
            // 用于处理边界条件,比如原始选择器和:deep没有空格:// .foo::v-deep(.bar) // 处理后 .foo[xxxxxxx] .bar
            const prev = selector.at(selector.index(n) - 1)
            if (!prev || !isSpaceCombinator(prev)) {
              selector.insertAfter(
                n,
                selectorParser.combinator({
                  value: ' '
                })
              )
            }
            selector.removeChild(n)
          } 
          return false
        }
      }
      // 其他代码...... 
    })
    // 其他代码...... 

  }