VueUse 之手撸 useElementStyle

详解 useElementStyle

本篇我们来实现一下 useElementStyle,它可以用于监听 DOM 元素的 style 属性的变化,方便我们在 Vue 中监听元素样式的一举一动,想想一下你女朋友在化妆时,你可以实时监听到她的样子,是不是很爽,但是你女朋友可能会不爽,因为你会看到她化妆时的丑样,但是你可以告诉她,你是为了学习 useElementStyle 才这么做的,她肯定会原谅你的。

设计

我们先来设计一下 useElementStyle 的结构:

参数:

  • target:要监听的节点

返回值:

  • style:元素的样式对象,类型为CSSStyleDeclaration
  • stop:停止监听
typescript 复制代码
interface UseElementStyleReturn {
  /**
   * 响应式变量,用于存储 DOM 元素的 style 属性
   */
  style: CSSStyleDeclaration
  /**
   * 停止监听 DOM 元素的变化
   */
  stop: () => void
}

export function useElementStyle(
  target: MaybeRef<HTMLElement | null | undefined>
): UseElementStyleReturn {
  const style = reactive({}) as CSSStyleDeclaration

  function stop() {
    // 停止监听 DOM 元素的变化
  }

  return {
    style,
    stop
  }
}

实现

基础实现

在实现功能之前我们需要想一下,什么时候需要去更新 响应式变量 style 的值:

  1. 当我们修改 DOM 元素的 style 属性时,我们需要更新响应式变量 style 的值
    • 此时我们使用 useMutationObserver 来监听 DOM 元素的 style 属性的变化,当监听到变化时,我们更新响应式变量 style 的值
  2. 初始化时,我们需要将 DOM 元素的 style 更新到响应式变量 style
  3. target 变化时,我们需要重新监听新的 DOM 元素的 style 属性的变化

我们来完成这三个需求:

注:我们先不考虑修改 style 时同步到 DOM 样式的问题,我们先来实现 DOM 属性变化时同步到响应式变量的功能,后续功能我们先欠着,本节课会实现的

typescript 复制代码
import { camelCase } from 'lodash-es'
import { useMutationObserver } from '../useMutationObserver'
import { MaybeRef, reactive, unref, watch } from 'vue'

interface UseElementStyleReturn {
  /**
   * 响应式变量,用于存储 DOM 元素的 style 属性
   */
  style: CSSStyleDeclaration
  /**
   * 停止监听 DOM 元素的变化
   */
  stop: () => void
}

export function useElementStyle(
  target: MaybeRef<HTMLElement | null | undefined>
): UseElementStyleReturn {
  const style = reactive({}) as CSSStyleDeclaration

  const { stop } = useMutationObserver(
    target,
    () => {
      // DOM 样式变化时更新响应式变量
      updateReactiveStyle()
    },
    {
      attributeFilter: ['style', 'class']
    }
  )

  /**
   * 将 DOM 元素的 style 属性的值更新到响应式变量
   */
  function updateReactiveStyle() {
    const el = unref(target)
    if (!el) return
    const computedStyle = window.getComputedStyle(el)
    for (const key of computedStyle) {
      // 使用 for of 循环到的 key 是 kebab-case 的,需要转换为 camelCase,如:background-color => backgroundColor,在 CSSStyleDeclaration 中,background-color 对应的属性名为 backgroundColor,因此需要转换,这是因为在 JavaScript 中,对象的属性名不能包含 `-`,因此在 CSSStyleDeclaration 中,所有的属性名都是 camelCase 的
      const _key = camelCase(key) // 我们使用 lodash-es 库中的 camelCase 函数来转换
      style[_key] = computedStyle[_key]
    }
  }

  /**
   * 监听DOM元素的变化,当DOM元素变化时,调用`updateReactiveStyle`更新响应式变量,启用`immediate`选项,表示立即执行一次,用于替代`onMounted`钩子
   */
  watch(() => unref(target), updateReactiveStyle, { immediate: true })

  return {
    style,
    stop
  }
}

上述代码中,我们实现了 useElementStyle,它使用 useMutationObserver 来监听 DOM 元素的 style 属性的变化,当监听到变化时,我们更新响应式变量 style 的值,然后将响应式变量 style 返回。

