前几天有个做后端的朋友拿着他自己整理的技术文档来找我,说代码贴进去之后“太平了”,跟CSDN博客里那种带语言标签、带复制按钮、深色底色的代码片完全不是一个味道,想让我帮忙改成那个样子。这事我做过不止一次,从最早手写pre标签堆样式,到后来接 highlight.js、Prism.js,中间踩的坑能写满一整页。所以干脆把整套做法摊开讲一遍——怎么用 HTML + CSS + JS 把普通代码块做成 CSDN 代码片的显示效果,包括结构怎么搭、配色怎么定、行号怎么对齐、复制按钮怎么写、语法高亮怎么接,以及那些看起来很小、但能让你对着屏幕调半天的细节。不管你是刚学前端、想给自己的博客或项目文档美化一下,还是做了几年想找一套能直接抄的模板,这篇应该都能用得上。
1. 先搞清楚 CSDN 代码片到底长什么样
动手之前得先把“目标长什么样”说清楚。很多人一上来就写样式,写着写着发现少了个语言标签,或者复制按钮的位置不对,又回头改结构,来回折腾。我习惯先截图放大,把视觉元素一层层拆开,再决定用什么标签承载。
1.1 拆解视觉结构:从语言标签到复制按钮
把 CSDN 的代码片放大看,它其实是五个部分叠在一起:
- 最外层容器:一块圆角矩形,深色背景,负责整体边界和阴影,同时是复制按钮定位的参考系。
- 顶部信息条:左边是语言标签(比如
Java、Python、C++),右边是“复制代码”按钮。这一条和下面的代码区共用底色,靠一条极细的分割线或者纯靠间距区分。 - 代码主体区:等宽字体,固定行高,行号在左侧单独一列,文字区域可横向滚动。
- 行号列:紧贴左边,颜色比正文浅一档,且不可被选中——你拖动选择代码的时候,行号不应该被一起复制走。
- 滚动条:横向滚动条只在内容超出时才出现,纵向一般不滚动,长代码靠折叠或者整体高度限制处理。
这五个部分对应到 HTML,我通常用三层嵌套:
<div class="code-block"> <div class="code-header"> <span class="code-lang">JavaScript</span> <button class="code-copy">复制代码</button> </div> <pre class="code-body"><code class="language-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 class="code-block"> <div class="code-header"> <span class="code-lang">JavaScript</span> <button class="code-copy" type="button">复制代码</button> </div> <pre class="code-body"><code class="language-javascript">console.log("hi");</code></pre> </div>.code-block:定位基准,position: relative,圆角和阴影都挂在这里。.code-header:display: 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.65:14px 字号乘以 1.65 约等于 23.1px。为什么不是 1.5 或者 2?1.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反过来,默认不识别,你必须给代码块标上class="language-python",它才知道用什么规则。好处是核心极小,适合自己对语言列表有明确控制的场景。
我的判断标准很直接:
- 代码来源不可控、语言五花八门,用 highlight.js。
- 代码来源固定、我能自己给每块打语言标记,用 Prism.js。
- 项目已经引了某个库(比如文档框架自带的),顺着它用,不要额外再引一套。
3.2 highlight.js 接入实操与主题改造
接入只需要三行,其中 CSS 是主题文件:
<link rel="stylesheet" href="./styles/atom-one-dark.min.css"> <script src="./scripts/highlight.min.js"></script> <script>hljs.highlightAll();</script>hljs.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 class="line">' + t + "</span>"; }) .join("\n"); }这里有两个必须记住的点。第一,user-select: none一定要加在行号伪元素上,否则用户选中代码复制时,行号会被一起拖进去。第二,行号是用inline-block加固定宽度,所以行高必须由父级统一控制,不要在行号上单独设line-height,一旦父子行高不一致,行号就会从第二行开始逐行偏移,越往下偏得越多。这个坑我踩过,当时以为是自己宽度算错了,折腾了半小时才发现是行高。
4.2 复制按钮:Clipboard API 与降级方案
复制功能现在优先用 Clipboard API:
document.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 class="box">hello</div>如果你直接写进页面,浏览器会渲染出一个真的 div,而不是把这段文本显示出来。所以必须把特殊字符转成实体:
&→&<→<>→>"→"'→'
顺序很重要:&必须第一个替换。如果你先替换了<变成<,之后再去替换&,这个刚刚生成的<里的&又会被二次转义成&lt;,显示出来就是一堆乱码。
5.2 转义函数实现与 XSS 边界
function escapeHtml(str) { return String(str) .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'"); }这个函数在你动态渲染代码时是必须的,尤其是代码内容来自用户输入或者接口返回的情况。不用它,别人往代码里塞一段<img src=x onerror=alert(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复制,空行也可能被顺手合并掉。测之前准备好一段“脏”代码,能省下大量返工。
我自己的习惯是,最后一定把整块代码复制到一个真实编辑器里粘贴一遍,看缩进、看换行、看有没有多余空行,这一步能揪出所有显示层面看不出来的问题。代码块这东西,看着是样式活,实际一大半功夫都花在内容处理上。