关于前端:Vue-3-的-SFC-Style-CSS-Variable-Injection-提案实现的背后

5次阅读

共计 8590 个字符,预计需要花费 22 分钟才能阅读完成。

前言

在 5 月 22 日的 Vue Conf 21 上,尤大介绍在介绍单文件组件(SFC)在编译阶段的优化的时候,讲了 SFC Style CSS Variable Injection 这个提案,即 <style> 动静变量注入。简略地讲,它能够让你在 <style> 中通过 v-bind 的形式应用 <script> 中定义好的变量。

这么一听,仿佛很像 CSS In JS?的确,从应用的角度是和 CSS In JS 很相似。然而,大家都晓得的是 CSS In JS 在一些场景下,存在肯定的性能问题,而 <style> 动静变量注入却不存相似的问题。

那么,<style> 动静变量注入又是怎么实现的?我想这是很多同学都会抱有的一个疑难,所以,明天就让咱们来彻底搞懂何为 <style> 动静变量注入,以及它实现的背地做了哪些事件。

1 什么是 <style> 动静变量注入

<style> 动静变量注入,依据 SFC 上尤大的总结,它次要有以下 5 点能力:

  • 不须要明确申明某个属性被注入作为 CSS 变量(会依据)
  • 响应式的变量
  • 在 Scoped/Non-scoped 模式下具备一样的体现
  • 不会净化子组件
  • 一般的 CSS 变量的应用不会被影响

上面,咱们来看一个简略应用 <style> 动静变量注入的例子:

<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>

对应的渲染到页面上:

从下面的代码片段,很容易得悉当咱们点击 click me 按钮,文字的背景色就会发生变化:

而这就是 <style> 动静变量注入赋予咱们的能力,让咱们 很便捷地 通过 <script> 中的变量来操作 <template> 中的 HTML 元素 款式的动静扭转

那么,这个过程又产生了什么?怎么实现的?有疑难是件坏事,接着让咱们来一步步揭开其幕后的实现原理。

2 <style> 动静变量注入的原理

在文章的开始,咱们讲了 <style> 动静变量注入的实现是源于在单文件(SFC)在编译阶段的优化。不过,这里并 不对 单文件组件编译的 全副过程 进行解说,不理解的同学能够看我之前写的文章 [从编译过程,了解 Vue3 动态节点晋升过程]()。

那么,上面让咱们聚焦 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`,它的 `type` 为 `Fragment`。所以,在 `setVarsOnVNode()` 办法中会命中 `vnode.type === Fragment` 的逻辑,会遍历 `vnode.children`,而后一直地递归调用 `setVarsOnVNode()`。而在后续的 `setVarsOnVNode()` 办法执行,则是命中 `vnode.shapeFlag & ShapeFlags.ELEMENT && vnode.el` 的逻辑,通过调用 `style.setProperty()` 办法来给每个 VNode 对应的 DOM(`vnode.el`)增加 ** 行内的 `style`**,其中 `key` 是先前解决 `<style>` 时 CSS `var()` 的值,`value` 则对应着 `<script>` 中定义的变量的值。这样一来,就实现了整个从 `<script>` 中的变量变动到 `<style>` 中款式变动的联动。这里咱们用一张图回顾这个过程:![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d35a4dba22e74eb3a00d69385b49bd4c~tplv-k3u1fbpfcp-zoom-1.image)

## 结语

如果,简略地概括 `<style>` 动静变量注入的话,可能几句话就能够表白。然而,其在源码层面又是怎么做的?这是很值得深刻理解的,通过这咱们能够懂得如何编写 `postcss` 插件、CSS `vars()` 是什么等技术点。并且,本来打算留有一个大节用于介绍如何手写一个 Vite 插件 [vite-plugin-vue2-css-vars](https://www.npmjs.com/package/vite-plugin-vue2-css-vars),让 Vue 2.x 也能够反对 `<style>` 动静变量注入。然而,思考到文章篇幅太长可能会给大家造成浏览上的阻碍。所以,这会在下一篇文章中介绍,不过目前这个插件曾经发到 NPM 上了,有趣味的同学也能够自行理解。最初,如果文中存在表白不当或谬误的中央,欢送各位同学提 Issue~

## 点赞

通过浏览本篇文章,如果有播种的话,能够 ** 点个赞 **,这将会成为我继续分享的能源,感激~
正文完
 0