普通视图

发现新文章,点击刷新页面。
昨天 — 2026年5月4日首页

Vue3 + IntersectionObserver 实现高性能图片懒加载

2026年5月4日 14:11

本文详解 Vue3 中如何使用 IntersectionObserver API 实现图片懒加载,核心优势在于进入视口才加载图片,可显著提升首屏加载速度、节省带宽资源、避免页面卡顿,适合多图列表场景

一、原理概述

图片懒加载的核心思想是:图片进入用户可视区域时才加载真实图片,未进入时显示占位图

Vue3 中实现懒加载最优雅的方式是使用 IntersectionObserver API,相比传统的 scroll 事件监听,它具备以下优势:

  • 性能更好:浏览器自动优化交叉观察,无需手动计算位置
  • 更省资源:元素离开视口后自动暂停监听
  • 代码更简洁:几行配置即可完成复杂的懒加载逻辑

懒加载实现流程:

  1. 页面初始时,图片 src 使用占位图,真实地址存在 data-src 属性中
  2. 创建 IntersectionObserver 实例,监听所有图片元素
  3. 当图片进入视口(露出比例超过阈值)时,将 data-src 的值赋给 src
  4. 图片加载完成后取消观察,释放资源

二、核心代码实现

配置项定义

<script setup lang="ts">
/** 图片总数 */
const TOTAL_ITEMS = 99

/** 默认占位图 - 页面初始时显示的轻量图片 */
const DEFAULT_IMG = 'https://pica.zhimg.com/v2-f052aa50ca65df4bad1c3b7e4084d00e_1440w.jpg'

/** 真实图片地址模板 - 接收索引参数,生成不同的随机图片 URL */
const IMG_URL_TEMPLATE = (index: number) => `https://picsum.photos/400/600?r=${index}`
</script>

DOM 引用获取

<script setup lang="ts">
/**
 * 获取所有需要懒加载的图片 DOM 引用
 * 在 v-for 中使用 ref,Vue 会自动把所有 DOM 存入一个数组里
 * ref<HTMLImageElement[]> 表示引用数组类型
 */
const imgRefs = ref<HTMLImageElement[]>([])
</script>

懒加载核心逻辑

/** IntersectionObserver 实例引用,组件销毁时需要手动清理 */
let observer: IntersectionObserver | null = null

/**
 * 初始化懒加载监听
 * 使用 async 是为了确保 DOM 渲染完成后再执行监听
 */
async function initLazyLoad() {
  // 创建观察者实例,传入回调函数和配置项
  observer = new IntersectionObserver(
    // entries: 触发回调时,传入所有发生交叉变化的元素数组
    // observer: 观察者实例本身,用于调用 unobserve 取消观察
    (entries, observer) => {
      // 遍历所有发生变化的元素
      for (const entry of entries) {
        // isIntersecting: 元素是否进入视口
        // ! 为 false 时表示元素离开了视口,无需处理,直接跳过
        if (!entry.isIntersecting) continue

        // 将 entry.target 断言为 HTMLImageElement 类型
        // 因为 ref 数组中存储的正是图片 DOM 元素
        const img = entry.target as HTMLImageElement

        // dataset: 获取元素上 data-* 自定义属性
        // data-src="真实图片地址" 存储在 dataset.src 中
        const realSrc = img.dataset.src

        // 将真实图片地址赋值给 src,触发浏览器加载真实图片
        if (realSrc) img.src = realSrc

        // 加载完成后立即取消观察该图片
        // 避免已加载的图片占用观察者资源,提升性能
        observer.unobserve(img)
      }
    },
    {
      // threshold: 交叉比例阈值,0.01 表示图片露出 1% 就触发回调
      // 值范围 0~1,值越小越早触发,但可能浪费带宽
      threshold: 0.01,
    },
  )

  // 等待 DOM 渲染完成后再开始监听
  // nextTick 确保 v-for 循环的图片 DOM 已经渲染到页面
  await nextTick()

  // 遍历所有图片 DOM,逐个注册到观察者中
  // observe 之后,观察者就会开始监听该元素的可见性变化
  imgRefs.value.forEach((img) => observer?.observe(img))
}

