Vue 基础体系 · 第 41/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue 封装浏览器 API:ResizeObserver、IntersectionObserver 和剪贴板
浏览器提供了许多不依赖 Vue 的能力:监听元素尺寸变化的 ResizeObserver、监听元素进入视口的 IntersectionObserver,以及通过 navigator.clipboard 访问系统剪贴板的异步 API。
这些 API 直接写在组件中通常并不复杂,但会同时涉及几个容易出错的问题:
- 元素是否已经挂载;
- 组件卸载后观察器是否仍然回调;
- SSR 环境中是否存在
window、navigator; - 观察器回调是否可能高频触发;
- 剪贴板操作是否满足安全上下文和用户手势要求;
- 目标 DOM 是否会因
v-if、v-for或条件渲染而改变。
Vue 的 Composition API 适合把这些“有生命周期的浏览器能力”封装为 Composable。本文从三个 API 的工作机制开始,逐步实现可运行的 TypeScript 封装,并说明它们的边界与故障路径。
一、先明确 Composable 要解决什么问题
Composable 是一个按照 Vue Composition API 组织可复用逻辑的函数。它通常:
- 创建响应式状态;
- 注册生命周期或监听器;
- 在状态变化时更新数据;
- 在作用域销毁时清理副作用;
- 将状态和操作函数返回给组件。
例如,直接在组件中使用 ResizeObserver 可能写成:
const element = ref<HTMLElement | null>(null)
const width = ref(0)
onMounted(() => {
if (!element.value) return
const observer = new ResizeObserver(([entry]) => {
width.value = entry.contentRect.width
})
observer.observe(element.value)
onBeforeUnmount(() => {
observer.disconnect()
})
})
这段代码在单个组件中可以工作,但复用时会产生重复代码。更重要的是,它把以下生命周期关系隐藏在组件内部:
组件挂载
↓
获取 DOM 元素
↓
创建 Observer
↓
observe(element)
↓
元素变化 → 回调 → 更新响应式状态
↓
组件卸载 → disconnect()
封装时,核心目标不是“把代码放进一个函数”,而是保证:
也就是说,只要 Vue 作用域结束,观察器就不能继续持有目标元素并产生回调。
二、为什么不能在 setup() 中直接读取 DOM
在 Composition API 中,模板引用通常声明为:
const target = ref<HTMLElement | null>(null)
然后在模板中绑定:
<div ref="target"></div>
但是,setup() 执行时,组件的 DOM 还没有挂载,因此此时:
console.log(target.value) // null
这是正常行为。DOM 引用只有在挂载阶段之后才会被赋值。
ResizeObserver 和 IntersectionObserver 都要求传入真实的 Element,所以不能在 setup() 的同步代码中直接调用:
// 错误示例
const observer = new ResizeObserver(callback)
observer.observe(target.value) // target.value 仍然可能是 null
更适合的做法是监听模板引用:
watch(
target,
(element, _, onCleanup) => {
if (!element) return
const observer = new ResizeObserver(callback)
observer.observe(element)
onCleanup(() => {
observer.disconnect()
})
},
{ immediate: true }
)
这里有两个重要性质:
target初始为null时,不会创建观察器;target变化时,旧观察器会先被清理,再绑定新元素。
第二点对 v-if、动态组件和列表渲染尤其重要。
三、ResizeObserver:观察元素尺寸,而不是窗口尺寸
3.1 ResizeObserver 的定义
ResizeObserver 用于观察一个或多个元素的盒模型尺寸变化。当被观察元素的尺寸发生变化时,浏览器会异步调用回调:
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
console.log(entry.contentRect.width)
}
})
observer.observe(element)
它与 window.resize 的区别是:
| 能力 | window.resize |
ResizeObserver |
|---|---|---|
| 观察对象 | 浏览器窗口 | 指定元素 |
| 元素因父布局变化而改变尺寸 | 不一定能感知 | 可以感知 |
| 元素因字体、内容、Grid/Flex 布局变化 | 不可靠 | 可以感知 |
| 是否适合组件级响应式布局 | 较弱 | 更适合 |
ResizeObserver 观察的是元素尺寸变化,不是滚动位置,也不是元素是否可见。
3.2 尺寸数据的含义
回调参数是 ResizeObserverEntry[]。每个 ResizeObserverEntry 至少包含:
interface ResizeObserverEntry {
target: Element
contentRect: DOMRectReadOnly
}
contentRect 表示内容盒尺寸,常用字段包括:
entry.contentRect.width
entry.contentRect.height
现代浏览器还提供:
entry.borderBoxSize
entry.contentBoxSize
entry.devicePixelContentBoxSize
它们的区别是:
contentBoxSize:内容盒尺寸;borderBoxSize:包含内边距和边框的盒尺寸;devicePixelContentBoxSize:以设备像素表达的内容盒尺寸,适合对画布等高精度渲染进行处理。
如果只需要普通的 CSS 像素宽高,contentRect 兼容性和可读性都较好。若需要精确对应 box-sizing: border-box 的布局尺寸,应明确选择 borderBoxSize,不能把 contentRect 的结果误认为边框盒尺寸。
现代浏览器中,contentBoxSize 等字段通常是数组,因为一次回调可能对应不同的盒片段。兼容代码可以这样读取:
function getInlineSize(entry: ResizeObserverEntry): number {
const boxSize = entry.contentBoxSize
if (Array.isArray(boxSize)) {
return boxSize[0]?.inlineSize ?? entry.contentRect.width
}
// 某些旧实现可能返回单个对象
return boxSize?.inlineSize ?? entry.contentRect.width
}
3.3 一个可复用的 useResizeObserver
下面的实现接收一个 Ref<Element | null>,返回当前宽高和错误状态。
// composables/useResizeObserver.ts
import {
onScopeDispose,
ref,
watch,
type Ref,
} from 'vue'
interface ResizeObserverState {
width: number
height: number
}
export function useResizeObserver(
target: Ref<Element | null>,
) {
const size = ref<ResizeObserverState>({
width: 0,
height: 0,
})
const supported = ref(
typeof window !== 'undefined' &&
'ResizeObserver' in window,
)
const error = ref<unknown>(null)
const stop = watch(
target,
(element, _, onCleanup) => {
if (!element || !supported.value) {
return
}
const observer = new ResizeObserver((entries) => {
const entry = entries[0]
if (!entry) return
size.value = {
width: entry.contentRect.width,
height: entry.contentRect.height,
}
})
try {
observer.observe(element)
} catch (err) {
error.value = err
}
onCleanup(() => {
observer.disconnect()
})
},
{
immediate: true,
},
)
onScopeDispose(() => {
stop()
})
return {
width: ref(() => size.value.width),
height: ref(() => size.value.height),
size,
supported,
error,
}
}
上面的 width: ref(() => ...) 并不是正确的 Vue 写法:ref 不会把函数自动变成计算属性。实际代码应使用 computed:
// composables/useResizeObserver.ts
import {
computed,
onScopeDispose,
ref,
watch,
type Ref,
} from 'vue'
export function useResizeObserver(
target: Ref<Element | null>,
) {
const size = ref({
width: 0,
height: 0,
})
const supported = ref(
typeof window !== 'undefined' &&
'ResizeObserver' in window,
)
const error = ref<unknown>(null)
const stop = watch(
target,
(element, _, onCleanup) => {
if (!element || !supported.value) {
return
}
const observer = new ResizeObserver((entries) => {
const entry = entries[0]
if (!entry) return
size.value = {
width: entry.contentRect.width,
height: entry.contentRect.height,
}
})
try {
observer.observe(element)
} catch (err) {
error.value = err
}
onCleanup(() => {
observer.disconnect()
})
},
{ immediate: true },
)
onScopeDispose(stop)
return {
size,
width: computed(() => size.value.width),
height: computed(() => size.value.height),
supported,
error,
}
}
这里的执行过程如下:
target初始为null;watch立即执行,但因为没有元素而返回;- 模板挂载后,
target.value变为真实元素; - 创建
ResizeObserver并调用observe(element); - 元素宽高变化时,更新
size; - 目标引用变化或作用域销毁时,执行
disconnect()。
onScopeDispose() 是 Composition API 的作用域清理机制。组件中的 setup() 会创建一个作用域,因此该 Composable 在组件卸载时会停止监听。watch 的 onCleanup 则负责清理当前目标元素对应的观察器。
3.4 使用示例:根据容器宽度切换布局
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useResizeObserver } from '@/composables/useResizeObserver'
const panel = ref<HTMLElement | null>(null)
const { width, height, supported, error } =
useResizeObserver(panel)
const layout = computed(() => {
if (width.value < 480) return 'narrow'
if (width.value < 768) return 'medium'
return 'wide'
})
</script>
<template>
<section ref="panel" class="panel">
<p>宽度:{{ Math.round(width) }}px</p>
<p>高度:{{ Math.round(height) }}px</p>
<p>布局:{{ layout }}</p>
<p v-if="!supported">
当前浏览器不支持 ResizeObserver
</p>
<p v-if="error">
观察尺寸失败:{{ String(error) }}
</p>
</section>
</template>
<style scoped>
.panel {
width: 100%;
resize: horizontal;
overflow: auto;
border: 1px solid #ccc;
padding: 16px;
box-sizing: border-box;
}
</style>
在支持元素调整大小的浏览器中,拖动 .panel 的右侧边缘会触发观察器,width 和 layout 随之更新。
这里不要用 window.innerWidth 替代 ResizeObserver。窗口可能没有变化,但父容器宽度仍可能因侧边栏展开、字体加载、Grid 重排或异步内容加载而变化。
3.5 ResizeObserver 的边界和失败表现
元素被设置为 display: none
如果元素为:
display: none;
它没有可参与布局的盒子,通常会报告零尺寸或不再产生有意义的尺寸变化。此时不能把“没有收到非零宽度”理解为观察器故障。
如果元素随后恢复显示,浏览器可能再次报告尺寸变化,但具体触发时机属于浏览器观察器调度过程,不应依赖某个同步时刻立即得到结果。
在回调中修改布局,可能形成循环
以下逻辑存在风险:
const observer = new ResizeObserver(([entry]) => {
const width = entry.contentRect.width
// 根据当前宽度修改自身宽度
element.style.width = `${width + 1}px`
})
尺寸变化触发回调,回调再次修改尺寸,于是可能形成:
浏览器会对 ResizeObserver 循环进行保护,可能产生类似 ResizeObserver loop completed with undelivered notifications 的错误或警告。正确做法是保证回调中的布局修改最终收敛,或把更新放入 requestAnimationFrame 并避免无条件修改:
let lastWidth = -1
const observer = new ResizeObserver(([entry]) => {
const width = Math.round(entry.contentRect.width)
if (width === lastWidth) return
lastWidth = width
requestAnimationFrame(() => {
// 只有在确实需要时修改布局
})
})
这不是保证消除所有循环的通用方案,关键仍然是让“观察结果”和“布局修改”之间存在稳定的终点。
高频回调不应直接触发昂贵计算
观察器回调可能在连续布局变化期间多次执行。如果每次都进行大型数据处理、同步布局读取和复杂渲染,可能造成卡顿。
可以将同一帧内的更新合并:
let frameId: number | null = null
let pendingWidth = 0
const observer = new ResizeObserver(([entry]) => {
pendingWidth = entry.contentRect.width
if (frameId !== null) return
frameId = requestAnimationFrame(() => {
frameId = null
width.value = pendingWidth
})
})
这是一种经验性调度策略,不是 ResizeObserver 规范要求。若业务需要每一次尺寸变化都被处理,则不能简单丢弃中间值。
四、IntersectionObserver:观察可见交叉比例
4.1 IntersectionObserver 的定义
IntersectionObserver 用于异步观察目标元素与根元素之间的交叉关系。
最常见的根元素是视口:
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
console.log(entry.isIntersecting)
console.log(entry.intersectionRatio)
}
})
observer.observe(element)
这里需要区分三个概念:
- 目标元素:被观察的元素;
- 根元素
root:用于计算交叉关系的容器,省略时通常表示视口; - 交叉区域:目标元素和根元素可见裁剪区域的重叠部分。
当根元素是视口时,浏览器概念上计算:
当目标元素没有面积时,比例计算存在特殊规则,不能简单按照普通面积公式推断。
IntersectionObserver 主要回答的是“目标是否与根区域相交”,它不等价于:
- 用户一定看到了内容;
- 内容一定在屏幕最前面;
- 内容一定没有被其他元素遮挡;
- 用户已经阅读了内容。
它关注的是几何交叉关系。
4.2 rootMargin 和 threshold
观察器配置通常包括:
const observer = new IntersectionObserver(callback, {
root: null,
rootMargin: '0px 0px 200px 0px',
threshold: 0,
})
root
null:使用视口;- 某个元素:使用该元素的内容区域作为根。
若根元素是滚动容器:
const observer = new IntersectionObserver(callback, {
root: scrollContainer,
})
目标元素必须位于该根元素的后代关系中,才适合用该根计算交叉关系。
rootMargin
rootMargin 类似 CSS 的 margin,用于扩大或缩小根区域。
例如:
rootMargin: '0px 0px 200px 0px'
表示把根区域的底部向外扩展 200px。目标元素尚未真正进入视口,但接近视口底部 200px 时,就可能被认为已经相交,常用于预加载。
threshold
threshold 表示交叉比例达到哪些阈值时触发通知:
threshold: [0, 0.5, 1]
意味着在比例跨过 0、0.5、1 等阈值时产生通知。
threshold: 0 适合判断“进入或离开根区域”;threshold: 1 要求目标完整进入根区域,但如果目标比根区域大,就可能永远达不到 1。
4.3 一次观察不一定只产生“进入”回调
注册观察后,浏览器通常会在适当时机提供一次初始交叉状态。之后,只有交叉比例跨过相关阈值时才会产生通知。
因此,下面这种逻辑是错误的:
const observer = new IntersectionObserver(() => {
loadMore()
})
observer.observe(sentinel)
如果 sentinel 初始就在视口内,初始通知也可能触发 loadMore()。如果加载操作改变了布局,使哨兵仍然在视口内,就可能再次触发,形成重复加载。
生产代码通常需要显式状态:
const loading = ref(false)
const hasMore = ref(true)
async function loadMore() {
if (loading.value || !hasMore.value) return
loading.value = true
try {
// 请求下一页
} finally {
loading.value = false
}
}
这里的两个条件分别防止:
loading.value:同一时刻存在多个并发请求;hasMore.value:服务端已经没有更多数据时继续请求。
4.4 一个可复用的 useIntersectionObserver
// composables/useIntersectionObserver.ts
import {
onScopeDispose,
ref,
watch,
type Ref,
} from 'vue'
export interface UseIntersectionObserverOptions
extends IntersectionObserverInit {}
export function useIntersectionObserver(
target: Ref<Element | null>,
options: UseIntersectionObserverOptions = {},
) {
const isIntersecting = ref(false)
const intersectionRatio = ref(0)
const supported = ref(
typeof window !== 'undefined' &&
'IntersectionObserver' in window,
)
const error = ref<unknown>(null)
const stop = watch(
target,
(element, _, onCleanup) => {
if (!element || !supported.value) {
return
}
const observer = new IntersectionObserver(
([entry]) => {
if (!entry) return
isIntersecting.value = entry.isIntersecting
intersectionRatio.value = entry.intersectionRatio
},
options,
)
try {
observer.observe(element)
} catch (err) {
error.value = err
}
onCleanup(() => {
observer.disconnect()
})
},
{ immediate: true },
)
onScopeDispose(stop)
return {
isIntersecting,
intersectionRatio,
supported,
error,
}
}
这个封装返回的是响应式状态,但没有在 Composable 内部自动执行网络请求。这样做是有意的:观察器只负责报告交叉状态,数据加载的并发控制、分页和错误重试属于业务层。
4.5 使用示例:滚动到底部加载下一页
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
interface Article {
id: number
title: string
}
const articles = ref<Article[]>([])
const sentinel = ref<HTMLElement | null>(null)
const page = ref(0)
const loading = ref(false)
const hasMore = ref(true)
const requestError = ref<string | null>(null)
const { isIntersecting, supported } =
useIntersectionObserver(sentinel, {
rootMargin: '0px 0px 300px 0px',
threshold: 0,
})
async function loadNextPage() {
if (loading.value || !hasMore.value) return
loading.value = true
requestError.value = null
try {
const nextPage = page.value + 1
const response = await fetch(`/api/articles?page=${nextPage}`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const result = await response.json() as {
items: Article[]
hasMore: boolean
}
articles.value.push(...result.items)
page.value = nextPage
hasMore.value = result.hasMore
} catch (error) {
requestError.value =
error instanceof Error
? error.message
: '加载失败'
} finally {
loading.value = false
}
}
watch(isIntersecting, (visible) => {
if (visible) {
void loadNextPage()
}
})
</script>
<template>
<main>
<p v-if="!supported">
当前浏览器不支持 IntersectionObserver
</p>
<article
v-for="article in articles"
:key="article.id"
>
{{ article.title }}
</article>
<p v-if="loading">正在加载……</p>
<p v-if="requestError">{{ requestError }}</p>
<p v-if="!hasMore">没有更多内容</p>
<div ref="sentinel" aria-hidden="true"></div>
</main>
</template>
关键路径是:
sentinel 进入根区域
↓
isIntersecting = true
↓
watch 回调执行
↓
loading / hasMore 检查
↓
请求下一页
↓
追加数据
↓
布局改变,观察器可能再次通知
↓
状态检查阻止重复请求
如果将 sentinel 放在一个自定义滚动容器中,必须把 root 设置为该容器;否则观察的是视口,而不是容器内部的滚动区域。
4.6 IntersectionObserver 的边界
“相交”不等于“用户看见”
一个元素可能与视口几何相交,但被以下因素影响:
- 被其他元素遮挡;
- 位于透明层下方;
- 透明度为 0;
- CSS 变换造成视觉效果与布局边界不一致;
- 页面处于后台或浏览器降低了观察频率。
因此,IntersectionObserver 适合懒加载、曝光近似统计和滚动触发,不适合作为严格的“用户确实阅读”证明。
观察器不负责取消请求
如果元素离开视口,观察器只会更新状态,不会自动取消已经发出的 fetch。若需要取消请求,应使用 AbortController:
const controller = new AbortController()
fetch('/api/data', {
signal: controller.signal,
})
// 不再需要时
controller.abort()
这属于请求生命周期控制,不能假设 IntersectionObserver.disconnect() 会替你取消网络请求。
五、剪贴板 API:异步访问系统剪贴板
5.1 navigator.clipboard 是什么
现代剪贴板 API 通过:
navigator.clipboard.writeText(text)
navigator.clipboard.readText()
进行异步文本写入和读取。
它与传统的:
document.execCommand('copy')
不同:
- Clipboard API 是 Promise 风格;
- 能力受安全上下文和权限策略约束;
- 写入和读取都可能失败;
- 不应把操作结果当成同步完成。
剪贴板属于浏览器和操作系统之间的能力,Vue 只负责组织状态和用户交互,不改变浏览器的安全规则。
5.2 安全上下文和用户手势
剪贴板 API 通常要求页面运行在安全上下文中,即:
https://页面;localhost等被浏览器视为安全的本地开发地址。
在不安全的 HTTP 页面中,navigator.clipboard 可能不存在,或者调用被拒绝。
写入通常还要求调用发生在用户激活上下文中,例如点击事件:
async function handleCopy() {
await navigator.clipboard.writeText('文本')
}
下面这种自动行为不可靠,可能被浏览器拒绝:
onMounted(() => {
void navigator.clipboard.writeText('自动复制')
})
读取比写入更敏感,通常还会受到权限和浏览器策略影响。因此不要在页面加载时自动执行 readText(),而应由用户明确点击“读取剪贴板”。
5.3 封装 useClipboard
// composables/useClipboard.ts
import {
computed,
onScopeDispose,
ref,
} from 'vue'
export function useClipboard() {
const copied = ref(false)
const busy = ref(false)
const error = ref<unknown>(null)
const supported = computed(() => {
return typeof navigator !== 'undefined' &&
'clipboard' in navigator
})
let resetTimer: ReturnType<typeof setTimeout> | undefined
async function copy(text: string): Promise<boolean> {
error.value = null
copied.value = false
if (!supported.value) {
error.value = new Error(
'当前环境不支持异步剪贴板 API',
)
return false
}
busy.value = true
try {
await navigator.clipboard.writeText(text)
copied.value = true
if (resetTimer !== undefined) {
clearTimeout(resetTimer)
}
resetTimer = setTimeout(() => {
copied.value = false
resetTimer = undefined
}, 2000)
return true
} catch (err) {
error.value = err
return false
} finally {
busy.value = false
}
}
async function read(): Promise<string | null> {
error.value = null
if (!supported.value) {
error.value = new Error(
'当前环境不支持异步剪贴板 API',
)
return null
}
busy.value = true
try {
return await navigator.clipboard.readText()
} catch (err) {
error.value = err
return null
} finally {
busy.value = false
}
}
onScopeDispose(() => {
if (resetTimer !== undefined) {
clearTimeout(resetTimer)
}
})
return {
copied,
busy,
error,
supported,
copy,
read,
}
}
这个实现处理了几个具体问题:
- SSR 或旧浏览器中没有
navigator时不会在模块执行阶段崩溃; copy和read都是异步函数;busy表示当前是否存在进行中的剪贴板操作;copied只在写入 Promise 成功后变为true;- 重复点击时会清理旧的重置定时器;
- 组件卸载时会清除定时器;
- 权限拒绝、用户取消或浏览器限制都会进入
error。
注意:'clipboard' in navigator 只能说明对象属性存在,不能保证本次操作一定成功。真正的能力检查必须以 Promise 是否成功为准。
5.4 使用示例:复制和读取
<script setup lang="ts">
import { ref } from 'vue'
import { useClipboard } from '@/composables/useClipboard'
const text = ref('这是一段要复制的文本')
const pastedText = ref<string | null>(null)
const {
copied,
busy,
error,
supported,
copy,
read,
} = useClipboard()
async function handleCopy() {
await copy(text.value)
}
async function handleRead() {
pastedText.value = await read()
}
</script>
<template>
<section>
<textarea v-model="text" rows="4" />
<button
type="button"
:disabled="busy || !supported"
@click="handleCopy"
>
{{ copied ? '已复制' : '复制文本' }}
</button>
<button
type="button"
:disabled="busy || !supported"
@click="handleRead"
>
读取剪贴板
</button>
<p v-if="pastedText !== null">
读取结果:{{ pastedText }}
</p>
<p v-if="error">
剪贴板操作失败:
{{ error instanceof Error ? error.message : String(error) }}
</p>
</section>
</template>
输入和预期结果如下:
- 在文本框输入内容;
- 点击“复制文本”;
writeText成功完成后,按钮显示“已复制”;- 点击“读取剪贴板”;
- 如果浏览器允许读取,则显示当前剪贴板文本;
- 如果页面不是安全上下文、用户拒绝权限或浏览器禁止读取,则显示错误。
复制成功的含义是浏览器完成了写入 Promise,而不是“用户一定能在所有系统剪贴板管理器中看到预期内容”。系统剪贴板可能被其他程序随后覆盖。
六、把三个 API 放到同一个组件中
下面是一个组合示例:卡片使用 ResizeObserver 获取自身宽度,使用 IntersectionObserver 判断是否接近视口,同时提供复制卡片内容的能力。
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useClipboard } from '@/composables/useClipboard'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useResizeObserver } from '@/composables/useResizeObserver'
const card = ref<HTMLElement | null>(null)
const {
width,
height,
} = useResizeObserver(card)
const {
isIntersecting,
} = useIntersectionObserver(card, {
rootMargin: '100px',
threshold: 0,
})
const {
copied,
busy: clipboardBusy,
error: clipboardError,
copy,
} = useClipboard()
const cardMode = computed(() => {
return width.value < 400 ? 'compact' : 'comfortable'
})
const content = computed(() => {
return `卡片尺寸:${Math.round(width.value)} × ${Math.round(height.value)}`
})
async function copyContent() {
await copy(content.value)
}
</script>
<template>
<article
ref="card"
class="card"
:class="[`card--${cardMode}`]"
>
<p>当前宽度:{{ Math.round(width) }}px</p>
<p>当前高度:{{ Math.round(height) }}px</p>
<p>
{{ isIntersecting ? '接近或位于可视区域' : '不在可视区域' }}
</p>
<button
type="button"
:disabled="clipboardBusy"
@click="copyContent"
>
{{ copied ? '复制成功' : '复制尺寸信息' }}
</button>
<p v-if="clipboardError">
{{ String(clipboardError) }}
</p>
</article>
</template>
<style scoped>
.card {
box-sizing: border-box;
width: 100%;
border: 1px solid #ddd;
padding: 16px;
}
.card--compact {
font-size: 14px;
}
.card--comfortable {
font-size: 16px;
}
</style>
这个组件中存在三条相互独立的数据流:
元素布局变化 ─────→ ResizeObserver ─────→ width / height ─────→ cardMode
元素与视口交叉 ──→ IntersectionObserver ─→ isIntersecting
用户点击按钮 ────→ Clipboard API ────────→ copied / error
它们不应该互相隐式触发。例如,元素进入视口不应自动读取剪贴板;元素尺寸变化也不应直接启动复制操作。每个浏览器 API 都有不同的安全约束和失败方式,分离数据流可以减少状态误判。
七、SSR、测试与兼容性处理
7.1 SSR 中不要在模块顶层访问浏览器对象
以下代码在服务端渲染环境中可能直接报错:
const observer = new ResizeObserver(...)
const clipboard = navigator.clipboard
因为服务端通常没有 window、document 和 navigator。
应当在运行时检查:
const hasResizeObserver =
typeof window !== 'undefined' &&
'ResizeObserver' in window
const hasClipboard =
typeof navigator !== 'undefined' &&
'clipboard' in navigator
不过,检查只解决“对象不存在”的问题,不解决权限拒绝、浏览器策略和网络环境问题。剪贴板操作仍必须捕获 Promise rejection。
7.2 单元测试中需要模拟 Observer
Node 测试环境通常没有真实的布局引擎,因此:
ResizeObserver不会自然报告真实尺寸;IntersectionObserver不会自然随滚动变化;navigator.clipboard可能不存在。
测试 Composable 时,应注入或模拟这些对象,并手动触发回调。例如测试 ResizeObserver 的重点不是验证浏览器布局,而是验证:
- 目标元素出现后调用了
observe; - 回调更新了响应式宽高;
- 目标变化或组件卸载后调用了
disconnect。
剪贴板测试则应模拟:
navigator.clipboard = {
writeText: vi.fn().mockResolvedValue(undefined),
readText: vi.fn().mockResolvedValue('测试文本'),
}
同时测试拒绝路径:
vi
.mocked(navigator.clipboard.writeText)
.mockRejectedValue(new DOMException('拒绝', 'NotAllowedError'))
7.3 版本敏感能力
本文使用的基础 API 属于现代浏览器中的标准能力,但具体支持范围取决于浏览器版本、运行环境和安全策略:
ResizeObserver和IntersectionObserver需要现代浏览器;navigator.clipboard.writeText的可用条件通常比普通 JavaScript API 更严格;navigator.clipboard.readText的权限限制通常比写入更严格;contentBoxSize、borderBoxSize等尺寸字段的细节存在历史实现差异;- Vue 示例基于 Vue 3 Composition API 和 TypeScript,未依赖 Vue 2 的选项式生命周期。
如果项目需要支持不具备这些能力的旧浏览器,应提供降级方案,而不是仅依赖类型声明。例如:
- IntersectionObserver 不可用时,使用节流后的滚动监听;
- ResizeObserver 不可用时,在明确场景下监听窗口变化并重新测量;
- Clipboard API 不可用时,在用户操作中使用受控的传统复制降级方案。
降级方案的行为不应伪装成完全等价:滚动监听无法天然覆盖所有元素布局变化,传统复制 API 也受到浏览器限制。
八、常见误解与诊断路径
误解一:调用 observe() 后会同步得到当前尺寸
观察器回调是异步通知机制,不应写出依赖同步结果的代码:
observer.observe(element)
console.log(width.value) // 不能保证已经更新
如果业务需要立即得到当前尺寸,可以先主动测量,再注册观察器:
const rect = element.getBoundingClientRect()
size.value = {
width: rect.width,
height: rect.height,
}
observer.observe(element)
主动测量和观察器通知之间可能存在时间差,因此仍需以观察器后续结果为准。
误解二:IntersectionObserver 回调只表示“刚刚进入”
回调表示交叉状态或比例达到阈值,初始观察也可能产生通知。诊断无限加载时,应检查:
- 哨兵是否初始就在可视区域;
root是否配置正确;rootMargin是否过大;- 是否缺少
loading锁; - 请求完成后布局是否仍让哨兵保持相交;
hasMore是否正确更新。
误解三:navigator.clipboard 存在就一定能复制
实际结果必须以 Promise 为准。常见失败原因包括:
- 页面使用不安全的 HTTP;
- 调用不在用户激活上下文中;
- 浏览器或嵌入式 WebView 禁止该能力;
- iframe 的权限策略不允许剪贴板;
- 用户拒绝权限;
- 页面失去焦点或浏览器执行了额外安全限制。
诊断时不要只打印:
console.log('clipboard' in navigator)
而应记录真正的异常类型:
try {
await navigator.clipboard.writeText(text)
} catch (error) {
if (error instanceof DOMException) {
console.error(error.name, error.message)
} else {
console.error(error)
}
}
误解四:组件卸载后观察器会自动停止
如果观察器只在组件内部创建,且正确使用 Vue 的作用域清理,通常可以随组件生命周期结束而清理。但如果把观察器存放在模块级变量、单例服务或全局事件总线上,就不能假设组件卸载会自动调用 disconnect()。
一旦观察器持有已卸载元素或闭包中的大量状态,就可能造成无意义的回调、状态更新和内存保留。封装时应明确观察器的所有权:
谁创建,谁负责 disconnect;
谁创建定时器,谁负责 clearTimeout;
谁发起请求,谁决定是否 abort。
九、三种 API 的取舍边界
这三个能力解决的是不同问题:
| API | 观察或操作对象 | 典型用途 | 不适合替代 |
|---|---|---|---|
ResizeObserver |
元素尺寸 | 容器响应式、Canvas 重设尺寸 | 判断是否进入视口 |
IntersectionObserver |
元素与根区域的交叉关系 | 懒加载、曝光近似、无限滚动 | 精确证明用户阅读 |
| Clipboard API | 系统剪贴板 | 复制文本、用户主动粘贴 | 无用户许可的后台读取 |
它们在 Vue 中的共同封装模式可以概括为:
const target = ref<Element | null>(null)
watch(target, (element, _, onCleanup) => {
if (!element) return
const resource = createBrowserResource(element)
onCleanup(() => {
resource.dispose()
})
})
但具体处理不能完全模板化:
ResizeObserver需要关注盒模型、布局循环和高频回调;IntersectionObserver需要关注根元素、阈值、初始通知和并发加载;- Clipboard API 需要关注安全上下文、用户手势、权限与 Promise rejection。
因此,Composable 的价值不仅是减少代码,更是把浏览器 API 的资源生命周期、响应式状态和失败路径明确地组织起来。只要目标元素变化、组件卸载、浏览器不支持或权限被拒绝时都能进入可预期状态,这种封装才真正适合在 Vue 组件中复用。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 拖拽交互:排序、跨容器、触摸、键盘和状态一致性
- 下一篇:Vue 样式工程:Scoped CSS、CSS Modules、变量、主题和覆盖边界
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论