# 如何实现复制功能

> 从使用场景、API 选型、兼容降级、移动端适配和排错清单几个层次，完整讲解 Web 页面中复制功能的实现方式。

- 发布于：2020-08-06
- 更新于：2026-05-14
- 分类：学习
- 标签：前端
- 原文：https://chanx.tech/blog/copy-with-vanilla-js

复制功能不是一句 `copy()` 就结束的事情。

在真实项目里，它通常出现在验证码、邀请码、分享链接、命令行代码块、订单号、接口 Token、富文本片段等场景中。用户点击按钮后，页面需要把指定内容写入系统剪贴板，并给出“已复制”或“复制失败”的反馈。

要把这个体验做稳，需要回答四个问题：

| 问题 | 影响 |
| :--- | :--- |
| 复制什么 | 纯文本、输入框内容、代码块、HTML 富文本的实现方式不同 |
| 在哪里复制 | HTTPS、localhost、普通 HTTP、WebView 的权限限制不同 |
| 用什么 API | 现代浏览器优先用 Clipboard API，旧环境需要兜底 |
| 失败怎么办 | 需要兼容方案、用户反馈和手动复制提示 |

这篇文章按“先选方案，再写工具函数，最后处理边界”的方式整理。

## 一、先建立方案选择表

实现复制功能时，可以先按环境选方案。

| 场景 | 推荐方案 | 说明 |
| :--- | :--- | :--- |
| 现代浏览器复制纯文本 | `navigator.clipboard.writeText` | 首选方案，语义清晰，异步调用 |
| 旧浏览器或部分 WebView | `document.execCommand('copy')` | 作为兼容兜底使用 |
| 复制 HTML 富文本 | `navigator.clipboard.write` + `ClipboardItem` | 兼容性和权限要求更高 |
| 复制输入框内容 | 读取 `input.value` 后复制 | 是否 `trim()` 要看业务 |
| 复制代码块或普通元素 | 读取 `textContent` 后复制 | 多行文本更稳定 |

实际项目中最稳妥的策略是：

```text
优先 Clipboard API
      ↓
失败或不支持
      ↓
使用 execCommand 兜底
      ↓
仍然失败
      ↓
提示用户手动复制
```

## 二、第一层：现代浏览器的 Clipboard API

现代浏览器提供了 `navigator.clipboard`。复制纯文本时，核心 API 是：

```javascript
await navigator.clipboard.writeText('hello world');
```

一个最小可用示例：

```html
<button id="copyBtn" type="button">复制</button>

<script>
  const copyBtn = document.querySelector('#copyBtn');

  copyBtn.addEventListener('click', async () => {
    try {
      await navigator.clipboard.writeText('hello world');
      copyBtn.textContent = '已复制';
    } catch (error) {
      copyBtn.textContent = '复制失败';
      console.error(error);
    }
  });
</script>
```

这段代码的重点不在 `writeText` 本身，而在它必须放进点击事件里。

### Clipboard API 的前置条件

`navigator.clipboard.writeText` 通常需要满足这些条件：

| 条件 | 原因 |
| :--- | :--- |
| 页面运行在安全上下文 | 通常需要 `https://` 或 `localhost` |
| 由用户行为触发 | 防止网页静默篡改剪贴板 |
| 浏览器支持 Clipboard API | 旧浏览器、老 WebView 可能没有 |
| 当前权限没有被禁止 | iframe、权限策略、浏览器设置都可能影响 |

下面这种自动复制就不可靠：

```javascript
setTimeout(async () => {
  await navigator.clipboard.writeText('hello world');
}, 1000);
```

因为它脱离了用户点击行为，浏览器很可能拒绝。

## 三、第二层：execCommand 兼容兜底

在 Clipboard API 不可用时，可以使用传统方案：

```javascript
document.execCommand('copy');
```

它不是直接“复制某个字符串”，而是复制当前页面选区里的内容。因此要人为创建一个临时输入区域。

### 基本流程

```text
创建 textarea
      ↓
写入要复制的文本
      ↓
选中 textarea 内容
      ↓
执行 document.execCommand('copy')
      ↓
删除 textarea
```

基础实现如下：

```javascript
function copyWithExecCommand(text) {
  const textarea = document.createElement('textarea');

  textarea.value = text;
  document.body.appendChild(textarea);

  textarea.select();
  const success = document.execCommand('copy');

  document.body.removeChild(textarea);
  return success;
}
```

这个版本能解释原理，但还不适合直接放到项目里。它在移动端可能拉起键盘，也可能因为临时节点可见导致页面抖动。

## 四、第三层：移动端和 WebView 适配

兼容方案要重点处理 iOS 和内嵌 WebView。一个更完整的兜底函数可以这样写：

