Vue 3 的 SFC Style CSS Variable Injection 提案實現的背後

前言

在 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

1 什麼是 <style> 動態變量注入

<style> 動態變量注入,根據 SFC 上尤大的總結,它主要有如下 5 點能力:前端

  • 不須要明確聲明某個屬性被注入做爲 CSS 變量(會根據)
  • 響應式的變量
  • 在 Scoped/Non-scoped 模式下具有不同的表現
  • 不會污染子組件
  • 普通的 CSS 變量的使用不會被影響

下面,咱們來看一個簡單使用 <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

2 <style> 動態變量注入的原理

在文章的開始,咱們講了 <style> 動態變量注入的實現是源於在單文件(SFC)在編譯階段的優化。不過,這裏並不對SFC 編譯的所有過程進行講解,不瞭解的同窗能夠看我以前寫的文章 [從編譯過程,理解 Vue3 靜態節點提高過程]()。typescript

那麼,下面讓咱們聚焦 SFC 在編譯過程對 <style> 動態變量注入的處理,首先是這個過程實現的 2 個關鍵點。

2.1 SFC 編譯對 <style> 動態變量注入的處理

SFC 在編譯過程對 <style> 動態變量注入的處理實現,主要是基於的 2 個關鍵點。這裏,咱們以上面的例子做爲示例分析:

  • 在對應 DOM 上綁定行內 style,經過 CSS var()) 在 CSS 中使用在行內 style 上定義的自定義屬性,對應的 HTML 部分:

    CSS 部分:
  • 經過動態更新 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() 方法生成,它會根據 isProdtruefalse 生成不一樣的值:

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 後獲得的包含 scriptstyletemplate 屬性的對象,每一個屬性包含了 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 編譯結果會是什麼?

3 從 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() 方法。

3.1 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,它的 typeFragment。因此,在 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!
相關文章
相關標籤/搜索