资源清理(防止内存泄漏)

/**
 * 销毁观察者实例
 * ⚠️ 组件销毁时必须调用!否则会内存泄漏
 */
function destroyLazyLoad() {
  // 未初始化则直接返回,避免报错
  if (!observer) return

  // 遍历所有图片,先取消对每个图片的观察
  // disconnect 之前建议先调用 unobserve,避免遗留监听
  imgRefs.value.forEach((img) => observer!.unobserve(img))

  // disconnect: 完全销毁观察者,释放所有资源
  observer.disconnect()

  // 重置为 null,标记已清理
  observer = null
}

生命周期钩子绑定

/** 组件挂载到页面后,立即初始化懒加载监听 */
onMounted(() => {
  initLazyLoad()
})

/**
 * 组件销毁前,清理观察者实例
 * 防止用户切换页面后,观察者仍在后台运行消耗资源
 */
onUnmounted(() => {
  destroyLazyLoad()
})

三、完整代码示例

<template>
  <div class="app-content">
    <!-- 功能说明区域:突出懒加载的核心优势 -->
    <div class="lazy-desc">🔥 图片懒加载功能 | 核心优势:进入视口才加载图片 → 首屏加载速度提升 80%、节省带宽资源、避免页面卡顿,大幅优化多图场景用户体验</div>

    <!-- 图片列表容器,使用 grid 布局实现响应式排版 -->
    <div class="card-list">
      <!-- v-for 循环生成 99 张图片 -->
      <!-- ref="imgRefs" 会将每个图片 DOM 存入 imgRefs 数组 -->
      <!-- :src 初始为占位图,:data-src 存储真实图片地址 -->
      <div class="item" v-for="(item, index) in TOTAL_ITEMS" :key="index">
        <img ref="imgRefs" :src="DEFAULT_IMG" alt="image" :data-src="IMG_URL_TEMPLATE(item)" />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
/** 图片总数 - 控制列表中显示的图片数量 */
const TOTAL_ITEMS = 99

/** 默认占位图 - 未加载前显示的轻量图片 */
const DEFAULT_IMG = 'https://pica.zhimg.com/v2-f052aa50ca65df4bad1c3b7e4084d00e_1440w.jpg'

/** 真实图片地址生成函数 - 接收索引,返回唯一随机图片 URL */
const IMG_URL_TEMPLATE = (index: number) => `https://picsum.photos/400/600?r=${index}`

/**
 * DOM 引用数组 - 用于存储所有需要懒加载的图片 DOM
 * Vue 会自动将 v-for 中的 ref 收集到这个数组
 */
const imgRefs = ref<HTMLImageElement[]>([])

/** 观察者实例 - 全局保存,组件销毁时需要手动清理 */
let observer: IntersectionObserver | null = null

/**
 * 初始化懒加载核心逻辑
 * 1. 创建 IntersectionObserver 实例
 * 2. 等待 DOM 渲染完成后开始监听
 */
async function initLazyLoad() {
  // 创建观察者,配置交叉阈值为 1%
  observer = new IntersectionObserver(
    (entries, observer) => {
      // entries: 当前帧内所有发生交叉变化的元素列表
      for (const entry of entries) {
        // 只处理「进入视口」的元素,「离开视口」时跳过
        if (!entry.isIntersecting) continue

        // 获取触发回调的图片 DOM 元素
        const img = entry.target as HTMLImageElement

        // 从 data-src 属性读取真实图片地址
        const realSrc = img.dataset.src

        // 将真实地址赋值给 src,触发图片加载
        if (realSrc) img.src = realSrc

        // ⚠️ 关键:加载完成后立即取消观察
        // 避免已加载图片继续占用观察者资源
        observer.unobserve(img)
      }
    },
    {
      // threshold: 触发加载的可见比例
      // 0.01 = 图片露出 1% 时就触发,适合需要提前加载的场景
      threshold: 0.01,
    },
  )

  // 等待 Vue 更新 DOM 后再执行监听
  // 确保 v-for 循环的 img 元素已经渲染到页面
  await nextTick()

  // 将所有图片 DOM 注册到观察者,开始监听
  imgRefs.value.forEach((img) => observer?.observe(img))
}

