掉了兩根頭髮後,我悟了!vue3的scoped原來是這樣避免樣式汙染(上)

前端欧阳發表於2024-06-27

前言

眾所周知,在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 world</div>
</template>

<style scoped>
.block {
  color: red;
}
</style>

經過編譯後,上面的demo程式碼就會變成下面這樣:

<template>
  <div data-v-c1c19b25 class="block">hello world</div>
</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檔案中的<template>模組、<template setup>模組、<style>模組。

然後呼叫getHash函式給descriptor物件生成一個id屬性,getHash函式程式碼如下:

import { createHash } from "node:crypto";
function getHash(text) {
  return createHash("sha256").update(text).digest("hex").substring(0, 8);
}

從上面的程式碼可以看出id是根據vue檔案的路徑呼叫node的createHash加密函式生成的,這裡生成的id就是scoped生成的自定義屬性data-v-x中的x部分。

然後在createDescriptor函式中將生成的descriptor物件快取起來,關於descriptor物件的處理就這麼多了。

接著在transformMain函式中會分別以descriptor物件為引數執行genTemplateCodegenScriptCodegenStyleCode函式,分別得到編譯後的render函式、編譯後的js程式碼、編譯後的style程式碼。

編譯後的render函式如下圖:
templateCode

從上圖中可以看到template模組已經編譯成了render函式

編譯後的js程式碼如下圖:
scriptCode

從上圖中可以看到script模組已經編譯成了一個名為_sfc_main的物件,因為我們這個demo中script模組沒有程式碼,所以這個物件是一個空物件。

編譯後的style程式碼如下圖:
stylesCode

從上圖中可以看到style模組已經編譯成了一個import語句。

最後就是使用換行符\ntemplateCodescriptCodestylesCode拼接起來就是vue檔案編譯後的js檔案啦,如下圖:
resolvedCode

想必細心的同學已經發現有地方不對啦,這裡的style模組編譯後是一條import語句,並不是真正的css程式碼。這條import語句依然還是import匯入的index.vue檔案,只是加了一些額外的query引數。

?vue&type=style&index=0&lang.css:這個query參數列明當前import匯入的是vue檔案的css部分。

還記得我們前面講過的transform鉤子函式嗎?vite每解析一個模組都會執行一次transform鉤子函式,這個import匯入vue檔案的css部分,當然也會觸發transform鉤子函式的執行。

第二次執行transform鉤子函式

當在瀏覽器中執行vue檔案編譯後的js檔案時會觸發import "/Users/xxx/index.vue?vue&type=style&index=0&lang.css"語句的執行,導致再次執行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
      );
    }
  }
}

由於此時的query中是有vue欄位,所以!query.vue的值為false,這次程式碼就不會走進transformMain函式中了。在else程式碼在先執行getDescriptor函式拿到descriptor物件,getDescriptor函式程式碼如下:

function getDescriptor(filename) {
  const _cache = cache;
  if (_cache.has(filename)) {
    return _cache.get(filename);
  }
}

我們在第一次執行transformMain函式的時候會去執行createDescriptor函式,他的作用是根據當前vue檔案的code程式碼字串生成一個descriptor物件,並且將這個descriptor物件快取起來了。在getDescriptor函式中就是將快取的descriptor物件取出來。

由於query中有type=style,所以程式碼會走到transformStyle函式中。

transformStyle函式

接著將斷點走進transformStyle函式,程式碼如下:

async function transformStyle(code, descriptor, index, options) {
  const block = descriptor.styles[index];
  const result = await options.compiler.compileStyleAsync({
    ...options.style,
    filename: descriptor.filename,
    id: `data-v-${descriptor.id}`,
    source: code,
    scoped: block.scoped,
  });

  return {
    code: result.code,
  };
}

從上面的程式碼可以看到transformStyle函式依然不是幹活的地方,而是呼叫的@vue/compiler-sfc包暴露出的compileStyleAsync函式。

在呼叫compileStyleAsync函式的時候有三個引數需要注意:sourceidscoped

source欄位的值為code,值是當前css程式碼字串。

id欄位的值為data-v-${descriptor.id},是不是覺得看著很熟悉?沒錯他就是使用scoped後vue幫我們自動生成的html自定義屬性data-v-x和css選擇屬性選擇器[data-v-x]

其中的descriptor.id就是在生成descriptor物件時根據vue檔案路徑加密生成的id。