updateReactiveStyle 函数中,我们使用 for of 遍历 window.getComputedStyle(el) 时,得到的 keykebab-case 的,我们需要将它转换为 camelCase,因为在 CSSStyleDeclaration 中,所有的属性名都是 camelCase 的,如:background-color 对应的属性名为 backgroundColor,但是使用 for of 遍历时,得到的 keykebab-case 的,因此我们需要转换一下。

我们来测试一下:

vue 复制代码
<template>
  <div>
    <n-button type="primary" @click="changeStyle">修改样式</n-button>
    <div ref="el" class="bg-sky-800 w-400 h-200 mt-20">
      <p>opacity:{{ style.opacity }}</p>
      <p>backgroundColor:{{ style.backgroundColor }}</p>
      <p>width:{{ style.width }}</p>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { ref } from 'vue'
import { useElementStyle } from './index'
const el = ref()
const { style } = useElementStyle(el)

/**
 * 手动修改 DOM 样式
 */
function changeStyle() {
  el.value.style.opacity = '0.5'
  el.value.style.backgroundColor = '#000'
  el.value.style.width = '300px'
}
</script>

我们来看一下效果:

还债-同步到响应式变量

我们已经完成了从DOM属性同步到响应式变量的功能,接下来我们需要完成从响应式变量同步到DOM属性的功能,我们来看一下我们的需求:

  1. 当我们修改响应式变量 style 时,我们需要将修改同步到 DOM 元素的 style 属性上
    • 此时我们需要监听响应式变量 style 的变化,当监听到变化时,我们将变化同步到 DOM 元素的 style 属性上

我们来完成这个需求:

typescript 复制代码
/**
 * 将响应式变量的值更新到 DOM 元素的 style 属性
 */
function updateElementStyle() {
  const el = unref(target)
  if (!el) return
  Object.entries(style).forEach(([key, value]) => {
    el.style[key] = value
  })
}

/**
 * 监听 style 的变化,当 style 变化时,调用 `updateElementStyle` 更新 DOM 元素的 style 属性
 */
watch(() => style, updateElementStyle, {
  deep: true
})

我们需要监听响应式变量 style 的变化,当 style 发生变化是,我们调用 updateElementStyle 方法,将变化同步到 DOM 元素的 style 属性上,大家想一下这波操作,有没有什么问题???

当然有的,当我们将响应式变量 style 的值同步到 DOM 的 style 上面时,肯定会触发 useMutationObserver 的回调函数,但是在 useMutationObserver 的回调函数中,我们又会将 DOM 的 style 同步到响应式变量 style 上,虽然不会导致死循环,因为当更新的值和原来的值相等时,Vue3会自动跳过,但是这样做会导致性能的浪费,因此我们在手动修改响应式变量 style 的变化时,需要忽略 useMutationObserver 的回调函数,对此我们有两种方式实现:

  1. 当我们监听到响应式变量 style 变化时,我们先停止 useMutationObserver 的监听,然后再将变化同步到 DOM 的 style 上,最后再重新开始监听。
  2. 我们还可以通过调用 useMutationObserver 返回的 takeRecords 函数,来阻止本次任务调用之前的 DOM 变化引起的回调函数的执行。

由于第一种方式不够优雅,所以我们使用 takeRecords 来实现:

typescript 复制代码
import { camelCase } from 'lodash-es'
import { type MaybeRef, reactive, unref, watch } from 'vue'
import { useMutationObserver } from '../useMutationObserver'

/**
 * useElementStyle 响应式操纵 DOM 元素的 style 属性
 * @param {MaybeRef<HTMLElement | null | undefined>} target
 * @returns {{stop: () => void, style: UnwrapNestedRefs<CSSStyleDeclaration>}}
 */