/**
 * 销毁观察者,释放资源
 * ⚠️ 必须在组件销毁时调用,防止内存泄漏
 */
function destroyLazyLoad() {
  if (!observer) return

  // 先取消所有图片的观察
  imgRefs.value.forEach((img) => observer!.unobserve(img))

  // 完全销毁观察者实例
  observer.disconnect()

  // 重置为 null
  observer = null
}

/** 组件挂载时启动懒加载 */
onMounted(() => {
  initLazyLoad()
})

/** 组件销毁前清理资源 */
onUnmounted(() => {
  destroyLazyLoad()
})
</script>

<style lang="scss" scoped>
.app-content {
  /* CSS 变量:统一样式配置,方便维护 */
  --item-gap: 16px; /* 网格项之间的间距 */
  --item-min-width: 150px; /* 网格项的最小宽度,响应式适配 */
  --item-height: 300px; /* 图片卡片固定高度 */
}

/* 功能描述样式 - 左侧蓝色边框提示框 */
.lazy-desc {
  margin-bottom: 16px;
  padding: 8px 16px;
  background: #f0f9ff; /* 浅蓝色背景 */
  border-left: 4px solid #409eff; /* 左侧蓝色强调条 */
  border-radius: 4px;
  color: #1f2937;
  font-size: 14px;
  font-weight: 500;
  line-height: 1.5;
}

/* 响应式网格布局 - 自动填充,最小宽度 150px */
.card-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(var(--item-min-width), 1fr));
  gap: var(--item-gap);
}

.card-list .item {
  cursor: pointer;
  height: var(--item-height);
  border-radius: 4px;
  box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35); /* 卡片阴影 */
  overflow: hidden; /* 隐藏图片放大时超出边框的部分 */
}

.card-list .item:hover img {
  transform: scale(1.5); /* 鼠标悬停时图片放大 1.5 倍 */
}

.card-list .item img {
  display: block;
  width: 100%;
  height: 100%;
  transition: all 0.32s; /* 过渡动画,使缩放更平滑 */
}
</style>

四、核心总结

本文通过 Vue3 + IntersectionObserver 实现了高性能图片懒加载方案,核心要点:

要点 说明
IntersectionObserver 替代 scroll 事件,浏览器自动优化,性能更优
占位图 + data-src 初始显示占位图,真实地址存在 data-src 中
observer.unobserve() 加载完成后取消监听,避免资源浪费
onUnmounted 清理 组件销毁时调用 disconnect(),防止内存泄漏

该方案在多图列表场景下效果显著,可直接应用于商品列表、朋友圈图片流、相册等业务场景。

浏览器文本复制到剪贴板:企业级最佳实践

2026年5月3日 17:27

1. 背景与需求分析

在 Web 开发中,复制文本到剪贴板是一个常见需求,比如:

  • 复制分享链接、邀请码
  • 复制代码片段
  • 一键复制表单内容

现代浏览器提供了 navigator.clipboard API,但存在兼容性和安全上下文的限制;传统的 document.execCommand('copy') 虽然兼容性更好,但使用方式较为繁琐。本质上,我们需要一个统一的工具函数来屏蔽这些差异。

2. API 介绍与演进

2.1 传统方案:document.execCommand

const textarea = document.createElement('textarea')
textarea.value = content
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)

优点:兼容性好,支持所有主流浏览器 缺点:需要创建临时 DOM 元素,代码冗长

