专栏名称: 奇舞精选
《奇舞精选》是由奇舞团维护的前端技术公众号。除周五外,每天向大家推荐一篇前端相关技术文章,每周五向大家推送汇总周刊内容。
目录
相关文章推荐
新疆949交通广播  ·  广电总局通知! ·  2 天前  
新疆949交通广播  ·  乌市疾控中心发布最新提示! ·  2 天前  
新疆949交通广播  ·  确认了!今起上调 ·  3 天前  
新疆949交通广播  ·  新疆这里发生5.0级地震 ·  3 天前  
51好读  ›  专栏  ›  奇舞精选

新知识get,vue3是如何实现在style中使用响应式变量?

奇舞精选  · 公众号  ·  · 2024-07-08 17:07

正文

前言

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

看个demo

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






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

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

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

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

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

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

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

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

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

从上图中可以看到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终端执行 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); 时就会走到这个断点中。如下图:

将字符串 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[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     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代码是什么样的,如下图:

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

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

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

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

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

执行 useCssVars 函数

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

在开始我们讲过了编译后的setup函数中多了一个 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指令绑定一个响应式变量,并且当响应式变量的值变化时样式也会同步更新。







请到「今天看啥」查看全文


推荐文章
新疆949交通广播  ·  广电总局通知!
2 天前
新疆949交通广播  ·  乌市疾控中心发布最新提示!
2 天前
新疆949交通广播  ·  确认了!今起上调
3 天前
新疆949交通广播  ·  新疆这里发生5.0级地震
3 天前
法律读库  ·  从规范司法角度看法院处罚医院案
8 年前
魔鬼心理学  ·  女朋友竟然用这招骗了我这么多红包!
8 年前