前言
vue2的時候想必大家有遇到需要在style模組中訪問script模組中的響應式變數,為此我們不得不使用css變數去實現。現在vue3已經內建了這個功能啦,可以在style中使用v-bind
指令繫結script模組中的響應式變數,這篇文章我們來講講vue是如何實現在style中使用script模組中的響應式變數。注:本文中使用的vue版本為3.4.19
。
關注公眾號:【前端歐陽】,給自己一個進階vue的機會
看個demo
我們來看個簡單的demo,index.vue檔案程式碼如下:
<template>
<div>
<p>222</p>
<span class="block">hello world</span>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
const primaryColor = ref("red");
</script>
<style scoped>
.block {
color: v-bind(primaryColor);
}
</style>
我們在script模組中定義了一個響應式變數primaryColor
,並且在style中使用v-bind
指令將primaryColor
變數繫結到color樣式上面。
我們在瀏覽器的network皮膚中來看看編譯後的js檔案,如下圖:
從上圖中可以看到在network皮膚中編譯後的index.vue檔案有兩個,並且第二個裡面有一些query引數,其中的type=style
就表示當前檔案的內容對應的是style模組。第一個index.vue對應的是template和script模組中的內容。
我們來看看第一個index.vue,如下圖:
從上圖中可以看到setup函式是script模組編譯後的內容,在setup
函式中多了一個_useCssVars
函式,從名字你應該猜到了,這個函式的作用是和css變數有關係。彆著急,我們接下來會詳細去講_useCssVars
函式。
我們再來看看第二個index.vue,如下圖:
從上圖中可以看到這個index.vue確實對應的是style模組中的內容,並且原本的color: v-bind(primaryColor);
已經變成了color: var(--c845efc6-primaryColor);
。
很明顯瀏覽器是不認識v-bind(primaryColor);
指令的,所以經過編譯後就變成了瀏覽器認識的css變數var(--c845efc6-primaryColor);
。
我們接著在elements皮膚中來看看此時class值為block的span元素,如下圖:
從上圖中可以看到color的值為css變數var(--c845efc6-primaryColor)
,這個我們前面講過。不同的是這裡從父級元素div中繼承過來一個--c845efc6-primaryColor: red;
。
這個就是宣告一個名為--c845efc6-primaryColor
的css變數,變數的值為red
。
還記得我們在script模組中定義的響應式變數primaryColor
嗎?他的值就是red
。
所以這個span元素最終color渲染出來的值就是red
。
接下來我們將透過debug的方式帶你搞清楚在style中是如何將指令v-bind(primaryColor)
編譯成css變數var(--c845efc6-primaryColor)
,以及_useCssVars
函式是如何生成宣告值為red
的css變數--c845efc6-primaryColor
。
doCompileStyle
函式
在前面的文章中我們講過了style模組實際是由doCompileStyle
函式函式處理的,具體如何呼叫到doCompileStyle
函式可以檢視我之前的文章: 掉了兩根頭髮後,我悟了!vue3的scoped原來是這樣避免樣式汙染。
我們需要給doCompileStyle
函式打個斷點,doCompileStyle
函式的程式碼位置在:node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js
。
還是一樣的套路啟動一個debug終端。這裡以vscode
舉例,開啟終端然後點選終端中的+
號旁邊的下拉箭頭,在下拉中點選Javascript Debug Terminal
就可以啟動一個debug
終端。
在debug終端執行yarn dev
,在瀏覽器中開啟對應的頁面,比如:http://localhost:5173/ 。
此時斷點將停留在doCompileStyle
函式中,在我們這個場景中doCompileStyle
函式簡化後的程式碼如下:
import postcss from "postcss";
function doCompileStyle(options) {
const {
filename,
id,
postcssOptions,
postcssPlugins,
} = options;
const source = options.source;
const shortId = id.replace(/^data-v-/, "");
const plugins = (postcssPlugins || []).slice();
plugins.unshift(cssVarsPlugin({ id: shortId, isProd }));
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);
}
}
在前面的文章掉了兩根頭髮後,我悟了!vue3的scoped原來是這樣避免樣式汙染中我們講過了,這裡id
的值為使用了scoped
後給html增加的自定義屬性data-v-x
,每個vue檔案生成的x
都是不一樣的。在doCompileStyle
函式中使用id.replace
方法拿到x
賦值給變數shortId
。
接著就是定義一個plugins
外掛陣列,並且將cssVarsPlugin
函式的返回結果push進去。
這裡cssVarsPlugin
函式就是返回了一個自定義的postcss
外掛。
最後就是執行result = postcss(plugins).process(source, postCSSOptions)
拿到經過postcss
轉換編譯器處理後的css。
可能有的小夥伴對postcss
不夠熟悉,我們這裡來簡單介紹一下。
postcss
是 css 的 transpiler(轉換編譯器,簡稱轉譯器),它對於 css 就像 babel 對於 js 一樣,能夠做 css 程式碼的分析和轉換。同時,它也提供了外掛機制來做自定義的轉換。
在我們這裡主要就是用到了postcss
提供的外掛機制來完成css scoped的自定義轉換,呼叫postcss
的時候我們傳入了source
,他的值是style模組中的css程式碼。並且傳入的plugins
外掛陣列中有個cssVarsPlugin
外掛,這個自定義外掛就是vue寫的用於處理在css中使用v-bind指令。
在執行postcss
對css程式碼進行轉換之前我們在debug終端來看看此時的css程式碼是什麼樣的,如下圖:
從上圖中可以看到此時的options.source
中還是v-bind(primaryColor)
指令。
cssVarsPlugin
外掛
cssVarsPlugin
外掛在我們這個場景中簡化後的程式碼如下:
const vBindRE = /v-bind\s*\(/g;
const cssVarsPlugin = (opts) => {
const { id, isProd } = opts;
return {
postcssPlugin: "vue-sfc-vars",
Declaration(decl) {
const value = decl.value;
if (vBindRE.test(value)) {
vBindRE.lastIndex = 0;
let transformed = "";
let lastIndex = 0;
let match;
while ((match = vBindRE.exec(value))) {
const start = match.index + match[0].length;
const end = lexBinding(value, start);
if (end !== null) {
const variable = normalizeExpression(value.slice(start, end));
transformed +=
value.slice(lastIndex, match.index) +
`var(--${genVarName(id, variable, isProd)})`;
lastIndex = end + 1;
}
}
decl.value = transformed + value.slice(lastIndex);
}
},
};
};
這裡的id就是我們在doCompileStyle
函式中傳過來的shortId
,每個vue檔案對應的shortId
值都是不同的。
這裡使用到了Declaration
鉤子函式,css中每個具體的樣式都會觸發這個Declaration
鉤子函式。
給Declaration
鉤子函式打個斷點,當post-css
處理到color: v-bind(primaryColor);
時就會走到這個斷點中。如下圖:
將字串v-bind(primaryColor)
賦值給變數value
,接著執行if (vBindRE.test(value))
。vBindRE
是一個正規表示式,這裡的意思是當前css的值是使用了v-bind指令才走到if語句裡面。
接著就是執行while ((match = vBindRE.exec(value)))
進行正規表示式匹配,如果value
的值符合vBindRE
正規表示式,也就是value
的值是v-bind
繫結的,那麼就走到while迴圈裡面去。
看到這裡有的小夥伴會問了,這裡使用if就可以了,為什麼還要使用while
迴圈呢?
答案是css的值可能是多個v-bind指令組成的,比如border: v-bind(borderWidth) solid v-bind(primaryColor);
。這裡的css值就由兩個v-bind組成,分別是v-bind(borderWidth)
和v-bind(primaryColor);
。
為了處理上面這種多個v-bind
指令組成的css值,所以就需要使用while迴圈搭配exec
方法。正規表示式使用了global標誌位的時候,js的RegExp
物件是有狀態的,它們會將上次成功匹配後的位置記錄在 lastIndex
屬性中。使用此特性,exec()
可用來對單個字串中的多次匹配結果進行逐條的遍歷。
在debug終端來看看此時的match
陣列是什麼樣的,如下圖:
從上圖中可以看到match[0]
的值是正規表示式匹配的字串,在我們這裡匹配的字串是v-bind(
。match.index
的值為匹配到的字元位於原始字串的基於 0 的索引值。
看到這裡有的小夥伴可能對match.index
的值有點不理解,我舉個簡單的例子你一下就明白了。
還是以v-bind(borderWidth) solid v-bind(primaryColor)
為例,這個字串就是原始字串,第一次在while迴圈中正規表示式匹配到第一個bind,此時的match.index
的值為0,也就是第一個v
在原始字串的位置。第二次在while迴圈中會基於第一次的位置接著向後找,會匹配到第二個v-bind指令,此時的match.index
的值同樣也是基於原始字串的位置,也就是第二個v-bind
中的v
的位置,值為26。
在while迴圈中使用const start = match.index + match[0].length
給start
變數賦值,match.index
的值是v-bind
中的v
的位置。match[0]
是正則匹配到的字串 v-bind(
。所以這個start
的位置就是v-bind(primaryColor)
中primaryColor
變數的開始位置,也就是p
所在的位置。
接著就是執行lexBinding
函式拿到v-bind(primaryColor)
中primaryColor
變數的結束位置,賦值給變數end
。在我們這個場景中簡化後的lexBinding
函式程式碼如下:
function lexBinding(content: string, start: number) {
for (let i = start; i < content.length; i++) {
const char = content.charAt(i);
if (char === `)`) {
return i;
}
}
return null;
}
簡化後的lexBinding
函式也很簡單,使用for迴圈遍歷v-bind(primaryColor)
字串,如果發現字串)
就說明找到了primaryColor
變數的結束位置。
接著來看拿到end
變數後的程式碼,會執行const variable = normalizeExpression(value.slice(start, end))
。這裡先執行了value.slice(start, end)
根據start
開始位置和end
結束位置提取出v-bind
指令繫結的變數,接著normalizeExpression
函式對其進行trim
去除空格。
在我們這個場景中簡化後的normalizeExpression
函式程式碼如下:
function normalizeExpression(exp) {
exp = exp.trim();
return exp;
}
將從v-bind
指令中提取出來的變數賦值給variable
變數,接著執行字串拼接拿到由v-bind
指令轉換成的css變數,程式碼如下:
transformed +=
value.slice(lastIndex, match.index) +
`var(--${genVarName(id, variable, isProd)})`;
這裡的value
是css變數值v-bind(primaryColor)
,在我們這裡lastIndex
的值為0,match.index
的值也是0,所以value.slice(lastIndex, match.index)
拿到的值也是空字串。
接著來看後面這部分,使用字串拼接得到:var(--變數)
。這個看著就很熟悉了,他就是一個css變數。變數名是呼叫genVarName
函式生成的,genVarName
函式程式碼如下:
import hash from "hash-sum";
function genVarName(id, raw, isProd) {
if (isProd) {
return hash(id + raw);
} else {
return `${id}-${getEscapedCssVarName(raw)}`;
}
}
這個id是根據當前vue元件路徑生成的,每個vue元件生成的id都不同。這個raw
也就是繫結的響應式變數,在這裡是primaryColor
。isProd
表示當前是不是生產環境。
如果是生產環境就根據id和變數名使用雜湊演算法生成一個加密的字串。
如果是開發環境就使用字串拼接將id
和變數名primaryColor
拼接起來得到一個css變數。getEscapedCssVarName
函式的程式碼也很簡單,是對變數中的特殊字元進行轉義,以便在 CSS 變數名中使用。程式碼如下:
const cssVarNameEscapeSymbolsRE = /[ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g;
function getEscapedCssVarName(key: string) {
return key.replace(cssVarNameEscapeSymbolsRE, (s) => `\\${s}`);
}
這也就是為什麼不同元件的primaryColor
生成的css變數名稱不會衝突的原因了,因為在生成的css變數前面拼接了一個id
,每個vue元件生成的id
值都不同。
拿到轉換成css變數的css值後,並且將其賦值給變數transformed
。接著就是執行lastIndex = end + 1
,在我們這裡lastIndex
就指向了字串的末尾。
最後就是執行decl.value = transformed + value.slice(lastIndex);
將v-bind
指令替換成css變數,由於lastIndex
是指向了字串的末尾,所以value.slice(lastIndex)
的值也是一個空字串。
所以在我們這裡實際是執行了decl.value = transformed
,執行完這句話後color的值就由v-bind(primaryColor)
轉換成了var(--c845efc6-primaryColor)
。
生成useCssVars
函式
前面我們講過了編譯後的setup函式中多了一個useCssVars
函式,實際在我們的原始碼中是沒有這個useCssVars
函式的。接下來我們來看看編譯時處理script模組時是如何生成useCssVars
函式的。
在之前的 為什麼defineProps宏函式不需要從vue中import匯入?文章中我們講過了vue的script模組中的程式碼是由compileScript
函式處理的,當然你沒看過那篇文章也不影響這篇文章的閱讀。
給compileScript
函式打個斷點,在我們這個場景中簡化後的compileScript
函式程式碼如下:
function compileScript(sfc, options) {
const ctx = new ScriptCompileContext(sfc, options);
const startOffset = ctx.startOffset;
ctx.s.prependLeft(
startOffset,
`
${genCssVarsCode(sfc.cssVars, ctx.bindingMetadata, scopeId, !!options.isProd)}
`
);
}
首先呼叫ScriptCompileContext
類new了一個ctx
上下文物件,我們這裡來介紹一下需要使用到的ctx
上下文物件中的兩個方法:ctx.s.toString
、ctx.s.prependLeft
。
-
ctx.s.toString
:返回此時由script模組編譯成的js程式碼。 -
ctx.s.prependLeft
:給編譯後的js程式碼在指定index
的前面插入字串。
給ctx.s.prependLeft
方法打個斷點,在debug終端使用ctx.s.toString
方法來看看此時由script模組編譯成的js程式碼是什麼樣的,如下圖:
從上圖中可以看到此時生成的js程式碼code字串只有一條import
語句和定義primaryColor
變數。
由於篇幅有限我們就不深入到genCssVarsCode
函式了,這個genCssVarsCode
函式會生成useCssVars
函式的呼叫。我們在debug終端來看看生成的code程式碼字串是什麼樣的,如下圖:
從上圖中可以看到genCssVarsCode
函式生成了一個useCssVars
函式。
執行ctx.s.prependLeft
函式後會將生成的useCssVars
函式插入到生成的js code程式碼字串的前面,我們在debug終端來看看,如下圖:
從上圖中可以看到此時的js code程式碼字串中已經有了一個useCssVars
函式了。
執行useCssVars
函式
前面我們講過了編譯時經過cssVarsPlugin
這個post-css
外掛處理後,v-bind(primaryColor)
指令就會編譯成了css變數var(--c845efc6-primaryColor)
。這裡只是使用css變數值的地方,那麼這個css變數的值又是在哪裡定義的呢?答案是在useCssVars
函式中。
在開始我們講過了編譯後的setup函式中多了一個useCssVars
函式,所以我們給useCssVars
函式打個斷點,重新整理瀏覽器此時程式碼就會走到斷點中了。如下圖:
從上圖中可以看到執行useCssVars
函式時傳入了一個回撥函式作為引數,這個回撥函式返回了一個物件。
將斷點走進useCssVars
函式,在我們這個場景中簡化後的useCssVars
函式程式碼如下:
function useCssVars(getter) {
const instance = getCurrentInstance();
const setVars = () => {
const vars = getter(instance.proxy);
setVarsOnVNode(instance.subTree, vars);
};
watchPostEffect(setVars);
}
在useCssVars
函式中先呼叫getCurrentInstance
函式拿到當前的vue例項,然後將setVars
函式作為引數傳入去執行watchPostEffect
函式。
這個watchPostEffect
函式大家應該知道,他是watchEffect()
使用 flush: 'post'
選項時的別名。
為什麼需要使用 flush: 'post'
呢?
答案是需要在setVars
回撥函式中需要去操作DOM,所以才需要使用 flush: 'post'
讓回撥函式在元件渲染完成之後去執行。
給setVars
函式打個斷點,元件渲染完成後斷點將會走進setVars
函式中。
首先會執行getter
函式,將返回值賦值給變數vars
。前面我們講過了這個getter
函式是呼叫useCssVars
函式時傳入的回撥函式,程式碼如下:
_useCssVars((_ctx) => ({
"c845efc6-primaryColor": primaryColor.value
}))
在這個回撥函式中會返回一個物件,物件的key為c845efc6-primaryColor
,這個key就是css變數var(--c845efc6-primaryColor)
括號中的內容。
物件的值是ref變數primaryColor
的值,由於這個程式碼是在watchPostEffect
的回撥函式中執行的,所以這裡的ref變數primaryColor
也被作為依賴進行收集了。當primaryColor
變數的值變化時,setVars
函式也將再次執行。這也就是為什麼在style中可以使用v-bind指令繫結一個響應式變數,並且當響應式變數的值變化時樣式也會同步更新。
接著就是執行setVarsOnVNode(instance.subTree, vars)
函式,傳入的第一個引數為instance.subTree
。他的值是當前vue元件根元素的虛擬DOM,也就是根元素div的虛擬DOM。第二個引數為useCssVars
傳入的回撥函式返回的物件,這是一個css變數組成的物件。
接著將斷點走進setVarsOnVNode
函式,在我們這個場景中簡化後的程式碼如下:
function setVarsOnVNode(vnode: VNode, vars) {
setVarsOnNode(vnode.el, vars);
}
在setVarsOnVNode
函式中是呼叫了setVarsOnNode
函式,不同的是傳入的第一個引數不再是虛擬DOM。而是vnode.el
虛擬DOM對應的真實DOM,也就是根節點div。
將斷點走進setVarsOnNode
函式,在我們這個場景中簡化後的setVarsOnNode
函式程式碼如下:
function setVarsOnNode(el: Node, vars) {
if (el.nodeType === 1) {
const style = el.style;
for (const key in vars) {
style.setProperty(`--${key}`, vars[key]);
}
}
}
在setVarsOnNode
函式中先使用if語句判斷el.nodeType === 1
,這個的意思是判斷當前節點型別是不是一個元素節點,比如<p>
和<div>
。如果是就走進if語句裡面,使用el.style
拿到根節點的style樣式。
這裡的vars
是css變數組成的物件,遍歷這個物件。物件的key為css變數名稱,物件的value為css變數的值。
接著就是遍歷css變數組成的物件,使用style.setProperty
方法給根節點div增加內聯樣式,也就是--c845efc6-primaryColor: red;
。span
元素由於是根節點div的子節點,所以他也繼承了樣式--c845efc6-primaryColor: red;
。
由於span元素的color經過編譯後已經變成了css變數var(--c845efc6-primaryColor)
,並且從根節點繼承過來css變數--c845efc6-primaryColor
的值為red
,所以最終span元素的color值為red
。
總結
下面這個是我總結的流程圖,如下(搭配流程圖後面的文字解釋一起服用效果最佳):
編譯階段script模組是由compileScript
函式處理的,compileScript
函式會去執行一個genCssVarsCode
函式。這個函式會返回一個useCssVars
函式的呼叫。然後在compileScript
函式中會呼叫ctx.s.prependLeft
方法將生成的useCssVars
函式插入到編譯後的setup函式中。
編譯階段style模組是由doCompileStyle
函式處理的,在doCompileStyle
函式中會呼叫postcss
對css樣式進行處理。vue自定義了一個名為cssVarsPlugin
的postcss
外掛,外掛中有個Declaration
鉤子函式,css中每個具體的樣式都會觸發這個Declaration
鉤子函式。
在Declaration
鉤子函式中使用正規表示式去匹配當前css值是不是v-bind
繫結的,如果是就將匹配到的v-bind
繫結的變數提取出來賦值給變數variable
。還有一個id
變數,他是根據當前vue元件的路徑生成的加密字串。使用字串拼接就可以得到var(--${id}-${variable})
,他就是由v-bind
編譯後生成的css變數。最終生成的css變數類似這樣:var(--c845efc6-primaryColor)
。
執行時階段初始化的時候會去執行setup函式,由於在編譯階段setup函式中插入了一個useCssVars
函式。使用在執行時階段初始化時useCssVars
函式會被執行。
在useCssVars
函式中執行了watchPostEffect
函式,他是watchEffect()
使用 flush: 'post'
選項時的別名。
由於我們需要在回撥中操作DOM,所以才需要使用flush: 'post'
,讓回撥函式在元件渲染之後去執行。由於在回撥函式中會去讀取v-bind
繫結的響應式變數,所以每次繫結的響應式變數值變化後都會再次執行呼叫watchPostEffect
傳入的回撥函式,以此讓響應式變數繫結的樣式儲存更新。
在watchPostEffect
傳入的回撥函式中會透過當前vue元件例項拿到真實DOM的根節點,然後遍歷css變數組成的物件,將這些css變數逐個在根節點上面定義,類似這樣:--c845efc6-primaryColor: red;
。由於css可以繼承,所以子節點都繼承了這個css定義。
我們的<span>
標籤在編譯階段由color: v-bind(primaryColor);
編譯成了css變數color: var(--c845efc6-primaryColor)
。並且在執行時由於useCssVars
函式的作用在根節點生成了css變數的定義--c845efc6-primaryColor: red;
。由於css繼承,所以span標籤也繼承了這個css變數的定義,所以span標籤渲染到頁面上的color值最終為red
。
關注公眾號:【前端歐陽】,給自己一個進階vue的機會