前言

前一段时间我发现自己的博客在一些设备上访问时,风扇会明显变快,尤其是手机端首页滚动时会有一种“页面没有卡死,但它好像一直在偷偷忙”的感觉。

一开始我以为是星空背景、图片多、玻璃效果这些比较显眼的东西导致的。后来认真把首页的自定义脚本拆开看,才发现瀑布流逻辑里藏着一个更直接的问题:它为了“保证手机端永远是一列”,写了非常多保护性代码、调试代码和定时检查。功能确实能跑,但它在页面空闲时也没有真正休息。

所以这次更新的目标并不是重新设计首页,也不是把动画、卡片或者封面图全部砍掉,而是在保持文章顺序、卡片视觉、分页、图片展示和 3/2/1 列响应式效果不变的前提下,把瀑布流改成一个更正常的前端模块。

本文的 Chrome 数据来自本地、固定提交、固定视口下的 Headless A/B 测量,可以用于验证本次脚本逻辑的变化;但它不能直接等价为所有访问者的 CPU、温度、风扇、GPU 或生产网络表现。真实设备上的有头浏览器回归仍然值得继续做。

旧逻辑到底在忙什么

先说结论:问题不在于“瀑布流”这个布局本身,而在于旧实现把布局、移动端兜底、故障修复和生产调试全部塞进了一段脚本里。

旧脚本有几类典型行为:

  1. 移动端每 100ms 扫描一次全部卡片,检查卡片有没有被改成绝对定位;
  2. 发现疑似异常后,对所有卡片再次写入一整段 style.cssText
  3. clicktouchstarttouchendscrollresize 等事件做捕获式监听,事件发生后又延迟重置样式;
  4. 保留生产环境中的 MutationObserver 和大量 console.log,用于打印样式变化和调用栈;
  5. 初始化时等待全部图片、设置超时、注入运行时 CSS,再通过多处定时器重新初始化。

如果只看“页面最终是不是一列”,它们似乎都是保险;但从浏览器的角度看,这相当于让一个本来只需在尺寸变化时工作的模块,变成了长期值班的看门狗。

一个 100ms 定时器为什么会有问题

很多时候看到 setInterval(..., 100) 会觉得只是“每秒 10 次”,听起来并不夸张。但它后面如果接的是 DOM 查询、字符串样式检查、全卡片循环和样式写入,成本就不只是一次函数调用了。

旧移动端逻辑的核心形态大致是这样:

1
2
3
4
5
6
7
setInterval(() => {
const items = document.querySelectorAll('.waterfall-item');

items.forEach((item) => {
// 检查 style / class,必要时重写全部卡片样式
});
}, 100);

首页当前有 15 张文章卡片。这样做的问题是:即使用户只是停在首页不动,浏览器也会持续遍历这些元素;滚动时又会叠加事件监听器触发的整批样式覆写与日志输出。

更麻烦的是,style.cssText = '...' 不是“只改一个属性”。它会把一串内联样式整体替换掉,容易覆盖其他模块留下的状态,也更容易迫使浏览器重新计算样式与布局。

重写前先确定边界

在真正开始改之前,我先给这次重写定了几个不能退让的目标:

目标 要保持的结果
大屏布局 最宽时为 3 列瀑布流
中等宽度 769px–1200px 为 2 列
窄屏布局 ≤768px 为 CSS 正常流单列
文章内容 不改变文章顺序、封面、摘要、标签和分页
视觉效果 保留原有卡片、悬浮、玻璃、封面和分页视觉规则
生命周期 脚本可重复初始化、可销毁,未来开启 PJAX 也不能越绑越多
性能方向 只有在“真的发生变化”时才请求重新布局

这里其实有一个很重要的前端分工:

  • CSS 负责静态规则:移动端一列、宽度、间距、视觉样式;
  • JavaScript 负责动态计算:桌面端哪张卡片放到哪一列、容器最终要多高。

如果移动端已经可以用正常文档流排成一列,就没有必要再用 JavaScript 不停把元素“强制改回一列”。

新结构:CSS 管单列,JS 管 masonry

现在的瀑布流脚本只在首页加载,并且将逻辑收敛到一个 WaterfallLayout 控制器中。

响应式列数:3 / 2 / 1,不硬塞卡片

当前断点逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const MOBILE_QUERY = window.matchMedia('(max-width: 768px)');
const MEDIUM_QUERY = window.matchMedia('(max-width: 1200px)');

