← 返回文章列表 专栏:前端与建站工程

更新于

长文排版与响应式阅读布局:双栏网格、行宽控制与目录滚动联动

设计适合长文阅读的 CSS Grid 双栏布局,将单行字数控制在 60~70 字符,实现代码块横向独立滚动与 Sticky 章节目录高亮联动。

长文双栏响应式网格、流式字号 clamp() 与宽表局部滚动容器
长文双栏响应式网格、流式字号 clamp() 与宽表局部滚动容器

在宽屏显示器上如果将文章正文撑满整个视口,视线从行尾跳回下一行行首时容易串行;而在手机端,过长的代码块或宽表格又常常把页面横向撑出滚动条。

本文从单行字符宽度(Measure)、中西文字体栈回退、CSS Grid 响应式断点,以及基于 IntersectionObserver 的目录滚动高亮四个方面,整理长文阅读页的实现细节。

中英混排的字体栈、行宽与行高

在长文排版中,影响阅读舒适度的三个基础参数是字体栈(Font Stack)、单行字符宽度(Line Measure)与行高(Line Height):

  1. 兼顾加载速度与屏显效果的字体栈:
    • 正文无衬线字体(Sans-serif):优先调用操作系统自带的黑体(macOS 的 PingFang SC、Windows 的 Microsoft YaHei UI、Linux 的 Noto Sans CJK SC),无需等待网络字体下载即可渲染;
    • 标题衬线体(Serif):页面主标题与文章标题使用 Noto Serif SC(字重 600),与无衬线正文形成层级区分;
    • 代码与终端等宽字体(Monospace):统一使用 'JetBrains Mono', 'Fira Code', 'SFMono-Regular', Consolas, monospace,确保字符 0 与 O、1 与 l 易于分辨。
  2. 控制单行行宽(62ch ~ 74ch): 中文长文较舒适的单行字数为 36 ~ 45 个汉字(约对应 680px ~ 780px 的正文净宽)。当桌面视口宽度达到 1240px 时,不让正文无限横向拉伸,而是将右侧空间留给常驻章节目录(Sticky TOC)与卡片内边距。
  3. 正文行高与段落间距: 中文方块字没有英文的上下延展笔画(Ascender / Descender),如果直接沿用英文站点常见的 line-height: 1.5,中英混排并夹杂行内代码框时就会显得上下拥挤。将正文行高设为 1.8 ~ 1.85、段落底边距设为 1.25em,能减轻长文阅读的压迫感。

响应式双栏网格与 minmax(0, 1fr) 宽度约束

在桌面端(视口宽度 > 1024px),文章页采用「左侧主阅读卡片 + 右侧 256px 常驻章节目录」的双栏网格;在平板与手机端(<= 1024px),目录移至正文上方并支持折叠,主阅读区收敛为单列。

长文阅读页宽屏、平板与手机端响应式断点及局部横向滚动边界图
桌面端采用主阅读区与右侧 Sticky 目录双栏并排;窄屏自动折叠为单列,代码块与宽表格在各自容器内部横向滚动,不撑宽整页视口。

编写双栏 CSS Grid 时,如果直接写成 grid-template-columns: 1fr 256px,一旦文章里出现一段不换行的长命令,左侧这一列就会按内部代码块宽度向右撑开。原因在于 CSS Grid 轨道中 1fr 的默认最小尺寸是 auto(即内部不可折行子元素的 min-content 宽度)。

要限制列宽不超出视口,需要将主内容列声明为 minmax(0, 1fr),并在主卡片上显式加上 min-width: 0:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/* 长文双栏响应式网格与防横向溢出样式 */
.article-layout {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 28px;
align-items: start;
}

.article-layout.has-toc {
grid-template-columns: minmax(0, 1fr) 256px;
}

.article-main {
min-width: 0; /* 允许 Grid 子项宽度收缩到小于内部代码块固有宽度 */
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 14px;
padding: clamp(22px, 4vw, 44px);
}

.article-aside {
position: sticky;
top: 84px; /* 避开顶部常驻导航栏高度 */
max-height: calc(100vh - 108px);
overflow-y: auto;
}