```javascript
function copyWithFallback(text) {
  const textarea = document.createElement('textarea');

  textarea.value = text;
  textarea.setAttribute('readonly', 'readonly');
  textarea.style.position = 'fixed';
  textarea.style.top = '-9999px';
  textarea.style.left = '-9999px';
  textarea.style.opacity = '0';

  document.body.appendChild(textarea);

  textarea.focus();
  textarea.select();
  textarea.setSelectionRange(0, textarea.value.length);

  let success = false;

  try {
    success = document.execCommand('copy');
  } catch (error) {
    console.error('execCommand 复制失败', error);
  } finally {
    document.body.removeChild(textarea);
  }

  return success;
}
```

### 关键处理拆解

| 代码 | 解决的问题 |
| :--- | :--- |
| `readonly` | 降低 iOS 拉起键盘、页面抖动的概率 |
| `position: fixed` | 避免临时节点影响页面布局和滚动位置 |
| `top/left: -9999px` | 让节点不可见但仍可被选中 |
| `select()` | 选中文本内容 |
| `setSelectionRange()` | 补强 iOS 上选区不完整的问题 |
| `finally` 删除节点 | 避免复制失败时遗留临时 DOM |

不要把临时节点设置为 `display: none`，隐藏元素通常无法被选中，也就无法复制。

## 五、最终封装：一个可复用的 copyText

项目里建议只暴露一个函数：`copyText`。

它负责判断环境、调用 Clipboard API、失败后自动降级，并返回一个布尔值告诉调用方是否成功。

```javascript
async function copyText(value) {
  const text = String(value ?? '');

  if (!text) {
    return false;
  }

  if (navigator.clipboard && window.isSecureContext) {
    try {
      await navigator.clipboard.writeText(text);
      return true;
    } catch (error) {
      console.warn('Clipboard API 复制失败，尝试兼容方案', error);
    }
  }

  return copyWithFallback(text);
}

function copyWithFallback(text) {
  const textarea = document.createElement('textarea');

  textarea.value = text;
  textarea.setAttribute('readonly', 'readonly');
  textarea.style.position = 'fixed';
  textarea.style.top = '-9999px';
  textarea.style.left = '-9999px';
  textarea.style.opacity = '0';

  document.body.appendChild(textarea);

  textarea.focus();
  textarea.select();
  textarea.setSelectionRange(0, textarea.value.length);

  let success = false;

  try {
    success = document.execCommand('copy');
  } catch (error) {
    console.error('复制失败', error);
  } finally {
    document.body.removeChild(textarea);
  }

  return success;
}
```

这个函数只做一件事：复制文本并返回结果。按钮文案、提示弹窗、埋点统计等 UI 逻辑应该放在调用处。

## 六、按使用场景落地

### 场景 1：复制普通文本

```html
<button id="copyBtn" type="button">复制链接</button>

<script>
  const copyBtn = document.querySelector('#copyBtn');

  copyBtn.addEventListener('click', async () => {
    const success = await copyText('https://example.com/invite?code=ABC123');

    copyBtn.textContent = success ? '已复制' : '复制失败';

    window.setTimeout(() => {
      copyBtn.textContent = '复制链接';
    }, 1500);
  });
</script>
```

### 场景 2：复制输入框内容

```html
<input id="inviteCode" value="ABC123" readonly />
<button id="copyInviteCode" type="button">复制邀请码</button>

<script>
  const input = document.querySelector('#inviteCode');
  const button = document.querySelector('#copyInviteCode');

  button.addEventListener('click', async () => {
    const success = await copyText(input.value);
    button.textContent = success ? '已复制' : '复制失败';
  });
</script>
```

是否使用 `trim()` 要看业务：

| 内容类型 | 是否建议 `trim()` |
| :--- | :--- |
| 邀请码、订单号、普通链接 | 通常可以 |
| 命令行、Token、密码、配置文本 | 谨慎处理，空格可能有意义 |

### 场景 3：复制代码块

```javascript
const code = document.querySelector('pre code');
const button = document.querySelector('#copyCode');

button.addEventListener('click', async () => {
  const success = await copyText(code.textContent);
  button.textContent = success ? '已复制' : '复制';
});
```

复制代码块时优先用 `textContent`。它不会因为元素样式、隐藏状态、视觉换行而改变原始文本。

### 场景 4：复制页面可见文本

如果你希望复制“用户看到的文字”，可以考虑 `innerText`：

```javascript
const article = document.querySelector('article');

copyText(article.innerText);
```

`innerText` 会更接近页面渲染后的文本，但也更容易受 CSS 影响。

### 场景 5：复制 HTML 富文本

复制富文本需要使用 `navigator.clipboard.write`：