getColumnCount(containerWidth) {
let columns = MOBILE_QUERY.matches ? 1 : (MEDIUM_QUERY.matches ? 2 : 3);

while (columns > 1) {
const itemWidth = (containerWidth - GAP * (columns - 1)) / columns;
if (itemWidth >= 220) break;
columns -= 1;
}

return columns;
}

这里除了普通断点外,还增加了一个最小卡片宽度约束:如果按照当前列数计算后,单张卡片会窄于 220px,就自动降一列。

这比只看屏幕宽度更稳一点。因为浏览器视口并不等于内容容器宽度:侧边栏、页面边距、缩放比例都会影响真正能放卡片的区域。

移动端:不要再“修复”正常流

移动端没有继续跑绝对定位瀑布流。脚本只清理桌面布局时自己写入的属性,把布局交回 CSS:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
applySingleColumn() {
this.container.classList.remove('is-masonry-layout');
this.container.style.removeProperty('height');
this.container.style.removeProperty('min-height');

this.items.forEach((item) => {
item.style.removeProperty('position');
item.style.removeProperty('left');
item.style.removeProperty('top');
item.style.removeProperty('width');
item.style.removeProperty('margin');
item.style.removeProperty('transform');
});
}

对应的移动端 CSS 是单列正常流:

1
2
3
4
5
6
7
8
9
10
@media (max-width: 768px) {
#recent-posts.waterfall-masonry .waterfall-container .waterfall-item {
width: 100% !important;
max-width: 100% !important;
position: static !important;
display: block !important;
margin: 0 0 20px !important;
transform: none !important;
}
}

这个改动看起来很朴素,但它是本次最关键的思路变化:移动端一列本来就是浏览器最擅长的布局方式,不需要额外开一个永不下班的 JavaScript 保安。

瀑布流如何只在必要时重排

桌面端仍然需要 JavaScript 算出每张卡片应该放在当前最短的列里。不同的是,新的实现不会再靠“等 100ms”“等全部图片”“每隔一段时间再检查一下”来碰运气,而是监听真正有意义的变化。

ResizeObserver:容器或卡片变了再说

window.resize 只能知道浏览器窗口尺寸变了,却不知道内容容器、字体、卡片内部内容何时真的改变了。

新的实现使用了两类 ResizeObserver

1
2
3
4
5
this.resizeObserver = new ResizeObserver(this.handleObservedResize);
this.resizeObserver.observe(this.container);

this.itemResizeObserver = new ResizeObserver(() => this.scheduleLayout());
this.items.forEach((item) => this.itemResizeObserver.observe(item));

它们分别关注:

  • 瀑布流容器宽度改变;
  • 单张卡片的高度改变。

例如字体加载完成、封面尺寸变化、窗口从平板切到桌面时,浏览器会通知对应元素,而不是让脚本每隔 100ms 猜一次“会不会变了”。

图片不再阻塞全部布局

旧逻辑会等待全部图片加载完成,甚至准备一个 3 秒超时兜底。这样会让首次布局受最慢图片影响。

新的思路是:先布局,然后只给尚未完成的图片注册一次性事件。图片加载成功或失败后,再请求一次重排:

1
2
image.addEventListener('load', handleImageSettled, { once: true });
image.addEventListener('error', handleImageSettled, { once: true });

这样一来:

  • 首页不需要等“所有图片都好了”才出现;
  • 单张图片晚到不会阻塞其他卡片;
  • 失败图片也能触发一次正确的重新计算;
  • 事件是一次性的,不会长期挂在页面上。

requestAnimationFrame:把同一帧的请求合并起来

多个变化有时会一起发生:容器宽度变化、卡片尺寸变化、图片加载、字体加载完成……如果每个事件都立即完整布局一次,短时间内仍然可能重复做很多工作。

所以这里又用 requestAnimationFrame 做了一层合并:

1
2
3
4
5
6
7
8
scheduleLayout() {
if (this.destroyed || this.frameId !== null) return;

this.frameId = window.requestAnimationFrame(() => {
this.frameId = null;
this.layout();
});
}

含义是:无论当前帧内来了多少次“我想重排”的请求,最终最多只执行一次 layout()

requestAnimationFrame 的意义不只是“更丝滑”。它还能让读写 DOM 的时机更靠近浏览器绘制节奏,减少短时间内反复测量和写入造成的布局抖动。

桌面 masonry 的计算过程

桌面端的算法本身没有什么特别神秘的地方:每次都把下一张卡片放进当前最短的一列。

1
2
3
4
5
6
7
8
9
10
const columnHeights = new Array(columns).fill(0);