@media (max-width: 1024px) {
.article-layout.has-toc {
grid-template-columns: minmax(0, 1fr);
}
.article-aside {
position: static;
max-height: none;
order: -1; /* 窄屏下将章节目录置于正文顶部 */
}
}

区分普通数据表与代码高亮表格的滚动规则

为了支持代码行号对齐,不少静态生成器的代码高亮输出结构为 <figure class="highlight"><table><tr><td class="gutter">...</td><td class="code">...</td></tr></table></figure>。

如果直接用 .article-content table 选择器给所有表格加上单元格边框和 display: block; overflow-x: auto;,代码块内部用于排版行号的双列表格也会被加上边框,导致行号与代码错位。

使用 :not(.highlight table) 可以将两类表格的样式与滚动容器分开:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/* 1. 多行代码块:由外层 figure.highlight 承担深色背景与局部横向滚动 */
.article-content figure.highlight {
position: relative;
margin: 0 0 1.5em;
padding-top: 34px;
border-radius: 10px;
background: var(--bg-code);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}

.article-content figure.highlight table {
width: 100%;
margin: 0;
border-collapse: collapse;
display: table;
}

/* 2. 正文 Markdown 数据表:排除代码高亮内部 table,独立提供横向滚动容器 */
.article-content table:not(.highlight table) {
width: 100%;
margin: 0 0 1.5em;
border-collapse: collapse;
font-size: 0.92rem;
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}

.article-content table:not(.highlight table) th,
.article-content table:not(.highlight table) td {
padding: 10px 14px;
border: 1px solid var(--border-color);
text-align: left;
}

同时,为了避免点击目录跳转章节时目标标题被顶部常驻导航栏遮挡,需要为正文标题设置 scroll-margin-top:

1
2
3
4
.article-content h2[id],
.article-content h3[id] {
scroll-margin-top: 88px;
}

目录滚动联动、代码复制与图片灯箱

在构建期生成静态目录 HTML 后,客户端可用原生 JavaScript 实现三项阅读辅助功能:

1. 章节目录滚动追踪(Scroll Spy)

使用浏览器原生的 IntersectionObserver 替代传统的 scroll 事件轮询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const tocLinks = Array.from(document.querySelectorAll('[data-toc-list] a'));
if (tocLinks.length > 0 && 'IntersectionObserver' in window) {
const targets = tocLinks
.map((a) => document.getElementById(decodeURIComponent((a.getAttribute('href') || '').slice(1))))
.filter(Boolean);

const spy = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const activeId = entry.target.id;
tocLinks.forEach((a) => {
const match = decodeURIComponent((a.getAttribute('href') || '').slice(1)) === activeId;
a.classList.toggle('is-active', match);
});
});
}, { rootMargin: '-76px 0px -68% 0px' });

targets.forEach((el) => spy.observe(el));
}

2. 剥离行号的代码复制与矢量图灯箱

  • 代码复制:点击代码块顶栏的「复制」按钮时,仅读取 td.code pre 节点的纯文本内容写入 navigator.clipboard,避免把左侧的 1 2 3 行号混入剪贴板;
  • 图片灯箱(Lightbox):正文中的 <figure class="article-diagram"> 默认按卡片宽度自适应展示;点击架构图时,可放大查看原图或在新标签页打开 SVG 文件。

长文排版与移动端适配自检表

测试场景 验证方法 核对结果
手机端 390px 视口防溢出 浏览器切换至 390×844 视口打开含长命令与宽表的文章 document.documentElement.scrollWidth === window.innerWidth,页面无整体横向滚动条
代码复制内容 点击任意多行代码块复制按钮并粘贴至终端 仅包含命令文本本身,不含行号数字与首尾多余空行
表格与代码样式隔离 同页包含 Markdown 参数对比表与代码块 数据表带网格边框且可左右滑动;代码块内部无多余单元格边框
目录锚点偏移定位 点击右侧目录任意 h2/h3 条目 页面滚动至对应标题,标题上方留有 88px 间距不被顶栏遮挡