export function useElementStyle(
  target: MaybeRef<HTMLElement | null | undefined>
) {
  const style = reactive({}) as CSSStyleDeclaration

  /**
   * 监听 DOM 元素的样式变化,由于 DOM 元素的样式受 `style` 属性和 `class` 属性的影响,因此需要监听这两个属性的变化
   */
  const { stop, takeRecords } = useMutationObserver(
    target,
    () => {
      updateReactiveStyle()
    },
    {
      attributeFilter: ['style', 'class']
    }
  )

  /**
   * 将 DOM 元素的 style 属性的值更新到响应式变量
   */
  function updateReactiveStyle() {
    const el = unref(target)
    if (!el) return

    // 获取 DOM 元素的 style 属性的值
    const computedStyle = window.getComputedStyle(el)
    for (const key of computedStyle) {
      // 使用 for of 循环到的 key 是 kebab-case 的,需要转换为 camelCase,如:background-color => backgroundColor,在 CSSStyleDeclaration 中,background-color 对应的属性名为 backgroundColor,因此需要转换,这是因为在 JavaScript 中,对象的属性名不能包含 `-`,因此在 CSSStyleDeclaration 中,所有的属性名都是 camelCase 的
      const _key = camelCase(key) // 我们使用 lodash-es 库中的 camelCase 函数来转换
      const value = computedStyle[_key]
      if (style[_key] === value) continue // 如果 DOM 元素的 style 属性的值和响应式变量的值相同,则不更新
      style[_key] = value
    }
  }

  /**
   * 将响应式变量的值更新到 DOM 元素的 style 属性
   */
  function updateElementStyle() {
    const el = unref(target)
    if (!el) return
    Object.entries(style).forEach(([key, value]) => {
      if (el.style[key] === value) return // 如果 DOM 元素的 style 属性的值和响应式变量的值相同,则不更新,阻止生成更多的 MutationRecord
      el.style[key] = value
    })
    // 阻止本次任务调用之前的 DOM 变化引起的回调函数的执行
    takeRecords()
  }

  /**
   * 监听 style 的变化,当 style 变化时,调用 `updateElementStyle` 更新 DOM 元素的 style 属性
   */
  watch(() => style, updateElementStyle, {
    deep: true
  })

  /**
   * 监听 DOM 元素的变化,当 DOM 元素变化时,调用 `updateReactiveStyle` 更新响应式变量
   */
  watch(() => unref(target), updateReactiveStyle, {
    immediate: true
  })

  return { style, stop }
}

在上述代码中,我们在 updateElementStyle 函数中,调用了 takeRecords 函数,来阻止本次任务调用之前的 DOM 变化引起的回调函数的执行。同时我们在更新属性时,先判断 DOM 元素的 style 属性的值和响应式变量的值是否相同,如果相同,则不更新,阻止生成更多的 MutationRecord

我们在组件中使用:

vue 复制代码
<template>
  <div>
    <n-button type="primary" @click="changeStyle">修改样式</n-button>
    <div ref="el" class="bg-sky-800 w-400 h-200 mt-20">
      <p>opacity:{{ style.opacity }}</p>
      <p>backgroundColor:{{ style.backgroundColor }}</p>
      <p>width:{{ style.width }}</p>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { ref } from 'vue'
import { useElementStyle } from './index'
const el = ref()
const { style } = useElementStyle(el)

/**
 * 通过响应式变量修改样式
 */
function changeStyle() {
  const { opacity, backgroundColor, width } = style
  style.opacity = opacity === '0.1' ? '1' : '0.1'
  style.backgroundColor = backgroundColor === '#f10215' ? '#000' : '#f10215'
  style.width = width === '400px' ? '300px' : '400px'
}
</script>

至此我们完成了 useElementStyle 的实现。

组件式封装

在上节课 useMutationObserver 中,我们对其封装了指令使用,本节课我们对 useElementStyle 封装成组件,监听组件的样式变化。

对组件的封装,其实我们是封装了一层组件的DOM,在组件中监听当前组件根元素的样式变化,我们只是监听组件根元素样式变化,所以我们不关心其它的 props,我们只接受一个 tag 用于表示根元素的标签名,我们默认创建一个 div ,我们来实现一下:

在 Vue3.3 中有其他的组件创建方式,在后续的课程中我们会讲到

typescript 复制代码
import { useElementStyle } from './index'
import { defineComponent, h, ref } from 'vue'

/**
 * UseElementStyle 组件,监听组件的样式变化
 */
export const UseElementStyle = defineComponent({
  name: 'UseElementStyle',
  props: {
    // 根组件标签
    tag: {
      type: String,
      default: 'div'
    }
  },
  setup(props, { slots }) {
    // 声明一个 ref,用于存储根组件的 DOM 元素实例
    const el = ref()
    /**
     * 响应式监听 DOM 元素的样式变化,获取到返回值,将其传递给默认插槽,在插槽中可以获取到样式的值
     */
    const data = useElementStyle(el)

    return () => {
      if (slots.default) {
        // 如果存在默认插槽,则渲染根组件,并将根组件的 DOM 元素实例传递给 useElementStyle
        return h(props.tag!, { ref: el }, slots.default(data))
      }
    }
  }
})

