• 掉了两根头发后,我悟了!vue3的scoped原来是这样避免样式污染(上)


    前言

    众所周知,在vue中使用scoped可以避免父组件的样式渗透到子组件中。使用了scoped后会给html增加自定义属性data-v-x,同时会给组件内CSS选择器添加对应的属性选择器[data-v-x]。这篇我们来讲讲vue是如何给CSS选择器添加对应的属性选择器[data-v-x]。注:本文中使用的vue版本为3.4.19@vitejs/plugin-vue的版本为5.0.4
    关注公众号:【前端欧阳】,给自己一个进阶vue的机会

    看个demo

    我们先来看个demo,代码如下:

    <template>
      <div class="block">hello worlddiv>
    template>
    
    <style scoped>
    .block {
      color: red;
    }
    style>
    

    经过编译后,上面的demo代码就会变成下面这样:

    <template>
      <div data-v-c1c19b25 class="block">hello worlddiv>
    template>
    
    <style>
    .block[data-v-c1c19b25] {
      color: red;
    }
    style>
    

    从上面的代码可以看到在div上多了一个data-v-c1c19b25自定义属性,并且css的属性选择器上面也多了一个[data-v-c1c19b25]

    可能有的小伙伴有疑问,为什么生成这样的代码就可以避免样式污染呢?

    .block[data-v-c1c19b25]:这里面包含两个选择器。.block是一个类选择器,表示class的值包含block[data-v-c1c19b25]是一个属性选择器,表示存在data-v-c1c19b25自定义属性的元素。

    所以只有class包含block,并且存在data-v-c1c19b25自定义属性的元素才能命中这个样式,这样就能避免样式污染。

    并且由于在同一个组件里面生成的data-v-x值是一样的,所以在同一组件内多个html元素只要class的值包含block,就可以命中color: red的样式。

    接下来我将通过debug的方式带你了解,vue是如何在css中生成.block[data-v-c1c19b25]这样的属性选择器。

    @vitejs/plugin-vue

    还是一样的套路启动一个debug终端。这里以vscode举例,打开终端然后点击终端中的+号旁边的下拉箭头,在下拉中点击Javascript Debug Terminal就可以启动一个debug终端。
    debug-terminal

    假如vue文件编译为js文件是一个毛线团,那么他的线头一定是vite.config.ts文件中使用@vitejs/plugin-vue的地方。通过这个线头开始debug我们就能够梳理清楚完整的工作流程。
    vite-config

    vuePlugin函数

    我们给上方图片的vue函数打了一个断点,然后在debug终端上面执行yarn dev,我们看到断点已经停留在了vue函数这里。然后点击step into,断点走到了@vitejs/plugin-vue库中的一个vuePlugin函数中。我们看到简化后的vuePlugin函数代码如下:

    function vuePlugin(rawOptions = {}) {
      return {
        name: "vite:vue",
        // ...省略其他插件钩子函数
        transform(code, id, opt) {
          // ..
        }
      };
    }
    

    @vitejs/plugin-vue是作为一个plugins插件在vite中使用,vuePlugin函数返回的对象中的transform方法就是对应的插件钩子函数。vite会在对应的时候调用这些插件的钩子函数,vite每解析一个模块都会执行一次transform钩子函数。更多vite钩子相关内容查看官网

    我们这里只需要看transform钩子函数,解析每个模块时调用。

    由于解析每个文件都会走到transform钩子函数中,但是我们只关注index.vue文件是如何解析的,所以我们给transform钩子函数打一个条件断点。如下图:
    conditional-breakpoint

    然后点击Continue(F5),vite服务启动后就会走到transform钩子函数中打的断点。我们可以看到简化后的transform钩子函数代码如下:

    function transform(code, id, opt) {
      const { filename, query } = parseVueRequest(id);
      if (!query.vue) {
        return transformMain(
          code,
          filename,
          options.value,
          this,
          ssr,
          customElementFilter.value(filename)
        );
      } else {
        const descriptor = getDescriptor(filename);
        if (query.type === "style") {
          return transformStyle(
            code,
            descriptor,
            Number(query.index || 0),
            options.value
          );
        }
      }
    }
    

    首先调用parseVueRequest函数解析出当前要处理的文件的filenamequery,在debug终端来看看此时这两个的值。如下图:
    query

    从上图中可以看到filename为当前处理的vue文件路径,query的值为空数组。所以此时代码会走到transformMain函数中。

    transformMain函数

    将断点走进transformMain函数,在我们这个场景中简化后的transformMain函数代码如下:

    async function transformMain(code, filename, options) {
      const { descriptor } = createDescriptor(filename, code, options);
    
      const { code: templateCode } = await genTemplateCode(
        descriptor
        // ...省略
      );
    
      const { code: scriptCode } = await genScriptCode(
        descriptor
        // ...省略
      );
    
      const stylesCode = await genStyleCode(
        descriptor
        // ...省略
      );
    
      const output = [scriptCode, templateCode, stylesCode];
      let resolvedCode = output.join("\n");
      return {
        code: resolvedCode,
      };
    }
    

    我们在 通过debug搞清楚.vue文件怎么变成.js文件文章中已经深入讲解过transformMain函数了,所以这篇文章我们不会深入到transformMain函数中使用到的每个函数中。

    首先调用createDescriptor函数根据当前vue文件的code代码字符串生成一个descriptor对象,简化后的createDescriptor函数代码如下:

    const cache = new Map();
    
    function createDescriptor(
      filename,
      source,
      { root, isProduction, sourceMap, compiler, template }
    ) {
      const { descriptor, errors } = compiler.parse(source, {
        filename,
        sourceMap,
        templateParseOptions: template?.compilerOptions,
      });
      const normalizedPath = slash(path.normalize(path.relative(root, filename)));
      descriptor.id = getHash(normalizedPath + (isProduction ? source : ""));
      cache.set(filename, descriptor);
      return { descriptor, errors };
    }
    

    首先调用compiler.parse方法根据当前vue文件的code代码字符串生成一个descriptor对象,此时的descriptor对象主要有三个属性templatescriptSetupstyle,分别对应的是vue文件中的