Vue3 + TS 实现一键复制指令 v-copy(优雅解决文本复制需求)

🔥 Vue3 + TS 实现一键复制指令 v-copy:优雅解决文本复制需求

在前端开发中,"一键复制"是高频使用的交互功能(如复制链接、订单号、邀请码等)。本文将教你基于 Vue3 + TypeScript 实现一个功能完善、体验友好、类型安全v-copy 自定义指令,支持自定义复制内容、复制成功/失败回调、复制提示等特性,开箱即用。

🎯 指令核心特性

  • ✅ 支持复制元素文本/自定义内容,灵活适配不同场景
  • ✅ 复制成功/失败回调,便于业务层处理交互反馈
  • ✅ 内置复制成功提示(可自定义),提升用户体验
  • ✅ 完整 TypeScript 类型定义,开发提示友好
  • ✅ 自动兼容剪贴板 API,低版本浏览器友好提示
  • ✅ 支持指令参数动态更新,适配动态内容
  • ✅ 无第三方依赖,轻量高效

📁 完整代码实现(v-copy.ts)

typescript 复制代码
// directives/v-copy.ts
import type { ObjectDirective, DirectiveBinding, App } from 'vue'

/**
 * 复制指令配置接口
 */
export interface CopyOptions {
  /** 要复制的内容,优先级高于元素文本 */
  content?: string
  /** 复制成功回调 */
  onSuccess?: (text: string) => void
  /** 复制失败回调 */
  onError?: (error: Error) => void
  /** 复制成功提示文本,默认"复制成功" */
  successTip?: string
  /** 提示显示时长(ms),默认2000 */
  tipDuration?: number
  /** 是否显示复制提示,默认true */
  showTip?: boolean
}

/**
 * 扩展元素属性,存储复制相关状态
 */
interface CopyElement extends HTMLElement {
  _copy?: {
    options: CopyOptions
    tipElement?: HTMLDivElement // 提示元素
    tipTimer?: number | null    // 提示定时器
    clickHandler: (e: MouseEvent) => void // 点击事件处理函数
  }
}

/**
 * 默认配置
 */
const DEFAULT_OPTIONS: CopyOptions = {
  successTip: '复制成功',
  tipDuration: 2000,
  showTip: true
}

/**
 * 创建复制成功提示元素
 * @param el 目标元素
 * @param text 提示文本
 * @returns 提示元素
 */
const createTipElement = (el: CopyElement, text: string): HTMLDivElement => {
  // 若已有提示元素,先移除
  if (el._copy?.tipElement) {
    document.body.removeChild(el._copy.tipElement)
    if (el._copy.tipTimer) {
      clearTimeout(el._copy.tipTimer)
      el._copy.tipTimer = null
    }
  }

  // 创建提示元素
  const tip = document.createElement('div')
  tip.style.position = 'absolute'
  tip.style.padding = '4px 12px'
  tip.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'
  tip.style.color = '#fff'
  tip.style.borderRadius = '4px'
  tip.style.fontSize = '14px'
  tip.style.zIndex = '9999'
  tip.style.transition = 'opacity 0.3s ease'
  tip.textContent = text

  // 计算提示位置(目标元素中心)
  const rect = el.getBoundingClientRect()
  const top = rect.top + window.scrollY - 40
  const left = rect.left + window.scrollX + (rect.width - tip.offsetWidth) / 2

  tip.style.top = `${top}px`
  tip.style.left = `${left}px`

  return tip
}

/**
 * 显示复制提示
 * @param el 目标元素
 * @param text 提示文本
 * @param duration 显示时长
 */
const showCopyTip = (el: CopyElement, text: string, duration: number) => {
  if (!el._copy) return

  // 创建并挂载提示元素
  const tip = createTipElement(el, text)
  document.body.appendChild(tip)
  el._copy.tipElement = tip

  // 定时隐藏提示
  el._copy.tipTimer = window.setTimeout(() => {
    tip.style.opacity = '0'
    setTimeout(() => {
      document.body.removeChild(tip)
      el._copy!.tipElement = undefined
      el._copy!.tipTimer = null
    }, 300)
  }, duration) as unknown as number
}

/**
 * 核心复制逻辑
 * @param text 要复制的文本
 * @returns Promise<string> 复制成功的文本
 */
