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 获取插槽的作用域值
相关推荐
Elena_Lucky_baby几秒前
实现路由懒加载的方式有哪些?
前端·javascript·vue.js
Domain-zhuo1 分钟前
如何利用webpack来优化前端性能?
前端·webpack·前端框架·node.js·ecmascript
理想不理想v5 分钟前
webpack如何自定义插件?示例
前端·webpack·node.js
小华同学ai23 分钟前
ShowDoc:Star12.3k,福利项目,个人小团队的在线文档“简单、易用、轻量化”还专门针对API文档、技术文档做了优化
前端·程序员·github
一雨方知深秋24 分钟前
智慧商城:封装getters实现动态统计 + 全选反选功能
开发语言·javascript·vue2·foreach·find·every
海威的技术博客26 分钟前
关于JS中的this指向问题
开发语言·javascript·ecmascript
王解40 分钟前
Vue CLI 脚手架创建项目流程详解 (2)
前端·javascript·vue.js
刘大浪44 分钟前
vue.js滑动到顶便锁定位置
前端·javascript·vue.js
小金刚®1 小时前
构建简洁之美:我的第一个前端页面
前端
ordinary901 小时前
指令-v-for的key
前端·javascript·vue.js