前言
眾所周知,在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
終端。
假如vue
檔案編譯為js
檔案是一個毛線團,那麼他的線頭一定是vite.config.ts
檔案中使用@vitejs/plugin-vue
的地方。透過這個線頭開始debug
我們就能夠梳理清楚完整的工作流程。
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
鉤子函式打一個條件斷點。如下圖:
然後點選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
函式解析出當前要處理的檔案的filename
和query
,在debug終端來看看此時這兩個的值。如下圖:
從上圖中可以看到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
物件主要有三個屬性template
、scriptSetup
、style
,分別對應的是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
物件為引數執行genTemplateCode
、genScriptCode
、genStyleCode
函式,分別得到編譯後的render函式、編譯後的js程式碼、編譯後的style程式碼。
編譯後的render函式如下圖:
從上圖中可以看到template模組已經編譯成了render函式
編譯後的js程式碼如下圖:
從上圖中可以看到script模組已經編譯成了一個名為_sfc_main
的物件,因為我們這個demo中script模組沒有程式碼,所以這個物件是一個空物件。
編譯後的style程式碼如下圖:
從上圖中可以看到style模組已經編譯成了一個import語句。
最後就是使用換行符\n
將templateCode
、scriptCode
、stylesCode
拼接起來就是vue檔案編譯後的js檔案啦,如下圖:
想必細心的同學已經發現有地方不對啦,這裡的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
函式的時候有三個引數需要注意:source
、id
和scoped
。
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
定義了一堆變數,我們主要關注source
和longId
。
其中的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程式碼是什麼樣的,如下圖:
從上圖可以看到此時的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.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
是container容器,node
才是具體要操作的選擇器節點。
比如我們這裡要執行的selector.insertAfter
方法就是在selector
容器中在一個指定節點後面去插入一個新的節點。這個和操作瀏覽器DOM API很相似。
我們再來看看要插入的節點,selectorParser.attribute
函式的作用是建立一個attribute屬性選擇器。在我們這裡就是建立一個[data-v-x]
的屬性選擇器,如下圖:
所以這裡就是在.block
類選擇器後面插入一個[data-v-c1c19b25]
的屬性選擇器。
我們在debug終端來看看執行insertAfter
函式後的selector
選擇器,如下圖:
將斷點逐層走出,直到processRule
函式中。我們在debug終端來看看此時被重寫後的rule.selector
字串的值是什麼樣的,如下圖
原來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
函式。並且傳入了id
、code
(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的機會