前言

vue2的时候想必大家有遇到需要在style模块中访问script模块中的响应式变量,为此我们不得不使用css变量去实现。现在vue3已经内置了这个功能啦,可以在style中使用
v-bind
指令绑定script模块中的响应式变量,这篇文章我们来讲讲vue是如何实现在style中使用script模块中的响应式变量。注:本文中使用的vue版本为
3.4.19

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

看个demo

我们来看个简单的demo,index.vue文件代码如下:

<template>
  <div>
    <p>222</p>
    <span class="block">hello world</span>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";

const primaryColor = ref("red");
</script>

<style scoped>
.block {
  color: v-bind(primaryColor);
}
</style>

我们在script模块中定义了一个响应式变量
primaryColor
,并且在style中使用
v-bind
指令将
primaryColor
变量绑定到color样式上面。

我们在浏览器的network面板中来看看编译后的js文件,如下图:
index-vue

从上图中可以看到在network面板中编译后的index.vue文件有两个,并且第二个里面有一些query参数,其中的
type=style
就表示当前文件的内容对应的是style模块。第一个index.vue对应的是template和script模块中的内容。

我们来看看第一个index.vue,如下图:
setup

从上图中可以看到setup函数是script模块编译后的内容,在
setup
函数中多了一个
_useCssVars
函数,从名字你应该猜到了,这个函数的作用是和css变量有关系。别着急,我们接下来会详细去讲
_useCssVars
函数。

我们再来看看第二个index.vue,如下图:
style

从上图中可以看到这个index.vue确实对应的是style模块中的内容,并且原本的
color: v-bind(primaryColor);
已经变成了
color: var(--c845efc6-primaryColor);

很明显浏览器是不认识
v-bind(primaryColor);
指令的,所以经过编译后就变成了浏览器认识的css变量
var(--c845efc6-primaryColor);

我们接着在elements面板中来看看此时class值为block的span元素,如下图:
elements

从上图中可以看到color的值为css变量
var(--c845efc6-primaryColor)
,这个我们前面讲过。不同的是这里从父级元素div中继承过来一个
--c845efc6-primaryColor: red;

这个就是声明一个名为
--c845efc6-primaryColor
的css变量,变量的值为
red

还记得我们在script模块中定义的响应式变量
primaryColor
吗?他的值就是
red

所以这个span元素最终color渲染出来的值就是
red

接下来我们将通过debug的方式带你搞清楚在style中是如何将指令
v-bind(primaryColor)
编译成css变量
var(--c845efc6-primaryColor)
,以及
_useCssVars
函数是如何生成声明值为
red
的css变量
--c845efc6-primaryColor

doCompileStyle
函数

在前面的文章中我们讲过了style模块实际是由
doCompileStyle
函数函数处理的,具体如何调用到
doCompileStyle
函数可以查看我之前的文章:
掉了两根头发后,我悟了!vue3的scoped原来是这样避免样式污染

我们需要给
doCompileStyle
函数打个断点,
doCompileStyle
函数的代码位置在:
node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js

还是一样的套路启动一个debug终端。这里以
vscode
举例,打开终端然后点击终端中的
+
号旁边的下拉箭头,在下拉中点击
Javascript Debug Terminal
就可以启动一个
debug
终端。
debug-terminal

在debug终端执行
yarn dev
,在浏览器中打开对应的页面,比如:
http://localhost:5173/

此时断点将停留在
doCompileStyle
函数中,在我们这个场景中
doCompileStyle
函数简化后的代码如下:

import postcss from "postcss";

function doCompileStyle(options) {
  const {
    filename,
    id,
    postcssOptions,
    postcssPlugins,
  } = options;
  const source = options.source;
  const shortId = id.replace(/^data-v-/, "");

  const plugins = (postcssPlugins || []).slice();
  plugins.unshift(cssVarsPlugin({ id: shortId, isProd }));

  const postCSSOptions = {
    ...postcssOptions,
    to: filename,
    from: filename,
  };
  let result;
  try {
    result = postcss(plugins).process(source, postCSSOptions);
    return result.then((result) => ({
      code: result.css || "",
      // ...省略
    }));
  } catch (e: any) {
    errors.push(e);
  }
}

在前面的文章
掉了两根头发后,我悟了!vue3的scoped原来是这样避免样式污染
中我们讲过了,这里
id
的值为使用了
scoped
后给html增加的自定义属性
data-v-x
,每个vue文件生成的
x
都是不一样的。在
doCompileStyle
函数中使用
id.replace
方法拿到
x
赋值给变量
shortId