const copyToClipboard = async (text: string): Promise<string> => {
  // 优先使用 Clipboard API(现代浏览器)
  if (navigator.clipboard && window.isSecureContext) {
    try {
      await navigator.clipboard.writeText(text)
      return text
    } catch (err) {
      // 降级处理
      throw new Error(`剪贴板API复制失败: ${(err as Error).message}`)
    }
  }

  // 降级方案:创建临时textarea元素
  const textarea = document.createElement('textarea')
  textarea.value = text
  // 隐藏textarea
  textarea.style.position = 'absolute'
  textarea.style.opacity = '0'
  textarea.style.pointerEvents = 'none'
  document.body.appendChild(textarea)

  try {
    // 选中并复制
    textarea.select()
    textarea.setSelectionRange(0, textarea.value.length) // 兼容移动设备
    const success = document.execCommand('copy')
    if (!success) {
      throw new Error('execCommand复制失败')
    }
    return text
  } finally {
    // 清理临时元素
    document.body.removeChild(textarea)
  }
}

/**
 * 清理复制相关资源
 * @param el 目标元素
 */
const cleanup = (el: CopyElement) => {
  const copyData = el._copy
  if (!copyData) return

  // 移除点击事件
  el.removeEventListener('click', copyData.clickHandler)
  
  // 清理提示定时器和元素
  if (copyData.tipTimer) {
    clearTimeout(copyData.tipTimer)
    copyData.tipTimer = null
  }
  if (copyData.tipElement) {
    document.body.removeChild(copyData.tipElement)
    copyData.tipElement = undefined
  }
  
  // 删除扩展属性
  delete el._copy
}

// 创建独立的初始化函数
const initializeCopy = (el: CopyElement, binding: DirectiveBinding<CopyOptions | string>) => {
  // 将 mounted 中的所有逻辑移动到这里
  // 1. 解析指令参数
  let options: CopyOptions = { ...DEFAULT_OPTIONS }
  
  if (typeof binding.value === 'string') {
    options.content = binding.value
  } else if (typeof binding.value === 'object' && binding.value !== null) {
    options = { ...DEFAULT_OPTIONS, ...binding.value }
  }
  // 2. 定义点击处理函数
  const clickHandler = async (e: MouseEvent) => {
    e.preventDefault()
    
    const copyText = options.content || el.textContent?.trim() || ''
    if (!copyText) {
      const error = new Error('无可用的复制内容')
      options.onError?.(error)
      console.warn('[v-copy] 无可用的复制内容')
      return
    }
    try {
      await copyToClipboard(copyText)
      options.onSuccess?.(copyText)
      
      if (options.showTip) {
        showCopyTip(el, options.successTip!, options.tipDuration!)
      }
    } catch (error) {
      const err = error as Error
      options.onError?.(err)
      console.error('[v-copy] 复制失败:', err.message)
      
      if (options.showTip) {
        showCopyTip(el, '复制失败', options.tipDuration!)
      }
    }
  }
  // 3. 绑定点击事件
  el.addEventListener('click', clickHandler)
  // 4. 存储状态到元素
  el._copy = {
    options,
    clickHandler,
    tipTimer: null
  }
  // 5. 给元素添加可点击样式提示
  el.style.cursor = 'pointer'
}

/**
 * v-copy 自定义指令实现
 */
export const copyDirective: ObjectDirective<CopyElement, CopyOptions | string> = {
  /**
   * 指令挂载时初始化
   */
  mounted(el: CopyElement, binding: DirectiveBinding<CopyOptions | string>) {
   initializeCopy(el, binding)
  },

  /**
   * 指令更新时处理参数变化
   */
  updated(el: CopyElement, binding: DirectiveBinding<CopyOptions | string>) {
    // 先清理旧配置
    cleanup(el)
    // 重新初始化
    initializeCopy(el, binding)
  },

  /**
   * 指令卸载时清理资源
   */
  unmounted(el: CopyElement) {
    cleanup(el)
  }
}

/**
 * 全局注册复制指令
 * @param app Vue应用实例
 * @param directiveName 指令名称,默认copy
 */
export const setupCopyDirective = (app: App, directiveName: string = 'copy') => {
  app.directive(directiveName, copyDirective)
}

// TypeScript 类型扩展
declare module 'vue' {
  export interface ComponentCustomDirectives {
    copy: typeof copyDirective
  }
}

🚀 快速上手

1. 全局注册指令(main.ts)

