1. 需求拆解:一个“复制当前地址”到底要做多少事
“复制当前地址”这个需求,看着像一行代码的事:拿到location.href,扔进navigator.clipboard.writeText,完事。我在早期项目里也是这么写的,结果上线第一周就收到一堆反馈——有人在分享出去的链接里带着自己的登录票据,有人在手机浏览器上点按钮毫无反应,还有人在笔记本上点完之后页面“嗖”地跳到了顶部。从那一刻起我才明白,这不是一个复制功能,这是一个剪贴板写入 + 链接安全治理 + 交互状态管理 + 兼容性兜底的组合工程。
这篇内容围绕 Vue 3 环境下的“复制当前地址”功能展开,讲清楚 Clipboard API 该怎么封装、降级链路怎么排、URL 该怎么做安全过滤,以及一堆只有真正跑过线上项目才会遇到的细节。适合两类人看:一类是刚开始写 Vue 3 组合式函数、想照着做一个能直接落地的小工具的同学;另一类是在项目里已经写过复制按钮、但被各种兼容问题和安全审计打过回票的同行。全文代码基于 Vue 3 的setup语法与Composition API,不依赖任何第三方剪贴板库,你可以直接抄进现有工程。
先把这个需求的边界划清楚,后面所有设计都是从这里长出来的。一个合格的“复制当前地址”,至少要回答下面这些问题:复制的是原始href还是清洗过的地址?地址里带不带会泄露身份的查询参数?当前环境是不是安全上下文?浏览器是不是允许在非用户手势的调用栈里写剪贴板?写入失败之后用户看得到什么、还能不能自己手动复制?复制成功后按钮状态怎么复位、连点会不会出问题?页面在 iframe 里、在 SSR 首屏里、在 hash 路由下分别表现如何?这些问题的答案不一样,代码结构就完全不一样。
我见过太多项目把这段逻辑直接塞在模板的@click里,写成三行内联代码。当时能跑,等到要做埋点、要做失败提示、要做参数脱敏的时候,就要动整个页面。所以第一步永远不是写writeText,而是把这几件事拆成独立关注点:取值(当前地址是什么)、净化(哪些部分不能带出去)、写入(用哪条通道写进剪贴板)、反馈(用户怎么知道成功还是失败)。这四件事彼此独立,任何一件改动都不该连累另外三件。想明白这一层,封装才有意义。
2. 方案选型:Clipboard API 与老式命令怎么排优先级
2.1 现代剪贴板 API 的能力边界与几个必然的坑
navigator.clipboard.writeText()是现在的主力方案,异步、返回 Promise、不用操作 DOM,看起来完美。但它有两个硬性前提:页面必须处于安全上下文(HTTPS 或localhost),以及浏览器授予了剪贴板写权限。第一条经常被忽略——本地开发用http://192.168.x.x调试的时候,navigator.clipboard直接是undefined,代码在判断处就断了,新人往往以为是浏览器版本问题,其实是协议问题。
第二个坑更隐蔽:用户手势的时效性。浏览器要求剪贴板写入发生在用户激活(user activation)的调用栈附近。如果你的copy()里先await了一个接口请求、或者先await了一个动态import,再调writeText,Chrome 多数情况还放行,Safari 和部分移动端浏览器就会抛出NotAllowedError。我在一个需要先上报埋点再复制的项目里被这个坑咬过一次,最后把写入动作提到最前面,埋点改成void异步发出,问题立刻消失。
我的建议是:判断条件写成三段式,别只判断navigator.clipboard存不存在。
// 判断当前环境是否具备现代剪贴板写入能力 export function canUseClipboardApi() { if (typeof window === 'undefined' || typeof navigator === 'undefined') return false if (!window.isSecureContext) return false if (!navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') return false return true }这段判断放在onMounted之后再执行,因为 SSR 场景下window和navigator都不存在,在模块顶层直接访问会炸掉整个构建。这一点在 Nuxt 之类的同构框架里尤其要注意,服务端渲染阶段拿不到任何浏览器对象,所有环境探测必须延迟到客户端挂载。
2.2 三层降级链路:每一层负责什么,什么时候放弃
降级不是“加个 try/catch 就行”,而是要有明确的层级和退出条件。我通常排三层:
| 层级 | 手段 | 适用条件 | 主要缺陷 |
|---|---|---|---|
| 第一层 | navigator.clipboard.writeText | 安全上下文且 API 存在 | 权限被拒、手势失效时报错 |
| 第二层 | 隐藏 textarea +document.execCommand('copy') | API 不可用或第一层抛错 | 命令已废弃、会改动选区与滚动位置 |
| 第三层 | 展示只读输入框让用户手动复制 | 前两层全部失败 | 体验差,但保底可用 |
第二层用到的document.execCommand('copy')虽然是废弃 API,但到今天主流浏览器依然保留实现,作为兜底完全够用。它的核心原理是:把文本放进一个可选中元素,用代码选中它,再执行复制命令。这里有几个必须处理好的细节——元素必须可见(display: none会导致选不中),必须不能被用户察觉(用固定定位挪到视口外),必须在复制后立刻移除,并且要保存并恢复原来的选区,否则用户正在编辑的输入框光标会被抢走。
// 第二层兜底:选区式复制,注意元素可见性与选区恢复 export function copyByExecCommand(text) { const activeEl = document.activeElement const selection = document.getSelection() const savedRanges = [] if (selection) { for (let i = 0; i < selection.rangeCount; i++) { savedRanges.push(selection.getRangeAt(i)) } } const textarea = document.createElement('textarea') textarea.value = text textarea.setAttribute('readonly', '') // 挪到视口外,避免页面闪动,同时保持可选中 textarea.style.position = 'fixed' textarea.style.top = '-9999px' textarea.style.left = '-9999px' textarea.style.opacity = '0' document.body.appendChild(textarea) let ok = false try { textarea.select() textarea.setSelectionRange(0, text.length) ok = document.execCommand('copy') } catch (err) { ok = false } finally { document.body.removeChild(textarea) if (selection) { selection.removeAllRanges() savedRanges.forEach((range) => selection.addRange(range)) } if (activeEl && typeof activeEl.focus === 'function') { activeEl.focus({ preventScroll: true }) } } return ok }这段代码里藏着两个容易被忽略的点。一是focus({ preventScroll: true }),如果不加这个参数,恢复焦点时浏览器会滚动到该元素所在位置,用户会看到页面“跳了一下”。二是setSelectionRange(0, text.length),移动端对select()的支持不一致,补上这段更稳。至于opacity: 0而不是display: none,是因为后者会让元素不可选中,execCommand必然返回false。
3. 组合式封装:写一个能直接进项目的复制工具
3.1 对外接口怎么设计才不会被业务绑死
封装这件事最怕“为当前页面定制”。我见过一个复制函数把页面标题、分享文案、埋点字段全部硬编码在里面,后来另一个页面要用,只能复制一份改名。正确的做法是把工具做成只负责文本的函数,而把“文本从哪里来”交给调用方。
我倾向于导出两个东西:一个纯函数模块clipboard.js,负责“把一段文本写进剪贴板”;一个组合式函数useCopyUrl.js,负责“把当前地址清洗后复制并管理状态”。前者可以在任何地方复用,后者专供页面使用。这种分层在后来接需求时救了我很多次——有一次产品要求复制按钮改成复制一段带标题和链接的分享文案,我只改了组合式函数里的取值逻辑,纯函数一行没动。
组合式函数的返回值我固定成这几个字段:copied(是否处于成功态)、pending(是否正在写入)、error(错误信息)、copy()(触发方法)、fallbackText(失败时给用户手动复制的文本)、showFallback(是否展示手动复制面板)。这样模板里只需要绑定这几个状态,不用关心内部走的是哪条通道。
3.2 核心实现:状态机、超时与连点保护
复制是一个典型的瞬态操作,必须有状态机。idle → pending → success或idle → pending → error,成功态停留两秒左右自动回落到idle。这里最容易出问题的是定时器泄漏:用户连续点击,每次都会注册一个新的复位定时器,组件卸载前没清理就会在已销毁的组件上改状态。Vue 3 里用onBeforeUnmount统一清理,是很简单但必须做的事。
import { ref, onBeforeUnmount } from 'vue' import { writeText } from './clipboard' export function useCopyUrl(options = {}) { const { buildText, cooldown = 2000 } = options const copied = ref(false) const pending = ref(false) const error = ref('') const fallbackText = ref('') const showFallback = ref(false) let resetTimer = null let inflight = false function clearTimer() { if (resetTimer) { clearTimeout(resetTimer) resetTimer = null } } function reset() { copied.value = false error.value = '' pending.value = false } async function copy() { // 连点保护:写入过程未结束前直接忽略后续点击 if (inflight) return inflight = true pending.value = true error.value = '' showFallback.value = false clearTimer() let text = '' try { text = typeof buildText === 'function' ? buildText() : String(buildText || '') } catch (err) { text = '' } fallbackText.value = text const result = await writeText(text) inflight = false pending.value = false if (result.ok) { copied.value = true resetTimer = setTimeout(() => { copied.value = false }, cooldown) } else { error.value = result.message || '复制失败' showFallback.value = true } } onBeforeUnmount(() => { clearTimer() inflight = false }) return { copied, pending, error, fallbackText, showFallback, copy, reset } }注意inflight这个普通变量而不是ref。它只用于同步判断,不需要触发视图更新,用ref反而会多一次响应式开销。这种细节在按钮上体现得很明显——用ref做连点锁,某些情况下会因为更新时机差异导致短时间内放行两次。
3.3 写入器实现:把三层兜底串成一条链
writeText这个纯函数是整个封装的心脏,它的职责就是“尽力写进去,写不进去如实返回”。逻辑很直白:能走 API 就走 API,抛错就降级,降级也失败就返回失败让上层展示手动面板。
import { canUseClipboardApi, copyByExecCommand } from './env' export async function writeText(text) { const value = String(text ?? '') if (!value) { return { ok: false, reason: 'empty', message: '没有可复制的内容' } } if (canUseClipboardApi()) { try { await navigator.clipboard.writeText(value) return { ok: true, via: 'clipboard-api' } } catch (err) { // 常见为 NotAllowedError(权限/手势失效)或 SecurityError const reason = err && err.name ? err.name : 'unknown' if (reason !== 'NotAllowedError' && reason !== 'SecurityError') { // 非权限类异常也继续尝试兜底,但保留原因便于排查 console.warn('[clipboard] api failed:', err) } } } if (copyByExecCommand(value)) { return { ok: true, via: 'exec-command' } } return { ok: false, reason: 'unsupported', message: '当前浏览器不支持自动复制,请手动复制' } }这段代码有一个值得说的取舍:第一层失败后无条件尝试第二层,而不是根据错误类型决定要不要降级。原因是错误类型的判断在不同浏览器上并不可靠,NotAllowedError有时也意味着“这个文档不允许访问剪贴板”,此时execCommand反而可能成功。降级成本很低,多试一次比给用户弹一句“复制失败”要划算。
4. 安全过滤:把不该带出去的部分摘干净
4.1 为什么不能直接复制 location.href
这一节是我认为整个需求里最该被重视的部分。location.href是一个完全不可信的值:它可能带着一次性登录票据、带着内部灰度标识、带着用户身份信息、带着第三方追踪参数。用户点一下“复制链接”发到群里,等于把这些东西原样广播出去。我参与过一个内部系统的改版,就是因为复制出来的链接里带着会话参数,被安全团队在例会上点了名。
过滤要分层做,从粗到细依次是:协议层(只允许 http/https,挡掉javascript:和data:这类危险协议)、结构层(用URL对象解析,去掉用户名密码、去掉默认端口、规范化路径)、参数层(按黑名单剔除敏感键,按白名单保留必要键)、长度层(超长截断,避免超出目标平台限制)。四层都过了,才允许进入剪贴板。
const PROTOCOL_WHITELIST = ['http:', 'https:'] export function sanitizeUrl(rawUrl, options = {}) { const { maxLength = 2000, keepParams = [], dropParams = [] } = options let url try { url = new URL(String(rawUrl)) } catch (err) { return '' } if (!PROTOCOL_WHITELIST.includes(url.protocol)) return '' // 去掉 userinfo,避免把账号信息带出去 url.username = '' url.password = '' // 去掉 hash 中的敏感片段,交给自己实现的规则处理 const hash = url.hash url.hash = '' const cleaned = new URLSearchParams() const keepSet = new Set(keepParams.map((k) => k.toLowerCase())) const dropSet = new Set(dropParams.map((k) => k.toLowerCase())) for (const [key, value] of url.searchParams.entries()) { const lower = key.toLowerCase() if (dropSet.has(lower)) continue if (keepSet.size > 0 && !keepSet.has(lower)) continue // 单值长度限制,防止参数里塞大段文本 if (value.length > 300) continue cleaned.append(key, value) } url.search = cleaned.toString() url.hash = hash const result = url.toString() return result.length > maxLength ? result.slice(0, maxLength) : result }4.2 参数黑名单:把规则写成人能看懂的样子
黑名单不是一个固定数组就完事,它需要覆盖几类典型风险。第一类是凭证类:token、access_token、ticket、session、sessionid、auth、sign、signature、code、state、key、secret。第二类是身份类:uid、userid、user_id、openid、unionid、mobile、phone、email。第三类是内部标识类:debug、env、gray、abtest、whitelist、internal。第四类是追踪类:utm_source、utm_medium、utm_campaign、spm、from、share_source、ref。
这四类的处理策略其实不一样。凭证类和身份类必须硬剔除,宁可链接打不开也不能泄露;内部标识类建议剔除,避免外部用户看到内部字段;追踪类则看业务诉求,如果这个复制动作本身就是为了分享传播,保留utm反而是正确的,能统计到分享带来的流量。所以我更推荐把规则做成可配置,页面按自己的场景传数组,而不是在工具里写死一份全局黑名单。
匹配方式上,我倾向用「精确键名 + 正则前缀」的组合。像token这种精确键直接比对,像utm_这种前缀统一处理,像sign这种可能出现在sign2、sign_v2里的,用包含匹配更保险。但包含匹配容易误杀,比如design里含有sign,这就是一个大坑——我第一版就是这么写的,结果把分页参数designId给删了,排查了半天。后来改成同步维护一份「豁免键」名单,凡是命中豁免的直接放行,才彻底解决。
4.3 hash 路由与内容长度:两个经常翻车的点
hash 路由下location.href的形态是https://host/#/path?a=1&b=2,注意查询参数在整个链接的hash部分里。new URL()解析出来的url.search是空的,参数全在url.hash里。如果你的过滤逻辑只处理search,那在 hash 路由项目里就等于什么也没过滤。正确的做法是先判断hash里是否包含?,如果有,把?后面的部分单独解出来走同一套清洗逻辑,再拼回去。
function sanitizeHashQuery(url, predicate) { const hash = url.hash const index = hash.indexOf('?') if (index === -1) return const path = hash.slice(0, index) const query = hash.slice(index + 1) const params = new URLSearchParams(query) const next = new URLSearchParams() for (const [key, value] of params.entries()) { if (predicate(key, value)) next.append(key, value) } const nextQuery = next.toString() url.hash = nextQuery ? `${path}?${nextQuery}` : path }长度限制同样容易被跳过。不同平台对链接长度有隐性上限,短则几百字符,长则几千字符,超出部分在某些聊天软件里会被直接截断,而截断往往发生在参数中间,链接就变成无效的了。我的处理方式是:清洗后如果超过 2000 字符,优先丢弃可选参数(比如追踪类),保留路径和核心参数;如果还是超长,直接截断并在控制台输出告警,方便开发阶段发现。不要静默截断,静默是最难排查的问题来源。
最后补一道输出后复检:清洗完的结果必须能被new URL()成功解析,且协议在白名单内,才能进入剪贴板。这一步看起来冗余,实际上能挡住一类由拼接 bug 引入的畸形链接。
5. 交互与状态:让“复制”这个动作真正被用户感知
5.1 按钮上的三种状态与防误触细节
复制按钮的文案需要在三态之间切换:默认态“复制链接”、成功态“已复制”、失败态“复制失败,点此手动复制”。实现上最常见的问题是用v-if整体替换按钮,导致按钮宽度变化、布局抖动。稳妥的做法是固定按钮宽度(或者用min-width撑住最长文案),只切换内部文本和图标。
另一个细节是成功态要不要禁用按钮。我的经验是不要禁用,而是用inflight做静默拦截——按钮保持可点击外观,点击后如果还处于成功态,直接重试并重置定时器。禁用状态在视觉上会让用户觉得“这按钮坏了”,尤其在某些低端机上状态更新有延迟时更明显。
无障碍这块也别省。给按钮加上aria-live="polite"的旁白区域,成功和失败都往里写一句提示,读屏用户才知道操作结果。看似小题大做,但这类细节在评审里经常被问到。
5.2 失败兜底面板:把控制权交还用户
自动复制失败的时候,最忌讳的就是弹一句“复制失败”就完事。用户此刻的诉求很明确——我要拿到这段链接。所以失败态必须给出一条能走通的路:展示一个只读输入框,里面是清洗好的完整地址,自动聚焦并全选,用户按一次Ctrl+C(或者在手机上长按)就能完成。
<template> <div class="copy-url"> <button type="button" class="copy-url__btn" :class="{ 'is-success': copied }" @click="copy"> {{ buttonText }} </button> <div v-if="showFallback" class="copy-url__fallback"> <p class="copy-url__tip">{{ error }}</p> <input ref="fallbackInput" class="copy-url__input" type="text" readonly :value="fallbackText" @focus="$event.target.select()" /> </div> <span class="copy-url__sr" aria-live="polite">{{ copied ? '链接已复制' : error }}</span> </div> </template>配套的buttonText用一个计算属性就够了,三种状态各自返回文案。手动面板聚焦要用nextTick加focus()加select()三连,少了nextTick会出现“输入框刚渲染还没挂到 DOM 上就聚焦”的失败。移动端还有一个坑:某些浏览器在focus()时会自动弹出软键盘,而只读输入框弹键盘毫无意义。可以在移动端改成不自动聚焦,靠用户手动长按选中。
6. 常见问题与排查实录
6.1 故障速查表
| 现象 | 高概率原因 | 处理方式 |
|---|---|---|
| 按钮点了完全没反应 | navigator.clipboard为undefined,且降级层也没跑起来 | 检查是否 HTTPS 或 localhost,检查降级函数是否被条件短路 |
控制台报NotAllowedError | 用户手势已失效,或权限被拒 | 把写入动作前移到await之前,减少异步前置 |
控制台报SecurityError | 页面处于 iframe 且未授权,或协议不合规 | 检查 iframe 的allow属性,确认是安全上下文 |
| 复制后页面跳到顶部 | 使用了focus()未加preventScroll | 全部焦点恢复加{ preventScroll: true } |
| 复制成功但内容是空的 | 取值函数抛错被吞掉,或过滤后为空 | 在取值处加 try/catch 并输出告警,空值时提前返回失败 |
| 移动端点了按钮页面缩放 | 输入框font-size小于 16px | 只读输入框字号不小于 16px,或设置viewport约束 |
| hash 路由下参数没被过滤 | 只处理了url.search,忽略了url.hash | 单独解析 hash 中的查询串 |
| 分享链接在聊天软件里打不开 | 链接被截断 | 加长度上限,超长时优先丢追踪参数 |
6.2 我在实际项目里踩过的几个坑
第一个坑是在onMounted之前调用。有个页面把复制逻辑写在setup顶层,用来做“进入页面自动复制”的骚操作。结果 SSR 阶段服务端执行时navigator不存在,直接白屏。后来改成onMounted里调用,并且包了一层if (typeof window === 'undefined') return。这件事之后,我在任何涉及浏览器 API 的封装里都会加环境判断,成本极低。
第二个坑是把链接清洗写在了复制之后。顺序颠倒的后果是,某些失败分支里用户看到的手动面板里是原始未清洗的链接,恰恰是失败时最容易暴露敏感参数的时刻。现在我的实现里,清洗一定发生在最前面,fallbackText存的就是清洗后的结果,任何分支都不会再碰原始值。
第三个坑是并发覆盖。产品希望支持长按批量复制多条链接,用户连续点了三个不同的按钮,结果三次异步写入的返回顺序不确定,最后成功态落在了错误的那一条上。解决办法是给每次调用分配一个自增的requestId,回来的时候比对当前 id,不匹配就丢弃结果。这个思路和接口竞态处理是一样的,只是很多人不会想到复制也需要。
第四个坑跟测试有关。写单元测试时直接mock掉navigator.clipboard很容易,但isSecureContext在测试环境里默认是false,导致代码一直走降级分支,测了个寂寞。后来在测试里显式Object.defineProperty(window, 'isSecureContext', { value: true })才覆盖到主路径。这类环境差异最好在测试文件顶部集中处理,不然每加一个用例就要重复一遍。
7. 上线前的自检清单与扩展思路
功能写完不等于能上线,我给自己定了一份固定检查项,每次接这类需求都过一遍。清单很朴素,但每一条都对应过一次真实事故:是否只在客户端执行(SSR 安全)、是否处理了空值与非法 URL(调用new URL一定包 try/catch)、敏感参数是否按页面配置剔除(别指望一份全局黑名单解决所有场景)、失败态是否有可操作的手动路径(不能只弹提示)、定时器是否在卸载时清理(避免内存泄漏和状态错乱)、按钮是否有可访问名称和状态播报(读屏可用)、连点是否被拦截(避免状态错乱)、是否有埋点区分成功通道(clipboard-api和exec-command的占比能反映用户环境分布,很有参考价值)。
扩展方向其实挺多的。比如把清洗规则内置成几套预设:“公开分享”模式只保留路径和少量业务参数,“内部流转”模式保留完整参数但剔除凭证,“埋点调试”模式则把所有utm都保留下来。再用一个preset参数切换,调用方不用关心具体规则。又比如给复制行为加一层“短链生成”能力,链接过长时先请求后端换个短链,再写入剪贴板,用户体验会好很多。
我个人在最近一个项目里的体会是,这个功能真正的价值不在那段writeText调用,而在清洗规则和失败兜底。代码量最大的部分恰恰是最不“技术”的部分——判断哪些参数该留、哪些不该留,判断失败之后用户还能怎么走完这条路。把这两块打磨好,剩下的兼容性处理都是机械劳动,照着表一条条填就行。