一屏能塞多少信息:三列卡片、侧栏重做与移动端
我这站是 Typecho 1.3 加 sagiri 主题,跑在一台 2 核 2G 的云服务器上,同一台机器还挂着 QQ 机器人。内容不多:18 篇文章、6 个分类、26 个标签。按理说信息密度低到这个程度,首页应该很清爽才对吧。
恰恰相反。改版前我用 1080p 的窗口打开列表页,一屏只完整看到 1.5 张卡片——第二张的摘要被切在屏幕下沿,剩下半张空的。这不是内容太多,是容器太窄、内边距太胖、卡片上多了不该有的东西。这篇就复盘这一轮布局改版:从量数字开始,到三列卡片、侧栏重做、移动端翻车,再到无限滚动踩的三个坑。
一、一屏只放 1.5 张卡片的博客
先把现象说精确。容器宽度 1110,扣掉右侧栏之后,正文内容区只剩 772。这 772 里,每张卡片的内边距是 40——注意是四边 40,不是上下 40。也就是说卡片本身 772 宽,实际能放字的地方只有 772 减 80 左右。
内边距 40 这件事,单看不算错,配上封面图就出问题了:封面按容器宽度撑开,高度跟着比例走;再往下是标题、两行摘要、「阅读全文」按钮、底部元信息。每个元素的高度都不夸张,叠在一起,单张卡片高度轻松奔到 400 以上。一屏可视高度 900 出头,去掉顶部导航和标题区,剩给你的就刚好一张半。
我当时的感受是「这站看起来好空」。但页面上的元素一个不少——它空,是因为每一屏只让你看到 1.5 条内容,剩下全是留白和重复的边距。
二、先量后改:把几何数字摆出来
吃过太多「看着差不多就改」的亏,这次先测。方法很土:把页面拉到目标宽度,用控制台对几个关键盒子取 getBoundingClientRect(),把宽度读出来记在本子上。
改之前:
- 容器
1110px - 内容区(列表那一栏)
772px - 卡片内边距
40px
改之后:
- 容器
1110px→1240px - 内容区
772px→937px - 卡片三列,每列
299px,内边距40px→20px
这组数字自洽得让我踏实:299 × 3 = 897,加上两列之间的间距 20 × 2 = 40,正好 937。也就是说内容区宽度、列宽、列间距三者对得上,没有哪个盒子偷偷多吃了 10px。改版最怕的就是「看起来对了」,能算平的数字比看起来对重要得多。
内边距从 40 砍到 20,是同一轮里收益最大的一次手术:内容区宽度只涨了 165,但卡片里可用的文字宽度涨了 205,因为省的边距是双份的(左右各一)。
三、布局是 CSS Grid 的一行,难的是卡片里留什么
结构上真没什么可讲的,一行就够:
.posts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
}minmax(0, 1fr) 里那个 0 是有必要的。写成 1fr 时,轨道的最小尺寸默认按内容算,遇到长标题或者一张不肯缩的图,那一列就会把网格撑破,第三列直接被挤出容器——我第一次见到这个现象时以为是浏览器 bug,其实是 fr 的 auto 最小值在作祟。
真正花时间的是每张卡片里放什么。三列之后每列只有 299,任何「补充信息」都会挤掉主信息,所以我做了一轮减法:
- 封面:固定
aspect-ratio: 16 / 9。不给固定比例的图片,加载完成前后会把整行卡片顶得上下跳。 - 标题:一行,超出省略。原来允许两行,三列之后两行标题会吃掉封面以下全部空间。
- 摘要:两行,
-webkit-line-clamp: 2。 - 砍掉「阅读全文」:四个字加一圈内边距,高度顶得上一行摘要,而它要干的事——点进文章——整张卡片本来就能干。
- 整卡伪链接:卡片容器
position: relative,标题里的<a>加一个::after铺满整张卡片。
.post-card { position: relative; padding: 20px; }
.post-card h2 a::after {
content: "";
position: absolute;
inset: 0;
}这个伪元素方案有个附赠好处:整张卡片可点,但 DOM 里依然只有一个链接,键盘 Tab 不会在每张卡上停三次,读屏也不会念出一堆「链接 链接 链接」。
顺便说一句,压卡片高度我全程没动过字号。把 14px 改成 12px 确实能让一屏多塞半张卡,但那是拿可读性换密度。这轮改版给自己定的规矩是:密度只能从边距和冗余元素里挤,不能从字上挤。
减法做完,同样的 772 宽度下,一屏能看到的东西翻了一倍多。这就是我后来一直记着的一句话:布局问题的答案,一半在 CSS,一半在「这块内容到底要不要留」。
四、侧栏从「数字格子」变成「站长卡 + 数据 + 词云」
原来的侧栏是主题自带的样式:一堆带底色的小方块,每块一个标题加几条链接,密度很高但读不出主次。我把它整个重做成三张卡。
第一张是站长卡。 头像、昵称、身份徽标、一句签名、所在地(带图标)、加入时间,最底下两个社交图标。头像我写的是 width: 100px,量出来却是 104——因为外面还挂了一圈 border: 2px,而 box-sizing 不是全局 border-box。这种 4px 的偏差在截图里看不出来,但对齐时会让整张卡左右差一点,很别扭。两行元信息用 text-overflow: ellipsis 加 white-space: nowrap,长度不可控的文字一律不许换行。
第二张是站点数据。 18 篇文章、6 个分类、26 个标签,三个等分格子,中间用 1px 竖线分隔:
.s2-stats { display: flex; }
.s2-stat { flex: 1 1 0; min-width: 0; }
.s2-stat + .s2-stat { border-left: 1px solid #333d49; }flex: 1 1 0 而不是 1,是为了让三格宽度完全相等(flex-basis: 0),否则格子宽度会跟着「文章/分类/标签」这三个词的宽度走,数字看起来就不在一条基准线上。
第三张是分类与标签。 上面是词云,下面是胶囊。词云这里有点打算盘:
- 22 个词,按文章数从少到多排,字号在 12px 到 26px 之间线性映射:
size = 12 + (n - min) / (max - min) × 14。 - 我这站最少的标签是 1 篇,最多的是 6 篇,所以步长是 2.8px,实测出来的字号就是 12 / 14.8 / 17.6 / 20.4 / 23.2 / 26 这 6 档。
- 颜色不跟字号挂钩,而是按索引
i % 8在 8 个颜色里循环。
为什么颜色不跟数量走?因为我试过——同一个数量级的词如果颜色一致,整片云会变成三四种颜色的色块,看着像分组的,实际并不传达任何信息。8 色循环只是让相邻词不撞色,纯装饰,别给它加语义。
胶囊部分 6 个分类、16 个标签。分类用浅底深字的实心胶囊,标签用「描边 + 12% 底色」的半透明胶囊,一眼能分出来这是两类东西。每个胶囊里的数字是文章数,字号 10px、透明度 0.7——它得能读,但不能跟名字抢注意力。
侧栏整体宽 280,桌面端 position: sticky。这里有个连带效应:sticky 之后侧栏高度不能超过视口,所以三张卡必须能压缩;我先按内容排,量出来超过一屏,就去掉了卡片之间的多余外边距、把胶囊的行高收到刚好不挤压的程度。
五、isPc() 是按 User-Agent 判断的:手机连侧栏的 HTML 都没有
主题里有个 isPc(),判断方式是对普通 User-Agent 跑正则。它不是响应式断点,是设备判定:桌面 UA 输出侧栏那一整块 HTML,手机 UA 连这段 HTML 都不打印。
我一开始以为这是好事——手机上少一大坨 DOM,少解析少渲染,省流量。但它埋了两个隐患:
- 两套判定标准并存。 布局靠 CSS 媒体查询(我这套是
@media (min-width: 992px)),侧栏存不存在靠 UA 正则。这两者可以不一致:平板 UA 被当成手机,但窗口宽度 1200,于是你有一个宽到能放侧栏、却没有侧栏的版面,右边空一块。 - 一旦 HTML 没输出,CSS 救不回来。 隐藏可以用
display: none逆转,没输出就是没输出,你在样式里写什么都补不出这段内容。
我当时觉得第二条无所谓:手机不需要侧栏嘛。这个判断后面直接把我送进了下一节。
六、改完更糟:手机上侧栏是空的
新侧栏上线,桌面端一切正常。手机上我随手一测,侧栏区域——不是不显示,是显示了一块空白。实测 display 不是 none,盒子在,里面空的。
查了半个小时才想明白根因:模板里侧栏默认激活的那个模块是「文章目录」,而目录不是 PHP 渲染的,是主题 JS 在页面加载后扫正文标题动态生成的;那段 JS 在移动端直接不构建 TOC。于是「目录」这个模块在手机上初始化完就是零个子节点,剩下两块内容当时还挂在目录模块的开关下面,自然一起空。
一句话总结这个 bug:我把内容的可见性押在一个由 JS 生成的东西上,而那段 JS 在移动端根本不会跑。 结构和样式都没错,错在依赖关系。
修法分两边:
- 侧栏里除目录之外的三张卡,全部改成 PHP 直出,不依赖任何 JS。这样即使 TOC 不在,侧栏也有内容。
- 移动端不再输出侧栏——回到
isPc()的分流,但这次是我主动选的,而不是被主题默认值带着走。手机屏幕就 390 宽,一张站长卡会把首屏文章全推下去,不如不给。
坑在于:这两条要一起做才成立。只做第一条,手机上会多出一屏侧栏把列表顶到下面;只做第二条,「UA 是手机但屏幕很宽」这一类设备还是拿到空侧栏。
七、看板娘压在侧栏上
看板娘的定位原本是右下角、宽 300。侧栏一重做,问题立刻暴露:她正坐在侧栏上。
我没有靠眼睛判断,而是把两个盒子的矩形取出来求交集。实测覆盖了两块:数据卡 150 × 79,词云区 150 × 152——不是擦边,是把「18 篇文章 6 个分类 26 个标签」这行数字拦掉一半,词云右下角那一簇词也看不见。
修法是把定位基准从「视口右下角」换成「内容区右边缘」:宽度改成桌面 240,锚定内容区右边缘再向里收。思路是——她本来就该站在内容区边上,而不是站在窗口边上;窗口有多宽跟她无关。
顺带把 646KB 的 l2d-widget.min.js 也挪了:不再首屏同步加载,改成 window.load 之后再在空闲时间注入。这两件事是一起做的,效果也叠起来了:首屏传输量从 703KB 掉到 48.8KB,同时她终于不再挡住侧栏的数字。
手机端是 200,并且禁用拖动——小屏上拖到一半盖住正文,比固定在角落更烦人。
八、分页换成滚动加载,然后踩了三个坑
列表页的分页器换成了滚动加载:XHR 拉下一页、DOMParser 解析、把 #posts > article 逐个 importNode 插到当前列表末尾、再从新文档里取下一页链接。逻辑不复杂,坑全在细节上。
坑一:IntersectionObserver 的哨兵留在视口内,不会重复触发。
我原本指望「哨兵进入视口 → 加载 → 哨兵被推出视口外 → 下次再进 → 再加载」这个循环自己转起来。实际是:如果加载完一页之后哨兵仍然在视口里(内容不够高,或者 rootMargin 给得太大),它在交叉状态上从来没有「离开再进入」,回调就不会再来一次,列表停在第二页。
修法是加一层自检:每页插入完,手动量一次哨兵的 getBoundingClientRect(),只要它还在「视口高度 + 600」之内,就 setTimeout 再拉一页。观察器负责「用户滚到了」,自检负责「内容还不够填」。
if (url) {
var r = sentinel.getBoundingClientRect();
if (r.top < window.innerHeight + 600) setTimeout(load, 250);
}那个 250ms 不是玄学,是给浏览器一点时间把新插入的节点布局完,否则量到的还是旧高度。
坑二:前导分号。
这段脚本塞进页面之后,控制台一直报 setInterval(...) is not a function。盯着这行看了很久——那不是我写的代码,是主题里另一段。真相是:我注入的脚本被上一条语句「接」走了。上一条语句末尾没有分号,我的 IIFE 一开头是 (function(){...})(),JS 解析器把它理解成「上一句的返回值(...)」——一次函数调用。于是后面那一行少了个语句分隔,整段解析全歪了。
修法只有一个字符:
;(function () { /* ... */ })();手写的文件可以规规矩矩,但注入别人页面的脚本必须假设「我前面那句没有分号」。从那以后我所有注入型脚本第一行都以分号开头。
坑三:懒加载不接管新插入的节点。
主题的图片懒加载是初始化时扫一遍 DOM、收集 img.lazy-loader、然后统一绑观察器的。我后来插入的节点不在那次扫描范围内,结果是新卡片全是一块块空白占位——图在 lazy-src 里躺着,永远没人给它赋 src。
修法是在导入节点时顺手把这件事做掉,别指望主题:
var imgs = node.querySelectorAll("img.lazy-loader[lazy-src]");
for (var i = 0; i < imgs.length; i++) {
imgs[i].src = imgs[i].getAttribute("lazy-src");
imgs[i].classList.remove("lazy-loader");
}代价是新插入的这几张图立即加载,不享受懒加载。考虑到它们就在用户刚滚到的位置附近,这个代价可以接受——比让用户看一片灰块强。
九、收尾:改版真正的成本不在写样式
把这一轮算下来,CSS 大概占两成时间,剩下八成花在验证上:验证 1240 容器下三列不溢出、验证胶囊在 280 宽侧栏里不换行、验证手机 390 宽没有横向滚动。样式写错一眼能看见,验证没做到位的错误要等用户来告诉你。
几个把我坑过的验证细节,一并记下:
- 移动端要点必须用真触屏事件。 我一开始用
page.mouse模拟点击,选项菜单每次都能正常展开,看起来全绿。换成page.touchscreen.tap之后立刻失败——某些交互只绑在touchstart上,鼠标事件根本没走那条路径。假通过比报错更危险。 - 别指望在真机上精确测双击。 走 CDP 的单次 touch 往返要 300–600ms,而双击判定窗口通常小于 320ms,用 CDP 打两次 tap 永远凑不进一个双击。要测这种时间敏感的行为,只能在页内直接派发真实的
TouchEvent。 position: sticky的元素在整页截图里不会绘制。 我拿fullPage截图看移动端版面,发现侧栏整块空白,一度以为又是第六节那个「没输出」的 bug。真相是整页截图会重排视口,sticky 元素在这种合成过程中会掉出绘制范围。看 sticky 一定要用视口截图,或者直接读 DOM 的矩形。
回到标题那个问题——一屏能塞多少信息?改完之后,同一个 1080p 窗口一屏能看到三张完整卡片加侧栏三块内容,而且没有一处是靠缩字号换来的。数字上就是三个动作:容器从 1110 到 1240、卡片内边距从 40 到 20、卡片从「一张半」变成三列 299。
但更值钱的是三个教训,它们跟 CSS 关系都不大:
一是先量后改。772 这个数字是量出来的,「有点窄」是感觉出来的,两者能推出的方案完全不同。
二是别把内容的可见性押在 JS 上。手机侧栏那个空白盒子,本质是一段永远不会在移动端执行的代码握着内容开关。
三是改完要按断点挨个验一遍,而且要用真的输入方式验。像素级的偏差、只在触屏上出现的交互、只在整页截图里消失的 sticky,这三类问题都不会在「看起来对了」的那一眼里露头。
下一轮想收拾的是字体加载和图片格式,那大概是另一个「量完发现跟想的不一样」的故事。