在 Vue3 入口文件中注册指令,全局可用:

typescript 复制代码
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { setupCopyDirective } from './directives/v-copy'

const app = createApp(App)

// 注册复制指令(默认名称v-copy)
setupCopyDirective(app)

app.mount('#app')

2. 基础使用(直接传复制内容)

最简单的用法:直接传递要复制的字符串,使用默认配置(显示"复制成功"提示):

vue 复制代码
<template>
  <!-- 复制固定文本 -->
  <button v-copy="'https://github.com/your-repo'">
    复制我的GitHub地址
  </button>

  <!-- 复制元素文本内容 -->
  <div v-copy style="cursor: pointer;">
    点击复制这段文本:1234567890
  </div>
</template>

3. 高级使用(自定义配置)

通过对象参数配置完整的复制规则,支持自定义提示、回调函数:

vue 复制代码
<template>
  <div>
    <input v-model="copyText" placeholder="输入要复制的内容" />
    
    <button 
      v-copy="{
        content: copyText,
        successTip: '链接复制成功啦~',
        tipDuration: 1500,
        onSuccess: handleCopySuccess,
        onError: handleCopyError
      }"
    >
      自定义复制配置
    </button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const copyText = ref('https://example.com/custom-link')

// 复制成功回调
const handleCopySuccess = (text: string) => {
  console.log('复制成功,内容:', text)
  // 可在这里添加自定义提示,如使用Element Plus的Message
  // ElMessage.success(`复制成功:${text}`)
}

// 复制失败回调
const handleCopyError = (error: Error) => {
  console.error('复制失败:', error)
  // ElMessage.error('复制失败,请手动复制')
}
</script>

4. 动态内容复制

适配动态变化的复制内容(如接口返回的订单号、邀请码):

vue 复制代码
<template>
  <div>
    <div>您的邀请码:<span v-copy="inviteCode">{{ inviteCode }}</span></div>
    <button @click="refreshInviteCode">刷新邀请码</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

// 模拟动态邀请码
const inviteCode = ref('ABC123456')

// 刷新邀请码
const refreshInviteCode = () => {
  // 生成随机邀请码
  const randomCode = Math.random().toString(36).substring(2, 8).toUpperCase()
  inviteCode.value = randomCode
}
</script>

<style>
/* 给可复制元素添加样式提示 */
span[V-copy] {
  color: #409eff;
  text-decoration: underline;
  cursor: pointer;
}
</style>

5. 关闭默认提示(自定义反馈)

若需自定义复制反馈(如使用UI库的提示组件),可关闭默认提示:

vue 复制代码
<template>
  <button 
    v-copy="{
      content: '自定义反馈示例',
      showTip: false,
      onSuccess: () => ElMessage.success('复制成功✅'),
      onError: () => ElMessage.error('复制失败❌')
    }"
  >
    自定义反馈提示
  </button>
</template>

<script setup lang="ts">
import { ElMessage } from 'element-plus'
</script>

🔧 核心知识点解析

1. 复制实现原理

指令兼容两种复制方案,保证最大兼容性:

  • 现代浏览器 :使用 navigator.clipboard.writeText()(异步、安全,推荐)
  • 降级方案 :创建临时 textarea 元素,通过 document.execCommand('copy') 复制(兼容低版本浏览器)

2. 提示组件实现

  • 动态创建提示元素,基于目标元素位置计算居中显示
  • 使用定时器自动隐藏提示,添加过渡动画提升体验
  • 重复点击时自动清理旧提示,避免多个提示叠加

3. 内存泄漏防护

  • unmounted 钩子中移除点击事件、清理定时器、删除临时提示元素
  • updated 钩子中先清理旧配置,再初始化新配置
  • 扩展元素属性存储状态,卸载时删除属性释放内存

4. TypeScript 类型优化

  • 定义 CopyOptions 接口,明确配置项类型
  • 扩展 HTMLElement 类型,添加复制状态属性
  • 支持两种参数类型(字符串/对象),类型推导自动适配

📋 配置项说明

配置项 类型 默认值 说明
content string - 要复制的内容,优先级高于元素文本
onSuccess (text: string) => void - 复制成功回调,参数为复制的文本
onError (error: Error) => void - 复制失败回调,参数为错误对象
successTip string '复制成功' 复制成功提示文本
tipDuration number 2000 提示显示时长,单位ms
showTip boolean true 是否显示默认的复制提示

