
前几天有个做后端的朋友拿着他自己整理的技术文档来找我说代码贴进去之后“太平了”跟CSDN博客里那种带语言标签、带复制按钮、深色底色的代码片完全不是一个味道想让我帮忙改成那个样子。这事我做过不止一次从最早手写pre标签堆样式到后来接 highlight.js、Prism.js中间踩的坑能写满一整页。所以干脆把整套做法摊开讲一遍——怎么用 HTML CSS JS 把普通代码块做成 CSDN 代码片的显示效果包括结构怎么搭、配色怎么定、行号怎么对齐、复制按钮怎么写、语法高亮怎么接以及那些看起来很小、但能让你对着屏幕调半天的细节。不管你是刚学前端、想给自己的博客或项目文档美化一下还是做了几年想找一套能直接抄的模板这篇应该都能用得上。1. 先搞清楚 CSDN 代码片到底长什么样动手之前得先把“目标长什么样”说清楚。很多人一上来就写样式写着写着发现少了个语言标签或者复制按钮的位置不对又回头改结构来回折腾。我习惯先截图放大把视觉元素一层层拆开再决定用什么标签承载。1.1 拆解视觉结构从语言标签到复制按钮把 CSDN 的代码片放大看它其实是五个部分叠在一起最外层容器一块圆角矩形深色背景负责整体边界和阴影同时是复制按钮定位的参考系。顶部信息条左边是语言标签比如Java、Python、C右边是“复制代码”按钮。这一条和下面的代码区共用底色靠一条极细的分割线或者纯靠间距区分。代码主体区等宽字体固定行高行号在左侧单独一列文字区域可横向滚动。行号列紧贴左边颜色比正文浅一档且不可被选中——你拖动选择代码的时候行号不应该被一起复制走。滚动条横向滚动条只在内容超出时才出现纵向一般不滚动长代码靠折叠或者整体高度限制处理。这五个部分对应到 HTML我通常用三层嵌套div classcode-block div classcode-header span classcode-langJavaScript/span button classcode-copy复制代码/button /div pre classcode-bodycode classlanguage-javascript.../code/pre /div为什么不把语言标签直接塞进pre里面因为语言标签和复制按钮属于“工具区”代码属于“内容区”两者的字体、对齐方式、交互行为都不一样。混在一起往后加折叠、加全屏按钮时会非常难受。1.2 为什么直接写 code 标签不行新手最容易犯的错是直接写code var a 1; var b 2; /code页面上会变成一行挤在一起缩进也全没了。原因是 HTML 在渲染时会折叠连续空白换行、制表符、多个空格统统压成一个空格。code标签只负责“语义上表示这是一段代码”并顺便把字体换成等宽的它不做空白保留。真正保留空白的是pre标签pre的意思是 preformatted预格式化里面的空白原样输出。所以标准写法是pre套code外层pre负责保留换行和空格、提供横向滚动。内层code负责语义标注并作为语法高亮脚本的挂载点高亮库基本都是找pre code这个选择器。注意pre默认white-space: pre遇到长行会撑破容器而不是换行。代码块想要横向滚动就必须显式设置overflow-x: auto否则整个页面会被一行超长代码顶宽。1.3 技术选型手写 CSS 还是上高亮库这一步不同的项目选择差别很大我列个表对比一下你可以直接对号入座。方案体积上手难度语言覆盖适合场景纯 CSS 手写最小几 KB低无高亮只能整块一个颜色静态文档、演示页、内网小工具highlight.js中全量约 1MB按需可压到几十 KB低自动识别190 语言博客、笔记、通用代码展示Prism.js小核心约 2KB中需要手动指定语言按需加载对体积敏感、追求轻量自己写 token 着色极小高只覆盖你要的几种只展示固定语言的场景我一般这么选如果代码语言比较杂、又不想操心直接上 highlight.js如果是企业内网、追求零依赖、只展示 JS 和 Python那就自己写几十行着色规则反而更可控。2. 手写一套基础代码块HTML 结构与 CSS 细节选型确定后先把外壳搭出来。外壳搭得稳后面接什么库都只是换个内部实现。2.1 结构设计三层嵌套的职责划分再强调一次三层结构的分工这是整个方案的地基div classcode-block div classcode-header span classcode-langJavaScript/span button classcode-copy typebutton复制代码/button /div pre classcode-bodycode classlanguage-javascriptconsole.log(hi);/code/pre /div.code-block定位基准position: relative圆角和阴影都挂在这里。.code-headerdisplay: flex两端对齐justify-content: space-between高度固定通常在 36 到 40 像素之间。.code-body真正的代码容器控制内边距、滚动和字体。有一处细节值得说pre和code都不设背景色背景统一交给.code-block。这样滚动条出现时滚动条区域也是同色视觉上不会出现一条突兀的白边。我早期就是把背景放在pre上结果横向滚动条露出来的时候下面是页面底色看着像裂了一道缝。2.2 深色皮肤的关键 CSS 参数与取值计算配色不用凭感觉我有一套固定取值直接抄就行.code-block { position: relative; background: #282c34; border-radius: 6px; overflow: hidden; margin: 16px 0; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); } .code-header { display: flex; align-items: center; justify-content: space-between; height: 38px; padding: 0 14px; font-size: 12px; color: #abb2bf; border-bottom: 1px solid rgba(255, 255, 255, 0.08); } .code-body { margin: 0; padding: 14px 16px; overflow-x: auto; font-family: JetBrains Mono, Fira Code, Consolas, Monaco, monospace; font-size: 14px; line-height: 1.65; color: #abb2bf; tab-size: 4; } .code-body code { font-family: inherit; background: none; padding: 0; white-space: pre; }几个参数的选择理由背景#282c34这是 Atom One Dark 的底色饱和度低长时间看眼睛不累。选色的时候记住一个原则——代码底色要比正文底色暗但暗得有限度纯黑#000反而不舒服因为和亮色 token 的对比度过高容易产生光晕。行高1.6514px 字号乘以 1.65 约等于 23.1px。为什么不是 1.5 或者 21.5 行间距太紧多行代码挤成一团2.0 又太散一屏看不到几行。1.6 到 1.7 是兼顾密度和可读性的区间。这个值后面做行号对齐时会再次用到行号的行高必须和这里完全一致否则会逐行错位。内边距14px 16px上下 14 和头部 38 加起来视觉节奏舒服左右 16 是为了让代码不贴边同时给横向滚动留出呼吸空间。tab-size: 4默认制表符按 8 个空格渲染很多代码复制过来缩进会宽得离谱。设成 4 和主流编辑器对齐。一个容易忽略的点overflow: hidden加在.code-block上是为了让圆角裁掉内部溢出的内容。但如果你在.code-body上单独设了overflow-x: auto圆角裁剪就不会影响滚动条两者不冲突。2.3 等宽字体栈与中英文混排的坑字体栈的顺序是有讲究的前面放英文等宽字体最后兜底放monospace。这样中西文都能照顾到但中文字符在等宽字体里的宽度是英文字符的两倍会出现“注释里的中文把代码顶歪”的现象。font-family: JetBrains Mono, Fira Code, Cascadia Code, Consolas, Monaco, Courier New, monospace;JetBrains Mono 和 Fira Code 都带编程连字ligature!、、会渲染成更好看的符号。想开启得加一句.code-body { font-variant-ligatures: contextual; font-feature-settings: liga 1, calt 1; }连字是好看但有个坑连字会让字符的宽度发生视觉变化如果你同时用了行号列做对齐某些行的注释和代码会看起来差一点点。所以我的建议是做展示型代码块时开连字做需要精确对齐的编辑器类场景时关掉。中英混排还有个细节中文和英文之间最好加一点点字距不然中文紧贴英文时会显得很挤.code-body code { letter-spacing: 0.02em; }3. 语法高亮落地highlight.js 与 Prism.js 怎么选外壳搭好了代码是清一色的浅灰。要让关键字变蓝、字符串变绿、注释变灰就得靠高亮库。3.1 两种方案的对比与选型依据highlight.js的特点是自动语言识别。你只要给它一段代码它会自己猜是什么语言猜中率对常见语言来说还不错。代价是体积大因为要带上识别器。它的 CDN 全量版本接近 1MB但官方提供了按语言打包的定制版只勾选你要的语言能压到几十 KB。Prism.js反过来默认不识别你必须给代码块标上classlanguage-python它才知道用什么规则。好处是核心极小适合自己对语言列表有明确控制的场景。我的判断标准很直接代码来源不可控、语言五花八门用 highlight.js。代码来源固定、我能自己给每块打语言标记用 Prism.js。项目已经引了某个库比如文档框架自带的顺着它用不要额外再引一套。3.2 highlight.js 接入实操与主题改造接入只需要三行其中 CSS 是主题文件link relstylesheet href./styles/atom-one-dark.min.css script src./scripts/highlight.min.js/script scripthljs.highlightAll();/scripthljs.highlightAll()会自动扫描页面上所有pre code并处理。如果你只想处理特定容器可以用document.querySelectorAll(.code-body code).forEach(function (el) { hljs.highlightElement(el); });这一步有个顺序问题必须注意先插入代码内容再调用高亮。如果你是动态渲染页面高亮调用必须放在 DOM 更新之后。我遇到过页面用模板引擎异步渲染代码结果高亮脚本先跑扫到的是空元素整块代码全是灰的排查了半天才发现是时序问题。主题改造方面官方主题的背景色往往和我们自己定好的#282c34不一致会出现“头部一个色、代码区另一个色”的割裂感。解决办法是覆盖掉主题里的背景声明.code-body code.hljs, .code-body .hljs { background: transparent !important; padding: 0 !important; }让高亮库只负责给 token 上色背景和间距完全交给我们自己控制这样视觉才统一。3.3 手写一套轻量 token 着色可选如果你只展示 JS、Python 两三种语言又不想引入外部依赖自己写一套几十行的着色规则完全够用。核心思路是先把代码按正则切分成 token再给不同类别的 token 套上span加类名。const RULES [ { type: comment, re: /(\/\/[^\n]*|\/\*[\s\S]*?\*\/|#[^\n]*)/ }, { type: string, re: /([^]*|[^]*)/ }, { type: keyword, re: /\b(var|let|const|function|return|if|else|for|while|class|new)\b/ }, { type: number, re: /\b(\d(\.\d)?)\b/ } ];然后按顺序匹配、替换、拼回去。要注意正则的执行顺序注释和字符串必须排在关键字前面否则字符串里的if会被当成关键字着色注释里的数字也会被误染。这是最容易出错的地方顺序错了效果就是零散的色块乱跳。再配上对应的颜色类.hljs-comment { color: #5c6370; font-style: italic; } .hljs-string { color: #98c379; } .hljs-keyword { color: #c678dd; } .hljs-number { color: #d19a66; }这套配色和 Atom One Dark 一致即使后面换成 highlight.js类名也能对上不用重写样式。4. 让代码块“活”起来行号、复制、折叠、语言标签到这里代码已经好看了一半但还差几个交互尤其是复制按钮——很多人做完才发现用户最需要的功能其实就是一键复制。4.1 行号的三种实现方式与对齐坑行号有三种常见做法各有取舍方式实现优点缺点CSS 计数器counter-increment配合伪元素纯 CSS零 JS复制时会带上行号文本独立列左右两个div并排行号可单独控制两边行高必须一致易错位JS 生成按\n切分逐行包span控制力最强有性能开销我推荐 CSS 计数器方案简单且稳定.code-body code { counter-reset: line; } .code-body .line { counter-increment: line; } .code-body .line::before { content: counter(line); display: inline-block; width: 2.5em; margin-right: 1em; text-align: right; color: #5c6370; user-select: none; }配合 JS 把每行包起来function wrapLines(codeEl) { const lines codeEl.textContent.split(\n); codeEl.innerHTML lines .map(function (t) { return span classline t /span; }) .join(\n); }这里有两个必须记住的点。第一user-select: none一定要加在行号伪元素上否则用户选中代码复制时行号会被一起拖进去。第二行号是用inline-block加固定宽度所以行高必须由父级统一控制不要在行号上单独设line-height一旦父子行高不一致行号就会从第二行开始逐行偏移越往下偏得越多。这个坑我踩过当时以为是自己宽度算错了折腾了半小时才发现是行高。4.2 复制按钮Clipboard API 与降级方案复制功能现在优先用 Clipboard APIdocument.querySelectorAll(.code-copy).forEach(function (btn) { btn.addEventListener(click, function () { const block btn.closest(.code-block); const codeEl block.querySelector(code); const text codeEl.innerText; navigator.clipboard.writeText(text).then(function () { btn.textContent 已复制; setTimeout(function () { btn.textContent 复制代码; }, 1500); }).catch(function () { fallbackCopy(text, btn); }); }); });navigator.clipboard有个硬性前提必须在 HTTPS 或者 localhost 环境下才可用。如果你的页面部署在普通 HTTP 环境直接调用会报错或者静默失败。所以我一般都会写降级function fallbackCopy(text, btn) { 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); document.body.removeChild(ta); btn.textContent 已复制; setTimeout(function () { btn.textContent 复制代码; }, 1500); }用textarea而不是input是因为input遇到换行会丢内容。opacity: 0而不是display: none是因为隐藏元素无法被select()选中。提示复制取的文本用innerText而不是textContent。两者大部分时候一样但在某些浏览器下innerText更贴近用户看到的渲染结果。不过要注意innerText会把行号伪元素的内容带进去——所以前面那个user-select: none和伪元素方案要配套使用如果发现复制出来带行号就是这里出了问题。4.3 折叠展开与长代码处理超过三四十行的代码一屏放不下直接展开会把页面拉得很长。我的处理方式是默认折叠超过阈值才出现“展开”按钮const MAX_HEIGHT 420; document.querySelectorAll(.code-body).forEach(function (body) { if (body.scrollHeight MAX_HEIGHT) { body.style.maxHeight MAX_HEIGHT px; body.style.overflowY hidden; // 追加展开按钮逻辑 } });折叠状态给个渐隐遮罩比硬切好看得多.code-body.is-collapsed::after { content: ; position: absolute; left: 0; right: 0; bottom: 0; height: 60px; background: linear-gradient(to bottom, transparent, #282c34); pointer-events: none; }pointer-events: none很重要不加的话这层遮罩会挡住下方按钮的点击用户点展开没反应还以为是 JS 挂了。5. 内容转义代码里的尖括号怎么处理这一节是重灾区。只要你的代码里出现 HTML 标签直接塞进code里就会被浏览器当标签解析页面结构直接乱掉。5.1 HTML 实体转义的必要性假设你要展示这么一段div classboxhello/div如果你直接写进页面浏览器会渲染出一个真的 div而不是把这段文本显示出来。所以必须把特殊字符转成实体→amp;→lt;→gt;→quot;→#39;顺序很重要必须第一个替换。如果你先替换了变成lt;之后再去替换这个刚刚生成的lt;里的又会被二次转义成amp;lt;显示出来就是一堆乱码。5.2 转义函数实现与 XSS 边界function escapeHtml(str) { return String(str) .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;) .replace(//g, #39;); }这个函数在你动态渲染代码时是必须的尤其是代码内容来自用户输入或者接口返回的情况。不用它别人往代码里塞一段img srcx onerroralert(1)你的页面就直接执行了。这属于典型的 XSS 注入虽然你现在可能只是在做个人博客但这个习惯必须养成。注意先转义再做语法高亮。如果你先高亮、高亮库生成了大量span标签你再整体转义那些span会被转成文本显示出来整块代码全变成标签字符串前功尽弃。正确顺序永远是拿到原始文本 → 转义 → 高亮 → 插入 DOM。6. 常见问题排查实录与速查表写到这功能基本齐了。但真正上线之后问题往往出在你想不到的地方。下面这些是我这些年真金白银踩出来的。6.1 高频问题与解决思路现象可能原因解决办法缩进全部丢失只用了code没套pre补上pre或设置white-space: pre长代码撑破布局pre默认不换行不滚动加overflow-x: auto和max-width行号从第二行开始错位行号与代码行高不一致行高统一由父容器控制复制出来带行号行号可被选中给行号伪元素加user-select: none复制按钮无反应非 HTTPS 环境加execCommand降级方案高亮不生效脚本执行早于内容渲染把高亮调用放到 DOM 更新之后中文注释把代码顶歪等宽字体中文宽度是英文两倍降低字号或接受两倍宽度用letter-spacing微调代码里标签被解析没做实体转义渲染前统一escapeHtml头部和代码区颜色不一致高亮主题自带背景用!important覆盖为透明深色代码块边缘发白背景设在pre上背景统一挂到最外层容器6.2 独家避坑经验第一别在高亮后做行号包裹。高亮库生成的是嵌套span结构你在外面按\n切分很容易切到标签中间把 HTML 结构切碎。正确做法就两种要么先按行包裹、再对每行单独高亮要么先高亮、再用white-space: pre配合 CSS 计数器让行号自然对齐不切分 DOM。我推荐后者省事且不会破坏结构。第二横向滚动条会吃掉底部内边距。在 Windows 浏览器上滚动条占高度导致代码块底部看起来比顶部窄。解决是给pre加padding-bottom补偿或者用scrollbar-width: thin让滚动条变细。Mac 上因为是浮层滚动条看不到这个问题所以很多人是在别人的 Windows 机器上才发现。第三字体加载会闪一下。用了网络字体的话字体加载完成前是回退字体加载完成后整块代码宽度跳变行号对齐全乱。两个办法把等宽字体本地化或者给pre设font-display: swap之后再加一个min-width兜底。我在内网项目里基本都直接写系统字体栈稳定比好看重要。第四别在代码块里用float布局。复制按钮早期我用float: right实现结果按钮高度一变化头部高度就跟着变视觉上头部在抖。换成 flex 之后完全没这个问题。任何“固定在某个角落”的元素用position: absolute加容器的position: relative都比float可靠。第五动效要克制。复制成功的那一下最舒服的反馈是按钮文字从“复制代码”变成“已复制”而不是弹一个 toast。代码块本身就是视觉焦点再加动画会分散注意力。1500 毫秒回到原状态这个时长是我试下来最自然的短了看不清长了显得迟钝。第六测试用真代码别用hello world。我一开始拿两行代码测试什么问题都没有换成一段三十行的真实项目代码之后长行、中文注释、空行、tab 缩进全部冒出来。空行尤其要注意用按行切分方案时空行会没有内容行号却照常显示看起来像少了一行如果用innerText复制空行也可能被顺手合并掉。测之前准备好一段“脏”代码能省下大量返工。我自己的习惯是最后一定把整块代码复制到一个真实编辑器里粘贴一遍看缩进、看换行、看有没有多余空行这一步能揪出所有显示层面看不出来的问题。代码块这东西看着是样式活实际一大半功夫都花在内容处理上。