我们提供安全,免费的手游软件下载!

安卓手机游戏下载_安卓手机软件下载_安卓手机应用免费下载-先锋下载

当前位置: 主页 > 软件教程 > 软件教程

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

来源:网络 更新时间:2024-07-08 11:32:01

前言

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

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

看个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代码是什么样的,如下图:

从上图中可以看到此时的 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 < 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代码是什么样的,如下图:

从上图中可以看到此时生成的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指令绑定一个响应式变量,并且当响应式变量的值变化时样式也会同步更新。

接着就是执行 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 ,这个的意思是判断当前节点类型是不是一个元素节点,比如

。如果是就走进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

总结

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

编译阶段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定义。

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

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