在付费内容平台、在线考试系统、小说站上,你大概都遇到过:选不中文字、右键没反应、复制出来是乱码。这些手段从最简单的一行 CSS 到最变态的字体混淆,技术含量跨了好几个量级,但底层逻辑只有一条:浏览器是客户端,用户对渲染结果有完全控制权。所有防复制方案都是在拉高复制成本,不存在无法绕过的方案。
这篇按防御强度从低到高,拆解七种主流手段的实现原理,每种附上对应的绕过方法。读完你会清楚:站在防御方该怎么组合,站在使用方该怎么拆解。
CSS user-select: none:最常见也最脆弱的一层
最简单的防复制,一行 CSS:
body { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none;}效果是鼠标拖拽选不中任何文字。大量博客站和文档站用的就是这一招。
绕过方法:
方法一,DevTools 直接覆写。打开开发者工具 → Elements 面板 → 选中 body(或对应元素),在 Styles 面板里添加 user-select: auto !important。
方法二,注入样式。方法三,浏览器阅读模式(Firefox / Safari 会重新渲染页面内容,user-select 不生效)。
const s = document.createElement('style');s.textContent = '* { user-select: auto !important; }';document.head.appendChild(s);Firefox:地址栏左侧点「切换到阅读视图」Safari:菜单栏 → 显示 → 显示阅读器Chrome:安装阅读模式扩展(原生不内置)防御强度:极低。 只能挡住不会打开 DevTools 的用户。但胜在零成本、零副作用,作为第一层防线没有坏处。
JS 事件拦截:禁右键、禁选择、禁快捷键
比 CSS 稍重的一层,用 JavaScript 拦截所有和复制相关的事件:
// 禁止右键菜单document.addEventListener('contextmenu', e => e.preventDefault());
// 禁止选择document.addEventListener('selectstart', e => e.preventDefault());
// 禁止复制document.addEventListener('copy', e => e.preventDefault());
// 禁止 Ctrl+C / Ctrl+A 等快捷键document.addEventListener('keydown', e => { if (e.ctrlKey && ['c', 'a', 'u', 's'].includes(e.key.toLowerCase())) { e.preventDefault(); }});四件套一起上,右键、选择、Ctrl+C、Ctrl+A、Ctrl+U(查看源码)、Ctrl+S(保存页面)全部失效。
绕过方法:
方法一,禁用 JavaScript。浏览器设置里关掉当前站的 JS,所有事件监听都不存在了。Chrome 可以在 DevTools → Sources 面板底部点齿轮图标 → 勾选 “Disable JavaScript”。
方法二,用 DevTools 查看和移除事件监听器:
// Chrome DevTools 专属 API,查看 document 上所有监听器getEventListeners(document);// 输出:{contextmenu: Array(1), selectstart: Array(1), copy: Array(1), ...}// 暴力方案:克隆节点替换,所有事件监听一次性清除// cloneNode(true) 深拷贝 DOM 但不拷贝事件监听器document.body.parentNode.replaceChild( document.body.cloneNode(true), document.body);替换后页面样式不变,但所有通过 addEventListener 绑定的事件全部消失。
方法三,用浏览器扩展。“Allow Copy” 类扩展的原理也是上面这些:注入脚本覆写 user-select,移除事件拦截。
防御强度:低。 JS 跑在客户端,用户有最终控制权。但对普通用户来说,这一层配合 CSS 已经够用了。
剪贴板劫持:复制到的不是你选的内容
这一层不阻止你复制,而是在你复制的时候偷换内容:
document.addEventListener('copy', e => { e.preventDefault(); const selection = window.getSelection().toString(); // 在复制的内容后面追加版权信息 const modified = selection + '\n\n——来源:example.com,转载请注明出处'; e.clipboardData.setData('text/plain', modified);});// 现代 Clipboard API,需要用户授权document.addEventListener('copy', async () => { await navigator.clipboard.writeText('你复制了个寂寞');});更过分的做法是直接替换成广告、乱码,甚至把你选中的文字改成完全不同的内容。知乎早期就用追加来源的方式。
绕过方法:
跳过剪贴板,直接从 DOM 取文本,不经过 copy 事件:
// 先在页面上选中文字,再在控制台执行window.getSelection().toString();document.querySelector('.article-content').textContent;防御强度:低。 本质上只是在 copy 事件里做文章,绕过 copy 事件就完全失效。
透明遮罩层:看得见选不中
在内容区上面盖一个全透明的 div,占满整个内容区域:
<div style="position: relative;"> <div class="content"> 这里是要保护的内容... </div> <!-- 透明遮罩:拦截所有鼠标事件 --> <div style=" position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 9999; background: transparent; "></div></div>鼠标点击和拖拽事件都被遮罩层吃掉了,底下的文字选不中。部分在线阅读器和文档预览系统用的就是这个思路。
绕过方法:
/* DevTools Elements 面板找到遮罩 div,加一条 */pointer-events: none;// 控制台批量处理:所有绝对定位的高层元素设为不拦截事件document.querySelectorAll('*').forEach(el => { const s = getComputedStyle(el); if (s.position === 'absolute' && s.zIndex > 100 && s.opacity !== '0') { el.style.pointerEvents = 'none'; }});也可以直接从 DOM 取 textContent,和上一节一样。
防御强度:低。 遮罩层在 DOM 里清晰可见,删掉就没了。但对不看 DevTools 的用户来说是有效的视觉欺骗。
文字转图片 / Canvas 渲染:DOM 里根本没有文本
前面四种方法有一个共同弱点:文字始终以文本形式存在于 DOM 中,用 textContent 就能取到。把文字渲染成图片可以从根本上消除这个入口。
服务端渲染成图片: 后端把文章内容渲染成一张张图片,前端用 <img> 展示。部分在线文档预览(Google Docs 的只读分享页、某些 PDF 在线查看器)就是这个思路。
前端 Canvas 绘制: 把文字画到 <canvas> 上,用户看到的是像素而不是文本节点:
const canvas = document.createElement('canvas');canvas.width = 800;canvas.height = 400;const ctx = canvas.getContext('2d');ctx.font = '16px sans-serif';ctx.fillStyle = '#333';
const text = '这段文字无法通过 DOM 获取';ctx.fillText(text, 20, 40);
document.body.appendChild(canvas);Canvas 里的内容在 DOM 中只是一个 <canvas> 元素,textContent 返回空字符串。
绕过方法:
OCR(光学字符识别)。浏览器环境下可以直接用 Tesseract.js:
// npm install tesseract.jsimport { createWorker } from 'tesseract.js';
async function ocrFromCanvas(canvas) { const worker = await createWorker('chi_sim'); // 中文简体 const { data: { text } } = await worker.recognize(canvas); await worker.terminate(); return text;}
// 也可以先把 canvas 转成图片 URLconst dataUrl = canvas.toDataURL('image/png');对于服务端渲染的图片,直接对 <img> 元素做 OCR,或者截图后用系统 OCR(macOS 的实况文本、Windows 的 PowerToys Text Extractor)。
防御强度:中。 第一次出现了不在 DOM 里直接可取的情况。但 OCR 技术成熟,中文识别准确率已经很高(Tesseract 对规范字体的识别率 > 95%)。代价是:图片体积远大于文本、不可被搜索引擎索引、无法被屏幕阅读器读取、不支持响应式排版。
无障碍问题文字转图片会让使用屏幕阅读器的用户完全无法获取内容。如果你的站点需要满足 WCAG 标准,这个方案不可接受。即便加了
alt文本,长篇内容的阅读体验也远不如真实文本。
DOM 碎片化 + CSS 视觉重排:复制出来是乱序
这一招比较巧妙:文字以文本形式存在于 DOM 中,但 DOM 顺序和视觉顺序不一致。
假设要展示「前端安全」四个字,DOM 结构可能是:
<div class="protected"> <span style="order: 3">安</span> <span style="order: 1">前</span> <span style="order: 4">全</span> <span style="order: 2">端</span></div>
<style>.protected { display: flex; }</style>视觉上看到的是「前端安全」(flex order 控制排列顺序),但 textContent 取到的是 DOM 顺序:「安前全端」。
更激进的做法是用绝对定位把每个字符放到精确的像素坐标上,DOM 顺序完全随机打乱。有的实现甚至会插入不可见的干扰字符(font-size: 0 或 color: transparent),复制出来夹杂一堆乱码。
绕过方法:
核心思路:按视觉坐标还原真实顺序。
function extractByVisualOrder(container) { const spans = [...container.querySelectorAll('span')]; // 按视觉位置排序:先按 top(行),再按 left(列) spans.sort((a, b) => { const ra = a.getBoundingClientRect(); const rb = b.getBoundingClientRect(); if (Math.abs(ra.top - rb.top) > 5) return ra.top - rb.top; // 不同行 return ra.left - rb.left; // 同一行 }); return spans.map(s => s.textContent).join('');}
const result = extractByVisualOrder(document.querySelector('.protected'));console.log(result); // "前端安全"getBoundingClientRect() 返回的是元素在屏幕上的实际渲染位置,和 DOM 顺序、CSS 排列方式无关。按坐标排序就能还原视觉顺序。
对于插入的不可见干扰字符,额外过滤一下:
spans.filter(s => { const style = getComputedStyle(s); return style.fontSize !== '0px' && style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';});防御强度:中高。 需要写针对性的脚本才能提取,简单的 textContent 拿到的是乱序内容。但只要文字还在 DOM 里,就一定能通过坐标还原。
字体混淆:看到的字和底层编码不同
这是目前防御强度最高的前端防复制手段。
原理:制作一个自定义字体文件(@font-face),把 Unicode 码位重新映射。比如字体里把 U+5B89(安)的位置画成「前」的字形,把 U+524D(前)的位置画成「端」的字形……HTML 里写的是一串看起来无意义的字符,但加载自定义字体后渲染出来是正确的文章。
@font-face { font-family: 'Obfuscated'; src: url('/fonts/obfuscated.woff2') format('woff2');}.protected { font-family: 'Obfuscated', sans-serif;}<p class="protected">安前全端</p><!-- 视觉上显示的是完全不同的内容 --><!-- 复制粘贴到其他地方,因为没有这个字体,显示的是乱码 -->起点中文网、某些付费文档平台用的就是这种方案(或其变体)。每次请求生成不同的映射表 + 不同的字体文件,连缓存都没法复用。
怎么确认一个站用了字体混淆选中文字复制粘贴到记事本里。如果粘贴出来的内容和你看到的完全不同(乱码或不同字符),基本可以确认是字体混淆。另一个信号:查看 DevTools → Network 面板,如果有自定义字体文件(
.woff2/.woff)在加载,而且字体名不是常见的系统字体,嫌疑很大。
绕过方法:
需要逆向字体文件的映射关系。用 opentype.js 解析字体:
// npm install opentype.jsimport opentype from 'opentype.js';
async function reverseFontMapping(fontUrl) { const response = await fetch(fontUrl); const buffer = await response.arrayBuffer(); const font = opentype.parse(buffer);
const mapping = {}; // 遍历字体的 cmap 表,建立码位 → 字形的反向映射 const glyphs = font.glyphs; for (let i = 0; i < glyphs.length; i++) { const glyph = glyphs.get(i); if (glyph.unicode) { // glyph.path 可以和标准字体做轮廓比对 // 实际场景需要更复杂的字形匹配算法 mapping[glyph.unicode] = glyph; } } return mapping;}实际操作比这复杂得多:需要把混淆字体里每个字形和标准字体做视觉比对(字形轮廓相似度),才能还原出真实映射。自动化工具存在,但不是开箱即用。
另一条路是截图 + OCR,和上一节一样。字体混淆再怎么混淆,屏幕上显示的像素是正确的。
防御强度:高。 DOM 里的文本和真实内容完全脱钩,复制出来是乱码,连 textContent 取到的都是错的。逆向字体需要专业知识,OCR 虽然通用但处理大量文本的成本不低。缺点是字体文件体积大(中文字体动辄几 MB)、每次请求生成新字体对服务端也有压力。
七种手段的防御纵深
每一层都拦不住有耐心的攻击者,但每多一层就筛掉一批不愿意付出对应成本的人。
网页是怎么加载的:扩展工作在哪一层
前面七种手段看起来是七个孤立的技巧,但把它们放回网页加载的完整流程里,每个手段卡住的位置不同,扩展能赢前三层的原因也很清晰。
从 URL 到像素:加载管线的五个阶段
第一阶段:网络请求。 DNS 解析域名 → TCP/TLS 握手 → 发送 HTTP 请求 → 服务器返回 HTML。
第二阶段:HTML 解析。 浏览器逐字节解析 HTML,从上往下构建 DOM 树。遇到 <script> 标签时默认暂停解析,先下载并执行这段 JS,然后才继续。这就是为什么页面脚本的执行时机和它在 HTML 里的位置有关——写在哪里,就在那个位置执行。
第三阶段:样式计算。 解析 CSS(包括 @font-face 触发的字体文件下载),构建 CSSOM,和 DOM 合并成渲染树。user-select: none 在这一步生效。
第四阶段:布局与绘制。 计算每个节点的几何位置(layout),栅格化成像素(paint),合成到屏幕(composite)。透明遮罩和字体混淆的视觉效果在这一步呈现。
第五阶段:交互循环。 页面就绪,进入事件循环等待用户输入。每次点击、拖拽选择、Ctrl+C 都走同一个事件分发路径:从 window 开始捕获(capture)向下到目标元素,再从目标元素冒泡(bubble)回到 window。JS 事件拦截和剪贴板劫持就是在这一步做文章。
七种手段各自卡在哪一步
| 手段 | 卡住的阶段 | 具体位置 |
|---|---|---|
CSS user-select | 第三阶段:样式计算 | CSSOM 中的选择属性 |
| JS 事件拦截 | 第五阶段:交互循环 | 事件分发路径上的监听器 |
| 剪贴板劫持 | 第五阶段:交互循环 | copy 事件的 handler |
| 透明遮罩 | 第四阶段(呈现)+ 第五阶段(拦截) | 层叠上下文中盖在内容上方 |
| 文字转图片 | 第二阶段:HTML 解析 | DOM 里根本没有文本节点 |
| DOM 碎片化 | 第二阶段:HTML 解析 | DOM 顺序与视觉顺序不一致 |
| 字体混淆 | 第三阶段:样式计算 | @font-face 的码位映射 |
扩展卡在哪:两个切入点
浏览器的 content script 机制给了扩展两个关键切入点:
切入点 1:解析阶段的最早期(document_start)。 content script 在 DOM 树刚创建、页面自己的 <script> 还没执行时注入。这保证了扩展的事件监听器先于页面注册——抢占的是时间上的先手。
切入点 2:样式计算阶段。 注入 user-select: auto !important 直接覆盖页面的 user-select: none。MutationObserver 监视样式节点,页面试图删掉时自动重新注入——覆盖的是渲染规则。
切入点 3:事件分发的最顶端(window 捕获阶段)。 所有事件到页面元素之前必经 window 的捕获阶段。在这里调 stopImmediatePropagation(),事件就不会到达页面注册的任何监听器——抢占的是传播路径上的先手。
隔离世界(Isolated World)Content script 和页面脚本共享同一个 DOM,但运行在独立的 JavaScript 上下文里。页面访问不到扩展的变量和函数,也无法通过
removeEventListener移除扩展的监听器——它根本拿不到引用。这就是为什么页面脚本对扩展几乎没有反制手段。
前三层防复制手段全部失效的根本原因:它们和扩展在同一个战场上作战,但扩展在时间(注入更早)、传播路径(捕获更靠前)、上下文(隔离世界不可见)三个维度上都卡在了页面代码够不着的位置。
后四层手段扩展处理不了,原因也可以从这张图上读出来:透明遮罩是 DOM 结构问题(形态太多,无法通用识别),图片和字体是内容呈现层问题(DOM 里就没有可取的文本,或者取到的是乱码),DOM 碎片化是结构语义问题(需要理解视觉排序)。这些层面的问题,注入时机再早也没用。
一个实际实现:Enable Copy 浏览器扩展
前面七节的绕过方法大多要开 DevTools 手动操作,每次遇到防复制页面都来一遍显然不现实。我把前三层的绕过逻辑打包成了一个浏览器扩展:
它对应处理文章里的前三层:
| 文章中的手段 | 扩展的对应实现 |
|---|---|
CSS user-select: none | 注入 user-select: auto !important,并用 MutationObserver 监听——页面把样式节点删掉时自动重新注入 |
| JS 事件拦截 | 内容脚本在 document_start 阶段注入,早于页面自己的脚本;在 window 捕获阶段对 contextmenu / selectstart / copy / cut 调 stopImmediatePropagation(),事件根本到不了页面的监听器 |
| 剪贴板劫持 | 同上,copy / cut 事件在捕获阶段被放行,劫持代码不会执行 |
有个实现细节值得说一下:为什么用捕获阶段的 stopImmediatePropagation() 而不是简单地 removeEventListener?因为扩展拿不到页面脚本注册监听器时的函数引用,没法精确移除。但事件传播有顺序——捕获阶段先于冒泡阶段,window 又处于捕获链的最顶端。在 window 上注册捕获监听器,调 stopImmediatePropagation() 阻止事件继续传播,页面的监听器就收不到事件了。不需要知道页面绑了什么,直接在传播路径上截断。
document_start 的时机选择也关键:内容脚本在页面任何脚本执行之前注入,保证扩展的监听器先于页面注册。如果晚于页面脚本,页面注册在前的监听器会先收到事件,拦截就失效了。
第四层(透明遮罩)没有覆盖——遮罩层形态太多样,通用识别成本高,实际遇到的频率也低。第五层(图片/Canvas)和第七层(字体混淆)在原理上就无法通过 DOM 操作解决,README 里也明确写了这两个限制,需要配合 OCR。
仓库里附了一个 test-page.html,模拟了禁选择、禁右键、复制劫持、禁快捷键、CSS 限制五种场景,装完扩展可以直接验证效果。
实战中怎么组合:成本取舍
没有单一手段能做到绝对防复制,但多层组合可以把成本拉到大部分人不愿意付的程度。
| 手段 | 防御强度 | 实现成本 | 用户体验影响 | 无障碍影响 |
|---|---|---|---|---|
CSS user-select | 极低 | 几乎为零 | 无 | 无 |
| JS 事件拦截 | 低 | 低 | 轻微(右键失效) | 轻微 |
| 剪贴板劫持 | 低 | 低 | 中(复制内容被篡改) | 低 |
| 透明遮罩 | 低 | 低 | 中(可能影响交互) | 中 |
| 文字转图片 | 中 | 中 | 高(不可搜索、不可缩放) | 严重 |
| DOM 碎片化 | 中高 | 高 | 低(视觉正常) | 严重 |
| 字体混淆 | 高 | 很高 | 低(视觉正常) | 严重 |
普通博客 / 内容站: CSS user-select + JS 事件拦截 + 剪贴板追加来源,三层叠加已经能挡住 95% 的随手复制。实现成本低,用户体验影响可控。
付费内容平台: 在上面的基础上加 DOM 碎片化或字体混淆。两者都能保持视觉正常,但复制出来是乱码。字体混淆更强但成本更高,需要服务端配合生成字体。
在线考试 / 法律文书: 文字转图片 + Canvas 渲染是最常见的方案。配合禁用 DevTools 的检测(检测窗口尺寸变化、debugger 断点),进一步提高提取难度。但要注意无障碍合规问题。
底线不管你选哪种方案,别忘了用户体验。禁右键会影响翻译插件和辅助功能,文字转图片会让屏幕阅读器用户完全失去内容,字体混淆会导致浏览器内搜索失效。防复制的每一层都在消耗用户体验预算——加到什么程度,取决于你的内容值多少钱。
如果用户能看到,用户就能复制。区别只在于成本。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时