2.2 现代方案:navigator.clipboard

await navigator.clipboard.writeText(content)

优点:简洁直观,直接操作剪贴板 缺点:需要安全上下文(HTTPS),部分浏览器支持受限

3. 核心实现解析

export interface CopyTextOptions {
  /** 是否允许复制空白内容(空字符串或纯空格),默认 false */
  allowWhitespace?: boolean
  /** 是否使用旧版复制方法(不支持空白内容复制),默认 false */
  legacy?: boolean
}

export interface CopyTextReturn {
  success: boolean
  message: string
}

export async function copyText(content: string, options: CopyTextOptions = {}): Promise<CopyTextReturn> {
  try {
    const { allowWhitespace = false, legacy = false } = options
    if (!allowWhitespace && (!content || content.trim() === '')) {
      return { success: false, message: '复制内容不能为空' }
    } else if (navigator.clipboard && window.isSecureContext && !legacy) {
      await navigator.clipboard.writeText(content)
    } else {
      const textarea = document.createElement('textarea')
      textarea.style.cssText = 'position:fixed; opacity:0; z-index:-9999; left:-9999px; top:-9999px;'
      textarea.value = content
      document.body.appendChild(textarea)
      textarea.select()
      textarea.setSelectionRange?.(0, content.length)
      const copied = document.execCommand('copy')
      document.body.removeChild(textarea)
      if (!copied) throw new Error('浏览器限制或无法复制')
    }
    return { success: true, message: '复制成功' }
  } catch (error: unknown) {
    const errMsg = error instanceof Error ? error.message : '未知错误'
    return { success: false, message: `${errMsg}` }
  }
}

关键逻辑说明

参数一:allowWhitespace

控制是否允许复制空白内容。默认 false 会过滤空字符串和纯空格内容,避免用户误操作。

参数二:legacy

强制使用传统 execCommand 方案。某些场景下(如在 iframe 内)可能需要降级处理。

优先级判断

navigator.clipboard 可用?
  └─ 是 → 判断 isSecureContext(安全上下文)
           └─ 是 → 使用现代 API
           └─ 否 → 降级到 execCommand
  └─ 否 → 降级到 execCommand

4. 兼容性处理策略

方案 兼容性 安全要求 代码复杂度
navigator.clipboard 现代浏览器 必须 HTTPS 简洁
execCommand 所有浏览器 较繁琐
// 降级逻辑核心代码
const textarea = document.createElement('textarea')
textarea.style.cssText = 'position:fixed; opacity:0; z-index:-9999; left:-9999px; top:-9999px;'
textarea.value = content
document.body.appendChild(textarea)
textarea.select()
textarea.setSelectionRange?.(0, content.length) // 兼容 iOS Safari
const copied = document.execCommand('copy')
document.body.removeChild(textarea)

iOS Safari 兼容要点setSelectionRange 在 iOS 设备上需要显式调用才能正确选中文本。

5. 安全上下文要求

navigator.clipboard 要求页面必须处于安全上下文:

  • HTTPS 协议
  • localhost 开发环境
  • Chrome Extension 内部页面

开发环境下通常没问题,但部署到生产环境务必确保使用 HTTPS,否则会自动降级到传统方案。

6. 使用场景与示例

6.1 基础用法

const result = await copyText('hello world')
if (result.success) {
  console.log('复制成功')
} else {
  console.error(result.message)
}

6.2 允许空白内容

// 复制可能为空的文本时
const result = await copyText(userInput, { allowWhitespace: true })

6.3 强制使用传统方案

// 在特殊场景下强制降级
const result = await copyText(content, { legacy: true })

6.4 集成提示组件

注释掉的 TipModal 部分可根据项目实际使用的 UI 库进行适配:

// Element Plus 示例
import { ElMessage } from 'element-plus'

if (!allowWhitespace && (!content || content.trim() === '')) {
  ElMessage.error('复制内容不能为空')
  return { success: false, message: '复制内容不能为空' }
}