const positions = itemHeights.map((height) => {
const column = columnHeights.indexOf(Math.min(...columnHeights));
const x = column * (itemWidth + GAP);
const y = columnHeights[column];

columnHeights[column] = y + height + GAP;
return { x, y };
});

随后使用 translate3d(x, y, 0) 将卡片移动到对应位置,并把容器高度设置为最高一列的高度。这样分页仍然能自然出现在卡片列表之后。

这里有两个细节:

  1. 先统一写入卡片宽度,再强制浏览器应用新宽度,然后测量卡片高度;否则内容换行变化后,拿到的高度可能不是目标列宽下的真实值。
  2. 只操作控制器自己拥有的内联属性:positionlefttopwidthmargintransform 与容器高度。不再用 style.cssText = '' 清空别人可能写入的状态。

生命周期:初始化以外,还要会收尾

写一个页面脚本很容易,难的是保证它不会在重复初始化后留下旧监听器、旧 observer、旧 RAF。

所以新的控制器提供了 destroy()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
destroy() {
if (this.frameId !== null) {
window.cancelAnimationFrame(this.frameId);
}

this.resizeObserver?.disconnect();
this.itemResizeObserver?.disconnect();

this.mediaCleanup.forEach((cleanup) => cleanup());
this.imageListeners.forEach(({ image, handleImageSettled }) => {
image.removeEventListener('load', handleImageSettled);
image.removeEventListener('error', handleImageSettled);
});
}

目前我的博客 PJAX 是关闭状态,但这里仍然预留了:

1
2
document.addEventListener('pjax:send', destroy);
document.addEventListener('pjax:complete', initialize);

这算是一种比较重要的“以后少踩坑”的习惯:只要模块创建了 observer、timer、RAF 或事件监听,就应该从一开始想清楚它们在哪里销毁。

不只看感觉:这次测了什么

为了不把“感觉更快了”直接写成结论,这次保留了优化前提交 69772c8 和实现提交 9988ac4,分别生成独立的静态站点产物,在本地 Chrome 中进行 A/B 测量。

测试条件:

项目 设定
浏览器 Chrome 150.0.7871.100,Headless 模式
缓存 禁用 HTTP 缓存,每轮独立临时浏览器 profile
首页内容 两边均为 15 张卡片、15 张封面图
运行次数 每个版本 / 视口 / 场景运行 3 次,取中位数
空闲场景 页面稳定后静置 15 秒
滚动场景 requestAnimationFrame 驱动的受控往返滚动 15 秒
视口 375×812 / DPR 2、1024×900 / DPR 1、1440×900 / DPR 1

采集的不是单一“跑分”,而是几类能解释问题的数据:

  • Chrome 的 TaskDurationScriptDurationLayoutDurationRecalculate Style
  • 超过 50ms 的 Long Task;
  • 瀑布流自身的 interval tick、cssText 写入、调试输出等专项计数;
  • 文章卡片数量、列数、是否重叠、分页是否仍在卡片后面。

核心数据:移动端的看门狗真的消失了吗

先看最直接的移动端专项数据。下面全部是 15 秒滚动窗口、三次中位数:

指标 优化前 优化后 结果
瀑布流 interval tick 150 0 旧的约 10Hz 轮询已移除
滚动期 cssText 整体写入 14,352 次 0 次 不再整批覆盖卡片内联样式
滚动期 console.* 调用 81,643 次 0 次 生产调试输出已移除
瀑布流生产 MutationObserver 源码出现次数 1 0 调试 observer 已删除

换算成一分钟,旧逻辑大约会进行:

  • 600 次轮询;
  • 57,408 次整批 cssText 写入;
  • 326,572 次调试调用。

而新实现中,这三类由瀑布流自身造成的持续工作均为 0。

这也是为什么本次优化最重要的不是“把某一段代码写得更短”,而是把一个持续轮询模型变成了事件驱动模型

移动端主线程数据

15 秒滚动场景 优化前 优化后 当前相对基线
TaskDuration 2,946.57 ms 2,889.36 ms -1.9%
ScriptDuration 1,779.76 ms 262.02 ms -85.3%
Recalculate Style 232.16 ms 255.82 ms +10.2%
Long Task 数量 0 1 当前样本出现一次 50–54ms Long Task,未归因至瀑布流调用栈

这组数据和专项计数是能互相印证的:旧版滚动期要反复跑脚本、重写样式、打印调试信息;新版本把这些持续工作移掉后,瀑布流相关脚本路径的成本下降非常明显。整页 TaskDuration 基本持平,说明首页里还有星空、页脚计时器和其他运行任务,不能把所有页面级成本都算到瀑布流头上。