在上述代码中,我们创建了一个 UseElementStyle 组件,该组件在 setup 中返回了一个函数,该函数的返回值是一个 VNode,我们在 VNode 中渲染了一个根元素,这个根元素的标签名是我们根据 props 传递的 tag,默认为 div,并将根元素的 ref 属性设置为 el,然后将 el 传递给 useElementStyle,在 useElementStyle 中,我们监听了 el 的样式变化,并将样式的值传递给默认插槽,以便我们在使用组件时可以使用插槽的作用域值。下面我们在组件中使用它:

vue 复制代码
<template>
  <div>
    <n-button type="primary" @click="changeStyle">修改样式</n-button>
    <UseElementStyle
      ref="cmpRef"
      class="bg-sky-800 w-400 h-200 mt-20"
      v-slot="{ style, stop }"
    >
      <n-button class="m-10" type="primary" @click="stop">
        点击停止监听
      </n-button>
      <p>opacity:{{ style.opacity }}</p>
      <p>backgroundColor:{{ style.backgroundColor }}</p>
      <p>width:{{ style.width }}</p>
    </UseElementStyle>
  </div>
</template>

<script lang="ts" setup>
import { ref } from 'vue'
import { UseElementStyle } from './component'

const cmpRef = ref()

function getRandomColor() {
  return (
    '#' +
    Math.floor(Math.random() * 0xffffff)
      .toString(16)
      .padEnd(6, '0')
  )
}

/**
 * 通过组件实例获取到dom样式修改
 */
function changeStyle() {
  // 获取到组件实例的根元素
  const el = cmpRef.value?.$el as HTMLElement
  const { opacity, width } = window.getComputedStyle(el)
  el.style.opacity = opacity === '0.5' ? '1' : '0.5'
  el.style.backgroundColor = getRandomColor()
  el.style.width = width === '400px' ? '300px' : '400px'
}
</script>

在上述代码中,我们使用 UseElementStyle 组件,将 ref 属性设置为 cmpRef,同时我们使用插槽的作用域获取到组件内部传递的值,然后在 cmpRef 中获取到组件实例的根元素,然后修改根元素的样式,我们来看一下效果:

我们可以看到,当我们使用 UseElementStyle 组件时,我们可以获取到组件内部的样式,同时我们可以通过 stop 函数停止监听,当我们调用 stop 函数时,组件内部元素的样式变化,将不在被 style 监听。

总结

我们来总结一下本篇文章的收获:

  1. 学习了如何使用 useMutationObserver 来实现一个 useElementStyle,用于监听 DOM 元素的 style 属性的变化
  2. 学习了如何使用 takeRecords 函数来阻止本次任务调用之前的 DOM 变化引起的回调函数的执行
  3. 学习了如何将 useElementStyle 封装成组件,用于监听组件的样式变化
  4. 学习了如何使用 setup 返回一个函数创建组件
  5. 学习了如何使用 slots 获取插槽的作用域值
相关推荐
小曲曲1 小时前
接口上传视频和oss直传视频到阿里云组件
javascript·阿里云·音视频
学不会•2 小时前
css数据不固定情况下,循环加不同背景颜色
前端·javascript·html
EasyNTS3 小时前
H.264/H.265播放器EasyPlayer.js视频流媒体播放器关于websocket1006的异常断连
javascript·h.265·h.264
活宝小娜4 小时前
vue不刷新浏览器更新页面的方法
前端·javascript·vue.js
程序视点4 小时前
【Vue3新工具】Pinia.js:提升开发效率,更轻量、更高效的状态管理方案!
前端·javascript·vue.js·typescript·vue·ecmascript
coldriversnow5 小时前
在Vue中,vue document.onkeydown 无效
前端·javascript·vue.js
我开心就好o5 小时前
uniapp点左上角返回键, 重复来回跳转的问题 解决方案
前端·javascript·uni-app
开心工作室_kaic5 小时前
ssm161基于web的资源共享平台的共享与开发+jsp(论文+源码)_kaic
java·开发语言·前端
刚刚好ā5 小时前
js作用域超全介绍--全局作用域、局部作用、块级作用域
前端·javascript·vue.js·vue
沉默璇年7 小时前
react中useMemo的使用场景
前端·react.js·前端框架