在 5月22日的 Vue Conf 21 上,尤大介紹在介紹單文件組件(SFC)在編譯階段的優化的時候,講了 SFC Style CSS Variable Injection 這個提案,即 <style>
動態變量注入。簡單地講,它可讓你在 <style>
中經過 v-bind
的方式使用 <script>
中定義好的變量。javascript
這麼一聽,彷佛很像 CSS In JS?確實,從使用的角度是和 CSS In JS 很相似。可是,你們都知道的是 CSS In JS 在一些場景下,存在必定的性能問題,而 <style>
動態變量注入卻不存相似的問題。css
那麼, <style>
動態變量注入又是怎麼實現的?我想這是不少同窗都會抱有的一個疑問,因此,今天就讓咱們來完全搞懂何爲 <style>
動態變量注入,以及它實現的背後作了哪些事情。html
<style>
動態變量注入<style>
動態變量注入,根據 SFC 上尤大的總結,它主要有如下 5 點能力:前端
下面,咱們來看一個簡單使用 <style>
動態變量注入的例子:vue
<template> <p class="word">{{ msg }}</p> <button @click="changeColor"> click me </button> </template> <script setup> import { ref } from "vue" const msg = 'Hello World!' let color = ref("red") const changeColor = () => { if (color.value === 'black') { color.value = "red" } else { color.value = "black" } } </script> <style scoped> .word { background: v-bind(color) } </style>
對應的渲染到頁面上:java
從上面的代碼片斷,很容易得知當咱們點擊 click me
按鈕,文字的背景色就會發生變化:node
而這就是 <style>
動態變量注入賦予咱們的能力,讓咱們很便捷地經過 <script>
中的變量來操做 <template>
中的 HTML 元素樣式的動態改變。git
那麼,這個過程又發生了什麼?怎麼實現的?有疑問是件好事,接着讓咱們來一步步揭開其幕後的實現原理。github
<style>
動態變量注入的原理在文章的開始,咱們講了 <style>
動態變量注入的實現是源於在單文件(SFC)在編譯階段的優化。不過,這裏並不對SFC 編譯的所有過程進行講解,不瞭解的同窗能夠看我以前寫的文章 [從編譯過程,理解 Vue3 靜態節點提高過程]()。typescript
那麼,下面讓咱們聚焦 SFC 在編譯過程對 <style>
動態變量注入的處理,首先是這個過程實現的 2 個關鍵點。
<style>
動態變量注入的處理SFC 在編譯過程對 <style>
動態變量注入的處理實現,主要是基於的 2 個關鍵點。這裏,咱們以上面的例子做爲示例分析:
style
,經過 CSS var()
) 在 CSS 中使用在行內 style
上定義的自定義屬性,對應的 HTML 部分:color
變量來實現行內 style
屬性值的變化,進而改變使用了該 CSS 自定義屬性的 HTML 元素樣式那麼,顯然要完成這一整個過程,不一樣於在沒有 <style>
動態變量注入前的 SFC 編譯,這裏須要對 <style>
、<script>
增長相應的特殊處理。下面,咱們分 2 點來說解:
1.SFC 編譯 <style>
相關處理
你們都知道的是在 Vue SFC 的 <style>
部分編譯主要是由 postcss
完成的。而這在 Vue 源碼中對應着 packages/compiler-sfc/sfc/compileStyle.ts
中的 doCompileStyle()
方法。
這裏,咱們看一下其針對 <style>
動態變量注入的編譯處理,對應的代碼(僞代碼):
export function doCompileStyle( options: SFCAsyncStyleCompileOptions ): SFCStyleCompileResults | Promise<SFCStyleCompileResults> { const { ... id, ... } = options ... const plugins = (postcssPlugins || []).slice() plugins.unshift(cssVarsPlugin({ id: shortId, isProd })) ... }
能夠看到,在使用 postcss
編譯 <style>
以前會加入 cssVarsPlugin
插件,並給 cssVarsPlugin
傳入 shortId
(即 scopedId
替換掉 data-v
內的結果)和 isProd
(是否處於生產環境)。
cssVarsPlugin
則是使用了 postcss
插件提供的 Declaration
方法,來訪問 <style>
中聲明的全部 CSS 屬性的值,每次訪問經過正則來匹配 v-bind
指令的內容,而後再使用 replace()
方法將該屬性值替換爲 var(--xxxx-xx)
,表如今上面這個例子會是這樣:
cssVarsPlugin
插件的定義:
const cssVarRE = /\bv-bind\(\s*(?:'([^']+)'|"([^"]+)"|([^'"][^)]*))\s*\)/g const cssVarsPlugin: PluginCreator<CssVarsPluginOptions> = opts => { const { id, isProd } = opts! return { postcssPlugin: 'vue-sfc-vars', Declaration(decl) { // rewrite CSS variables if (cssVarRE.test(decl.value)) { decl.value = decl.value.replace(cssVarRE, (_, $1, $2, $3) => { return `var(--${genVarName(id, $1 || $2 || $3, isProd)})` }) } } } }
這裏 CSS var()
的變量名即 --
(以後的內容)是由 genVarName()
方法生成,它會根據 isProd
爲 true
或 false
生成不一樣的值:
function genVarName(id: string, raw: string, isProd: boolean): string { if (isProd) { return hash(id + raw) } else { return `${id}-${raw.replace(/([^\w-])/g, '_')}` } }
2.SFC 編譯 <script>
相關處理
若是,僅僅站在 <script>
的角度,顯然是沒法感知當前 SFC 是否使用了 <style>
動態變量注入。因此,須要從 SFC 出發來標識當前是否使用了 <style>
動態變量注入。
在 packages/compiler-sfc/parse.ts
中的 parse
方法中會對解析 SFC 獲得的 descriptor
對象調用 parseCssVars()
方法來獲取 <style>
中使用到 v-bind
的全部變量。
descriptor
指的是解析 SFC 後獲得的包含script
、style
、template
屬性的對象,每一個屬性包含了 SFC 中每一個塊(Block)的信息,例如<style>
的屬性scoped
和內容等。
對應的 parse()
方法中部分代碼(僞代碼):
function parse( source: string, { sourceMap = true, filename = 'anonymous.vue', sourceRoot = '', pad = false, compiler = CompilerDOM }: SFCParseOptions = {} ): SFCParseResult { //... descriptor.cssVars = parseCssVars(descriptor) if (descriptor.cssVars.length) { warnExperimental(`v-bind() CSS variable injection`, 231) } //... }
能夠看到,這裏會將 parseCssVars()
方法返回的結果(數組)賦值給 descriptor.cssVars
。而後,在編譯 script
的時候,根據 descriptor.cssVars.length
判斷是否注入 <style>
動態變量注入相關的代碼。
在項目中使用了
<style>
動態變量注入,會在終端種看到提示告知咱們這個特性仍然處於實驗中之類的信息。
而編譯 script
是由 package/compile-sfc/src/compileScript.ts
中的 compileScript
方法完成,這裏咱們看一下其針對 <style>
動態變量注入的處理:
export function compileScript( sfc: SFCDescriptor, options: SFCScriptCompileOptions ): SFCScriptBlock { //... const cssVars = sfc.cssVars //... const needRewrite = cssVars.length || hasInheritAttrsFlag let content = script.content if (needRewrite) { //... if (cssVars.length) { content += genNormalScriptCssVarsCode( cssVars, bindings, scopeId, !!options.isProd ) } } //... }
對於前面咱們舉的例子(使用了 <style>
動態變量注入),顯然 cssVars.length
是存在的,因此這裏會調用 genNormalScriptCssVarsCode()
方法來生成對應的代碼。
genNormalScriptCssVarsCode()
的定義:
// package/compile-sfc/src/cssVars.ts const CSS_VARS_HELPER = `useCssVars` function genNormalScriptCssVarsCode( cssVars: string[], bindings: BindingMetadata, id: string, isProd: boolean ): string { return ( `\nimport { ${CSS_VARS_HELPER} as _${CSS_VARS_HELPER} } from 'vue'\n` + `const __injectCSSVars__ = () => {\n${genCssVarsCode( cssVars, bindings, id, isProd )}}\n` + `const __setup__ = __default__.setup\n` + `__default__.setup = __setup__\n` + ` ? (props, ctx) => { __injectCSSVars__();return __setup__(props, ctx) }\n` + ` : __injectCSSVars__\n` ) }
genNormalScriptCssVarsCode()
方法主要作了這 3 件事:
useCssVars()
方法,其主要是監聽 watchEffect
動態注入的變量,而後再更新對應的 CSS Vars()
的值__injectCSSVars__
方法,其主要是調用了 genCssVarsCode()
方法來生成 <style>
動態樣式相關的代碼<script setup>
狀況下的組合 API 使用(對應這裏 __setup__
),若是它存在則重寫 __default__.setup
爲 (props, ctx) => { __injectCSSVars__();return __setup__(props, ctx) }
那麼,到這裏咱們就已經大體分析完 SFC 編譯對 <style>
動態變量注入的處理,其中部分邏輯並無過多展開講解(避免陷入套娃的狀況),有興趣的同窗能夠自行了解。下面,咱們就針對前面這個例子,看一下 SFC 編譯結果會是什麼?
<style>
動態變量注入實現細節這裏,咱們直接經過 Vue 官方的 SFC Playground 來查看上面這個例子通過 SFC 編譯後輸出的代碼:
import { useCssVars as _useCssVars, unref as _unref } from 'vue' import { toDisplayString as _toDisplayString, createVNode as _createVNode, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock, withScopeId as _withScopeId } from "vue" const _withId = /*#__PURE__*/_withScopeId("data-v-f13b4d11") import { ref } from "vue" const __sfc__ = { expose: [], setup(__props) { _useCssVars(_ctx => ({ "f13b4d11-color": (_unref(color)) })) const msg = 'Hello World!' let color = ref("red") const changeColor = () => { if (color.value === 'black') { color.value = "red" } else { color.value = "black" } } return (_ctx, _cache) => { return (_openBlock(), _createBlock(_Fragment, null, [ _createVNode("p", { class: "word" }, _toDisplayString(msg)), _createVNode("button", { onClick: changeColor }, " click me ") ], 64 /* STABLE_FRAGMENT */)) } } } __sfc__.__scopeId = "data-v-f13b4d11" __sfc__.__file = "App.vue" export default __sfc__
能夠看到 SFC 編譯的結果,輸出了單文件對象 __sfc__
、render
函數、<style>
動態變量注入等相關的代碼。那麼拋開前二者,咱們直接看 <style>
動態變量注入相關的代碼:
_useCssVars(_ctx => ({ "f13b4d11-color": (_unref(color)) }))
這裏調用了 _useCssVars()
方法,即在源碼中指的是 useCssVars()
方法,而後傳入了一個函數,該函數會返回一個對象 { "f13b4d11-color": (_unref(color)) }
。那麼,下面咱們來看一下 useCssVars()
方法。
useCssVars()
方法是定義在 runtime-dom/src/helpers/useCssVars.ts
中:
// runtime-dom/src/helpers/useCssVars.ts function useCssVars(getter: (ctx: any) => Record<string, string>) { if (!__BROWSER__ && !__TEST__) return const instance = getCurrentInstance() if (!instance) { __DEV__ && warn(`useCssVars is called without current active component instance.`) return } const setVars = () => setVarsOnVNode(instance.subTree, getter(instance.proxy!)) onMounted(() => watchEffect(setVars, { flush: 'post' })) onUpdated(setVars) }
useCssVars
主要作了這 4 件事:
instance
,用於後續操做組件實例的 VNode Tree,即 instance.subTree
setVars()
方法,它會調用 setVarsOnVNode()
方法,並 instance.subTree
、接收到的 getter()
方法傳入onMounted()
生命週期中添加 watchEffect
,每次掛載組件的時候都會調用 setVars()
方法onUpdated()
生命週期中添加 setVars()
方法,每次組件更新的時候都會調用 setVars()
方法能夠看到,不管是 onMounted()
或者 onUpdated()
生命週期,它們都會調用 setVars()
方法,本質上也就是 setVarsOnVNode()
方法,咱們先來看一下它的定義:
function setVarsOnVNode(vnode: VNode, vars: Record<string, string>) { if (__FEATURE_SUSPENSE__ && vnode.shapeFlag & ShapeFlags.SUSPENSE) { const suspense = vnode.suspense! vnode = suspense.activeBranch! if (suspense.pendingBranch && !suspense.isHydrating) { suspense.effects.push(() => { setVarsOnVNode(suspense.activeBranch!, vars) }) } } while (vnode.component) { vnode = vnode.component.subTree } if (vnode.shapeFlag & ShapeFlags.ELEMENT && vnode.el) { const style = vnode.el.style for (const key in vars) { style.setProperty(`--${key}`, vars[key]) } } else if (vnode.type === Fragment) { ;(vnode.children as VNode[]).forEach(c => setVarsOnVNode(c, vars)) } }
對於前面咱們這個栗子,因爲初始傳入的是 instance.subtree
,它的 type
爲 Fragment
。因此,在 setVarsOnVNode()
方法中會命中 vnode.type === Fragment
的邏輯,會遍歷 vnode.children
,而後不斷地遞歸調用 setVarsOnVNode()
。
這裏不對 FEATURE_SUSPENSE 和 vnode.component 狀況作展開分析,有興趣的同窗能夠自行了解
而在後續的 setVarsOnVNode()
方法的執行,若是知足 vnode.shapeFlag & ShapeFlags.ELEMENT && vnode.el
的邏輯,則會調用 style.setProperty()
方法來給每一個 VNode
對應的 DOM(vnode.el
)添加行內的 style
,其中 key
是先前處理 <style>
時 CSS var()
的值,value
則對應着 <script>
中定義的變量的值。
這樣一來,就完成了整個從 <script>
中的變量變化到 <style>
中樣式變化的聯動。這裏咱們用圖例來簡單回顧一下這個過程:
若是,簡單地歸納 <style>
動態變量注入的話,可能幾句話就能夠表達。可是,其在源碼層面又是怎麼作的?這是很值得深刻了解的,經過這咱們能夠懂得如何編寫 postcss
插件、CSS vars()
是什麼等技術點。
而且,本來打算留有一個小節用於介紹如何手寫一個 Vite 插件 vite-plugin-vue2-css-vars,讓 Vue 2.x 也能夠支持 <style>
動態變量注入。可是,考慮到文章篇幅太長可能會給你們形成閱讀上的障礙。因此,這會在下一篇文章中介紹,不過目前這個插件已經發到 NPM 上了,有興趣的同窗也能夠自行了解。
最後,若是文中存在表達不當或錯誤的地方,歡迎各位同窗提 Issue~
經過閱讀本篇文章,若是有收穫的話,能夠點個贊,這將會成爲我持續分享的動力,感謝~
我是五柳,喜歡創新、搗鼓源碼,專一於源碼(Vue 三、Vite)、前端工程化、跨端等技術學習和分享。此外,個人全部文章都會收錄在 https://github.com/WJCHumble/Blog,歡迎 Watch Or Star!