
很多人在 CSDN 上看到一段排版舒服的代码块第一反应是这肯定是平台自己封装的富文本组件我自己的页面做不出来。真去翻一次开发者工具就会发现那东西朴素得有点让人失望无非是一层容器、一个头部工具条、一个 pre 包着 code再加上一段控制复制按钮的 JavaScript。没有影子 DOM没有 canvas也没有任何黑魔法。你拿记事本写一个 html 文件双击打开同样能做出九成相似的效果。这篇东西就是把我自己复刻这套代码块样式的过程完整摊开讲一遍包括结构怎么划、CSS 参数取多少、语法高亮要不要引库、复制按钮为什么在本地会失灵以及行号、横向滚动、深色模式这些一旦上线就会冒出来的问题。适合正在写技术博客、做文档站点、给后台系统写帮助页的人看前端基础一般也能跟上因为核心就是 html 加 cssJavaScript 只占很小一块。1. 先把 CSDN 代码片拆开它到底是几层结构叠出来的拆解任何 UI 的第一步都不是抄样式而是先搞清层级关系。在 CSDN 文章页里对着代码块右键检查你会看到一段被折叠得很整齐的 DOM展开之后层次其实非常浅通常不超过三层嵌套。这个观察很重要因为很多人复刻失败的原因不是 CSS 写错了而是一开始就把结构搞复杂了套了五六个 div最后定位互相打架圆角和阴影到处漏。我习惯把这类组件按职责切成三段外层是容器层负责圆角、背景底色、外边距是整个块的视觉边界中间是头部层横跨顶部一条左边放语言名右边放复制按钮底部是内容层也就是真正承载代码的那块区域负责内边距、字体、行高和滚动。三段各管各的互不越界这样后面改任何一个参数都不用担心牵一发动全身。层级典型标签主要职责容易踩的坑容器层figure或div圆角、底色、外层间距、整体阴影用figure时忘了清掉浏览器默认的 margin头部层figcaption或div语言标签、复制按钮、顶部分隔高度没固定复制按钮换文字时整条会跳内容层pre code代码文本、字体、行高、横向滚动忘了设overflow-x长行把页面撑宽1.1 容器层、头部层、内容层各自的边界在哪容器层最忌讳的一点是把内边距写在它身上。我一开始就是这么干的padding: 16px直接加在外层结果头部工具条的背景色没办法铺满整条左右两边各留出一条缝看着特别别扭。正确的做法是容器只负责边框和圆角padding设为 0把内边距下沉到内容层头部层则用不满宽的背景块去贴住容器的左右边缘。这样头部条的底色才能严丝合缝地贴住圆角内侧。内容层的边界问题主要是横向溢出。代码里但凡有一行特别长比如一个没换行的长 URL 或者一大串链式调用如果不做处理pre元素会按照内容宽度撑开把整个页面顶出一个横向滚动条。解决办法是给内容层加上overflow-x: auto让它自己内部滚这样页面整体宽度不受影响。这一点在移动端尤其明显我见过不少博客在手机上整页都能左右拖动根源就是这里。还有一个小细节值得单独说容器层最好不要设固定高度。代码块的行数是不确定的三行和三十行都得能正常显示。有人为了看起来整齐设了height: 300px结果短代码块下面拖着一大片空白长的又被截断。让它按内容撑开是这类组件唯一合理的选择。1.2 视觉参数实测圆角、内边距、字号这些数字从哪来复刻样式最容易走偏的地方就是凭感觉调数值。我的建议是直接在浏览器里量别猜。用开发者工具的取色器和标尺对着目标元素点几下很快就能把关键参数凑出来。下面这组是我自己反复调过、在 14 寸笔记本和 1080P 显示器上都看着舒服的取值你可以当基准再微调。容器圆角6px到8px超过 10px 会显得过于圆润失去技术感内容层内边距上下12px、左右16px这个比例在视觉上最平衡代码字号14px行高1.6也就是约22.4px行与行之间不挤不散头部高度36px左右语言标签字号12px等宽字体栈Consolas, Menlo, Monaco, Courier New, monospace行高这一项特别值得较真。因为等宽字体本身字面高度大行高给到 1.4 以下中文注释和英文代码混排时就会显得拥挤给到 1.8 以上代码块又会被拉得很松散一屏看不了几行。1.6 是我试过之后觉得最耐看的值长期阅读眼睛不累。字号也别贪大14px 配上等宽字体实际视觉宽度差不多相当于常规正文的 15.5px已经足够清晰了。2. 用 pre 和 code 搭骨架语义化标签怎么分工结构定下来之后选标签这一步其实有两个流派一派全部用div想怎么摆怎么摆另一派坚持用语义化标签figure配figcaption加pre code。我自己是坚定的后一派理由不是显得专业而是这套标签能免费拿到两样东西屏幕阅读器能识别出这是代码内容并正确朗读换行搜索引擎也能更准确地判断页面里存在代码片段。pre和code的分工必须说清楚这是新手最容易混的地方。code负责这是代码这个语义pre负责保留空白和换行这个排版行为。也就是说pre是拿来维持缩进和换行的code是拿来标明内容的。两者嵌套使用才是标准写法。如果你只用pre不加code浏览器还是会按原样显示但语义上丢了信息只用code不加pre代码里的缩进和换行会被浏览器合并成一整行看着就是一坨。2.1 语义化标签和纯 div 方案的取舍用figure有个必须记住的副作用浏览器默认给figure加了上下各1em的 margin左右还有40px。如果你不复位会发现代码块左边莫名其妙空出一大块而且和上下段落的间距也不对。开工第一件事就是把这两个值清掉。这不是什么坑是规范里写明的默认样式只是很多人不看。纯div方案的唯一优势是省心不用记这些默认值。但代价是语义全丢而且以后要做点击代码块展开全文这类扩展时你会发现自己没有可以挂钩的语义节点。我个人的判断标准很简单这块内容是不是一段带标题的代码是就用figure不是就用div。绝大多数场景下都该用前者。2.2 头部工具条绝对定位还是 flex 布局头部工具条里有两个元素语言标签在左复制按钮在右。实现方式有两种我两种都写过最后长期用的是 flex。第一种是绝对定位把复制按钮position: absolute; right: 12px; top: 8px语言标签留在正常流里。这种写法最省事缺点是按钮脱离文档流之后头部条的高度必须靠语言标签撑起来一旦语言标签的文字变了或者被隐藏整条就会塌掉。我之前做过一个可以隐藏语言标签的版本一切换就出问题。第二种是display: flex; justify-content: space-between; align-items: center两个元素都在正常流里高度自然被内容撑开也不会互相打扰。想加第三个元素比如折叠按钮直接往里面塞就行。这个方案唯一的注意点是头部条要设min-height避免语言标签为空时整条变得过矮。我现在默认就是 flex 方案没有任何回头的想法。2.3 一份可以直接跑起来的静态骨架把上面的结论拼起来就是一个完整的静态版本。下面这份代码我建议你直接存成.html文件双击打开看效果改动参数也能立刻看到变化比在项目里调试快得多。!DOCTYPE html html langzh-cn head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1 title代码块样式试验页/title style :root { --code-bg: #f6f7f9; --code-bar-bg: #eceff3; --code-border: #e2e5ea; --code-text: #24292f; --code-accent: #3a7afe; --code-radius: 8px; --code-gutter: 16px; --code-font: Consolas, Menlo, Monaco, Courier New, monospace; } .code-card { margin: 20px 0; padding: 0; border: 1px solid var(--code-border); border-radius: var(--code-radius); background: var(--code-bg); overflow: hidden; } .code-card__bar { display: flex; align-items: center; justify-content: space-between; min-height: 36px; padding: 0 12px; background: var(--code-bar-bg); border-bottom: 1px solid var(--code-border); } .code-card__lang { font-size: 12px; letter-spacing: 0.5px; color: #6b7280; text-transform: uppercase; } .code-card__copy { border: 0; background: transparent; color: var(--code-accent); font-size: 12px; cursor: pointer; padding: 4px 8px; border-radius: 4px; } .code-card__copy:hover { background: rgba(58, 122, 254, 0.1); } .code-card__pre { margin: 0; padding: 12px var(--code-gutter); overflow-x: auto; font-family: var(--code-font); font-size: 14px; line-height: 1.6; color: var(--code-text); background: var(--code-bg); } .code-card__pre code { font-family: inherit; font-size: inherit; white-space: pre; tab-size: 4; } /style /head body figure classcode-card figcaption classcode-card__bar span classcode-card__langhtml/span button classcode-card__copy typebutton复制/button /figcaption pre classcode-card__precodelt;div classboxgt; 内容 lt;/divgt;/code/pre /figure /body /html这里有个埋伏点要提前说pre里面的内容必须做 HTML 转义。上面代码里写的lt;和gt;不是装饰是必须的。如果你直接把真实的尖括号写进pre浏览器会把它当标签解析整块内容就消失了。这个问题的详细处理放在后面第 5 节讲。3. 语法高亮手写 span 还是引现成的库代码块光有排版还差点意思配色分不出关键字和字符串读起来依然费劲。语法高亮的实现路径本质上就两条一是自己给每个词套span加上类名二是引入 highlight.js 或 Prism 这类现成的库。这两条路没有绝对优劣关键是看你页面里有多少代码、代码更新频率多高。判断标准我总结成一个很粗暴的规则如果这个页面里代码块少于 5 个而且内容基本不动就手写超过 5 个或者经常更新就引库。手写的好处是零依赖、零构建、整块 HTML 复制到哪都能用特别适合那种一次性的讲解页面。缺点是改代码的时候要同步改 span稍微一多就是体力活。3.1 手写 token 的适用边界与类名约定手写高亮其实就是给不同类型的词配不同的类名比如关键字、字符串、注释、数字各一个颜色。类名没有强制标准但我建议跟主流库靠拢用hljs-keyword、hljs-string、hljs-comment这套命名将来想换成现成的库CSS 几乎不用重写。pre classcode-card__precode classlanguage-javascriptspan classhljs-keywordconst/span list [span classhljs-number1/span, span classhljs-number2/span, span classhljs-number3/span]; span classhljs-comment// 求和/span span classhljs-keywordconst/span total list.span classhljs-title function_reduce/span(span classhljs-function(span classhljs-paramsa, b/span) gt;/span a b, span classhljs-number0/span);/code/pre写的时候有个细节很容易忘里的同样要转义成gt;也要转成lt;。我当初手写第一版的时候漏了这个导致箭头函数那一行直接断掉排查了好一会儿才反应过来是转义问题。3.2 引入 highlight.js 时容易冲掉自己样式的三个地方引库的流程很简单挂上 CSS 和 JS然后在页面加载后调用一次初始化。但真正上手之后有三个地方会跟你的自定义样式打架。第一个是背景色。库自带的主题文件里hljs这个类通常会带上自己的background和padding。你辛苦调好的容器底色会被覆盖掉视觉上看起来像是代码块里又嵌了一块不同颜色的区域。处理方式是在自己的 CSS 里显式覆盖.hljs { background: transparent; padding: 0; }。第二个是字号和字体。有些主题文件里带font-family和font-size声明作用是让高亮区域自己也能独立成一个代码样式但在已经有容器的场景下这就变成了双重控制。统一在.code-card__pre code上定死字体和字号再用.hljs覆盖一层font: inherit就不会出问题了。第三个是初始化时机。如果你用DOMContentLoaded绑定初始化但页面的代码块是通过异步请求后塞进 DOM 的那后插入的块根本不会被高亮。这种情况要在内容插入完成之后手动调用一次高亮函数指定只处理新加入的节点。提示引库之后一定要在暗色模式和窄屏下各看一遍。不少高亮主题的配色是针对浅底设计的切到深色背景后注释色和关键字色的对比度会掉到看不清的程度。4. 复制按钮从能用到好用中间隔着好几个坑复制按钮看着简单实际上是我在这个组件上花时间最多的地方。原因很简单它的核心 API 有环境限制一旦你没意识到这个限制就会陷入代码明明没问题为什么点了没反应的困惑里。4.1 为什么本地双击打开 html 时复制会失灵现在推荐的写法是navigator.clipboard.writeText()但它有一个硬性前提页面必须运行在安全上下文里也就是https协议或者localhost。如果你只是双击本地文件、地址栏显示的是file://开头Chrome 里这个 API 会直接不可用navigator.clipboard甚至是undefined。我第一次遇到这个现象的时候一度以为是浏览器版本问题换了两个浏览器都一样后来才想明白是协议的问题。所以调试阶段必须准备两条路要么起一个本地静态服务器用localhost访问要么老老实实写降级方案别指望单一 API。4.2 一套带降级的复制函数降级方案用的是老办法动态创建一个textarea把文本塞进去选中然后调用document.execCommand(copy)。这个方法虽然被标记为过时但在不支持 Clipboard API 的环境里依然是唯一可行的兜底。function copyText(text) { if (navigator.clipboard window.isSecureContext) { return navigator.clipboard.writeText(text); } return new Promise(function (resolve, reject) { var ta document.createElement(textarea); ta.value text; ta.setAttribute(readonly, ); ta.style.position fixed; ta.style.top -9999px; ta.style.opacity 0; document.body.appendChild(ta); ta.select(); ta.setSelectionRange(0, ta.value.length); var ok false; try { ok document.execCommand(copy); } catch (e) { ok false; } document.body.removeChild(ta); ok ? resolve() : reject(new Error(复制失败)); }); }注意ta.style.position fixed和top: -9999px这两行。早期有人用display: none隐藏 textarea结果在某些浏览器里选不中复制出来的内容为空。用定位移出视口是更稳的做法元素还在渲染树里选中逻辑正常工作。按钮的点击反馈也值得花两分钟做一下。最简单的做法是点击后把按钮文字从复制改成已复制setTimeout两秒后改回来。但这里有个隐患如果用户两秒内连点三次就会挂起三个定时器最后一个先执行完按钮文字会提前变回去。稳妥的写法是把定时器 ID 存下来每次点击前先clearTimeout一次。4.3 复制出来的文本多空行、少换行是怎么回事这个问题在我自己的页面上出现过非常费解明明页面上看着是五行代码粘贴到编辑器里变成了九行中间全是空的。后来定位到原因是我在写 HTML 时为了让源码好看pre内部的每行代码都做了缩进对齐而那些缩进用的空格和换行textContent会原封不动地取出来。解决办法有两个。一是写pre内部内容时不要为了源码美观做额外缩进让它左对齐到code标签的位置。二是取文本时不要用innerText尽量用textContent然后在取到之后自己做一次清理。innerText会受 CSS 影响如果页面上有white-space或者隐藏元素取出来的结果可能和你想的完全不一样这个坑我踩过一次就不想再踩第二次。var raw codeEl.textContent; // 去掉首尾多余空行保留中间正常的缩进 var clean raw.replace(/^\s*\n/, ).replace(/\n\s*$/, );5. 只有真做过才会遇到的细节转义、行号、长行、移动端这一节讲的全是上线之后才会冒出来的问题也是最能把能看和好用区分开的地方。这些东西文档里基本不会写因为每一条都是踩出来的。5.1 HTML 转义哪些字符必须换哪些可以不换必须转义的只有三个字符但优先级不同。排第一必须换成amp;否则浏览器会把lt;这种序列当成实体去解析。必须换成lt;这是最关键的一个不换的话后面对内容会被当成标签吞掉。严格来说可以不换因为单独的在 HTML 里不构成标签起始但为了保险和可读性我还是习惯一起换掉。引号要不要换取决于上下文。在文本节点里和都不需要转义但如果这段代码是写在某个属性值里面比如title...那引号就必须处理。我的做法很粗暴不管上下文统一用脚本转一遍多转总比漏转安全。如果在 JavaScript 里动态生成高亮内容最容易漏的是反引号和${}。模板字符串在 JavaScript 里是特殊语法如果你用模板字符串去拼 HTML里面真实的${会触发插值直接把内容变空。这种问题很难一眼看出因为浏览器控制台不会报错你只会看到页面上的代码块少了一段。写这类代码时我一般直接换成普通字符串拼接或者先把${转成\${别图省事。5.2 行号列的三种做法与各自的代价行号看着是个小功能实现方式却会直接影响后面的维护成本。我前后试过三种各有各的问题。做法实现方式优点代价独立栏额外加一列div放数字滚动时位置好控制需要和代码行高严格对齐换行就错位CSS 计数器每行包一个span用::before输出序号纯 CSS语义干净代码长时 DOM 节点暴增内联生成生成阶段直接写进文本最稳定复制时必须剔除否则用户会连行号一起粘走我现在偏向第二种但会加一个限制只对超过 10 行的代码块启用。因为行号在短代码块上几乎没意义反而占掉了本来就宝贵的左边距。长代码块加上行号讨论问题时说第 12 行会很方便这才是它真正的价值。如果用了行号复制功能就必须做对应处理。用户点复制粘到编辑器里的内容如果带着一堆行号数字那就废了。做法是在取文本时跳过行号节点只取代码本体。所以如果你一开始就把行号写在了code内部当成文本后面再想剥离就只能靠正则清洗很容易误伤这是我建议用::before生成序号的原因。5.3 长行溢出与移动端的两个取舍前面提过overflow-x: auto这是基础。但用久了会发现一个体验问题横向滚动条默认样式在各浏览器里长得都不一样有些还会盖住最后一行代码。我的处理是给滚动条单独写样式高度压到 8px 左右颜色调浅既不抢眼又不至于看不见。.code-card__pre::-webkit-scrollbar { height: 8px; } .code-card__pre::-webkit-scrollbar-thumb { background: rgba(128, 128, 128, 0.35); border-radius: 4px; } .code-card__pre::-webkit-scrollbar-track { background: transparent; }移动端要单独说因为代码块在小屏上是最容易崩的组件。两个取舍摆在面前一是缩小字号二是允许横向滚动。我选的是后者。字号降到 12px 以下之后等宽字体在小屏上已经很难分辨l和1、0和O读代码变成猜谜。宁可让用户左右滑一下也要保住字号。真的嫌宽可以用媒体查询把左右内边距从 16px 压到 12px这能挤出一点空间代价是视觉效果稍紧。还有一个 iOS 上的小问题横屏切竖屏之后某些浏览器会自动放大字号。加一句-webkit-text-size-adjust: 100%就能压住。这个属性写在html或body上都可以我一般放html。6. 深色模式颜色收进变量之后还剩两件麻烦事前面第 2 节的代码里我把所有颜色都写成了 CSS 变量这不是为了好看是为了深色模式。变量抽出来之后切主题只需要改一处根节点的变量集合其余样式一个字都不用动。但变量抽好只是第一步后面还有两件事必须处理不处理的话主题切换就是半成品。6.1 系统偏好与手动切换的优先级怎么排prefers-color-scheme能读到系统的深色偏好用它做默认值很合适。但它有个问题用户在系统里设了深色不代表他希望你这个网站也是深色有时候他只是晚上开了系统深色。所以必须留一个手动切换的入口而且手动选择要能覆盖系统偏好。我的做法是在html上挂一个>:root { --code-bg: #f6f7f9; --code-text: #24292f; } media (prefers-color-scheme: dark) { :root:not([data-themelight]) { --code-bg: #1f2430; --code-text: #d6deeb; } } :root[data-themedark] { --code-bg: #1f2430; --code-text: #d6deeb; }这里:root:not([data-themelight])这个写法是核心。它的意思是系统是深色并且用户没有明确选过浅色两个条件同时满足才应用深色变量。少了这个:not用户手动选的浅色就会被系统偏好直接压掉怎么点都切不回来。我第一次写的时候就漏了这个判断调了半天才发现问题出在选择器上。另外还有个体验细节切换主题时不要加过渡动画。颜色过渡在文字上会显得糊而且切得快的时候会有拖影。背景色和文字色直接瞬间切换反而更干脆。6.2 高亮主题必须跟着页面主题一起换如果引了 highlight.js那么主题文件是独立的一份 CSS它有自己的颜色定义。页面切到深色之后如果只改了自己的变量而没有换高亮主题就会出现深色底上配深色关键字的尴尬局面看起来像是代码被吞了。处理方式有两种。简单的是准备两套高亮 CSS 文件切换时通过disabled属性启用对应那一套稍微讲究一点的是不用现成主题文件自己按 token 类名写一套颜色规则用 CSS 变量控制。我后来长期用的是第二种因为自己写一遍之后代码块的配色风格能跟整个页面统一不会出现这一块明显是别的地方搬来的那种割裂感。.code-card__pre .hljs-keyword { color: var(--hljs-keyword, #c678dd); } .code-card__pre .hljs-string { color: var(--hljs-string, #98c379); } .code-card__pre .hljs-comment { color: var(--hljs-comment, #7f848e); } .code-card__pre .hljs-number { color: var(--hljs-number, #d19a66); }深色下的 token 配色有个通用原则饱和度要降低亮度要拉高。直接拿浅色主题的颜色往深底上放紫色和蓝色会显得特别刺眼读两行眼睛就累。我一般会把关键字色的亮度提高 10% 左右字符串色稍微压一点饱和度整体观感会舒服很多。我自己做这套东西最大的体会是代码块这个组件真正的难点从来不在怎么让它显示出来而在那些显示出来之后才会被发现的小问题。我前前后后改过好几版每一版都是上线之后被人指出来的有人复制粘贴发现多了空行有人在手机上横滑发现滚动条盖住代码有人晚上看觉得关键字太亮。这些问题在静态截图里一个都看不出来。所以如果你准备抄这套结构我的建议是先把它跑起来然后自己在手机、暗色模式、超长代码这三个场景里各点一遍改完这几个地方剩下的就都是锦上添花了。