接着就是定义一个
plugins
插件数组,并且将
cssVarsPlugin
函数的返回结果push进去。

这里
cssVarsPlugin
函数就是返回了一个自定义的
postcss
插件。

最后就是执行
result = postcss(plugins).process(source, postCSSOptions)
拿到经过
postcss
转换编译器处理后的css。

可能有的小伙伴对
postcss
不够熟悉,我们这里来简单介绍一下。

postcss
是 css 的 transpiler(转换编译器,简称转译器),它对于 css 就像 babel 对于 js 一样,能够做 css 代码的分析和转换。同时,它也提供了插件机制来做自定义的转换。

在我们这里主要就是用到了
postcss
提供的插件机制来完成css scoped的自定义转换,调用
postcss
的时候我们传入了
source
,他的值是style模块中的css代码。并且传入的
plugins
插件数组中有个
cssVarsPlugin
插件,这个自定义插件就是vue写的用于处理在css中使用v-bind指令。

在执行
postcss
对css代码进行转换之前我们在debug终端来看看此时的css代码是什么样的,如下图:

source

从上图中可以看到此时的
options.source
中还是
v-bind(primaryColor)
指令。

cssVarsPlugin
插件

cssVarsPlugin
插件在我们这个场景中简化后的代码如下:

const vBindRE = /v-bind\s*\(/g;
const cssVarsPlugin = (opts) => {
  const { id, isProd } = opts;
  return {
    postcssPlugin: "vue-sfc-vars",
    Declaration(decl) {
      const value = decl.value;
      if (vBindRE.test(value)) {
        vBindRE.lastIndex = 0;
        let transformed = "";
        let lastIndex = 0;
        let match;
        while ((match = vBindRE.exec(value))) {
          const start = match.index + match[0].length;
          const end = lexBinding(value, start);
          if (end !== null) {
            const variable = normalizeExpression(value.slice(start, end));
            transformed +=
              value.slice(lastIndex, match.index) +
              `var(--${genVarName(id, variable, isProd)})`;
            lastIndex = end + 1;
          }
        }
        decl.value = transformed + value.slice(lastIndex);
      }
    },
  };
};

这里的id就是我们在
doCompileStyle
函数中传过来的
shortId
,每个vue文件对应的
shortId
值都是不同的。

这里使用到了
Declaration
钩子函数,css中每个具体的样式都会触发这个
Declaration
钩子函数。


Declaration
钩子函数打个断点,当
post-css
处理到
color: v-bind(primaryColor);
时就会走到这个断点中。如下图:
before-decl

将字符串
v-bind(primaryColor)
赋值给变量
value
,接着执行
if (vBindRE.test(value))

vBindRE
是一个正则表达式,这里的意思是当前css的值是使用了v-bind指令才走到if语句里面。

接着就是执行
while ((match = vBindRE.exec(value)))
进行正则表达式匹配,如果
value
的值符合
vBindRE
正则表达式,也就是
value
的值是
v-bind
绑定的,那么就走到while循环里面去。

看到这里有的小伙伴会问了,这里使用if就可以了,为什么还要使用
while
循环呢?

答案是css的值可能是多个v-bind指令组成的,比如
border: v-bind(borderWidth) solid v-bind(primaryColor);
。这里的css值就由两个v-bind组成,分别是
v-bind(borderWidth)

v-bind(primaryColor);

为了处理上面这种多个
v-bind
指令组成的css值,所以就需要使用while循环搭配
exec
方法。正则表达式使用了global标志位的时候,js的
RegExp
对象是
有状态
的,它们会将上次成功匹配后的位置记录在
lastIndex
属性中。使用此特性,
exec()
可用来对单个字符串中的多次匹配结果进行逐条的遍历。

在debug终端来看看此时的
match
数组是什么样的,如下图:
match

从上图中可以看到
match[0]
的值是正则表达式匹配的字符串,在我们这里匹配的字符串是
v-bind(

match.index
的值为匹配到的字符位于原始字符串的基于 0 的索引值。

看到这里有的小伙伴可能对
match.index
的值有点不理解,我举个简单的例子你一下就明白了。

还是以
v-bind(borderWidth) solid v-bind(primaryColor)
为例,这个字符串就是原始字符串,第一次在while循环中正则表达式匹配到第一个bind,此时的
match.index
的值为0,也就是第一个
v
在原始字符串的位置。第二次在while循环中会基于第一次的位置接着向后找,会匹配到第二个v-bind指令,此时的
match.index
的值同样也是基于原始字符串的位置,也就是第二个
v-bind
中的
v
的位置,值为26。

在while循环中使用
const start = match.index + match[0].length

start
变量赋值,
match.index
的值是
v-bind
中的
v
的位置。
match[0]
是正则匹配到的字符串
v-bind(
。所以这个
start
的位置就是
v-bind(primaryColor)

primaryColor
变量的开始位置,也就是
p
所在的位置。

接着就是执行
lexBinding
函数拿到
v-bind(primaryColor)

primaryColor
变量的结束位置,赋值给变量
end
。在我们这个场景中简化后的
lexBinding
函数代码如下:

function lexBinding(content: string, start: number) {
  for (let i = start; i < content.length; i++) {
    const char = content.charAt(i);
    if (char === `)`) {
      return i;
    }
  }
  return null;
}

简化后的
lexBinding
函数也很简单,使用for循环遍历
v-bind(primaryColor)
字符串,如果发现字符串
)
就说明找到了
primaryColor
变量的结束位置。

接着来看拿到
end
变量后的代码,会执行
const variable = normalizeExpression(value.slice(start, end))
。这里先执行了
value.slice(start, end)
根据
start
开始位置和
end
结束位置提取出
v-bind
指令绑定的变量,接着
normalizeExpression
函数对其进行
trim
去除空格。

在我们这个场景中简化后的
normalizeExpression
函数代码如下:

function normalizeExpression(exp) {
  exp = exp.trim();
  return exp;
}

将从
v-bind
指令中提取出来的变量赋值给
variable
变量,接着执行字符串拼接拿到由
v-bind
指令转换成的css变量,代码如下:

transformed +=
  value.slice(lastIndex, match.index) +
  `var(--${genVarName(id, variable, isProd)})`;

这里的
value
是css变量值
v-bind(primaryColor)
,在我们这里
lastIndex
的值为0,
match.index
的值也是0,所以
value.slice(lastIndex, match.index)
拿到的值也是空字符串。

接着来看后面这部分,使用字符串拼接得到:
var(--变量)
。这个看着就很熟悉了,他就是一个css变量。变量名是调用
genVarName
函数生成的,
genVarName
函数代码如下:

import hash from "hash-sum";
function genVarName(id, raw, isProd) {
  if (isProd) {
    return hash(id + raw);
  } else {
    return `${id}-${getEscapedCssVarName(raw)}`;
  }
}

这个id是根据当前vue组件路径生成的,每个vue组件生成的id都不同。这个
raw
也就是绑定的响应式变量,在这里是
primaryColor

isProd
表示当前是不是生产环境。

如果是生产环境就根据id和变量名使用哈希算法生成一个加密的字符串。

如果是开发环境就使用字符串拼接将
id
和变量名
primaryColor
拼接起来得到一个css变量。
getEscapedCssVarName
函数的代码也很简单,是对变量中的特殊字符进行转义,以便在 CSS 变量名中使用。代码如下:

const cssVarNameEscapeSymbolsRE = /[ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g;
function getEscapedCssVarName(key: string) {
  return key.replace(cssVarNameEscapeSymbolsRE, (s) => `\\${s}`);
}

这也就是为什么不同组件的
primaryColor
生成的css变量名称不会冲突的原因了,因为在生成的css变量前面拼接了一个
id
,每个vue组件生成的
id
值都不同。

拿到转换成css变量的css值后,并且将其赋值给变量
transformed
。接着就是执行
lastIndex = end + 1
,在我们这里
lastIndex
就指向了字符串的末尾。

最后就是执行
decl.value = transformed + value.slice(lastIndex);

v-bind
指令替换成css变量,由于
lastIndex
是指向了字符串的末尾,所以
value.slice(lastIndex)
的值也是一个空字符串。

所以在我们这里实际是执行了
decl.value = transformed
,执行完这句话后color的值就由
v-bind(primaryColor)
转换成了
var(--c845efc6-primaryColor)

生成
useCssVars
函数

前面我们讲过了编译后的setup函数中多了一个
useCssVars
函数,实际在我们的源代码中是没有这个
useCssVars
函数的。接下来我们来看看编译时处理script模块时是如何生成
useCssVars
函数的。

在之前的
为什么defineProps宏函数不需要从vue中import导入?
文章中我们讲过了vue的script模块中的代码是由
compileScript
函数处理的,当然你没看过那篇文章也不影响这篇文章的阅读。


compileScript
函数打个断点,在我们这个场景中简化后的
compileScript
函数代码如下:

function compileScript(sfc, options) {
  const ctx = new ScriptCompileContext(sfc, options);
  const startOffset = ctx.startOffset;

  ctx.s.prependLeft(
    startOffset,
    `
${genCssVarsCode(sfc.cssVars, ctx.bindingMetadata, scopeId, !!options.isProd)}
`
  );
}

首先调用
ScriptCompileContext
类new了一个
ctx
上下文对象,我们这里来介绍一下需要使用到的
ctx
上下文对象中的两个方法:
ctx.s.toString

ctx.s.prependLeft

  • ctx.s.toString
    :返回此时由script模块编译成的js代码。

  • ctx.s.prependLeft
    :给编译后的js代码在指定
    index
    的前面插入字符串。


ctx.s.prependLeft
方法打个断点,在debug终端使用
ctx.s.toString
方法来看看此时由script模块编译成的js代码是什么样的,如下图:
before-append

从上图中可以看到此时生成的js代码code字符串只有一条
import
语句和定义
primaryColor
变量。

由于篇幅有限我们就不深入到
genCssVarsCode
函数了,这个
genCssVarsCode
函数会生成
useCssVars
函数的调用。我们在debug终端来看看生成的code代码字符串是什么样的,如下图:
genCssVarsCode

从上图中可以看到
genCssVarsCode
函数生成了一个
useCssVars
函数。

执行
ctx.s.prependLeft
函数后会将生成的
useCssVars
函数插入到生成的js code代码字符串的前面,我们在debug终端来看看,如下图:
after-append

从上图中可以看到此时的js code代码字符串中已经有了一个
useCssVars
函数了。

执行
useCssVars
函数

前面我们讲过了编译时经过
cssVarsPlugin
这个
post-css
插件处理后,
v-bind(primaryColor)
指令就会编译成了css变量
var(--c845efc6-primaryColor)
。这里只是使用css变量值的地方,那么这个css变量的值又是在哪里定义的呢?答案是在
useCssVars
函数中。

在开始我们讲过了编译后的setup函数中多了一个
useCssVars
函数,所以我们给
useCssVars
函数打个断点,刷新浏览器此时代码就会走到断点中了。如下图:
useCssVars

从上图中可以看到执行
useCssVars
函数时传入了一个回调函数作为参数,这个回调函数返回了一个对象。

将断点走进
useCssVars
函数,在我们这个场景中简化后的
useCssVars
函数代码如下:

function useCssVars(getter) {
  const instance = getCurrentInstance();

  const setVars = () => {
    const vars = getter(instance.proxy);
    setVarsOnVNode(instance.subTree, vars);
  };

  watchPostEffect(setVars);
}


useCssVars
函数中先调用
getCurrentInstance
函数拿到当前的vue实例,然后将
setVars
函数作为参数传入去执行
watchPostEffect
函数。

这个
watchPostEffect
函数大家应该知道,他是
watchEffect()
使用
flush: 'post'
选项时的别名。

为什么需要使用
flush: 'post'
呢?

答案是需要在
setVars
回调函数中需要去操作DOM,所以才需要使用
flush: 'post'
让回调函数在组件渲染完成之后去执行。


setVars
函数打个断点,组件渲染完成后断点将会走进
setVars
函数中。

首先会执行
getter
函数,将返回值赋值给变量
vars
。前面我们讲过了这个
getter
函数是调用
useCssVars
函数时传入的回调函数,代码如下:

_useCssVars((_ctx) => ({
  "c845efc6-primaryColor": primaryColor.value
}))

在这个回调函数中会返回一个对象,对象的key为
c845efc6-primaryColor
,这个key就是css变量
var(--c845efc6-primaryColor)
括号中的内容。

对象的值是ref变量
primaryColor
的值,由于这个代码是在
watchPostEffect
的回调函数中执行的,所以这里的ref变量
primaryColor
也被作为依赖进行收集了。当
primaryColor
变量的值变化时,
setVars
函数也将再次执行。这也就是为什么在style中可以使用v-bind指令绑定一个响应式变量,并且当响应式变量的值变化时样式也会同步更新。

接着就是执行
setVarsOnVNode(instance.subTree, vars)
函数,传入的第一个参数为
instance.subTree
。他的值是当前vue组件根元素的虚拟DOM,也就是根元素div的虚拟DOM。第二个参数为
useCssVars
传入的回调函数返回的对象,这是一个css变量组成的对象。

接着将断点走进
setVarsOnVNode
函数,在我们这个场景中简化后的代码如下:

function setVarsOnVNode(vnode: VNode, vars) {
  setVarsOnNode(vnode.el, vars);
}


setVarsOnVNode
函数中是调用了
setVarsOnNode
函数,不同的是传入的第一个参数不再是虚拟DOM。而是
vnode.el
虚拟DOM对应的真实DOM,也就是根节点div。

将断点走进
setVarsOnNode
函数,在我们这个场景中简化后的
setVarsOnNode
函数代码如下:

function setVarsOnNode(el: Node, vars) {
  if (el.nodeType === 1) {
    const style = el.style;
    for (const key in vars) {
      style.setProperty(`--${key}`, vars[key]);
    }
  }
}


setVarsOnNode
函数中先使用if语句判断
el.nodeType === 1
,这个的意思是判断当前节点类型是不是一个元素节点,比如
<p>

<div>
。如果是就走进if语句里面,使用
el.style
拿到根节点的style样式。

这里的
vars
是css变量组成的对象,遍历这个对象。对象的key为css变量名称,对象的value为css变量的值。

接着就是遍历css变量组成的对象,使用
style.setProperty
方法给根节点div增加内联样式,也就是
--c845efc6-primaryColor: red;

span
元素由于是根节点div的子节点,所以他也继承了样式
--c845efc6-primaryColor: red;

由于span元素的color经过编译后已经变成了css变量
var(--c845efc6-primaryColor)
,并且从根节点继承过来css变量
--c845efc6-primaryColor
的值为
red
,所以最终span元素的color值为
red

总结

下面这个是我总结的流程图,如下(
搭配流程图后面的文字解释一起服用效果最佳
):
full-progress

编译阶段script模块是由
compileScript
函数处理的,
compileScript
函数会去执行一个
genCssVarsCode
函数。这个函数会返回一个
useCssVars
函数的调用。然后在
compileScript
函数中会调用
ctx.s.prependLeft
方法将生成的
useCssVars
函数插入到编译后的setup函数中。

编译阶段style模块是由
doCompileStyle
函数处理的,在
doCompileStyle
函数中会调用
postcss
对css样式进行处理。vue自定义了一个名为
cssVarsPlugin

postcss
插件,插件中有个
Declaration
钩子函数,css中每个具体的样式都会触发这个
Declaration
钩子函数。


Declaration
钩子函数中使用正则表达式去匹配当前css值是不是
v-bind
绑定的,如果是就将匹配到的
v-bind
绑定的变量提取出来赋值给变量
variable
。还有一个
id
变量,他是根据当前vue组件的路径生成的加密字符串。使用字符串拼接就可以得到
var(--${id}-${variable})
,他就是由
v-bind
编译后生成的css变量。最终生成的css变量类似这样:
var(--c845efc6-primaryColor)

运行时阶段初始化的时候会去执行setup函数,由于在编译阶段setup函数中插入了一个
useCssVars
函数。使用在运行时阶段初始化时
useCssVars
函数会被执行。


useCssVars
函数中执行了
watchPostEffect
函数,他是
watchEffect()
使用
flush: 'post'
选项时的别名。

由于我们需要在回调中操作DOM,所以才需要使用
flush: 'post'
,让回调函数在组件渲染之后去执行。由于在回调函数中会去读取
v-bind
绑定的响应式变量,所以每次绑定的响应式变量值变化后都会再次执行调用
watchPostEffect
传入的回调函数,以此让响应式变量绑定的样式保存更新。


watchPostEffect
传入的回调函数中会通过当前vue组件实例拿到真实DOM的根节点,然后遍历css变量组成的对象,将这些css变量逐个在根节点上面定义,类似这样:
--c845efc6-primaryColor: red;
。由于css可以继承,所以子节点都继承了这个css定义。

我们的
<span>
标签在编译阶段由
color: v-bind(primaryColor);
编译成了css变量
color: var(--c845efc6-primaryColor)
。并且在运行时由于
useCssVars
函数的作用在根节点生成了css变量的定义
--c845efc6-primaryColor: red;
。由于css继承,所以span标签也继承了这个css变量的定义,所以span标签渲染到页面上的color值最终为
red

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

标签: none

添加新评论