🎯 常见使用场景

场景1:复制订单号/优惠券码

vue 复制代码
<template>
  <div class="order-card">
    <div class="label">订单号:</div>
    <div class="value" v-copy="{ successTip: '订单号复制成功' }">
      {{ orderNo }}
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

// 模拟接口返回的订单号
const orderNo = ref('ORD20240124123456789')
</script>

<style>
.order-card {
  display: flex;
  align-items: center;
  padding: 10px;
}
.label {
  margin-right: 8px;
  color: #666;
}
.value {
  color: #409eff;
  cursor: pointer;
}
</style>

场景2:复制分享链接

vue 复制代码
<template>
  <div class="share-card">
    <input 
      type="text" 
      readonly 
      v-model="shareLink"
      class="link-input"
    />
    <button 
      v-copy="{
        content: shareLink,
        onSuccess: () => ElMessage.success('分享链接已复制'),
        onError: () => ElMessage.error('复制失败,请手动复制')
      }"
      class="copy-btn"
    >
      复制链接
    </button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'

// 模拟生成分享链接
const shareLink = ref(`https://example.com/share?uid=${Math.random().toString(36).substring(2, 10)}`)
</script>

<style>
.share-card {
  display: flex;
  gap: 8px;
  padding: 10px;
}
.link-input {
  flex: 1;
  padding: 6px;
  border: 1px solid #e5e7eb;
  border-radius: 4px;
}
.copy-btn {
  padding: 6px 12px;
  background: #409eff;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

🚨 注意事项

  1. Clipboard API 安全限制navigator.clipboard 仅在安全上下文(HTTPS)或本地开发环境(localhost)中可用,HTTP 环境会自动降级到 execCommand
  2. 移动端兼容性 :部分移动端浏览器对 execCommand('copy') 支持有限,建议在实际项目中测试。
  3. 复制内容为空 :指令会校验复制内容,为空时触发 onError 并打印警告,需确保复制内容有效。
  4. 样式提示 :指令会自动给元素添加 cursor: pointer,可根据需要覆盖样式。

📌 总结

本文实现的 v-copy 指令具备以下核心优势:

  1. 兼容性强:兼容现代浏览器和低版本浏览器,自动降级处理。
  2. 体验友好:内置居中提示,支持自定义提示文本和时长,交互体验佳。
  3. 配置灵活:支持自定义复制内容、成功/失败回调,适配各种业务场景。
  4. 类型安全:基于 TypeScript 开发,类型提示完善,减少开发错误。
  5. 轻量无依赖:无需引入第三方库,体积小,性能优。

这个指令可以直接集成到你的 Vue3 项目中,解决各种文本复制需求。如果需要进一步扩展,可以在此基础上增加:

  • 支持复制富文本(HTML内容)
  • 支持自定义提示样式(颜色、位置、动画)
  • 支持双击复制/长按复制
  • 支持复制后自动清空输入框

希望这篇文章对你有帮助,欢迎点赞、收藏、评论交流!

相关推荐
雨季6662 小时前
构建 OpenHarmony 简易文字行数统计器:用字符串分割实现纯文本结构感知
开发语言·前端·javascript·flutter·ui·dart
雨季6662 小时前
Flutter 三端应用实战:OpenHarmony 简易倒序文本查看器开发指南
开发语言·javascript·flutter·ui
小北方城市网2 小时前
Redis 分布式锁高可用实现:从原理到生产级落地
java·前端·javascript·spring boot·redis·分布式·wpf
console.log('npc')2 小时前
vue2 使用高德接口查询天气
前端·vue.js
2401_892000522 小时前
Flutter for OpenHarmony 猫咪管家App实战 - 添加支出实现
前端·javascript·flutter
天马37982 小时前
Canvas 倾斜矩形绘制波浪效果
开发语言·前端·javascript
天天向上10243 小时前
vue3 实现el-table 部分行不让勾选
前端·javascript·vue.js
qx093 小时前
esm模块与commonjs模块相互调用的方法
开发语言·前端·javascript
摘星编程4 小时前
在OpenHarmony上用React Native:SectionList吸顶分组标题
javascript·react native·react.js
Mr Xu_4 小时前
前端实战:基于Element Plus的CustomTable表格组件封装与应用
前端·javascript·vue.js·elementui