平板和桌面:布局没有被牺牲掉

移动端是这次的主要目标,但我也用相同的脚本测了两个桌面断点,确认新结构没有靠牺牲其他设备的布局换取移动端的数字。

视口 预期列数 当前布局结果 卡片重叠 分页位置
375×812 1 单列正常流 位于卡片之后
1024×900 2 两列 masonry 位于卡片之后
1440×900 3 三列 masonry 位于卡片之后

平板端的旧实现本来就不会运行移动端看门狗,所以主线程数据基本持平,这其实也是符合预期的:重写的收益重点并不是承诺“所有断点都百分之几十变快”,而是让逻辑更稳定、更可维护,并清理移动端持续空转的问题。

桌面端的结果如下:

15 秒场景 / 指标 优化前 优化后 当前相对基线
空闲 LayoutDuration 73.26 ms 57.29 ms -21.8%
空闲 TaskDuration 1,613.05 ms 1,447.66 ms -10.3%
滚动 LayoutDuration 72.63 ms 61.95 ms -14.7%
滚动 ScriptDuration 291.32 ms 278.69 ms -4.3%

这些桌面数据可以说明新布局没有带来明显额外的主线程负担,部分聚合指标还更低;但它们仍然是全页面数据,所以我不会把它包装成“桌面性能提升了固定百分比”的营销结论。

这次重写带来的几个前端经验

1. 不要用轮询修复本该由状态设计解决的问题

当一个模块需要不停检查“样式有没有被别人改掉”,通常更值得问的是:

  • 样式控制权是不是分散了?
  • CSS 与 JS 是否在互相覆盖?
  • 是否应该让 CSS 负责静态布局?
  • 是否能监听真实变化,而不是定期猜测?

轮询不是完全不能用,但它应该是非常明确、短时间、可停止的兜底,而不是页面访问期间永久存在的默认逻辑。

2. DOM 样式操作要有“所有权”意识

style.cssText 很方便,但它像是把整张便签撕掉再重新写一张。对于多人、多模块、主题加插件的页面来说,最好只改自己拥有的属性:

1
2
item.style.transform = `translate3d(${x}px, ${y}px, 0)`;
item.style.width = `${itemWidth}px`;

销毁时也只移除这些属性。这样别的模块即使给卡片加了可访问性状态、过渡效果或其他内联信息,也不容易被误伤。

3. 观察变化,不要等一个“万能超时”

“等 100ms”“最多等 3 秒”能让代码暂时跑起来,但它不是状态正确性的证明。

  • 图片加载:用 load / error
  • 元素尺寸:用 ResizeObserver
  • 断点变化:用 matchMedia
  • 一帧内的重复请求:用 requestAnimationFrame 合并。

这样代码的触发条件更接近真实页面状态,也更容易解释和维护。

4. 性能优化需要可证伪的数据

这次在写优化之前,我也很容易说“删掉一堆代码肯定快了”。但真正测量之后会发现:

  • 移动端专项路径的收益非常明确;
  • 平板和桌面的聚合指标没有出现同样夸张的变化;
  • 全页面 Task 时间会受到许多其他模块影响;
  • Headless 浏览器无法回答“手机还热不热”“风扇还响不响”。

所以性能结论最好附带测试环境、版本、视口、运行次数和统计方式。这样下一次继续优化星空、视频或其他页面时,才能知道到底改对了什么。

总结

这次首页瀑布流优化,最终没有靠删除瀑布流效果来换性能,而是把它从一段长期自我修复、自我调试的脚本,变成了一个职责明确的布局控制器:

  1. 移动端交给 CSS 正常流,不再进行 100ms 轮询;
  2. 桌面端保留 masonry,支持 3 / 2 / 1 列响应式适配;
  3. 通过 ResizeObserver、图片事件和媒体查询感知真实变化
  4. 通过 requestAnimationFrame 合并同一帧重排
  5. 只改自己拥有的样式属性,并提供完整销毁路径
  6. 用固定提交的 A/B 测量记录结果,而不是只凭感觉下结论

最让我满意的其实不是某一个百分比,而是移动端那几项最不该存在的持续工作终于归零了:轮询没了、整批 cssText 覆写没了、海量调试输出也没了。

后面我还会继续在真实设备、有头浏览器和不同主题模式下观察它的表现;但至少现在,首页瀑布流终于可以在不需要工作的时候安静下来啦。🌙