前言
在Vue中如果要动态改变样式,可在style中使用v-bind绑定响应式变量,能达到动态更新样式的效果。最近产品刚好提了类似场景的需求:将列表数据通过卡片形式显示,卡片背景根据数据类型区分。v-bind解决这种场景样式就显得so easy。
Style编译相关文章:
-
不使用
v-bind提前将各种数据类型的class定义好,例如
card-black、card-blue、card-pink,并将其动态赋值到元素的class上。这种方式问题在于当数据类型多大上10种时,css中将会定义大量的card-xxxx样式。
<template>
<div class="container">
<div :class="['card', cls]">
<span>{{ title }}</span>
</div>
</div>
</template>
<style scoped>
.container {
display: flex;
}
.card {
font-weight: 700;
width: 200px;
height:80px;
background-size: 100%;
}
.card-black {
background-image: url('./assets/bg/black.png');
}
.card-blue {
background-image: url('./assets/bg/blue.png');
}
.card-pink {
background-image: url('./assets/bg/pink.png');
}
</style>
-
使用
v-bind使用
v-bind的好处非常明显,只需定义一个class,并通过v-bind(变量名)方式动态绑定在setup中声明的变量(如imageUrl)。
<script lang="ts" setup>
import { computed } from 'vue'
const props = defineProps<{ image: string; title: string }>()
const imageUrl = computed(() => `url(${props.image})`)
</script>
<template>
<div class="container">
<div :class="['card']">
<span>{{ title }}</span>
</div>
</div>
</template>
<style scoped>
.container {
display: flex;
}
.card {
font-weight: 700;
width: 200px;
height:80px;
background-size: 100%;
background-image: v-bind(imageUrl);
}
</style>
v-bind确实为动态样式提供了便捷,但为什么Vue的样式可以使用v-bind,并且它的实现原理和css变量有何区别?本篇内容将结合编译后的script代码以及Vue源码来分析解答这两个问题。
v-bind实现原理和css变量有何区别?
从浏览器打开devTools,查看container元素的样式,发现class card有包含background-image:var(--dddd478a-imageUrl),并且元素的style有设置--dddd478a-imageUrl:var(xxx)。可见Vue在样式中使用v-bind的原理是根据v-bind传入的变量动态生成一个css变量,变量命名采用--组件ID-变量名形式,如示例中的dddd478a即为组件唯一ID,这个ID也会作为scoped样式的作用域标识。
如果imageUrl值更新,那如何触发css变量更新?查看卡片组件生成的源码,在定义组件的setup方法中有调用_useCssVars函数,通过方法名可猜测其目的是将imageUrl.value的值赋值给css变量dddd478a-imageUrl,并且内部还支持对imageUrl.value的监听。
const _sfc_main = /* @__PURE__ */ _defineComponent({
setup(__props, { expose: __expose }) {
__expose();
_useCssVars((_ctx) => ({
"dddd478a-imageUrl": imageUrl.value
}));
...
}
});
Vue对外暴露了useCssVars函数,使用方式如下所示。除了会将响应式变量的初始值父给css变量,当响应式变量值更新时也会自动重新赋值给css变量。
import { useCssVars } from 'vue';
以上仅是根据编译后的源码作出的初步猜测,因此还得进一步验证。
为什么Vue的样式可以使用v-bind
要回答为什么Vue的样式可以使用v-bind,首先得知道如何编译解析v-bind,其次得了解如何在setup中生成_useCssVars((_ctx) => ...)代码。
Vue如何编译解析v-bind
当在浏览器使用devTools查看Card组件生成的源码时,发现有如下三个文件,分别为源文件、编译后的css文件、编译后的script。需要注意的是,prod环境生成的文件会经过bundle处理,但原理和dev相似。
查看第二个css文件源码内容,包含__vite_id和__vite_css两个变量。__vite_css为scoped css编译后的结果,其中就包含有 background-image: var(--dddd478a-imageUrl)。
__vite_id变量的作用是什么?该变量会作为参数传递给__vite_updateStyle函数,该函数将在document的head创建一个style标签,标签的内容正是__vite_css。
在devTools的Elements标签查看head部分,发现通过__vite_updateStyle函数新增的css已插入到head中,其中属性data-vite-dev-id的值正是__vite_id,这样的目的是热更新时能快速移出并重新插入。
上述代码中__vite_css的值如何生成?我们从Vue提供的SFC源码编译函数compileSFCScript说起。先看Vue在单元测试提供的一段测试代码:
const { content } = compileSFCScript(
`<script setup>
import { defineProps, ref } from 'vue'
const color = 'red'
const size = ref('10px')
defineProps({
foo: String
})
</script>\n` +
`<style>
div {
color: v-bind(color);
font-size: v-bind(size);
border: v-bind(foo);
}
</style>`,
)
传递给compileSFCScript函数的源代码片段最终的content是什么,带着这个疑问进入compileSFCScript源码。其源码执行流程可分为两部分:Descriptor生成、Script目标代码生成。
export function compileSFCScript(
src: string,
options?: Partial<SFCScriptCompileOptions>,
parseOptions?: SFCParseOptions,
) {
const { descriptor, errors } = parse(src, parseOptions)
if (errors.length) {
console.warn(errors[0])
}
return compileScript(descriptor, {
...options,
id: mockId,
})
}
流程说明:
- Descriptor生成:将SFC代码内容转换为数据结构表达,类似于AST;
- Script目标代码生成:将
Descriptor转换为Script目标代码;
Descriptor生成:parse
parse函数签名:
export function parse(
source: string,
options: SFCParseOptions = {},
): { descriptor: SFCDescriptor };
parse函数会将原始代码转换为描述对象Descriptor,Descriptor主要包含script、styles、template、cssVars等属性。那么这些属性在parse函数中如何生成?
export interface SFCDescriptor {
template: SFCTemplateBlock | null
script: SFCScriptBlock | null
scriptSetup: SFCScriptBlock | null
styles: SFCStyleBlock[]
cssVars: string[]
}
parse函数代码量比较大,我们只需关注和上述几个属性相关的逻辑即可,可分为以下几个阶段:
- 构建AST
首先得将源码构建为语法树,接下来才好识别哪一部分是script,哪一部分是style节点。
const ast = compiler.parse(source, { parseMode: 'sfc',... })
上文中的单元测试代码通过编译生成的ast节点包含tag为script、style两个子节点。假如有多个style代码片段,children会包含多个tag为style的节点。
接着会遍历ast的子节点,根据tag类型生成SFCDescriptor中的script、styles等字段。
- SFCScriptBlock生成
当子节点的tag为script时,调用ceateBlock函数提取script标签上的属性信息,例如setup、代码位置loc等等。
// node.tag为script
const scriptBlock = createBlock(node, source, pad) as SFCScriptBlock
const isSetup = !!scriptBlock.attrs.setup
if (isSetup && !descriptor.scriptSetup) {
descriptor.scriptSetup = scriptBlock
}
...
3. SFCStyleBlock生成
const styleBlock = createBlock(node, source, pad) as SFCStyleBlock
SFCStyleBlock的生成和SFCScriptBlock类似,区别在于type为style,并且可能包含scoped、module等属性。
- cssVars生成
descriptor.cssVars = parseCssVars(descriptor)
再回顾下上文提到的一段代码:
_useCssVars((_ctx) => ({
"dddd478a-imageUrl": imageUrl.value
}));
而决定_useCssVars函数中包含哪些css属性的函数正是parseCssVars,它将遍历所有tag为style节点的源码,并通过正则表达式提取v-bind(变量)中的变量名,并统一加到descriptor.cssVars属性上,其作用是提供给后续的compileScript函数使用。
以下代码为parseCssVars函数的核心片段,通过正则表达式找到v-bind(...)中左括号、右括号的索引位置,而两者之间正好为变量字符串。
const vBindRE = /v-bind\s*\(/g
const start = match.index + match[0].length
const end = lexBinding(content, start)
if (end !== null) {
const variable = normalizeExpression(content.slice(start, end))
if (!vars.includes(variable)) {
vars.push(variable)
}
}
通过以上4个步骤,成功获取到descriptor相关信息,并作为参数提供给下一步compileScript函数使用。
生成_useCssVars((_ctx) => ...)代码:compileScript
通过parse函数生成的Descriptor对象包含script、cssVars等属性,而在compileScript函数中会根据这些信息生成_useCssVars代码片段。
compileScript函数签名如下,sfc为上一步parse函数生成的Descriptor代码描述对象,返回的结果为SFCScriptBlock,也就是script部分的代码块。
export function compileScript(
sfc: SFCDescriptor,
options: SFCScriptCompileOptions,
): SFCScriptBlock
返回类型SFCScriptBlock定义如下:
export interface SFCScriptBlock {
type: 'script'
setup?: string | boolean
bindings?: BindingMetadata
imports?: Record<string, ImportBinding>
scriptAst?: import('@babel/types').Statement[]
scriptSetupAst?: import('@babel/types').Statement[]
content: string
attrs: Record<string, string | true>
lang?: string
}
SFCScriptBlock属性说明(以setup模式为例):
- scriptAst、scriptSetupAst: script部分源码生成的AST;
- setup: script是否为组合式setup模式;
- bindings: 在script中定义的各种类型常量或变量通通都会放到bindings中,并且会标识出类型,如:
setup-let、setup-const、setup-reactive-const、setup-ref等等; - imports:在
script头部各种导入信息收集,打包过程会根据这些导入信息进行bundle; - content:编译后的目标代码;
compileScript函数比较复杂,接下来的分析主线将围绕如何生成_useCssVars。获取SFCScriptBlock信息的前置条件是script代码生成AST,因此先实例化一个ScriptCompileContext类型的上下文对象。
const ctx = new ScriptCompileContext(sfc, options)
在ScriptCompileContext构造函数内部会调用babel的parse函数将源码转换为抽象语法树。
this.scriptSetupAst =
descriptor.scriptSetup &&
parse(descriptor.scriptSetup!.content, this.startOffset!)
有了抽象语法树,像bindings、imports信息就能轻松获取。接下来就是最重要的一步,_useCssVars代码块的生成。
假如在style中有匹配到v-bind代码,则sfc.cssVars将包含在v-bind中定义的变量。由于_useCssVars依赖Vue提供的useCssVars函数,因此需要在helperImports中导入CSS_VARS_HELPERS(也即是useCSSVars)。接下来调用genCssVarsCode函数生成最终的_useCssVars代码块,并将其插入到script代码的前置部分。
if (sfc.cssVars.length) {
ctx.helperImports.add(CSS_VARS_HELPER)
ctx.helperImports.add('unref')
ctx.s.prependLeft(
startOffset,
`\n${genCssVarsCode(
sfc.cssVars,
ctx.bindingMetadata,
scopeId,
!!options.isProd,
)}\n`,
)
}
genCssVarsCode函数会遍历cssVars并生成最终的代码字符串,需要注意的是如果是生产环境,则css变量名直接是scopedId,如xxxxxxxxx、yyyyyyyyy。
_useCssVars(_ctx => ({
"xxxxxxxx-color": (color),
"xxxxxxxx-size": (size.value),
"xxxxxxxx-foo": (__props.foo)
}))
至此_useCssVars代码块就注入到script了,并且当依赖的size.value或props.foo发生变化时,也会实时更新到css变量。至于如何监听并更新到css变量,这部分逻辑包含在useCssVars函数中。
useCssVars如何动态更新
由于绑定的size、props.foo都为响应式,因此useCssVars需要对这些值进行监听,一旦有变化都需要更新到css变量上。useCssVars函数签名如下所示,ctx为组件的上下文对象,参数getter函数用于获取响应式变量值,如size.value、props.foo等。
export function useCssVars(getter: (ctx: any) => Record<string, string>): void
useCssVars首先获取当前组件的实体对象:
const instance = getCurrentInstance()
然后声明setVars方法,该方法将通过getter获取最新的响应式变量,并使用setVarsOnVNode更新到DOM元素的style上。假如size.value设置为14,则给style附加css变量--dddd478a-size: 14。假如组件包含Teleport,说明部分DOM挂载在组件外的其他DOM下,因此需要调用updateTeleports单独处理这部分css变量。
const setVars = () => {
const vars = getter(instance.proxy)
setVarsOnVNode(instance.subTree, vars)
updateTeleports(vars)
}
setVars函数定义好了,但哪些地方有使用setVars方法?一共有两处:
-
监听响应式变化
通过watchPostEffect监听setVars中包含的响应式变量,因此当这些变量(例如size.value、props.foo)有更新,watchPostEffect会重新触发setVars,从而同步到DOM上的CSS变量。onBeforeMount(() => { watchPostEffect(setVars) }) -
DOM元素变化:
当组件容器DOM元素挂载的任何子元素结构或者属性有变化,也会触发setVars方法。实现原理是通过MutationObserver对DOM节点执行监听,主要是通过它提供的observer(domNode, options)方法来处理监听。 onMounted(() => { const ob = new MutationObserver(setVars) ob.observe(instance.subTree.el!.parentNode, { childList: true }) onUnmounted(() => ob.disconnect()) }) 当组件卸载时,得执行ob.disconnect方法断开监听。
通过以上两种场景对setVars方法的调用,确保了相关的响应式变量或DOM节点有变化时,生成的CSS变量都能实时得到更新。
总结
通过浏览器devTools工具不难发现在style代码块中添加的v-bind最终会转换为style的css变量,就是说v-bind使用的正是css自定义变量方式来达到和Vue响应式对象关联。再通过一步步解析SFC代码编译函数compileSFCScript,也证明了我们在devTools工具分析的结果。
其实本篇仅浅析了script代码的生成,而style代码块如何转换为原生的css样式却没有提到.Vue也提供了compileStyle专门编译style代码块,但由于涉及到less、scss等编写语言,因此其编译过程比较复杂,我将在下一篇《为什么Vue的样式可以使用v-bind,和css变量有何区别(下)?》专门分析,尽请期待!
我是
前端下饭菜,原创不易,未经本人同意,请勿转载。各位看官动动手,帮忙关注、点赞、收藏、评论!