Vue3的样式竟然可以使用v-bind,和css变量有何区别?

3,344 阅读4分钟

前言

在Vue中如果要动态改变样式,可在style中使用v-bind绑定响应式变量,能达到动态更新样式的效果。最近产品刚好提了类似场景的需求:将列表数据通过卡片形式显示,卡片背景根据数据类型区分。v-bind解决这种场景样式就显得so easy

image.png

Style编译相关文章:

  • 不使用v-bind

    提前将各种数据类型的class定义好,例如card-blackcard-bluecard-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样式的作用域标识。

image.png

如果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相似。

image.png

查看第二个css文件源码内容,包含__vite_id__vite_css两个变量。__vite_css为scoped css编译后的结果,其中就包含有 background-image: var(--dddd478a-imageUrl)

image.png

__vite_id变量的作用是什么?该变量会作为参数传递给__vite_updateStyle函数,该函数将在document的head创建一个style标签,标签的内容正是__vite_css

devTools的Elements标签查看head部分,发现通过__vite_updateStyle函数新增的css已插入到head中,其中属性data-vite-dev-id的值正是__vite_id,这样的目的是热更新时能快速移出并重新插入。

image.png

上述代码中__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函数会将原始代码转换为描述对象DescriptorDescriptor主要包含script、styles、template、cssVars等属性。那么这些属性在parse函数中如何生成?

export interface SFCDescriptor {
  template: SFCTemplateBlock | null
  script: SFCScriptBlock | null
  scriptSetup: SFCScriptBlock | null
  styles: SFCStyleBlock[]
  cssVars: string[]
}

parse函数代码量比较大,我们只需关注和上述几个属性相关的逻辑即可,可分为以下几个阶段:

  1. 构建AST

首先得将源码构建为语法树,接下来才好识别哪一部分是script,哪一部分是style节点。

 const ast = compiler.parse(source, { parseMode: 'sfc',... })

上文中的单元测试代码通过编译生成的ast节点包含tag为script、style两个子节点。假如有多个style代码片段,children会包含多个tag为style的节点。

image.png

接着会遍历ast的子节点,根据tag类型生成SFCDescriptor中的script、styles等字段。

  1. 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等属性。

  1. 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-letsetup-constsetup-reactive-constsetup-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,如xxxxxxxxxyyyyyyyyy

_useCssVars(_ctx => ({
    "xxxxxxxx-color": (color),
    "xxxxxxxx-size": (size.value),
    "xxxxxxxx-foo": (__props.foo)
}))

至此_useCssVars代码块就注入到script了,并且当依赖的size.valueprops.foo发生变化时,也会实时更新到css变量。至于如何监听并更新到css变量,这部分逻辑包含在useCssVars函数中

useCssVars如何动态更新

由于绑定的size、props.foo都为响应式,因此useCssVars需要对这些值进行监听,一旦有变化都需要更新到css变量上。useCssVars函数签名如下所示,ctx为组件的上下文对象,参数getter函数用于获取响应式变量值,如size.valueprops.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.valueprops.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变量有何区别(下)?》专门分析,尽请期待!

我是前端下饭菜,原创不易,未经本人同意,请勿转载。各位看官动动手,帮忙关注、点赞、收藏、评论!