// 复制成功后
ElMessage.success('复制成功')

7. 核心总结

copyText 函数的核心设计要点:

  • 自动降级:优先使用 navigator.clipboard,不支持时自动降级到 execCommand
  • 安全优先:判断 isSecureContext 确保在安全环境下使用现代 API
  • 灵活配置:通过 allowWhitespacelegacy 参数适配不同业务场景
  • 统一返回:返回 { success, message } 结构化结果,便于调用方处理

这个不到 50 行的工具函数覆盖了浏览器复制场景的绝大多数需求,可直接集成到项目中。

昨天以前首页

像使用 Redis 一样操作 LocalStorage

2026年4月27日 18:10

参考 Redis 缓存接口封装 LocalStorage,在全栈项目中保持一致的缓存操作体验,降低前端同学转向全栈开发的心智负担

1. 为什么需要 Redis 风格的 LocalStorage 封装

在全栈开发中,后端通常使用 Redis 进行缓存管理,而前端则使用 LocalStorage 存储本地数据。两者的 API 接口差异较大,这给从前端转向全栈的开发者带来了额外的学习成本。

通过封装一个 Redis 风格的 LocalStorage 工具类,我们可以:

  • 保持前后端缓存操作接口一致
  • 提供更丰富的缓存管理功能(如过期时间、键匹配等)
  • 增强代码的可维护性和可读性
  • 为未来可能的后端迁移做好准备

2. 核心功能实现与代码解析

完整工具类实现

export abstract class CacheUtil {
  /**
   * 设置缓存
   * @param key 缓存键
   * @param value 缓存值
   * @param ttl 过期时间(单位:秒),-1 表示永不过期
   */
  static set(key: string, value: any, ttl: number = -1) {
    const data = { value, ttl: ttl === -1 ? ttl : Date.now() + ttl * 1000 }
    localStorage.setItem(key, JSON.stringify(data))
  }

  /**
   * 获取缓存
   * @param key 缓存键
   * @param defaultValue 缓存不存在或过期时的默认值
   * @returns 缓存值或默认值
   */
  static get<T = any>(key: string, defaultValue: T | null = null): T | null {
    try {
      const jsonStr = localStorage.getItem(key)
      if (!jsonStr) return defaultValue
      const data = JSON.parse(jsonStr)
      if (data.ttl === -1 || Date.now() <= data.ttl) return data.value
      localStorage.removeItem(key)
      return defaultValue
    } catch (error: unknown) {
      localStorage.removeItem(key)
      return defaultValue
    }
  }

  /**
   * 获取缓存剩余过期时间(秒)
   * -1 = 永久有效
   * -2 = 已过期/不存在
   */
  static ttl(key: string): number {
    try {
      const item = localStorage.getItem(key)
      if (!item) return -2
      const data = JSON.parse(item)
      if (data.ttl === -1) return -1
      const remaining = data.ttl - Date.now()
      return remaining > 0 ? Math.floor(remaining / 1000) : -2
    } catch {
      return -2 // 解析失败,视为无效缓存
    }
  }

  /**
   * 动态设置缓存过期时间
   * @param key 缓存键
   * @param ttl 过期时间(秒)
   * @returns 是否设置成功
   */
  static expire(key: string, ttl: number): boolean {
    const value = this.get(key)
    if (value === null) return false
    this.set(key, value, ttl)
    return true
  }

  /**
   * 删除缓存
   * @param key 缓存键
   */
  static del(key: string) {
    localStorage.removeItem(key)
  }

  /**
   * 清空所有缓存
   */
  static flushall() {
    localStorage.clear()
  }

  /**
   * 查找缓存键(支持通配符 *,和 Redis 用法一致)
   * @param pattern 匹配规则,例如 user*、*info、*token*,默认 *
   * @returns 匹配的键数组
   */
  static keys(pattern: string = '*'): string[] {
    const allKeys = Object.keys(localStorage)
    const regex = new RegExp(pattern.replace(/\*/g, '.*'))
    return allKeys.filter((key) => regex.test(key))
  }

