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