scoped欄位的值為block.scoped,而block的值為descriptor.styles[index]。由於一個vue檔案可以寫多個style標籤,所以descriptor物件的styles屬性是一個陣列,分包對應多個style標籤。我們這裡只有一個style標籤,所以此時的index值為0。block.scoped的值為style標籤上面是否有使用scoped

直到進入compileStyleAsync函式之前程式碼其實一直都還在@vitejs/plugin-vue包中執行,真正幹活的地方是在@vue/compiler-sfc包中。

@vue/compiler-sfc

接著將斷點走進compileStyleAsync函式,程式碼如下:

function compileStyleAsync(options) {
  return doCompileStyle({
    ...options,
    isAsync: true,
  });
}

從上面的程式碼可以看到實際幹活的是doCompileStyle函式。

doCompileStyle函式

接著將斷點走進doCompileStyle函式,在我們這個場景中簡化後的doCompileStyle函式程式碼如下:

import postcss from "postcss";

function doCompileStyle(options) {
  const {
    filename,
    id,
    scoped = false,
    postcssOptions,
    postcssPlugins,
  } = options;
  const source = options.source;
  const shortId = id.replace(/^data-v-/, "");
  const longId = `data-v-${shortId}`;
  const plugins = (postcssPlugins || []).slice();

  if (scoped) {
    plugins.push(scopedPlugin(longId));
  }

  const postCSSOptions = {
    ...postcssOptions,
    to: filename,
    from: filename,
  };
  let result;
  try {
    result = postcss(plugins).process(source, postCSSOptions);
    return result.then((result) => ({
      code: result.css || "",
      // ...省略
    }));
  } catch (e: any) {
    errors.push(e);
  }
}

doCompileStyle函式中首先使用const定義了一堆變數,我們主要關注sourcelongId

其中的source為當前css程式碼字串,longId為根據vue檔案路徑加密生成的id,值的格式為data-v-x。他就是使用scoped後vue幫我們自動生成的html自定義屬性data-v-x和css選擇屬性選擇器[data-v-x]

接著就是判斷scoped是否為true,也就是style中使用有使用scoped。如果為true,就將scopedPlugin外掛push到plugins陣列中。從名字你應該猜到了這個plugin外掛就是用於處理css scoped的。

最後就是執行result = postcss(plugins).process(source, postCSSOptions)拿到經過postcss轉換編譯器處理後的css。

可能有的小夥伴對postcss不夠熟悉,我們這裡來簡單介紹一下。

postcss 是 css 的 transpiler(轉換編譯器,簡稱轉譯器),它對於 css 就像 babel 對於 js 一樣,能夠做 css 程式碼的分析和轉換。同時,它也提供了外掛機制來做自定義的轉換。

在我們這裡主要就是用到了postcss提供的外掛機制來完成css scoped的自定義轉換,呼叫postcss的時候我們傳入了source,他的值是style模組中的css程式碼。並且傳入的plugins外掛陣列中有個scopedPlugin外掛,這個自定義外掛就是vue寫的用於處理css scoped的外掛。

在執行postcss對css程式碼進行轉換之前我們在debug終端來看看此時的css程式碼是什麼樣的,如下圖:
before-postcss

從上圖可以看到此時的css程式碼還是和我們原始碼是一樣的,並沒有css選擇屬性選擇器[data-v-x]

scopedPlugin外掛

scopedPlugin外掛在我們這個場景中簡化後的程式碼如下:

const scopedPlugin = (id = "") => {
  return {
    postcssPlugin: "vue-sfc-scoped",
    Rule(rule) {
      processRule(id, rule);
    },
    // ...省略
  };
};

這裡的id就是我們在doCompileStyle函式中傳過來的longId,也就是生成的css選擇屬性選擇器[data-v-x]中的data-v-x

在我們這個場景中只需要關注Rule鉤子函式,當postcss處理到選擇器開頭的規則就會走到Rule鉤子函式。

我們這裡需要在使用了scoped後給css選擇器新增對應的屬性選擇器[data-v-x],所以我們需要在外掛中使用Rule鉤子函式,在處理css選擇器時手動給選擇器後面塞一個屬性選擇器[data-v-x]

Rule鉤子函式打個斷點,當postcss處理到我們程式碼中的.block時就會走到斷點中。在debug終端看看rule的值,如下圖:
rule

從上圖中可以看到此時rule.selector的值為.block,是一個class值為block的類選擇器。

processRule函式

將斷點走進processRule函式中,在我們這個場景中簡化後的processRule函式程式碼如下:

import selectorParser from "postcss-selector-parser";

