自己做博客或者维护内网文档站的人,迟早会碰到这么一件事:文章里的代码块太素了。就是<pre>默认那个白底黑字、字号还比正文小的样子,粘一段 HTML 进去,读者根本分不清哪是标签哪是属性。这时候很多人的第一反应是——照着 CSDN 的代码片抄一个。本文要聊的就是这件事:用纯 html 配合 css 和一点 js,复刻出 CSDN 那种带语言标签、带行号、带一键复制的代码块。它不依赖任何框架,一个文件就能跑,做静态博客、Markdown 渲染后的二次加工、后台管理系统的接口文档页都能用。零基础的前端新手可以拿它练 DOM 操作和 CSS 变量,有经验的同学可以直接跳到第三节抄结构、跳到第五节看组件化封装。我前后改过三四版,踩的坑基本都在第四节里。
1. 先把 CSDN 代码块拆开看清楚再动手
1.1 一个代码块到底由哪几层组成
很多人一上手就写background: #000,写到一半发现行号对不齐、复制按钮和语言标签挤在一起,回头推翻重做。问题出在没先做结构拆解。把 CSDN 的代码片放大看,它其实是四层叠出来的:
最外层是容器,负责圆角、边框、阴影、外边距,决定这个块在正文里"浮"起来还是"嵌"进去。往内第二层是头部栏,左边一个语言标签(比如javascript、c++),右边一个复制按钮,鼠标悬停才会变亮,有些版本还会在左边放三个小圆点模拟窗口按钮。第三层是代码区,它自己又分成两列:左边窄窄的一条是行号槽(gutter),右边才是真正的代码正文。第四层是词法单元,也就是被<span class="token keyword">这类标签包起来的关键字、字符串、注释,颜色全靠这一层的类名控制。
这四层里最容易做错的是第三层的切分。新手喜欢把行号拼进代码文本里一起输出,结果读者点复制,粘到编辑器里第一列全是数字,得手动删;或者把行号写成绝对定位的浮层,代码一横向滚动,行号就飞出去跟正文错位。正确的做法是行号单独一个元素、代码单独一个元素,两个并排,滚动的只有代码那一侧。这个认知上的差别,决定了后面几十行 CSS 是写三行还是一百行。
1.2 为什么值得花时间自己写,而不是随便找个库
现成的语法高亮库很多,highlight.js、Prism 都是一个文件引进去就能用,语言支持也全。那为什么还有人愿意自己写?我总结出三个真实理由。
第一个是体积控制。一个中文技术博客整体可能就 30KB 的 CSS,你为了几个代码块引一个 200KB 的高亮库,还带几十种用不上的语言包,性价比很难看。第二个是样式一致性。库自带的主题是别人定的,跟你的站点配色大概率不搭,你要覆盖它就得写一堆!important,越改越脏。第三个是可定制性,比如你想让代码块支持"点击行号高亮整行"、想让它跟随站点主题自动切明暗、想在复制成功时改按钮文案,自己写的代码改起来是分钟级的事,改库就得啃文档。
1.3 三条实现路线的取舍对比
我把常见的做法归成三类,你可以按项目体量选:
| 路线 | 额外依赖 | 高亮效果 | 上手成本 | 适合谁 |
|---|---|---|---|---|
| 纯 CSS + 手写高亮 | 无 | 只覆盖少量语言,规则自己定 | 低 | 静态页、文档页、教学示例 |
| CSS + 引入高亮库 | 1 个 JS/CSS 文件 | 主流语言全覆盖,边界情况处理得好 | 中 | 内容量大的技术博客 |
| 自研分词器 + 完整组件 | 无 | 可控但需要维护规则表 | 高 | 想彻底吃透原理、要做编辑器的人 |
下面第二节到第四节,我按第一条路线写全套代码,同时在第三节末尾附一个极简分词器,这样你既拿到了能直接用的成品,也理解了高亮库内部到底在干什么。等哪天你需要支持二十种语言了,再换成第二条路线,前面的结构代码一行都不用改——这就是先分层再动手的好处。
2. 核心细节逐项拆解:从配色到字体
2.1 容器造型:圆角、边框和阴影的取值逻辑
容器的视觉基调决定了这个代码块给人的第一印象。CSDN 用的是偏方正的圆角,大概 6px 左右,配一条比背景略深的边框,加一层非常淡的投影。这几个值都不是随便定的,背后有规律。
圆角取 6px 到 8px 是最舒服的区间。小于 4px 会显得很生硬,跟正文的卡片风格割裂;大于 12px 又太软,代码块本身是"硬"内容,圆角太大反而廉价。而且圆角要和头部栏配合:如果你打算让头部栏成为一个深色横条,那头部栏自己的上圆角必须和外层一样,否则会出现两个白角漏出来——这是新手最常见的视觉瑕疵。
边框的取色有个小技巧:不要用纯黑加透明度,那会让它在暖色背景上发灰。直接取底色往深里推两档,或者用rgba(27,31,35,.15)这种冷灰,跟正文的阴影色系保持一致。
阴影要克制。代码块本身有边框,再加很重的阴影会像按钮。我一般用0 1px 2px rgba(0,0,0,.04)这种几乎看不见的级别,它的作用不是"浮起来",而是让边界更实一点。如果你的站点正文是纯白,可以干脆去掉阴影,只留边框。
2.2 头部栏:语言标签和复制按钮怎么摆
头部栏高度我固定在 36px 到 40px。低于 34px 按钮的可点区域太小,移动端难点;高于 44px 又占地方,长代码块显得头重脚轻。
布局用 flex 最省事:justify-content: space-between把语言标签推左、按钮推右,中间自然留空。语言标签不要做太大,字号比正文小 1px、颜色比正文浅一档、字重 500,加一点字间距,读起来清爽。很多人喜欢给它加个胶囊背景,我建议只在暗色主题下加,亮色主题下加了容易脏。
复制按钮建议做成无边框的纯文字按钮,初始态只有图标或"复制"两个字,悬停时给一个浅色底。原因是它在一个 40px 高的窄条里,做实体按钮会抢戏。按钮的点击热区至少 28×28,可以用 padding 撑开,别用line-height撑——那会让图标居中变得很难调。
2.3 等宽字体栈和字号行高:这几个数字别乱改
代码块的字体设置是重灾区,尤其在中英文混排时。中文技术博客里代码注释经常是中文,如果字体栈只写monospace,中文注释会退化成系统默认字体,和英文部分行高对不上,看起来一高一低。
我实测下来比较稳的一套是:
.code-block code { font-family: "JetBrains Mono", "Fira Code", Menlo, Consolas, "Sarasa Mono SC", "Microsoft YaHei Mono", monospace; font-size: 13px; line-height: 1.6; letter-spacing: 0.2px; }字号 13px 是有依据的:正文一般 15px 或 16px,代码块比正文小 2 到 3px,视觉上主次分明,又不会小到费眼。行高 1.6 是个平衡点,低于 1.4 多行代码会挤成一块,高于 1.8 则显得松散、行号槽会拉得很长。字间距 0.2px 是为了补偿等宽字体在屏幕上偏挤的问题,尤其是i、l、1挨在一起的时候。
另外提醒一句:font-family里那些字体名,普通用户电脑上大概率一个都没有,最终还是会落到monospace。这不是问题,但你要保证退路是可靠的,别把monospace漏掉,也别忘了中文等宽字体(Sarasa Mono SC、Microsoft YaHei Mono),这两个才是保证中英混排对齐的关键。
2.4 亮暗两套配色用 CSS 变量管理
颜色不要硬编码进每个选择器。用 CSS 变量定义一套语义化的名字,切换主题时只换这一组值,比写两套样式表干净得多。下面是我在用的取值,亮色主题贴近常规文档站,暗色主题接近 CSDN 经典暗色:
| 变量名 | 亮色取值 | 暗色取值 | 对应部位 |
|---|---|---|---|
--cb-bg | #f7f8fa | #282c34 | 代码区背景 |
--cb-head-bg | #eef0f3 | #21252b | 头部栏背景 |
--cb-border | #e2e5ea | #3a3f47 | 整体边框 |
--cb-text | #24292f | #d7dae0 | 代码正文颜色 |
--cb-gutter | #9aa1ac | #5c6370 | 行号颜色 |
--cb-keyword | #c678dd | #c678dd | 关键字 |
--cb-string | #50a14f | #98c379 | 字符串 |
--cb-comment | #a0a1a7 | #7f848e | 注释 |
--cb-number | #986801 | #d19a66 | 数字 |
暗色主题下要注意一个细节:背景不要用纯黑#000。纯黑配亮色文字对比度过高,长时间看很累;用#282c34这类带一点蓝的深灰,眼睛会舒服很多。
3. 完整实操:从骨架到复制按钮全部落地
3.1 HTML 骨架怎么写才不留后患
结构目标是:代码文本只有一个来源,行号是派生的。这样复制的时候直接取代码元素的文本内容,永远不会带上行号。骨架长这样:
<div class="code-block">:root { --cb-bg: #f7f8fa; --cb-head-bg: #eef0f3; --cb-border: #e2e5ea; --cb-text: #24292f; --cb-gutter: #9aa1ac; --cb-keyword: #c678dd; --cb-string: #50a14f; --cb-comment: #a0a1a7; --cb-number: #986801; } [data-theme="dark"] { --cb-bg: #282c34; --cb-head-bg: #21252b; --cb-border: #3a3f47; --cb-text: #d7dae0; --cb-gutter: #5c6370; --cb-string: #98c379; --cb-comment: #7f848e; --cb-number: #d19a66; } .code-block { margin: 1.5em 0; border: 1px solid var(--cb-border); border-radius: 6px; overflow: hidden; /* 关键:裁掉头部栏的直角 */ background: var(--cb-bg); box-shadow: 0 1px 2px rgba(0, 0, 0, .04); } .code-block__head { display: flex; align-items: center; justify-content: space-between; height: 38px; padding: 0 12px; background: var(--cb-head-bg); border-bottom: 1px solid var(--cb-border); user-select: none; /* 头部文字不该被选中 */ } .code-block__lang { font-size: 12px; font-weight: 500; letter-spacing: .4px; color: var(--cb-gutter); text-transform: lowercase; } .code-block__copy { appearance: none; border: 0; background: transparent; padding: 6px 10px; border-radius: 4px; font-size: 12px; color: var(--cb-gutter); cursor: pointer; transition: background-color .15s, color .15s; } .code-block__copy:hover { background: rgba(127, 127, 127, .15); color: var(--cb-text); } .code-block__copy.is-done { color: #3fb950; } .code-block__body { display: flex; overflow: hidden; } .code-block__gutter { flex: 0 0 auto; padding: 12px 8px 12px 14px; text-align: right; font: 13px/1.6 ui-monospace, monospace; color: var(--cb-gutter); user-select: none; /* 行号永远不该被选中复制 */ background: transparent; } .code-block__pre { flex: 1 1 auto; margin: 0; padding: 12px 14px; overflow-x: auto; font-size: 13px; line-height: 1.6; } .code-block__code { font-family: "JetBrains Mono", Menlo, Consolas, "Sarasa Mono SC", monospace; color: var(--cb-text); white-space: pre; tab-size: 4; } .token-keyword { color: var(--cb-keyword); } .token-string { color: var(--cb-string); } .token-comment { color: var(--cb-comment); font-style: italic; } .token-number { color: var(--cb-number); } .code-block__pre::-webkit-scrollbar { height: 8px; } .code-block__pre::-webkit-scrollbar-thumb { background: rgba(127, 127, 127, .3); border-radius: 4px; } .code-block__pre::-webkit-scrollbar-track { background: transparent; }两处值得单独说。overflow: hidden加在最外层,是为了让头部栏的方形直角被容器裁掉,看起来是一个整体;如果你改成在头部栏上写border-radius: 6px 6px 0 0,一旦以后调整圆角大小就得改两个地方。行号槽的padding我写成上右下左四个值,是为了让首行的行号和代码首行在同一水平线上——上下 padding 必须和pre的上下 padding 完全一致(都是 12px),否则行号会整体偏移几像素,这种偏差肉眼特别容易捕捉到。
3.3 JavaScript:行号生成、复制按钮、简易高亮
脚本部分我拆成三个独立函数,互不耦合,将来哪一块不用了直接删。
// 1) 生成行号:只根据代码行数画,不参与文本内容 function renderGutter(block) { const code = block.querySelector('.code-block__code'); const gutter = block.querySelector('.code-block__gutter'); const lines = code.innerText.replace(/\n$/, '').split('\n').length; gutter.textContent = Array.from({ length: lines }, (_, i) => i + 1).join('\n'); }replace(/\n$/, '')这一句是专门防坑的:如果代码文本结尾带一个换行,split会多分出一行空字符串,行号就比实际代码多一个。这个 bug 很多人写完没发现,直到代码最后一行是空行才冒出来。
// 2) 复制:优先用异步剪贴板 API,失败时回退 document.addEventListener('click', async (e) => { const btn = e.target.closest('.code-block__copy'); if (!btn) return; const block = btn.closest('.code-block'); const code = block.querySelector('.code-block__code'); const text = code.innerText; // 只取代码元素,行号在别的节点里 try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); } else { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); } btn.classList.add('is-done'); btn.textContent = '已复制'; setTimeout(() => { btn.classList.remove('is-done'); btn.textContent = '复制'; }, 1600); } catch (err) { btn.textContent = '复制失败'; setTimeout(() => { btn.textContent = '复制'; }, 1600); } });这里用事件委托而不是给每个按钮绑监听,好处是动态插入的代码块自动生效。你的文章如果是异步渲染的(比如 Markdown 在前端解析),代码块是后插进 DOM 的,逐个绑定就会漏掉它们。另外window.isSecureContext这个判断别省,本地用file://打开页面时,剪贴板 API 是不给用的,没有回退逻辑的话复制按钮点下去毫无反应,你会以为是代码写错了。
// 3) 简易高亮:规则数组按优先级排列,谁先匹配到算谁的 const RULES = [ ['comment', /\/\/[^\n]*|\/\*[\s\S]*?\*\//], ['string', /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/], ['number', /\b\d+(?:\.\d+)?\b/], ['keyword', /\b(?:const|let|var|function|return|if|else|for|while|class|new|import|from|export|async|await|try|catch)\b/], ]; function escapeHtml(s) { return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); } function highlight(src) { let out = ''; let rest = src; outer: while (rest.length) { // 逐条规则试,取"起始位置最靠前"的那一条 let best = null; for (const [type, re] of RULES) { const m = re.exec(rest); if (m && (best === null || m.index < best.m.index)) { best = { type, m }; } } if (!best) { out += escapeHtml(rest); break outer; } out += escapeHtml(rest.slice(0, best.m.index)); out += '<span class="token-' + best.type + '">' + escapeHtml(best.m[0]) + '</span>'; rest = rest.slice(best.m.index + best.m[0].length); } return out; }这段"取最靠前匹配"的逻辑是整个高亮器的心脏,也是最容易写错的地方。如果你按规则顺序依次replace,会出现什么情况?字符串里的//会被先当成注释切掉,于是"https://example.com"后半段整块被涂成注释色。正确顺序必须是扫描式的:在一个位置上同时问所有规则"你最早能匹配到哪",取最靠前的那一个,处理完再往后推,这样注释和字符串天然拥有优先级。规则数组里把 comment 和 string 放在前面不是它们排序优先,而是为了让代码可读,真正的优先级由"最靠前匹配"这个机制保证。
3.4 组装起来的完整调用方式
把三块拼起来,页面加载后遍历一次即可:
<script> document.querySelectorAll('.code-block').forEach((block) => { const lang = block.dataset.lang || 'text'; block.querySelector('.code-block__lang').textContent = lang; const code = block.querySelector('.code-block__code'); const raw = code.textContent; // 原始文本,此时还是纯文本 code.innerHTML = highlight(raw); // 先高亮 renderGutter(block); // 再按行数画行号 }); </script>顺序很关键:先高亮再算行号。因为高亮会往文本里插<span>,但不会改变换行数量,所以行数不变;反过来如果先算行号再高亮,万一你的高亮函数动了换行(比如把\r\n归一化),行号就错位了。
另外,这段初始化代码要放在所有代码块的 HTML之后执行,或者包在DOMContentLoaded里。放在<head>里裸跑的话,querySelectorAll返回空集合,页面上一个行号都不会出现,控制台还不报错——这是排查起来最费劲的一类问题。
4. 常见问题与排查技巧实录
4.1 复制出来的内容带行号或丢缩进
带行号基本只有一个原因:行号和代码被放进了同一个元素或者被串成了一个字符串。检查你的行号是不是塞在code元素里。如果为了省事用了innerText直接取整个.code-block,那头部栏的"复制"两个字也会被复制进去,这个细节很多实现都翻过车。
丢缩进通常是两个原因。一是 HTML 源码里本身的缩进在压缩环节被工具吃掉了,这属于构建流程问题,需要在压缩配置里对pre做白名单。二是脚本读取时用了textContent之后又做了trim(),把首行前导空格删了。我的建议是:永远不要对代码文本做 trim,需要判断空行就在渲染阶段判断,别动原始字符串。
4.2 页面加载瞬间高亮"闪一下"才生效
这是典型的 FOUC,表现是代码先以纯黑文字显示,几十毫秒后才变成彩色。原因是高亮脚本在页面渲染之后才执行。三个可选的处理方式:把脚本放在 HTML 后面同步执行(最快生效,但会短暂阻塞渲染)、在容器上先加visibility: hidden,高亮完成后再显示、或者干脆把高亮放到构建阶段做,前端只渲染结果。小站点用第一种,内容多的站点用第三种最干净。
4.3 长代码横向滚动,行号跟着飘
行号槽如果没设flex: 0 0 auto而写成了flex: 1,横向滚动区一撑开,行号槽也会跟着被拉伸,导致数字和代码逐渐错位。还有一种情况是你把行号槽放在了pre内部,那它一定会跟着内容一起滚。正确结构就是 3.1 节给的那样:滚动条只出现在pre上,行号槽是它的兄弟节点,两者的纵向 padding 完全一致。
4.4 移动端体验的几个坑
小屏上代码块最容易出问题。一是长行会把整个页面撑出横向滚动(页面级滚动条),这是最难看的,必须在pre上写overflow-x: auto并且给它max-width: 100%。二是内部滚动条不好拖,行号槽可以加一个position: sticky; left: 0让它固定住。三是复制按钮太小,移动端点不准,建议在小屏媒体查询里把按钮的 padding 加大到10px 14px。
4.5 问题速查表
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 复制的文本首列是数字 | 行号混进了代码元素 | 行号独立成兄弟节点,复制只取code |
| 首行前导空格消失 | 对文本做了 trim 或压缩插件吃缩进 | 去掉 trim,压缩白名单加pre |
| 高亮颜色错乱、URL 被涂成注释 | 用连续 replace 而不是扫描式匹配 | 改成"取最早匹配位置"的循环 |
| 行号比实际行数多一个 | 结尾换行参与 split | 先replace(/\n$/, '')再拆分 |
| 点复制没反应 | 非安全上下文下剪贴板 API 不可用 | 加execCommand回退分支 |
| 行号与代码纵向错位 | 两侧上下 padding 不一致 | 统一为同一个值,如都是 12px |
| 头部栏出现两个白色直角 | 容器没有裁切 | 外层加overflow: hidden |
5. 进阶:把它做成可复用的组件
5.1 用自定义元素封装,页面里只写一行
如果一整站到处都在用,手写那堆 div 会写到吐。用自定义元素包一层,作者端只需要:
<code-block lang="javascript"> console.log('hello'); </code-block>class CodeBlock extends HTMLElement { connectedCallback() { if (this.dataset.ready) return; // 防止重复渲染 const lang = this.getAttribute('lang') || 'text'; const raw = this.textContent.replace(/^\n/, ''); // 干掉开头的换行 this.innerHTML = ` <div class="code-block"> <div class="code-block__head"> <span class="code-block__lang">${lang}</span> <button class="code-block__copy" type="button">复制</button> </div> <div class="code-block__body"> <div class="code-block__gutter" aria-hidden="true"></div> <pre class="code-block__pre"><code class="code-block__code"></code></pre> </div> </div>`; const code = this.querySelector('.code-block__code'); code.innerHTML = highlight(raw); renderGutter(this.querySelector('.code-block')); this.dataset.ready = '1'; } } customElements.define('code-block', CodeBlock);这里有两个小细节值得注意。this.textContent.replace(/^\n/, '')是为了去掉标签后紧跟的那个换行——HTML 里<code-block>换行再写内容,textContent会把这个换行算进去,结果第一行是个空行,行号从 2 开始。dataset.ready那个判断是防重复渲染,自定义元素的connectedCallback在节点被移动时可能触发多次。
5.2 打印和导出 PDF 时的适配
文档站经常有人打印,而深色背景打印出来是一团黑,非常难看。加一段打印样式就能解决:
@media print { .code-block { background: #fff; border: 1px solid #ccc; box-shadow: none; break-inside: avoid; /* 尽量别跨页断开 */ } .code-block__head { display: none; } /* 打印不需要复制按钮 */ .code-block__pre { overflow: visible; white-space: pre-wrap; word-break: break-all; } .code-block__code, .token-keyword, .token-string { color: #000; } }break-inside: avoid是关键的一行,它让一个代码块整体不被分页切开,避免半个函数出现在上一页、另外半个在下一页。white-space: pre-wrap则把横向滚动改成自动折行,因为打印出来的纸不会滚动。
5.3 性能和可访问性上最后收个尾
一个页面如果有三十个代码块,每次初始化都调一次highlight,在低端手机上能感觉到卡顿。我通常加一个延迟渲染:用IntersectionObserver监听,代码块进入视口前 200px 才执行高亮和行号生成,屏幕外的先保持纯文本。实测下来首屏渲染时间能降一截,而用户完全感知不到差别。
可访问性上,记得给.code-block加role="region"和aria-label,内容用语言名标注,比如aria-label="javascript 代码示例"。这样屏幕阅读器用户能知道这里是一段代码,也能知道是什么语言。另外行号槽的user-select: none不只是为了复制干净,对键盘用户也更友好——用 Shift 加方向键选代码时,不会不小心把行号也框进去。
顺带提一个后续扩展方向:现在的高亮规则只覆盖了 JS 的几个关键字。如果你想让同一个代码块自动适配 C、Python、HTML 多种语言,最省事的做法是把规则表按语言拆成对象(RULES.javascript、RULES.python),初始化时读>