网络日志

Typescript实现一键复制文本进剪贴板

Typescript实现一键复制文本进剪贴板

场景

在搭建一些展示代码的页面时,一个常见的需求是点击按钮可以把页面的代码复制进剪贴板。

目前 @vueuse/core 这个 Vue 的组合式 API 工具库提供了 useClipboard 方法来支持复制剪贴板功能,使用浏览器 Clipboard API 实现。核心代码是 await navigator!.clipboard.writeText(value)

我的应用场景是在使用 Vitepress + Typescript 搭建组件库文档站的过程中,应用了 @vueuse/core 实现点击按钮复制组件代码。在后续的测试中发现,在开发环境中点击按钮复制代码的功能正常,但是在进行打包部署至生产环境后,点击按钮会提示复制失败,两个环境使用的是同一版本的 Chrome 浏览器。

核心代码
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'

const vm = getCurrentInstance()!

const props = defineProps<{
  rawSource: string
}>()

const { copy, isSupported } = useClipboard({
  source: decodeURIComponent(props.rawSource),
  read: false,
})

const copyCode = async () => {
    // $message来自element-plus 
  const { $message } = vm.appContext.config.globalProperties;
  if (!isSupported) {
    $message.error('复制失败')
  }
  try {
    await copy()
    $message.success('复制成功')
  } catch (e: any) {
    $message.error(e.message)
  }
}

</script>

通过阅读 @vueuse/core 的源码,可以发现其isSupported 判断功能使用 Permissions API

核心的判断方法permissionStatus = await navigator!.permissions.query('clipboard-write')

用于判断用户是否有对剪贴板的写入权限,而在生产环境中,isSupported 判断的结果是不支持,而在开发环境中则是支持。

经过分析,发现跑打包后代码的浏览器 F12 中 'clipboard' in navigator === false

回头查阅 Clipboard API 的MDN文档有一项提示

Secure context: This feature is available only in secure contexts (HTTPS), in some or all supporting browsers.

以及 stackoverflow 上的问题讨论

This requires a secure origin — either HTTPS or localhost (or disabled by running Chrome with a flag). Just like for ServiceWorker, this state is indicated by the presence or absence of the property on the navigator object.

结论是 Clipboard API 仅支持在 安全上下文(Secure contexts) 中使用,在这里指的是基于 https 协议或者 localhost/127.0.0.1 本地环境访问的服务。

然而实际场景中确实存在需要部署在普通 http 环境中的服务,尤其是一些在企业内部的项目,需要寻找 Clipboard API 的替代方案。

方案

Clipboard API 出现之前,主流的剪切板操作使用 document.execCommand 来实现;

兼容思路是,判断是否支持 clipboard,不支持则退回 document.execCommand

document.execCommand 实现一键点击复制的流程

  • 记录当前页面中 focus/select 的内容
  • 新建一个 textarea
  • 将要复制的文本放入 textarea.value
  • 将 textarea 插入页面 document,并且设置样式使其不影响现有页面的展示
  • 选中 textarea 的文本
  • document.execCommand 复制进剪贴板
  • 移除 textarea
  • 从记录中还原页面中原选中内容
实现代码 copy-code.ts
export async function copyToClipboard(text: string) {
  try {
    return await navigator.clipboard.writeText(text)
  } catch {
    const element = document.createElement('textarea')
    const previouslyFocusedElement = document.activeElement

    element.value = text

    // Prevent keyboard from showing on mobile
    element.setAttribute('readonly', '')

    element.style.contain = 'strict'
    element.style.position = 'absolute'
    element.style.left = '-9999px'
    element.style.fontSize = '12pt' // Prevent zooming on iOS

    const selection = document.getSelection()
    const originalRange = selection
      ? selection.rangeCount > 0 && selection.getRangeAt(0)
      : null

    document.body.appendChild(element)
    element.select()

    // Explicit selection workaround for iOS
    element.selectionStart = 0
    element.selectionEnd = text.length

    document.execCommand('copy')
    document.body.removeChild(element)

    if (originalRange) {
      selection!.removeAllRanges() // originalRange can't be truthy when selection is falsy
      selection!.addRange(originalRange)
    }

    // Get the focus back on the previously focused element, if any
    if (previouslyFocusedElement) {
      ;(previouslyFocusedElement as HTMLElement).focus()
    }
  }
}
使用
<script setup lang="ts">
import { copyToClipboard } from './copy-code';

const vm = getCurrentInstance()!

const props = defineProps<{
  rawSource: string
}>()

const copyCode = async () => {
    // $message来自element-plus 
  const { $message } = vm.appContext.config.globalProperties;
  try {
    await copyToClipboard(decodeURIComponent(props.rawSource))
    $message.success('复制成功')
  } catch (e: any) {
    $message.error(e.message)
  }
}

</script>

参考