Vue 基础体系 · 第 41/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 封装浏览器 API:ResizeObserver、IntersectionObserver 和剪贴板

浏览器提供了许多不依赖 Vue 的能力:监听元素尺寸变化的 ResizeObserver、监听元素进入视口的 IntersectionObserver,以及通过 navigator.clipboard 访问系统剪贴板的异步 API。

这些 API 直接写在组件中通常并不复杂,但会同时涉及几个容易出错的问题:

  • 元素是否已经挂载;
  • 组件卸载后观察器是否仍然回调;
  • SSR 环境中是否存在 windownavigator
  • 观察器回调是否可能高频触发;
  • 剪贴板操作是否满足安全上下文和用户手势要求;
  • 目标 DOM 是否会因 v-ifv-for 或条件渲染而改变。

Vue 的 Composition API 适合把这些“有生命周期的浏览器能力”封装为 Composable。本文从三个 API 的工作机制开始,逐步实现可运行的 TypeScript 封装,并说明它们的边界与故障路径。


一、先明确 Composable 要解决什么问题

Composable 是一个按照 Vue Composition API 组织可复用逻辑的函数。它通常:

  1. 创建响应式状态;
  2. 注册生命周期或监听器;
  3. 在状态变化时更新数据;
  4. 在作用域销毁时清理副作用;
  5. 将状态和操作函数返回给组件。

例如,直接在组件中使用 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()

封装时,核心目标不是“把代码放进一个函数”,而是保证:

observer 的生命周期Vue 作用域生命周期\text{observer 的生命周期} \subseteq \text{Vue 作用域生命周期}

也就是说,只要 Vue 作用域结束,观察器就不能继续持有目标元素并产生回调。


二、为什么不能在 setup() 中直接读取 DOM

在 Composition API 中,模板引用通常声明为:

const target = ref<HTMLElement | null>(null)

然后在模板中绑定:

<div ref="target"></div>

但是,setup() 执行时,组件的 DOM 还没有挂载,因此此时:

console.log(target.value) // null

这是正常行为。DOM 引用只有在挂载阶段之后才会被赋值。

ResizeObserverIntersectionObserver 都要求传入真实的 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,
  }
}

这里的执行过程如下:

  1. target 初始为 null
  2. watch 立即执行,但因为没有元素而返回;
  3. 模板挂载后,target.value 变为真实元素;
  4. 创建 ResizeObserver 并调用 observe(element)
  5. 元素宽高变化时,更新 size
  6. 目标引用变化或作用域销毁时,执行 disconnect()

onScopeDispose() 是 Composition API 的作用域清理机制。组件中的 setup() 会创建一个作用域,因此该 Composable 在组件卸载时会停止监听。watchonCleanup 则负责清理当前目标元素对应的观察器。


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 的右侧边缘会触发观察器,widthlayout 随之更新。

这里不要用 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`
})

尺寸变化触发回调,回调再次修改尺寸,于是可能形成:

尺寸变化回调修改尺寸新的尺寸变化\text{尺寸变化} \rightarrow \text{回调} \rightarrow \text{修改尺寸} \rightarrow \text{新的尺寸变化}

浏览器会对 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:用于计算交叉关系的容器,省略时通常表示视口;
  • 交叉区域:目标元素和根元素可见裁剪区域的重叠部分。

当根元素是视口时,浏览器概念上计算:

intersectionRatio=交叉区域面积目标元素边界区域面积\text{intersectionRatio} = \frac{\text{交叉区域面积}} {\text{目标元素边界区域面积}}

当目标元素没有面积时,比例计算存在特殊规则,不能简单按照普通面积公式推断。

IntersectionObserver 主要回答的是“目标是否与根区域相交”,它不等价于:

  • 用户一定看到了内容;
  • 内容一定在屏幕最前面;
  • 内容一定没有被其他元素遮挡;
  • 用户已经阅读了内容。

它关注的是几何交叉关系。


4.2 rootMarginthreshold

观察器配置通常包括:

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]

意味着在比例跨过 00.51 等阈值时产生通知。

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 时不会在模块执行阶段崩溃;
  • copyread 都是异步函数;
  • 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>

输入和预期结果如下:

  1. 在文本框输入内容;
  2. 点击“复制文本”;
  3. writeText 成功完成后,按钮显示“已复制”;
  4. 点击“读取剪贴板”;
  5. 如果浏览器允许读取,则显示当前剪贴板文本;
  6. 如果页面不是安全上下文、用户拒绝权限或浏览器禁止读取,则显示错误。

复制成功的含义是浏览器完成了写入 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

因为服务端通常没有 windowdocumentnavigator

应当在运行时检查:

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 的重点不是验证浏览器布局,而是验证:

  1. 目标元素出现后调用了 observe
  2. 回调更新了响应式宽高;
  3. 目标变化或组件卸载后调用了 disconnect

剪贴板测试则应模拟:

navigator.clipboard = {
  writeText: vi.fn().mockResolvedValue(undefined),
  readText: vi.fn().mockResolvedValue('测试文本'),
}

同时测试拒绝路径:

vi
  .mocked(navigator.clipboard.writeText)
  .mockRejectedValue(new DOMException('拒绝', 'NotAllowedError'))

7.3 版本敏感能力

本文使用的基础 API 属于现代浏览器中的标准能力,但具体支持范围取决于浏览器版本、运行环境和安全策略:

  • ResizeObserverIntersectionObserver 需要现代浏览器;
  • navigator.clipboard.writeText 的可用条件通常比普通 JavaScript API 更严格;
  • navigator.clipboard.readText 的权限限制通常比写入更严格;
  • contentBoxSizeborderBoxSize 等尺寸字段的细节存在历史实现差异;
  • 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、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。