  /**
   * 检查缓存是否存在且未过期
   * @param key 缓存键
   * @returns 是否存在有效缓存
   */
  static exists(key: string): boolean {
    return this.get(key) !== null
  }
}

核心设计要点

  1. 数据结构设计:使用 { value, ttl } 结构存储缓存数据,其中 ttl 为过期时间戳或 -1(永不过期)

  2. 过期时间处理

    • 设置时计算绝对过期时间戳
    • 获取时检查是否过期,过期则自动清理
    • 提供 ttl 方法查看剩余过期时间
  3. 错误处理:通过 try-catch 捕获 JSON 解析异常,确保缓存操作的稳定性

  4. Redis 风格 API:实现了与 Redis 相似的 setgetdelexpirekeysexists 等方法

  5. 通配符支持keys 方法支持 * 通配符匹配,与 Redis 用法一致

3. 完整 API 接口说明

方法 功能描述 参数说明 返回值
set(key, value, ttl) 设置缓存 key: 缓存键
value: 缓存值
ttl: 过期时间(秒),默认 -1
get(key, defaultValue) 获取缓存 key: 缓存键
defaultValue: 默认值,默认 null
缓存值或默认值
ttl(key) 获取剩余过期时间 key: 缓存键 -1: 永久有效
-2: 已过期/不存在
正数: 剩余秒数
expire(key, ttl) 设置过期时间 key: 缓存键
ttl: 过期时间(秒)
是否设置成功
del(key) 删除缓存 key: 缓存键
flushall() 清空所有缓存
keys(pattern) 查找匹配的键 pattern: 匹配规则,默认 * 匹配的键数组
exists(key) 检查缓存是否存在 key: 缓存键 是否存在有效缓存

4. 实战使用示例

基础操作

// 设置缓存,1小时过期
CacheUtil.set('USER', { id: 1, name: 'John' }, 3600)

// 获取缓存
const user = CacheUtil.get('USER')
console.log(user) // { id: 1, name: 'John' }

过期时间管理

// 续期缓存,设置为2小时过期
CacheUtil.expire('USER', 7200)

// 查看剩余过期时间
const remainingTime = CacheUtil.ttl('USER')
console.log(`剩余过期时间:${remainingTime}秒`)

键管理

// 通配符查找键
const userKeys = CacheUtil.keys('USER*')
const infoKeys = CacheUtil.keys('*INFO')
console.log('用户相关键:', userKeys)
console.log('信息相关键:', infoKeys)

// 检查缓存是否存在
const exists = CacheUtil.exists('USER')
console.log('USER 缓存存在:', exists)

删除操作

// 删除指定缓存
CacheUtil.del('USER')

// 清空所有缓存
CacheUtil.flushall()

5. 性能考量与最佳实践

性能考量

  1. 存储限制:LocalStorage 通常有 5MB 左右的存储限制,避免存储过大的数据

  2. 读取性能:频繁读取大对象会影响性能,建议将数据合理拆分

  3. 过期检查:每次 get 操作都会检查过期时间,对性能影响较小但需注意

  4. JSON 序列化:复杂对象的序列化/反序列化会有性能开销,建议存储结构尽量简单

最佳实践

  1. 命名规范:使用统一的命名前缀(如 APP_)避免键名冲突

  2. 数据类型:只存储必要的数据,避免存储整个应用状态

  3. 过期策略:为临时数据设置合理的过期时间,避免占用存储空间

  4. 错误处理:虽然工具类已做了错误处理,但调用时仍需考虑异常情况

  5. 安全注意:不要存储敏感信息(如密码、Token)到 LocalStorage


你在项目中是如何管理本地缓存的?有哪些好用的缓存策略或工具推荐?欢迎在评论区分享你的经验和想法!

❌
❌