详解 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
的值:
- 当我们修改 DOM 元素的
style
属性时,我们需要更新响应式变量style
的值- 此时我们使用
useMutationObserver
来监听 DOM 元素的style
属性的变化,当监听到变化时,我们更新响应式变量style
的值
- 此时我们使用
- 初始化时,我们需要将 DOM 元素的
style
更新到响应式变量style
中 - 当
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)
时,得到的 key
是 kebab-case
的,我们需要将它转换为 camelCase
,因为在 CSSStyleDeclaration
中,所有的属性名都是 camelCase
的,如:background-color
对应的属性名为 backgroundColor
,但是使用 for of
遍历时,得到的 key
是 kebab-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属性的功能,我们来看一下我们的需求:
- 当我们修改响应式变量
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
的回调函数,对此我们有两种方式实现:
- 当我们监听到响应式变量
style
变化时,我们先停止useMutationObserver
的监听,然后再将变化同步到 DOM 的style
上,最后再重新开始监听。 - 我们还可以通过调用
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
监听。
总结
我们来总结一下本篇文章的收获:
- 学习了如何使用
useMutationObserver
来实现一个useElementStyle
,用于监听 DOM 元素的style
属性的变化 - 学习了如何使用
takeRecords
函数来阻止本次任务调用之前的 DOM 变化引起的回调函数的执行 - 学习了如何将
useElementStyle
封装成组件,用于监听组件的样式变化 - 学习了如何使用
setup
返回一个函数创建组件 - 学习了如何使用
slots
获取插槽的作用域值