零依赖复刻CSDN风格代码块:行号、一键复制与语法高亮

发布时间:2026/10/2 19:39:53
零依赖复刻CSDN风格代码块:行号、一键复制与语法高亮 自己做博客或者维护内网文档站的人迟早会碰到这么一件事文章里的代码块太素了。就是pre默认那个白底黑字、字号还比正文小的样子粘一段 HTML 进去读者根本分不清哪是标签哪是属性。这时候很多人的第一反应是——照着 CSDN 的代码片抄一个。本文要聊的就是这件事用纯 html 配合 css 和一点 js复刻出 CSDN 那种带语言标签、带行号、带一键复制的代码块。它不依赖任何框架一个文件就能跑做静态博客、Markdown 渲染后的二次加工、后台管理系统的接口文档页都能用。零基础的前端新手可以拿它练 DOM 操作和 CSS 变量有经验的同学可以直接跳到第三节抄结构、跳到第五节看组件化封装。我前后改过三四版踩的坑基本都在第四节里。1. 先把 CSDN 代码块拆开看清楚再动手1.1 一个代码块到底由哪几层组成很多人一上手就写background: #000写到一半发现行号对不齐、复制按钮和语言标签挤在一起回头推翻重做。问题出在没先做结构拆解。把 CSDN 的代码片放大看它其实是四层叠出来的最外层是容器负责圆角、边框、阴影、外边距决定这个块在正文里浮起来还是嵌进去。往内第二层是头部栏左边一个语言标签比如javascript、c右边一个复制按钮鼠标悬停才会变亮有些版本还会在左边放三个小圆点模拟窗口按钮。第三层是代码区它自己又分成两列左边窄窄的一条是行号槽gutter右边才是真正的代码正文。第四层是词法单元也就是被span classtoken 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 classcode-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-themedark] { --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, amp;).replace(//g, lt;).replace(//g, gt;); } 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 classtoken- 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: hidden5. 进阶把它做成可复用的组件5.1 用自定义元素封装页面里只写一行如果一整站到处都在用手写那堆 div 会写到吐。用自定义元素包一层作者端只需要code-block langjavascript console.log(hello); /code-blockclass CodeBlock extends HTMLElement { connectedCallback() { if (this.dataset.ready) return; // 防止重复渲染 const lang this.getAttribute(lang) || text; const raw this.textContent.replace(/^\n/, ); // 干掉开头的换行 this.innerHTML div classcode-block div classcode-block__head span classcode-block__lang${lang}/span button classcode-block__copy typebutton复制/button /div div classcode-block__body div classcode-block__gutter aria-hiddentrue/div pre classcode-block__precode classcode-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加roleregion和aria-label内容用语言名标注比如aria-labeljavascript 代码示例。这样屏幕阅读器用户能知道这里是一段代码也能知道是什么语言。另外行号槽的user-select: none不只是为了复制干净对键盘用户也更友好——用 Shift 加方向键选代码时不会不小心把行号也框进去。顺带提一个后续扩展方向现在的高亮规则只覆盖了 JS 的几个关键字。如果你想让同一个代码块自动适配 C、Python、HTML 多种语言最省事的做法是把规则表按语言拆成对象RULES.javascript、RULES.python初始化时读>

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询