function processRule(id: string, rule: Rule) {
  rule.selector = selectorParser((selectorRoot) => {
    selectorRoot.each((selector) => {
      rewriteSelector(id, selector, selectorRoot);
    });
  }).processSync(rule.selector);
}

前面我們講過rule.selector的值為.block,透過重寫rule.selector的值可以將當前css選擇器替換為一個新的選擇器。在processRule函式中就是使用postcss-selector-parser來解析一個選擇器,進行處理後返回一個新的選擇器。

processSync方法的作用為接收一個選擇器,然後在回撥中對解析出來的選擇器進行處理,最後將處理後的選擇器以字串的方式進行返回。

在我們這裡processSync方法接收的選擇器是字串.block,經過回撥函式處理後返回的選擇器字串就變成了.block[data-v-c1c19b25]

我們接下來看selectorParser回撥函式中的程式碼,在回撥函式中會使用selectorRoot.each去遍歷解析出來的選擇器。

為什麼這裡需要去遍歷呢?

答案是css選擇器可以這樣寫:.block.demo,如果是這樣的選擇器經過解析後,就會被解析成兩個選擇器,分別是.block.demo

在each遍歷中會呼叫rewriteSelector函式對當前選取器進行重寫。

rewriteSelector函式

將斷點走進rewriteSelector函式,在我們這個場景中簡化後的程式碼如下:

function rewriteSelector(id, selector) {
  let node;
  const idToAdd = id;

  selector.each((n) => {
    node = n;
  });

  selector.insertAfter(
    node,
    selectorParser.attribute({
      attribute: idToAdd,
      value: idToAdd,
      raws: {},
      quoteMark: `"`,
    })
  );
}

rewriteSelector函式中each遍歷當前selector選擇器,給node賦值。將斷點走到each遍歷之後,我們在debug終端來看看selector選擇器和node變數。如下圖:
selector

在這裡selector是container容器,node才是具體要操作的選擇器節點。

比如我們這裡要執行的selector.insertAfter方法就是在selector容器中在一個指定節點後面去插入一個新的節點。這個和操作瀏覽器DOM API很相似。

我們再來看看要插入的節點,selectorParser.attribute函式的作用是建立一個attribute屬性選擇器。在我們這裡就是建立一個[data-v-x]的屬性選擇器,如下圖:
attribute

所以這裡就是在.block類選擇器後面插入一個[data-v-c1c19b25]的屬性選擇器。

我們在debug終端來看看執行insertAfter函式後的selector選擇器,如下圖:
after-selector

將斷點逐層走出,直到processRule函式中。我們在debug終端來看看此時被重寫後的rule.selector字串的值是什麼樣的,如下圖
after-postcss

原來rule.selector的值為.block,透過重寫rule.selector的值可以將.block類選擇器替換為一個新的選擇器,而這個新的選擇器是在原來的.block類選擇器後面再塞一個[data-v-c1c19b25]屬性選擇器。

總結

這篇文章我們講了當使用scoped後,vue是如何給元件內CSS選擇器新增對應的屬性選擇器[data-v-x]。主要分為兩部分,分別在兩個包裡面執行。

  • 第一部分為在@vitejs/plugin-vue包內執行。

    • 首先會根據當前vue檔案的路徑進行加密演算法生成一個id,這個id就是新增的屬性選擇器[data-v-x]中的x

    • 然後就是執行transformStyle函式,這個transformStyle並不是實際幹活的地方,他呼叫了@vue/compiler-sfc包的compileStyleAsync函式。並且傳入了idcode(css程式碼字串)、scoped(是否在style中使用scoped)。

  • 第二部分在@vue/compiler-sfc包執行。

    • compileStyleAsync函式依然不是實際幹活的地方,而是呼叫了doCompileStyle函式。

    • doCompileStyle函式中,如果scoped為true就向plugins陣列中插入一個scopedPlugin外掛,這個是vue寫的postcss外掛,用於處理css scoped。然後使用postcss轉換編譯器對css程式碼進行轉換。

    • postcss處理到選擇器開頭的規則就會走到scopedPlugin外掛中的Rule鉤子函式中。在Rule鉤子函式中會執行processRule函式。

    • processRule函式中會使用postcss-selector-parser包將當前選擇器替換為一個新的選擇器,新的選擇器和原來的選擇器的區別是在後面會新增一個屬性選擇器[data-v-x]。其中的x就是根據當前vue檔案的路徑進行加密演算法生成的id

在下一篇文章中我們會講vue是如何給html元素增加自定義屬性data-v-x

關注公眾號:【前端歐陽】,給自己一個進階vue的機會

相關文章