```javascript
async function copyHtml(html, plainText) {
  const item = new ClipboardItem({
    'text/html': new Blob([html], { type: 'text/html' }),
    'text/plain': new Blob([plainText], { type: 'text/plain' }),
  });

  await navigator.clipboard.write([item]);
}
```

调用：

```javascript
await copyHtml('<strong>Hello</strong>', 'Hello');
```

富文本复制对浏览器支持、权限和安全上下文要求更高。普通业务里能复制纯文本就优先复制纯文本。

## 七、用户体验不要漏

复制成功只是技术完成，用户体验还需要反馈。

建议至少处理三种状态：

| 状态 | 页面反馈 |
| :--- | :--- |
| 默认 | 显示“复制” |
| 成功 | 短暂显示“已复制” |
| 失败 | 显示“复制失败”，必要时提示手动复制 |

示例：

```javascript
async function handleCopy(button, text) {
  const originalText = button.textContent;
  const success = await copyText(text);

  button.textContent = success ? '已复制' : '复制失败';

  window.setTimeout(() => {
    button.textContent = originalText;
  }, 1500);
}
```

如果复制的是重要信息，比如付款账号、API Token、恢复密钥，失败时不要只写 `console.error`，页面上也要提示用户。

## 八、排错清单

复制失败时，可以按这个顺序查。

### 1. 是否脱离了用户行为

推荐：

```javascript
button.addEventListener('click', async () => {
  await copyText('hello');
});
```

不推荐：

```javascript
window.addEventListener('load', async () => {
  await copyText('hello');
});
```

自动复制通常会被浏览器拦截。

### 2. 页面是不是安全上下文

Clipboard API 通常要求：

- `https://`
- `localhost`
- `127.0.0.1`

普通 `http://`、`file://` 页面可能失败。

### 3. 是否在 iframe 或 WebView 中

iframe 可能受到权限策略限制，WebView 可能缺少 Clipboard API 或被宿主 App 禁用。

这类场景里要特别依赖兜底方案，并准备好手动复制提示。

### 4. 临时节点是否真的被选中

如果使用 `execCommand`，确认没有写成：

```javascript
textarea.style.display = 'none';
```

隐藏元素无法正常选择。应该移出屏幕，而不是直接隐藏。

### 5. 多行文本是否被处理过

复制多行内容时，明确用 `\n` 拼接：

```javascript
copyText(['第一行', '第二行', '第三行'].join('\n'));
```

## 九、完整示例

下面是一份完整、可直接复制使用的版本：

```html
<p id="shareLink">https://example.com/invite?code=ABC123</p>
<button id="copyBtn" type="button">复制链接</button>

<script>
  const shareLink = document.querySelector('#shareLink');
  const copyBtn = document.querySelector('#copyBtn');

  copyBtn.addEventListener('click', async () => {
    await handleCopy(copyBtn, shareLink.textContent);
  });

  async function handleCopy(button, text) {
    const originalText = button.textContent;
    const success = await copyText(text);

    button.textContent = success ? '已复制' : '复制失败';

    window.setTimeout(() => {
      button.textContent = originalText;
    }, 1500);
  }

  async function copyText(value) {
    const text = String(value ?? '');

    if (!text) {
      return false;
    }

    if (navigator.clipboard && window.isSecureContext) {
      try {
        await navigator.clipboard.writeText(text);
        return true;
      } catch (error) {
        console.warn('Clipboard API 复制失败，尝试兼容方案', error);
      }
    }

    return copyWithFallback(text);
  }

  function copyWithFallback(text) {
    const textarea = document.createElement('textarea');

    textarea.value = text;
    textarea.setAttribute('readonly', 'readonly');
    textarea.style.position = 'fixed';
    textarea.style.top = '-9999px';
    textarea.style.left = '-9999px';
    textarea.style.opacity = '0';

    document.body.appendChild(textarea);

    textarea.focus();
    textarea.select();
    textarea.setSelectionRange(0, textarea.value.length);

    let success = false;

    try {
      success = document.execCommand('copy');
    } catch (error) {
      console.error('复制失败', error);
    } finally {
      document.body.removeChild(textarea);
    }

    return success;
  }
</script>
```

## 十、总结

复制功能可以拆成三个层次：

1. **能力层**：优先使用 `navigator.clipboard.writeText`，不支持再用 `execCommand`。
2. **兼容层**：处理 HTTPS、用户行为触发、iOS 选区、WebView 权限等问题。
3. **体验层**：成功、失败、恢复按钮文案、必要时提示手动复制。

不要把复制功能只理解成一行 API。真正可靠的复制功能，是 API 选型、浏览器权限、移动端兼容和用户反馈一起完成的。
