08-scenarios
面向大厂 P6 前端面试的标准答案集。题目来源:字节 / 腾讯 / 快手真实面经。 每题结构:【核心答案】(30 秒口述版)→ 【展开】(原理 + 可运行代码)→ 【追问 & 加分点】。
高频场景设计题
面向大厂 P6 前端面试的标准答案集。题目来源:字节 / 腾讯 / 快手真实面经。 每题结构:【核心答案】(30 秒口述版)→ 【展开】(原理 + 可运行代码)→ 【追问 & 加分点】。
目录
- 1. 虚拟列表完整原理与实现
- 2. 表格懒加载 / 滚动分页
- 3. 合并单元格表格的滚动懒加载
- 4. 为什么用 IntersectionObserver 替代 scroll 监听
- 5. 骨架屏 + 视口懒加载 + 错误兜底的加载体系
- 6. 精确倒计时组件
- 7. AI 对话打字机效果
- 8. 手写 SSE 解析器与流式 Markdown 渲染
- 9. BFF 层 Redis 缓存与数据库一致性
- 10. 并发操作数据库同一字段
- 11. 大数据量图表的聚合优化
1. 虚拟列表完整原理与实现
【核心答案】
虚拟列表的核心是用滚动位置换算出可视区的数据切片,只渲染这一小段 DOM,用一个撑高的占位元素维持真实滚动条长度,再用 transform 把渲染出来的这段推到正确的偏移位置。定高实现是纯数学:startIndex = floor(scrollTop / itemHeight),visibleCount = ceil(viewportHeight / itemHeight),加上下缓冲区(overscan)避免快速滚动时露白。不定高的关键在于"高度未知就无法算偏移",解法是预估高度 + 实测校正:先用预估高度建立位置索引(前缀和数组),每项渲染后用 ResizeObserver/getBoundingClientRect 拿真实高度,回写并增量修正后续所有项的偏移量,同时用二分查找在前缀和数组里根据 scrollTop 定位 startIndex(O(log n))。
【展开】
原理拆解
┌─ 外层容器 container (height: 500px, overflow-y: auto) ─┐
│ ┌─ 占位层 phantom (height: total = n × itemH) ───────┐ │ ← 撑出真实滚动条
│ │ │ │
│ │ ┌─ 内容层 content ────────────────────┐ │ │
│ │ │ transform: translateY(offsetY) │ │ │ ← 推到正确位置
│ │ │ ┌ item[start] ┐ │ │ │
│ │ │ │ item[start+1] │ 只有这几十个真实 │ │ │
│ │ │ │ ... │ 存在于 DOM 中 │ │ │
│ │ │ └ item[end] ┘ │ │ │
│ │ └─────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘
关键公式(定高):
total = itemCount × itemHeight
startIndex = floor(scrollTop / itemHeight)
visibleCount = ceil(containerHeight / itemHeight)
endIndex = startIndex + visibleCount
offsetY = startIndex × itemHeight
加上缓冲:
realStart = max(0, startIndex - overscan)
realEnd = min(count, endIndex + overscan)
offsetY = realStart × itemHeight为什么用 transform 而不是 margin-top / position: absolute + top:transform 只触发合成(Composite),不触发 Layout,滚动时更流畅。
为什么需要缓冲区(overscan):滚动事件的处理和渲染都发生在下一帧,快速滚动时可视区会先于 DOM 更新露出空白。上下各多渲染 3~5 项能覆盖大部分场景。
定高虚拟列表(原生 JS,完整可运行)
<div id="container" style="height:500px;overflow-y:auto;position:relative;border:1px solid #ddd">
<div id="phantom"></div> <!-- 撑高,产生真实滚动条 -->
<div id="content" style="position:absolute;top:0;left:0;right:0"></div>
</div>
<script>
class FixedVirtualList {
/**
* @param {HTMLElement} container 滚动容器
* @param {Array} data 全量数据
* @param {number} itemHeight 每项固定高度(px)
* @param {number} overscan 上下各多渲染几项(缓冲区)
* @param {(item:any, index:number) => string} renderItem 返回一项的 HTML
*/
constructor({ container, data, itemHeight, overscan = 3, renderItem }) {
this.container = container;
this.phantom = container.querySelector('#phantom');
this.content = container.querySelector('#content');
this.data = data;
this.itemHeight = itemHeight;
this.overscan = overscan;
this.renderItem = renderItem;
this.ticking = false; // rAF 节流标志位
this.lastStart = -1; // 上次渲染的起点,用于跳过无意义的重渲染
// 占位层撑出总高度 = 条数 × 行高
this.phantom.style.height = `${data.length * itemHeight}px`;
// 滚动监听:passive 表示不会调用 preventDefault,浏览器可以立即滚动,不必等回调
this.onScroll = this.onScroll.bind(this);
container.addEventListener('scroll', this.onScroll, { passive: true });
// 容器尺寸变化时重新计算可视条数
this.ro = new ResizeObserver(() => this.render());
this.ro.observe(container);
this.render();
}
onScroll() {
// rAF 节流:一帧内多次 scroll 只渲染一次,且渲染时机对齐浏览器绘制
if (this.ticking) return;
this.ticking = true;
requestAnimationFrame(() => {
this.ticking = false;
this.render();
});
}
render() {
const { scrollTop, clientHeight } = this.container;
const { itemHeight, overscan, data } = this;
// 1) 由 scrollTop 反推可视区的起止下标
const startIndex = Math.floor(scrollTop / itemHeight);
const visibleCount = Math.ceil(clientHeight / itemHeight);
// 2) 加缓冲并夹到合法范围
const start = Math.max(0, startIndex - overscan);
const end = Math.min(data.length, startIndex + visibleCount + overscan);
// 3) 起点没变就不重排 DOM(滚动一像素也重建 DOM 是常见性能坑)
if (start === this.lastStart && this.lastEnd === end) return;
this.lastStart = start;
this.lastEnd = end;
// 4) 生成可视区 DOM。用一次 innerHTML 赋值,避免逐个 append 触发多次布局
let html = '';
for (let i = start; i < end; i++) html += this.renderItem(data[i], i);
this.content.innerHTML = html;
// 5) 把内容层整体推到正确的偏移位置(transform 只触发合成,不触发布局)
this.content.style.transform = `translateY(${start * itemHeight}px)`;
}
/** 滚动到指定下标 */
scrollToIndex(index) {
this.container.scrollTop = index * this.itemHeight;
}
destroy() {
this.container.removeEventListener('scroll', this.onScroll);
this.ro.disconnect();
}
}
// ---------- 使用 ----------
const data = Array.from({ length: 100000 }, (_, i) => ({ id: i, text: `第 ${i} 行数据` }));
new FixedVirtualList({
container: document.getElementById('container'),
data,
itemHeight: 40,
overscan: 3,
renderItem: (item) =>
`<div style="height:40px;line-height:40px;padding:0 12px;border-bottom:1px solid #eee">
${item.text}
</div>`,
});
</script>10 万条数据,DOM 节点恒定在 ~20 个,滚动帧率满帧。
React 版(定高,含 key 复用)
import { useState, useRef, useMemo, useCallback } from 'react';
function VirtualList({ data, itemHeight, height, overscan = 3, renderItem }) {
const [scrollTop, setScrollTop] = useState(0);
const ticking = useRef(false);
const { start, end, offsetY, total } = useMemo(() => {
const total = data.length * itemHeight;
const startIndex = Math.floor(scrollTop / itemHeight);
const visibleCount = Math.ceil(height / itemHeight);
const start = Math.max(0, startIndex - overscan);
const end = Math.min(data.length, startIndex + visibleCount + overscan);
return { start, end, offsetY: start * itemHeight, total };
}, [scrollTop, data.length, itemHeight, height, overscan]);
const onScroll = useCallback((e) => {
const top = e.currentTarget.scrollTop;
if (ticking.current) return;
ticking.current = true;
requestAnimationFrame(() => {
ticking.current = false;
setScrollTop(top);
});
}, []);
const visible = data.slice(start, end);
return (
<div style={{ height, overflowY: 'auto', position: 'relative' }} onScroll={onScroll}>
<div style={{ height: total }} />
<div style={{ position: 'absolute', top: 0, left: 0, right: 0,
transform: `translateY(${offsetY}px)` }}>
{visible.map((item, i) => (
// key 必须用数据的稳定 id,不能用数组下标 i —— 否则滚动时 React 会
// 认为「同一个位置还是同一个组件」而复用错内容(有内部 state 时会串数据)
<div key={item.id} style={{ height: itemHeight }}>
{renderItem(item, start + i)}
</div>
))}
</div>
</div>
);
}不定高的处理:预估高度 + 实测校正
核心难点:高度未知 → 无法算 total、无法由 scrollTop 反推 startIndex、无法算 offsetY。
解法是维护一张位置索引表(每项的 top/bottom/height,本质是高度的前缀和):
import { useState, useRef, useCallback, useLayoutEffect, useMemo } from 'react';
function DynamicVirtualList({ data, estimatedHeight = 50, height, overscan = 3, renderItem }) {
// 位置表:每项 { index, height, top, bottom, measured }
// 初始全部用预估高度填充,随渲染逐步校正
const positions = useRef([]);
if (positions.current.length !== data.length) {
positions.current = data.map((_, i) => ({
index: i,
height: estimatedHeight,
top: i * estimatedHeight,
bottom: (i + 1) * estimatedHeight,
measured: false, // 是否已实测过
}));
}
const [scrollTop, setScrollTop] = useState(0);
const [, forceUpdate] = useState(0);
const itemRefs = useRef(new Map());
const containerRef = useRef(null);
const total = positions.current[positions.current.length - 1]?.bottom ?? 0;
/**
* 二分查找:找到第一个 bottom > scrollTop 的项,即可视区第一项。
* 因为 positions 的 bottom 是单调递增的(高度非负),所以可以二分,O(log n)。
* 数据量大时线性查找会成为滚动瓶颈。
*/
const findStartIndex = useCallback((top) => {
const list = positions.current;
let lo = 0, hi = list.length - 1, res = list.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (list[mid].bottom > top) { res = mid; hi = mid - 1; }
else { lo = mid + 1; }
}
return res;
}, []);
const { start, end, offsetY } = useMemo(() => {
const list = positions.current;
const startIndex = findStartIndex(scrollTop);
// 终点靠「累加真实高度直到超出视口高度」求得,不能再用除法
let endIndex = startIndex;
let acc = 0;
while (endIndex < list.length && acc < height) {
acc += list[endIndex].height;
endIndex++;
}
const s = Math.max(0, startIndex - overscan);
const e = Math.min(list.length, endIndex + overscan);
return { start: s, end: e, offsetY: list[s]?.top ?? 0 };
}, [scrollTop, height, overscan, findStartIndex, data.length]);
/**
* 渲染后校正:用 useLayoutEffect 保证在浏览器绘制前完成位置修正,
* 否则用户会看到「先跳一下再归位」的闪烁。
*/
useLayoutEffect(() => {
const list = positions.current;
let dirty = false;
for (let i = start; i < end; i++) {
const el = itemRefs.current.get(i);
if (!el) continue;
const realHeight = el.getBoundingClientRect().height;
const diff = realHeight - list[i].height;
if (Math.abs(diff) > 0.5) { // 有实质变化才修正,避免亚像素抖动
list[i].height = realHeight;
list[i].bottom = list[i].top + realHeight;
// 关键:把差值传播到【后面所有项】,保持 top/bottom 连续
// 这里是 O(n),数据量极大时可换成「线段树 / 树状数组」把更新降到 O(log n),
// 或采用「只修正到已渲染过的最远处,其余惰性计算」的策略
for (let j = i + 1; j < list.length; j++) {
list[j].top = list[j - 1].bottom;
list[j].bottom = list[j].top + list[j].height;
}
list[i].measured = true;
dirty = true;
}
}
if (dirty) forceUpdate((v) => v + 1); // 位置变了,重算可视区
});
const onScroll = (e) => setScrollTop(e.currentTarget.scrollTop);
return (
<div ref={containerRef} style={{ height, overflowY: 'auto', position: 'relative' }}
onScroll={onScroll}>
<div style={{ height: total }} />
<div style={{ position: 'absolute', top: 0, left: 0, right: 0,
transform: `translateY(${offsetY}px)` }}>
{data.slice(start, end).map((item, i) => {
const index = start + i;
return (
<div key={item.id}
ref={(el) => {
if (el) itemRefs.current.set(index, el);
else itemRefs.current.delete(index);
}}>
{renderItem(item, index)}
</div>
);
})}
</div>
</div>
);
}不定高的三个必须提到的坑:
- 滚动条长度会跳变:预估高度和真实高度差异大时,
total不断被修正,滚动条长度和位置都会跳。缓解手段:预估值尽量接近真实均值(可以用已实测项的移动平均动态更新预估值);只在必要时更新 total。 - 向上滚动时的位置漂移:向上滚动进入未测量区域,测量后高度变化会让当前视口内容"跳走"。标准修法是锚定(scroll anchoring):记住当前视口顶部那一项和它的偏移,修正高度后主动调整
scrollTop补偿高度差。 - 异步内容改变高度:项内有图片、折叠面板、富文本,高度会在渲染后再变。用
ResizeObserver持续观察每一个已渲染项(而不是只在 layout effect 里量一次):
// 用 ResizeObserver 持续跟踪高度变化(图片加载完、内容展开)
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const index = Number(entry.target.dataset.index);
const h = entry.contentRect.height;
updatePosition(index, h); // 同上:写回高度并向后传播
}
});
// 每个渲染出来的 item 在 mount 时 ro.observe(el),unmount 时 ro.unobserve(el)【追问 & 加分点】
- 为什么不用
content-visibility: auto代替虚拟列表?content-visibility: auto让浏览器跳过屏幕外元素的渲染工作(样式、布局、绘制),零 JS,是很好的轻量方案。但它不减少 DOM 节点数量——10 万个节点仍然存在于 DOM 树里,内存占用、querySelectorAll、事件冒泡、React reconciliation 的成本都还在。所以:几百到几千项用content-visibility够了;上万项必须虚拟化。 - 横向虚拟化 / 二维虚拟化:宽表格(几百列)需要同时对行和列虚拟化,思路一致但要处理固定列(fixed columns)——固定列不参与横向虚拟化,单独渲染并用
position: sticky或独立的同步滚动层。 - 无障碍与可搜索性代价:虚拟列表里 Ctrl+F 搜不到未渲染的内容,屏幕阅读器也读不到。需要用
aria-setsize/aria-posinset告知总数和位置,并提供额外的搜索入口。这是必须承认的 trade-off。 - 成熟方案:
@tanstack/virtual(框架无关,API 干净,不定高支持好)、react-window/react-virtuoso(后者对不定高和聊天场景的反向滚动支持最好)。面试可以说"生产我会用 TanStack Virtual,但原理必须清楚,因为定制需求(合并单元格、分组吸顶)一定要自己改"。
2. 表格懒加载 / 滚动分页
【核心答案】
标准实现是在列表底部放一个空的哨兵元素(sentinel),用 IntersectionObserver 观察它,进入视口就触发下一页请求;配合三个状态位 loading / hasMore / error 做并发保护和终止条件。后端配合用**游标分页(cursor / seek pagination)**而不是 OFFSET——OFFSET 100000 需要数据库扫描并丢弃前 10 万行,深翻页会越来越慢,且数据插入会导致翻页重复/遗漏;游标分页用 WHERE (sort_key, id) < (last_sort_key, last_id) ORDER BY ... LIMIT n 走索引,恒定耗时且天然稳定。10 万条仍卡的进一步优化是三件事叠加:虚拟化(DOM 与数据量解耦)、分片渲染(首批数据分帧插入,不阻塞主线程)、Web Worker 预处理(把 JSON.parse 和数据加工搬出主线程)。
【展开】
基础实现(React Hook,可直接用)
import { useEffect, useRef, useState, useCallback } from 'react';
function useInfiniteList(fetchPage) {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState(null);
const cursorRef = useRef(null); // 游标,而非页码
const loadingRef = useRef(false); // 用 ref 做并发锁:state 更新是异步的,
// 只靠 loading state 会漏掉同一帧内的重复触发
const sentinelRef = useRef(null);
const abortRef = useRef(null);
const loadMore = useCallback(async () => {
if (loadingRef.current || !hasMore) return;
loadingRef.current = true;
setLoading(true);
setError(null);
// 组件卸载或重新加载时取消在途请求,避免 setState on unmounted / 竞态覆盖
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
try {
const { list, nextCursor } = await fetchPage(cursorRef.current, ctrl.signal);
setItems((prev) => prev.concat(list));
cursorRef.current = nextCursor;
// hasMore 由后端返回的 nextCursor 决定,不要用 list.length < pageSize 猜
// (最后一页恰好满页时会多发一次空请求,虽然无害但不优雅;
// 更严重的是过滤场景下某页返回 0 条不代表没有下一页)
setHasMore(Boolean(nextCursor));
} catch (e) {
if (e.name !== 'AbortError') setError(e);
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [fetchPage, hasMore]);
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting) loadMore(); },
{
// root 默认是视口;列表在内部滚动容器里时必须显式指定 root
root: null,
// 提前 300px 触发,让数据在用户滚到底之前就准备好(关键的体验优化)
rootMargin: '0px 0px 300px 0px',
threshold: 0,
}
);
io.observe(el);
return () => io.disconnect();
}, [loadMore]);
useEffect(() => () => abortRef.current?.abort(), []);
return { items, loading, hasMore, error, sentinelRef, retry: loadMore };
}
// ---------- 使用 ----------
function List() {
const { items, loading, hasMore, error, sentinelRef, retry } = useInfiniteList(
async (cursor, signal) => {
const url = `/api/items?limit=50${cursor ? `&cursor=${cursor}` : ''}`;
const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json(); // { list: [...], nextCursor: 'xxx' | null }
}
);
return (
<div>
{items.map((it) => <Row key={it.id} data={it} />)}
{/* 哨兵:高度 1px 也可以,但要保证它在文档流中真实存在且可被观察到。
注意不能用 display:none —— 那样永远不会 intersect */}
<div ref={sentinelRef} style={{ height: 1 }} />
{loading && <Skeleton rows={5} />}
{error && <button onClick={retry}>加载失败,点击重试</button>}
{!hasMore && <div className="end">— 已经到底了 —</div>}
</div>
);
}后端配合:游标分页
-- ❌ OFFSET 分页:深翻页时数据库必须扫描并丢弃前 N 行,越翻越慢
SELECT * FROM orders ORDER BY created_at DESC LIMIT 50 OFFSET 100000;
-- ✅ 游标(Seek/Keyset)分页:走复合索引,恒定耗时
-- 必须带上唯一的 tie-breaker(id),否则 created_at 相同的行会重复或遗漏
SELECT * FROM orders
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 50;
-- 需要索引:INDEX (created_at DESC, id DESC)游标的两个额外好处(面试要说出来):
- 稳定性:翻页过程中有新数据插入,OFFSET 会导致某些记录重复出现或被跳过;游标基于排序键定位,不受插入影响。
- 可加密:把
(created_at, id)base64 编码成不透明 cursor,避免暴露内部排序字段,也方便后续改排序实现而不破坏契约。
OFFSET 仍有其适用场景:需要"跳到第 N 页"的传统分页器。这时可以用混合方案:常规滚动用游标,跳页时退化为 OFFSET。
常规性能要点
rootMargin提前量:设为一屏左右(rootMargin: '0px 0px 100% 0px'或固定 300~600px),让请求在用户到达底部之前发出,感知上"无限流畅"。- 并发保护:
loadingRef同步锁 +AbortController取消在途请求。 - 列表长度上限:无限滚动累积到几千条后即使有虚拟化,内存和状态更新也变重。可以做窗口化数据(只保留视口附近 ±N 页,滚走的页释放,滚回来重新拉),或引导用户使用筛选。
- 滚动恢复:从详情页返回时恢复位置。需要持久化
scrollTop+ 已加载数据(sessionStorage / 状态管理),且要在数据恢复后再设置 scrollTop(否则容器高度不够,设置无效)。
10 万条下拉加载仍卡顿:三层叠加优化
先定位(不要直接堆方案):Performance 录制,看卡在哪一段。
| 现象 | 原因 | 对策 |
|---|---|---|
| 请求回来那一刻冻住 | JSON.parse + 数据加工是长任务 | Web Worker |
| 新数据插入时冻住 | 一次性插入几千个 DOM,Layout 爆炸 | 虚拟化 + 分片渲染 |
| 滚动持续掉帧 | DOM 节点数已达几万,每次样式重算都很慢 | 虚拟化(根本解) |
| 内存持续增长直到崩溃 | 全量数据 + 全量 DOM 都在内存 | 虚拟化 + 数据窗口化 |
① Web Worker 预处理
// worker.js
self.onmessage = ({ data: { raw } }) => {
// parse 和加工全在 worker 线程,主线程期间完全空闲、可响应交互
const list = JSON.parse(raw);
const processed = list.map((it) => ({
id: it.id,
name: it.name,
// 预格式化:把每行渲染时的重复计算提前做掉一次
amountText: (it.amount / 100).toFixed(2),
dateText: new Date(it.ts).toLocaleDateString('zh-CN'),
}));
self.postMessage(processed);
};
// 主线程
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
async function loadPage(cursor) {
const res = await fetch(`/api/items?cursor=${cursor}`);
const raw = await res.text(); // 注意用 text(),把 parse 也交给 worker
return new Promise((resolve) => {
worker.onmessage = ({ data }) => resolve(data);
worker.postMessage({ raw });
});
}注意:
postMessage是结构化克隆,有序列化开销。如果加工结果本身很大,收益会被通信成本吃掉。此时应在 worker 内就完成"聚合/降采样",只回传渲染需要的部分;或对二进制数据用Transferable(零拷贝)。
② 虚拟化 + 无限滚动结合
关键点:虚拟列表里哨兵元素通常不在 DOM 中(它在列表最底部,而底部没被渲染)。两种解法:
- 把哨兵放在虚拟列表容器之后(在 phantom 层下面),它随滚动条到底而进入视口 —— 简单可靠。
- 或者不用 IO,直接在虚拟列表的
render里判断end >= data.length - threshold时触发loadMore()—— 因为虚拟列表本来就在算 end,这是最省的做法。
// 虚拟列表 + 无限滚动:用可视区终点判断,不额外用哨兵
useEffect(() => {
if (end >= data.length - 10 && hasMore && !loadingRef.current) {
loadMore();
}
}, [end, data.length, hasMore, loadMore]);③ 分片渲染(首批数据量大时的过渡手段)
// 把「一次插入 5000 个」拆成「每帧插入 100 个」,主线程每帧都能喘口气
function renderInChunks(items, renderOne, container, batchSize = 100) {
let i = 0;
function step() {
const frag = document.createDocumentFragment(); // 批量插入只触发一次 reflow
const end = Math.min(i + batchSize, items.length);
for (; i < end; i++) frag.appendChild(renderOne(items[i]));
container.appendChild(frag);
if (i < items.length) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}分片渲染只是把卡顿摊薄,DOM 总量不变。有虚拟化就不需要分片——两者是替代关系而非叠加关系。分片适用于"必须全量渲染"的场景(比如打印、导出预览)。
【追问 & 加分点】
- 虚拟列表 + 无限滚动 + 不定高,三者叠加最难的点是什么? 是"新数据到达时的位置稳定性"。追加数据会改变 total 高度;如果同时有未测量项被测量,
scrollTop对应的内容会漂移。工程解法是 scroll anchoring:追加前记录锚点项(视口顶部第一项)的 id 和它相对视口的偏移,追加并重算位置后,把scrollTop调整回锚点应在的位置。 - IntersectionObserver 的哨兵为什么有时不触发? 常见三个原因:① 哨兵在内部滚动容器里但
root没设为该容器(默认 root 是视口,容器滚动不产生 intersection 变化);② 哨兵height: 0且父元素overflow: hidden导致几何上永远不可见;③ 首屏数据不足一屏,哨兵一开始就在视口内 —— 这时 IO 会立即回调一次(这是正确行为,能自动补齐首屏,但要保证loading锁生效,否则会连续触发多次)。 - 要不要做"上拉加载"的双向无限滚动?(聊天记录场景)向上加载会改变上方内容高度,必须在插入前记录
scrollHeight,插入后用scrollTop += (newScrollHeight - oldScrollHeight)补偿,否则视口会跳。现代浏览器的overflow-anchor: auto(默认开启)能自动处理部分情况,但跨浏览器不可靠,还是显式补偿更稳。 - SEO 与可访问性:无限滚动的内容爬虫抓不到。需要同时提供分页 URL(
?page=2)+<link rel="next">,或用 History API 在滚动时同步 URL,保证深链和刷新可用。
3. 合并单元格表格的滚动懒加载
【核心答案】
难点在于 rowspan 让"行"不再是独立单元——如果按行截断加载,一个跨 5 行的合并单元格可能只加载到前 2 行,rowspan="5" 就会渲染出结构错乱的表格。解法是引入"块(block/group)"作为最小加载与渲染单位:数据层不按行分页,而是按合并区间的最大边界对齐分页 —— 后端返回时保证 endRow 落在所有合并区间的边界上(不切断任何一个合并组);同时为每个单元格维护一份合并元数据 { row, col, rowspan, colspan, hidden }(被合并覆盖的格子标 hidden,渲染时跳过),以及一个 rowIndex → blockId 的索引用于快速定位。渲染时按块整块加载、整块渲染、整块回收,保证任意时刻 DOM 里的表格结构都是自洽的。
【展开】
为什么行不能随便切
row 0 | A | 北京 | 100 |
row 1 | | | 200 | ← A 的 rowspan=3,覆盖 row0~row2
row 2 | | | 300 |
row 3 | B | 上海 | 400 | ← 这里才是安全的分块边界
如果第一页只加载 row0~row1:
<td rowspan="3">A</td> 会向下"伸出"到不存在的第 3 行
→ 表格布局崩坏、边框错位、后续插入行被挤掉数据结构设计
/** 单元格的合并元数据 */
interface CellMeta {
row: number; // 全局行号(不是当前块内的相对行号)
col: number; // 全局列号
rowspan: number; // 纵向合并数,1 表示不合并
colspan: number; // 横向合并数
/** 是否被其他单元格的合并区域覆盖。true 表示渲染时【完全跳过】这个格子,
* 不能渲染成空 <td>(会把后面的列挤歪) */
hidden: boolean;
value?: unknown;
}
/** 一个「块」:加载与渲染的最小单位,保证内部合并结构完整 */
interface Block {
id: string;
startRow: number; // 全局起始行(含)
endRow: number; // 全局结束行(不含),保证是合并边界
rows: CellMeta[][]; // 该块内的单元格矩阵
height: number; // 实测/预估高度,用于虚拟滚动定位
measured: boolean;
}
/** 表格模型 */
interface TableModel {
totalRows: number; // 全表行数(后端给,用于撑滚动条)
blocks: Map<string, Block>; // 已加载的块
blockIndex: Array<{ // 块的位置索引(前缀和),用于二分定位
id: string; startRow: number; endRow: number;
top: number; bottom: number;
}>;
/** 全局行号 → blockId,O(log n) 二分定位当前滚动位置属于哪个块 */
rowToBlock(row: number): string | null;
}关键约定:分块边界必须是"合并安全边界" —— 即不存在任何一个合并区间跨越该边界。这个约定由后端保证最可靠:
后端分页逻辑(伪码):
1. 按请求的 limit 取到候选 endRow
2. 检查是否有合并区间 [s, e) 满足 s < endRow < e
3. 若有,把 endRow 向后扩展到 max(e)(或向前收缩到 min(s)),直到无跨越
4. 返回 { startRow, endRow, cells, nextCursor: endRow }
=> 每页行数会略有浮动,这是可接受的代价;换来的是前端结构永远自洽如果后端不配合(老接口只能按固定行数返回),前端要做缓冲区兜底:把"尚未闭合的合并组"暂存,等下一页数据到达后再补齐,未闭合期间该组的单元格暂不渲染或渲染为占位。这更复杂且容易出 bug,能推动后端改是更好的选择。
合并元数据的预处理(从原始 merges 生成 hidden 标记)
/**
* 把「合并区间列表」展开成完整的单元格矩阵,标记出被覆盖的格子。
* @param {number} rowCount 行数
* @param {number} colCount 列数
* @param {Array<{row,col,rowspan,colspan}>} merges 合并区间(左上角坐标 + 跨度)
* @param {(r:number,c:number)=>any} getValue 取原始值
* @returns {CellMeta[][]}
*/
function buildCellMatrix(rowCount, colCount, merges, getValue) {
// 1) 初始化:所有格子默认 1x1、可见
const matrix = Array.from({ length: rowCount }, (_, r) =>
Array.from({ length: colCount }, (_, c) => ({
row: r, col: c, rowspan: 1, colspan: 1, hidden: false, value: getValue(r, c),
}))
);
// 2) 应用合并:左上角格子写入 span,覆盖范围内的其他格子标记 hidden
for (const { row, col, rowspan = 1, colspan = 1 } of merges) {
if (row >= rowCount || col >= colCount) continue;
matrix[row][col].rowspan = rowspan;
matrix[row][col].colspan = colspan;
for (let r = row; r < Math.min(row + rowspan, rowCount); r++) {
for (let c = col; c < Math.min(col + colspan, colCount); c++) {
if (r === row && c === col) continue; // 跳过锚点格自身
matrix[r][c].hidden = true; // 被覆盖 → 渲染时完全不输出 <td>
}
}
}
return matrix;
}
/**
* 计算「合并安全边界」:所有不被任何合并区间跨越的行号。
* 用差分数组统计每一行有多少个合并组「正跨越」它。
*/
function computeSafeBoundaries(rowCount, merges) {
const crossing = new Array(rowCount + 1).fill(0);
for (const { row, rowspan = 1 } of merges) {
if (rowspan <= 1) continue;
// 该合并组跨越了边界 row+1 ... row+rowspan-1
crossing[row + 1]++;
crossing[row + rowspan]--;
}
// 前缀和还原每个边界的跨越计数
const boundaries = [];
let cur = 0;
for (let i = 0; i <= rowCount; i++) {
cur += crossing[i];
if (cur === 0) boundaries.push(i); // 计数为 0 = 无合并跨越 = 安全边界
}
return boundaries; // 一定包含 0 和 rowCount
}
/** 给定期望的每页行数,把行区间对齐到安全边界 */
function planBlocks(rowCount, merges, targetRowsPerBlock = 50) {
const safe = computeSafeBoundaries(rowCount, merges);
const blocks = [];
let start = 0;
while (start < rowCount) {
const want = start + targetRowsPerBlock;
// 找到 >= want 的第一个安全边界(宁可多加载几行,也不能切断合并组)
let end = safe.find((b) => b >= want);
if (end === undefined || end <= start) end = rowCount;
blocks.push({ id: `blk_${start}_${end}`, startRow: start, endRow: end });
start = end;
}
return blocks;
}渲染逻辑
/** 渲染一个块:跳过 hidden 格,锚点格输出 rowSpan/colSpan */
function BlockRows({ block }) {
return (
<>
{block.rows.map((cells, i) => (
<tr key={block.startRow + i} data-row={block.startRow + i}>
{cells.map((cell) =>
// ⚠️ 被覆盖的格子必须【完全不渲染】,
// 渲染成 <td style="display:none"> 也不行 —— 会影响列宽计算
cell.hidden ? null : (
<td key={cell.col}
rowSpan={cell.rowspan > 1 ? cell.rowspan : undefined}
colSpan={cell.colspan > 1 ? cell.colspan : undefined}>
{String(cell.value ?? '')}
</td>
)
)}
</tr>
))}
</>
);
}
/**
* 块级虚拟滚动表格:
* - 未加载的块用「预估高度的占位 tr」撑开,保证滚动条长度正确
* - 进入视口(含 rootMargin 提前量)的块才真正请求数据并渲染
* - 划出视口足够远的块可以被回收(保留高度,丢弃 DOM)
*/
function MergedTable({ totalRows, blockPlan, loadBlock, colCount, estRowHeight = 36 }) {
const [loaded, setLoaded] = useState(new Map()); // blockId -> Block
const loadingSet = useRef(new Set());
const containerRef = useRef(null);
const ioRef = useRef(null);
useEffect(() => {
ioRef.current = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
const id = e.target.dataset.blockId;
if (loaded.has(id) || loadingSet.current.has(id)) continue;
loadingSet.current.add(id);
const plan = blockPlan.find((b) => b.id === id);
loadBlock(plan.startRow, plan.endRow)
.then((block) => setLoaded((m) => new Map(m).set(id, block)))
.finally(() => loadingSet.current.delete(id));
}
},
{ root: containerRef.current, rootMargin: '400px 0px' } // 提前两屏加载
);
return () => ioRef.current?.disconnect();
}, [blockPlan, loadBlock, loaded]);
return (
<div ref={containerRef} style={{ height: 600, overflow: 'auto' }}>
{/* table-layout: fixed —— 列宽由首行决定,不因内容变化重算,
这对「块动态加载」至关重要:否则每加载一块列宽都会跳动 */}
<table style={{ tableLayout: 'fixed', width: '100%', borderCollapse: 'collapse' }}>
<colgroup>
{Array.from({ length: colCount }, (_, i) => <col key={i} style={{ width: 120 }} />)}
</colgroup>
<tbody>
{blockPlan.map((plan) => {
const block = loaded.get(plan.id);
const rows = plan.endRow - plan.startRow;
if (block) return <BlockRows key={plan.id} block={block} />;
// 未加载:整块用一个 tr 占位,高度 = 行数 × 预估行高
return (
<tr key={plan.id}
data-block-id={plan.id}
ref={(el) => el && ioRef.current?.observe(el)}
style={{ height: rows * estRowHeight }}>
<td colSpan={colCount} style={{ padding: 0 }}>
<SkeletonRows count={Math.min(rows, 5)} />
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}几个必须处理的工程细节
table-layout: fixed+<colgroup>固定列宽。默认的auto布局会根据全部内容计算列宽,块动态加载时列宽会不断跳变,视觉上非常糟;而且 auto 布局本身对大表格性能很差。- 占位块的高度必须准。合并单元格的行高常常不一致(合并格内容多会撑高)。可以让后端在块元数据里返回该块的预估高度,或用已加载块的均值动态修正。
- 块回收(可选):块很多时,把远离视口的块卸载回占位状态(保留实测高度),控制 DOM 总量。回收要保守(保留前后各 2~3 块),否则来回滚动会频繁请求。
- 表头:
<thead>+position: sticky; top: 0,表头也可能有多级合并(colspan),逻辑同上但表头一次性全加载。 borderCollapse: collapse与 sticky 的兼容问题:collapse 模式下 sticky 表头的边框会消失(边框归属于被滚走的单元格)。常见解法是改用border-collapse: separate; border-spacing: 0+ 给单元格加box-shadow模拟边框。
【追问 & 加分点】
- 如果合并组超大(一个格子跨 1000 行)怎么办? 块的大小会被迫拉到 1000 行,失去懒加载意义。此时应该放弃原生 table 的 rowspan,改用绝对定位的自绘表格(div +
position: absolute+ 每个单元格显式top/left/width/height),这样单元格与行解耦,可以只渲染视口内的部分——即使一个单元格逻辑上跨 1000 行,也只渲染它落在视口里的那一段(配合position: sticky让合并格的文字始终可见)。AG Grid、Handsontable 这类专业表格都是这个路子。 - 排序/筛选后合并关系怎么办? 合并通常基于"相同值连续出现"这一语义(比如按部门合并)。排序改变行顺序后,合并区间必须重新计算,不能沿用旧的元数据。所以合并元数据应该是由排序后的数据派生出来的(后端在返回排序结果时一并计算),而不是固定挂在原始数据上。
- 导出/打印:虚拟化的表格只有视口内的 DOM,直接
window.print()或截 DOM 导出会丢数据。必须走"从数据层导出"的路径(后端生成 Excel,或前端用 SheetJS 从完整数据生成,合并信息用!merges字段表达)。 - 测试要点:合并边界的临界情况最容易出 bug —— 合并组恰好在块首/块尾、合并组横跨三个块、rowspan 超出剩余行数、colspan 超出列数。这些都应该有单测覆盖
buildCellMatrix和computeSafeBoundaries。
4. 为什么用 IntersectionObserver 替代 scroll 监听
【核心答案】
四个理由:① 异步、脱离主线程关键路径 —— 交叉判断由浏览器在渲染流水线内部完成,回调在合适时机批量派发,不会像 scroll 回调那样在每次滚动事件上同步执行;② 避免强制同步布局 —— 手写 scroll 方案里几乎一定要调 getBoundingClientRect() / offsetTop 来判断位置,这会强制浏览器同步执行布局(layout thrashing),滚动时每帧一次直接掉帧;③ 免去高频回调的节流/防抖心智 —— scroll 事件在 60fps 下可能一秒触发上百次,需要手动 rAF 节流且仍难保证准确;④ 天然支持嵌套滚动容器、多元素观察、阈值分级,一个 observer 观察成百上千个元素的成本远低于给每个元素挂 scroll 逻辑。代价是:不能拿到精确的实时位置(回调有延迟,不适合视差滚动这类需要逐帧位置的动画)、需要注意 root 配置和 polyfill(现代浏览器已全面支持)。
【展开】
对比
| 维度 | scroll 监听 | IntersectionObserver |
|---|---|---|
| 触发频率 | 高频,与滚动同步,可达每秒上百次 | 仅在"交叉状态改变"时触发,通常几次 |
| 执行时机 | 在主线程的事件处理阶段同步执行,阻塞滚动 | 异步派发,不阻塞滚动合成 |
| 位置计算 | 需手动 getBoundingClientRect() → 强制同步布局 | 浏览器内部在布局后计算,零额外布局成本 |
| 多元素 | N 个元素需要 N 次位置计算(或一次遍历) | 一个 observer 批量处理,成本近似 O(1) 摊销 |
| 嵌套滚动 | 需要找对滚动容器并分别监听 | root 参数直接指定 |
| 精确实时位置 | ✅ 可以 | ❌ 只有交叉状态和一个快照 rect |
scroll 方案的问题(代码对比)
// ❌ 典型的 scroll 实现:每次滚动都强制同步布局
window.addEventListener('scroll', () => {
images.forEach((img) => {
// getBoundingClientRect() 会强制浏览器【立即】执行布局计算,
// 打断浏览器的批处理优化。N 张图 = N 次(虽然同一帧内浏览器会缓存,
// 但只要中间有任何写 DOM 的操作就会反复失效 → layout thrashing)
const rect = img.getBoundingClientRect();
if (rect.top < innerHeight && rect.bottom > 0) {
img.src = img.dataset.src; // 写 DOM → 布局失效 → 下一次读又要重新布局
}
});
});
// ⚠️ 加了 rAF 节流会好一些,但强制布局的本质问题还在,
// 而且滚动惯性结束时可能漏掉最后一次判断
let ticking = false;
window.addEventListener('scroll', () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => { ticking = false; check(); });
}, { passive: true });// ✅ IntersectionObserver:零手写位置计算,零强制布局
const io = new IntersectionObserver(
(entries, observer) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img); // 加载过就取消观察,避免重复触发
}
},
{
root: null, // null = 视口;内部滚动容器要传该容器元素
rootMargin: '200px 0px', // 视口向外扩 200px,提前加载(可用负值收缩)
threshold: 0, // 交叉比例达到 0 就触发(哪怕 1px 进入)
}
);
document.querySelectorAll('img[data-src]').forEach((img) => io.observe(img));API 关键参数
new IntersectionObserver(callback, {
// 观察的「参照系」。默认 null = 浏览器视口。
// 元素在自定义滚动容器内滚动时【必须】指定为该容器,否则不会触发。
root: document.querySelector('.scroll-container'),
// 给 root 的边界加内边距,CSS margin 语法。
// 正值 = 扩大判定范围(提前触发),负值 = 收缩(延后触发)。
// ⚠️ root 为 null(视口)时,rootMargin 的百分比相对视口尺寸;
// 且在跨域 iframe 中有限制。
rootMargin: '0px 0px 300px 0px',
// 触发阈值:目标元素与 root 的交叉面积占目标面积的比例。
// 单值或数组。[0, 0.25, 0.5, 0.75, 1] 可以做「曝光进度」类需求。
threshold: [0, 0.5, 1],
});回调参数 entry 的常用字段:
entry.isIntersecting // 是否正在交叉(布尔)
entry.intersectionRatio // 交叉比例 0~1
entry.target // 目标元素
entry.time // 触发时的时间戳(DOMHighResTimeStamp)
entry.boundingClientRect // 目标元素的矩形(浏览器已算好,读它不会额外触发布局)
entry.intersectionRect // 交叉区域的矩形
entry.rootBounds // root 的矩形三个常见用法
// ① 图片懒加载(一次性)
const lazyImgIO = new IntersectionObserver((entries, ob) => {
entries.forEach((e) => {
if (!e.isIntersecting) return;
const img = e.target;
img.src = img.dataset.src;
img.srcset = img.dataset.srcset || '';
ob.unobserve(img); // 关键:加载后取消观察
});
}, { rootMargin: '200px' });
// ② 无限滚动哨兵(持续)
const sentinelIO = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) loadMore(); },
{ rootMargin: '0px 0px 400px 0px' }
);
sentinelIO.observe(document.querySelector('#sentinel'));
// ③ 曝光埋点(需要「持续可见 1 秒」才算曝光)
const exposureTimers = new Map();
const exposureIO = new IntersectionObserver((entries) => {
for (const e of entries) {
const id = e.target.dataset.id;
if (e.isIntersecting && e.intersectionRatio >= 0.5) {
// 进入且可见面积过半 → 起一个 1s 定时器
if (!exposureTimers.has(id)) {
exposureTimers.set(id, setTimeout(() => {
report('exposure', id);
exposureIO.unobserve(e.target); // 上报过就不再观察
exposureTimers.delete(id);
}, 1000));
}
} else {
// 划走了 → 取消定时器,本次不算曝光
clearTimeout(exposureTimers.get(id));
exposureTimers.delete(id);
}
}
}, { threshold: [0, 0.5] });React 封装
import { useEffect, useRef, useState } from 'react';
/** 返回 [ref, isIntersecting],once=true 时首次进入后停止观察 */
function useInView({ root = null, rootMargin = '0px', threshold = 0, once = false } = {}) {
const ref = useRef(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(([entry]) => {
setInView(entry.isIntersecting);
if (entry.isIntersecting && once) io.unobserve(el);
}, { root, rootMargin, threshold });
io.observe(el);
return () => io.disconnect(); // 卸载时务必断开,否则泄漏
}, [root, rootMargin, threshold, once]);
return [ref, inView];
}【追问 & 加分点】
- 什么情况下仍然必须用 scroll? 需要逐帧的连续位置时:视差滚动、滚动进度条、根据 scrollTop 连续插值的动画、滚动方向判断。这些用 IO 做不到(IO 只给状态变化点)。更现代的替代是 CSS Scroll-Driven Animations(
animation-timeline: scroll()/view()),完全在合成线程运行,零 JS,性能最好,但浏览器支持面还在铺开。 - IO 不是"完全免费"的:它仍然依赖布局信息,观察上万个元素时浏览器内部计算也有成本;而且回调是在主线程执行的——回调里做重活一样会卡。IO 省掉的是"手写位置计算和高频触发",不是"所有开销"。
- 常见坑:① 忘了
unobserve/disconnect造成内存泄漏(尤其 React 里元素卸载后 observer 还持有引用);②display: none的元素永远不 intersect;③ 元素初始就在视口内时 IO 会立即触发一次回调(这是规范行为,用来告知初始状态),无限滚动场景要有并发锁;④root必须是目标元素的祖先,不是任意元素;⑤ 在跨域 iframe 里rootBounds为 null。 - 相关的观察者家族:
ResizeObserver(尺寸变化,替代 window resize + 手动测量)、MutationObserver(DOM 结构变化)、PerformanceObserver(性能条目)。它们的共同设计哲学是"把高频轮询变成浏览器主动通知",能把这条讲出来会显得体系化。
5. 骨架屏 + 视口懒加载 + 错误兜底的加载体系
【核心答案】
把加载体验抽象成一个统一的状态机:idle → (进入视口) → loading(骨架屏)→ success | error(兜底 UI + 重试),三个能力各自负责一层:骨架屏负责"占位与感知优化"(结构必须与真实内容一致且尺寸对齐,否则内容到达时会 CLS 抖动);视口懒加载负责"只在需要时才付出成本"(IntersectionObserver 观察占位容器,进入视口才发起图片请求或加载组件 chunk);错误兜底负责"失败不白屏"(图片 onerror 换默认图、组件用 ErrorBoundary + 重试、chunk 加载失败自动重试并在多次失败后提示刷新)。三者用一个 <LazyBoundary> 组件统一封装,业务方零心智接入。
【展开】
整体设计
┌──────────── LazyBoundary ────────────┐
│ │
占位容器 ──IO──▶│ idle ──进入视口──▶ loading ──▶ success │
(有尺寸) │ (骨架屏) │ │
│ ▼ │
│ error │
│ (兜底 + 重试) │
└───────────────────────────────────────┘
关键:占位容器【必须有确定的尺寸】(aspect-ratio / min-height)
否则骨架 → 内容的切换会产生布局偏移 (CLS)一、图片加载体系
import { useState, useRef, useEffect, useCallback } from 'react';
/**
* 懒加载图片:骨架占位 → 进入视口加载 → 失败兜底 + 重试
* @param {string} src 真实图片地址
* @param {string} fallback 加载失败时的兜底图
* @param {number} ratio 宽高比(width/height),用于预留空间防 CLS
* @param {number} maxRetry 失败重试次数
*/
function LazyImage({ src, alt = '', fallback = '/img/placeholder.svg',
ratio = 16 / 9, maxRetry = 2, rootMargin = '200px' }) {
const [status, setStatus] = useState('idle'); // idle | loading | success | error
const [retry, setRetry] = useState(0);
const wrapRef = useRef(null);
// ① 视口检测:进入视口才切到 loading(真正发起请求)
useEffect(() => {
if (status !== 'idle') return;
const el = wrapRef.current;
if (!el) return;
const io = new IntersectionObserver(([e]) => {
if (e.isIntersecting) { setStatus('loading'); io.disconnect(); }
}, { rootMargin });
io.observe(el);
return () => io.disconnect();
}, [status, rootMargin]);
// ② 失败重试:指数退避,避免瞬时网络抖动导致的永久失败
const onError = useCallback(() => {
if (retry < maxRetry) {
const delay = 2 ** retry * 500; // 500ms, 1000ms, ...
setTimeout(() => { setRetry((r) => r + 1); setStatus('loading'); }, delay);
} else {
setStatus('error');
}
}, [retry, maxRetry]);
return (
<div ref={wrapRef}
style={{
position: 'relative',
width: '100%',
// ③ 关键:用 aspect-ratio 预留空间,图片到达前后高度不变 → 零 CLS
aspectRatio: String(ratio),
overflow: 'hidden',
background: '#f2f3f5',
}}>
{/* 骨架:微光动画,覆盖在容器上 */}
{status !== 'success' && status !== 'error' && <Shimmer />}
{/* 真实图片:只有进入视口(status !== 'idle')才写 src */}
{status !== 'idle' && status !== 'error' && (
<img
// key 带上 retry:重试时强制重新创建 img 元素,
// 否则同一个 src 赋回去浏览器可能直接用失败缓存不重发
key={retry}
src={retry ? `${src}${src.includes('?') ? '&' : '?'}_r=${retry}` : src}
alt={alt}
loading="lazy" // 原生懒加载兜底(IO 失效时仍生效)
decoding="async" // 图片解码不阻塞主线程
onLoad={() => setStatus('success')}
onError={onError}
style={{
width: '100%', height: '100%', objectFit: 'cover',
// 淡入过渡,避免图片"啪"地出现
opacity: status === 'success' ? 1 : 0,
transition: 'opacity .25s ease',
}}
/>
)}
{/* 错误兜底:默认图 + 点击重试 */}
{status === 'error' && (
<div style={{ position: 'absolute', inset: 0, display: 'flex',
flexDirection: 'column', alignItems: 'center',
justifyContent: 'center', gap: 8, background: '#f7f8fa' }}>
<img src={fallback} alt="加载失败" style={{ width: 48, height: 48 }} />
<button onClick={() => { setRetry(0); setStatus('loading'); }}>重新加载</button>
</div>
)}
</div>
);
}
/** 骨架微光效果 */
function Shimmer() {
return (
<div style={{ position: 'absolute', inset: 0, background:
'linear-gradient(90deg,#f2f3f5 25%,#e8e9eb 37%,#f2f3f5 63%)',
backgroundSize: '400% 100%', animation: 'shimmer 1.4s ease infinite' }} />
);
}
// CSS: @keyframes shimmer { 0% { background-position: 100% 50% }
// 100% { background-position: 0 50% } }二、组件懒加载体系(含 chunk 加载失败重试)
import { lazy, Suspense, Component } from 'react';
/**
* 带重试的动态 import。
* 网络抖动或发版后旧 chunk 被清理都会导致 "Loading chunk failed",
* 不做重试就是白屏 —— 这是线上最常见的懒加载事故。
*/
function retryImport(factory, retries = 3, interval = 800) {
return new Promise((resolve, reject) => {
factory()
.then(resolve)
.catch((err) => {
if (retries <= 0) {
// 多次失败大概率是「发版后旧 chunk 已被 CDN 清理」,
// 提示用户刷新可以拿到新的 chunk 映射表。
// 用 sessionStorage 做标记防止无限刷新死循环。
if (!sessionStorage.getItem('chunk_reloaded')) {
sessionStorage.setItem('chunk_reloaded', '1');
location.reload();
return;
}
reject(err);
return;
}
setTimeout(
() => retryImport(factory, retries - 1, interval * 2).then(resolve, reject),
interval
);
});
});
}
const HeavyChart = lazy(() => retryImport(() => import('./HeavyChart')));
/** 错误边界:捕获渲染期错误,提供重试(改 key 强制重新挂载子树) */
class ErrorBoundary extends Component {
state = { error: null, key: 0 };
static getDerivedStateFromError(error) { return { error }; }
componentDidCatch(error, info) {
// 上报到监控平台,带上组件栈便于定位
reportError({ message: error.message, stack: error.stack,
componentStack: info.componentStack });
}
reset = () => this.setState((s) => ({ error: null, key: s.key + 1 }));
render() {
if (this.state.error) {
return this.props.fallback?.(this.state.error, this.reset) ?? (
<div className="err">
<p>组件加载失败</p>
<button onClick={this.reset}>重试</button>
</div>
);
}
// key 变化会强制卸载并重新挂载整棵子树,从而重新触发 lazy 加载
return <div key={this.state.key}>{this.props.children}</div>;
}
}
/**
* 统一入口:视口懒加载 + 骨架 + 错误边界,三合一
* 业务方只需要 <LazyBoundary skeleton={<ChartSkeleton/>}><HeavyChart/></LazyBoundary>
*/
function LazyBoundary({ children, skeleton, minHeight = 200, rootMargin = '200px' }) {
const [ref, inView] = useInView({ rootMargin, once: true }); // 见第 4 题的 hook
return (
// minHeight 保证未加载时也占位,避免内容到达时页面跳动
<div ref={ref} style={{ minHeight }}>
{inView ? (
<ErrorBoundary>
{/* Suspense 处理「chunk 正在下载」的等待态,
ErrorBoundary 处理「chunk 下载失败」和「渲染报错」 */}
<Suspense fallback={skeleton}>{children}</Suspense>
</ErrorBoundary>
) : (
skeleton
)}
</div>
);
}三、骨架屏的三条设计原则(面试要点,不只是写代码)
- 结构对齐:骨架的块位置、数量、尺寸要和真实内容尽量一致。骨架是三行文字、内容是五行,切换时页面会跳 —— 骨架屏反而制造了 CLS。
- 不要过度动画:微光(shimmer)动画要柔和,动画本身也消耗合成资源;低端机上大面积 shimmer 反而掉帧。可以在
prefers-reduced-motion下关闭。 - 有最短展示时长:如果数据 50ms 就回来了,骨架闪一下比不显示更难受。做法是延迟展示 + 最短驻留:请求发出后 200ms 内不显示骨架(快请求直接出内容),一旦显示则至少驻留 300ms。
/** 防闪烁的加载态:快请求不显示骨架,慢请求显示后至少驻留一段时间 */
function useDelayedLoading(loading, { delay = 200, minDuration = 300 } = {}) {
const [show, setShow] = useState(false);
const shownAt = useRef(0);
useEffect(() => {
let t;
if (loading) {
// 延迟 delay 才显示:50ms 就返回的请求根本不会看到骨架
t = setTimeout(() => { shownAt.current = Date.now(); setShow(true); }, delay);
} else if (show) {
// 已经显示了:保证至少驻留 minDuration,避免一闪而过
const rest = Math.max(0, minDuration - (Date.now() - shownAt.current));
t = setTimeout(() => setShow(false), rest);
}
return () => clearTimeout(t);
}, [loading, show, delay, minDuration]);
return show;
}四、骨架屏的生成方式(可选加分)
- 手写:最可控,与真实布局强一致,但要维护两套结构。
- 自动生成:构建期用 puppeteer 打开页面,把元素替换成灰块,截取 DOM 生成骨架 HTML 内联进
index.html(如page-skeleton-webpack-plugin的思路)。首屏骨架内联到 HTML 里能在 JS 加载前就显示,感知提升最大。 - 组件复用:用同一个组件的
loading态渲染骨架(比如给<Card loading />),结构天然对齐,这是 Ant Design 的做法,工程上最省。
【追问 & 加分点】
- 原生
loading="lazy"和 IntersectionObserver 怎么选? 原生零成本、浏览器可以做更聪明的调度(考虑网络状况),首选;但提前量不可控(各浏览器的触发距离不一,且随网络状况变化)、无法做骨架状态管理、无法做失败重试。生产上常见是两者叠加:img 上加loading="lazy"兜底,外层用 IO 控制骨架和状态机。首屏 LCP 图片绝对不能加loading="lazy",那会推迟请求、直接拉高 LCP。 decoding="async"的作用:图片解码(decode)本身是有成本的,大图解码可能占用几十毫秒主线程。decoding="async"让浏览器在后台线程解码,不阻塞渲染。想更精细可以用img.decode()API 手动等解码完成再插入 DOM,彻底消除"插入瞬间的卡顿"。- ErrorBoundary 抓不到什么? 事件处理函数里的错误、异步代码(
setTimeout、Promise)里的错误、SSR 期间的错误、以及 ErrorBoundary 自身的错误。这些要靠window.onerror/unhandledrejection全局兜底。React 19 提供了onCaughtError/onUncaughtError根级回调,是更好的统一上报入口。 - 弱网/离线场景:可以读
navigator.connection.effectiveType,在2g/slow-2g下降级——不加载大图、直接用低清占位图、关闭动画。配合navigator.onLine+online事件,在恢复网络时自动重试失败的资源。这是"体系"二字的体现。
6. 精确倒计时组件
【核心答案】
setInterval 会漂移,原因有三层:① 它保证的是"至少间隔 N ms 后把回调放入任务队列",不是"精确 N ms 执行" —— 主线程忙时回调会排队;② 每次执行本身有耗时,误差会逐次累积(跑 1 小时可能差几秒到几十秒);③ 页面切到后台时浏览器会节流定时器(Chrome 后台标签页 timer 最低 1s 甚至更久,长时间后台还会进入冻结),回来后倒计时严重偏慢。正确做法是不依赖定时器计数,而是每次都用"目标时间戳 − 当前时间戳"重新计算剩余时间,定时器只负责"驱动重绘";驱动用 requestAnimationFrame 更好(与浏览器绘制同步、后台自动暂停、不产生无效渲染)。60fps 下 rAF 间隔约 16.7ms(1000/60)。页面切后台时 rAF 停止,靠 visibilitychange 回到前台时立刻重算一次即可自动校正——因为剩余时间本来就是从时间戳算的,中断多久都不影响正确性。
【展开】
setInterval 为什么漂移
// ❌ 错误示范:用计数器递减,误差会累积
let remain = 3600;
setInterval(() => {
remain -= 1; // 假设每次间隔正好 1000ms —— 这个假设不成立
render(remain);
}, 1000);
// 实际发生的事:
// t=0 设置定时器
// t=1000 主线程正忙(有个 200ms 的长任务) → 回调排队
// t=1200 回调才执行,remain 变成 3599,但真实已过去 1.2s
// t=2200 下一次触发(间隔从上次【执行完】开始算,误差继续累积)
// ...
// 跑 1 小时后可能少走十几秒到几十秒;后台标签页下差得更多三个原因逐条说清:
- HTML 规范只保证"不早于":
setInterval(fn, 1000)的语义是"至少 1000ms 后把 fn 加入任务队列",实际执行时间取决于主线程什么时候空闲。 - 误差累积:每次的迟到量不会被后续补偿,而是继续叠加。
- 后台节流:Chrome 对不可见页面把 timer 最小间隔钳到 1000ms(嵌套超过 5 层的 setTimeout 在前台也会被钳到 4ms);后台超过 5 分钟还会进入更激进的节流甚至冻结(Page Freezing)。移动端锁屏后 JS 可能完全停止。
正确实现:时间戳差值 + rAF 驱动
/**
* 精确倒计时。
* 核心思想:定时器只负责「什么时候重绘」,剩余时间永远由
* 目标时间戳 - 当前时间戳 计算得出,因此定时器再不准也不会累积误差。
*/
class Countdown {
/**
* @param {number} duration 倒计时总时长(ms)
* @param {(remain:number)=>void} onTick 每次更新的回调,参数为剩余毫秒
* @param {()=>void} onEnd 结束回调
* @param {number} interval 最小刷新间隔(ms),默认 1000(只显示到秒时无需每帧重绘)
* @param {number} [serverTime] 服务端当前时间戳,用于校正客户端时钟
*/
constructor({ duration, onTick, onEnd, interval = 1000, serverTime }) {
this.onTick = onTick;
this.onEnd = onEnd;
this.interval = interval;
// 客户端时钟可能被用户改过 → 用服务端时间校正出一个偏移量
this.clockOffset = serverTime ? serverTime - Date.now() : 0;
this.endAt = this.now() + duration; // 目标时间戳,这是唯一的真相来源
this.rafId = null;
this.lastRenderAt = 0;
this.running = false;
this.tick = this.tick.bind(this);
this.onVisibilityChange = this.onVisibilityChange.bind(this);
}
/** 校正过时钟偏移的"当前时间" */
now() {
return Date.now() + this.clockOffset;
}
start() {
if (this.running) return;
this.running = true;
document.addEventListener('visibilitychange', this.onVisibilityChange);
this.render(true); // 立即渲染一次,避免首帧空白
this.rafId = requestAnimationFrame(this.tick);
}
tick() {
if (!this.running) return;
this.render(false);
if (this.running) this.rafId = requestAnimationFrame(this.tick);
}
/**
* @param {boolean} force 是否忽略 interval 强制渲染
*/
render(force) {
const remain = Math.max(0, this.endAt - this.now()); // ★ 每次都重新算,零累积误差
if (remain <= 0) {
this.onTick(0);
this.stop();
this.onEnd?.();
return;
}
// rAF 每秒回调 ~60 次,但只显示到「秒」的话没必要每帧都 setState/操作 DOM。
// 用时间节流:距上次渲染超过 interval 才更新。
const t = this.now();
if (force || t - this.lastRenderAt >= this.interval) {
this.lastRenderAt = t;
this.onTick(remain);
}
}
onVisibilityChange() {
if (document.visibilityState === 'visible') {
// 从后台回来:rAF 在后台是暂停的,此刻立即强制渲染一次,
// 剩余时间会瞬间跳到正确值(因为它是从时间戳算的,中断不影响正确性)
this.render(true);
// 重新启动 rAF 循环(后台期间 rAF 不会被调度,回调链断了)
if (this.running && this.rafId == null) {
this.rafId = requestAnimationFrame(this.tick);
}
} else {
// 切到后台:主动取消 rAF,省电(浏览器本来也不会调度,这里是显式表达意图)
if (this.rafId != null) { cancelAnimationFrame(this.rafId); this.rafId = null; }
}
}
stop() {
this.running = false;
if (this.rafId != null) { cancelAnimationFrame(this.rafId); this.rafId = null; }
document.removeEventListener('visibilitychange', this.onVisibilityChange);
}
}
// ---------- 格式化工具 ----------
function formatRemain(ms) {
const total = Math.ceil(ms / 1000); // 用 ceil:剩 0.3s 时显示 1s 而不是 0s
const d = Math.floor(total / 86400);
const h = Math.floor((total % 86400) / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const pad = (n) => String(n).padStart(2, '0');
return d > 0 ? `${d}天 ${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(h)}:${pad(m)}:${pad(s)}`;
}
// ---------- 使用 ----------
const cd = new Countdown({
duration: 2 * 60 * 60 * 1000, // 2 小时
serverTime: window.__SERVER_TIME__, // 由后端注入,防止本地改时间作弊
interval: 1000,
onTick: (remain) => { document.getElementById('cd').textContent = formatRemain(remain); },
onEnd: () => { alert('活动已结束'); },
});
cd.start();React 版本
import { useEffect, useRef, useState, useCallback } from 'react';
/**
* @param {number} endAt 结束时间戳(ms,已用服务端时间校正过)
* @param {number} interval 最小刷新间隔,毫秒级动画传 0
*/
function useCountdown(endAt, interval = 1000) {
const [remain, setRemain] = useState(() => Math.max(0, endAt - Date.now()));
const rafRef = useRef(null);
const lastRef = useRef(0);
const loop = useCallback(() => {
const now = Date.now();
const left = Math.max(0, endAt - now); // ★ 永远从时间戳重算
if (left <= 0) { setRemain(0); return; } // 到点了,不再排下一帧
if (now - lastRef.current >= interval) {
lastRef.current = now;
setRemain(left); // 节流:避免每帧 setState 引发 60 次重渲染
}
rafRef.current = requestAnimationFrame(loop);
}, [endAt, interval]);
useEffect(() => {
lastRef.current = 0; // 保证首次立即渲染
rafRef.current = requestAnimationFrame(loop);
const onVisible = () => {
if (document.visibilityState === 'visible') {
// 回到前台立即校正一次,并重启已中断的 rAF 循环
lastRef.current = 0;
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(loop);
}
};
document.addEventListener('visibilitychange', onVisible);
return () => {
cancelAnimationFrame(rafRef.current);
document.removeEventListener('visibilitychange', onVisible);
};
}, [loop]);
return remain;
}
function Countdown({ endAt }) {
const remain = useCountdown(endAt);
return <span>{formatRemain(remain)}</span>;
}追问:60 帧下 rAF 的间隔是多少
约 16.7ms(1000 / 60 ≈ 16.67)。要补充三点才完整:
- rAF 的频率跟随显示器刷新率,不是固定 60Hz。120Hz 屏(iPhone ProMotion、高刷安卓、MacBook Pro)上约 8.3ms,144Hz 上约 6.9ms。所以绝不能假设 rAF 一定是 16.7ms 来做时间计算——这正是"用时间戳差值而不是帧计数"的另一个理由。
- rAF 的回调参数就是一个高精度时间戳(
DOMHighResTimeStamp,相对performance.timeOrigin),可以直接用它做增量计算:requestAnimationFrame((t) => { const dt = t - lastT; ... })。 - 实际间隔可能远大于 16.7ms:主线程有长任务时会掉帧,此时两次 rAF 之间可能是 33ms、50ms 甚至更多。所以逐帧动画必须按
dt插值,而不是按"每帧走固定距离"。
追问:页面切后台怎么办
rAF 在页面不可见时完全不会被调度(浏览器不渲染,自然不排帧),所以:
- 后台期间倒计时"不走"——但这不影响正确性,因为剩余时间是
endAt - Date.now()算出来的,回来那一刻立刻是正确值。 - 需要在
visibilitychange里做两件事:① 立即强制渲染一次(否则要等下一帧才更新,会有肉眼可见的延迟);② 重启 rAF 循环(后台期间回调链断了,必须重新requestAnimationFrame)。 - 如果业务要求"后台期间也要触发结束事件"(比如后台到点了要发通知),rAF 做不到。可以叠加一个低频
setTimeout(后台被钳到 1s 也够用)作为兜底,或者用 Web Worker 里的定时器(Worker 的定时器不受页面可见性节流影响,这是很实用的一招),或者干脆在回到前台时补发事件。
// 后台也要走的兜底方案:把定时器放进 Worker(不受页面节流影响)
// timer.worker.js
let id = null;
self.onmessage = ({ data }) => {
if (data.type === 'start') {
id = setInterval(() => self.postMessage({ type: 'tick', t: Date.now() }), data.interval);
} else if (data.type === 'stop') {
clearInterval(id);
}
};【追问 & 加分点】
- 服务端时间校正为什么必要? 客户端
Date.now()依赖用户系统时间,用户改时间就能让秒杀倒计时归零。标准做法:页面加载时从后端拿一次服务器时间戳(或直接读响应的Date头),算出offset = serverTime - clientTime,之后所有计算用Date.now() + offset。更严谨的还要扣掉网络往返延迟的一半((t4-t1-(t3-t2))/2,NTP 的思路)。但最终判定必须以服务端为准——前端倒计时只是展示,到点后的下单请求由服务端校验时间。 - 多个倒计时怎么办?(商品列表里每个卡片一个倒计时)绝不能开 N 个 rAF/interval。正确做法是一个全局调度器(单例 ticker),维护一个订阅列表,一个 rAF 循环里统一计算并批量通知,把 N 个循环降到 1 个。这是列表页性能的关键,也是很能体现工程素养的回答。
- 毫秒级显示的场景:需要显示到 10ms(如竞拍、抢票)时,
interval设为 0 让它每帧更新。此时要特别注意渲染成本:用textContent直接改 DOM 而不是走 React setState;数字用等宽字体(font-variant-numeric: tabular-nums)避免宽度跳动导致布局抖动。 setInterval的另一个坑:如果回调执行时间超过间隔,setInterval会导致回调"堆积"(浏览器会做一定合并,但仍可能连续触发)。用setTimeout自递归(每次执行完再排下一次)可以避免堆积,且能动态修正下次延迟:const drift = Date.now() - expected; setTimeout(next, Math.max(0, interval - drift))—— 这是不能用 rAF 时(如 Node 环境、需要后台运行)的正确写法。
7. AI 对话打字机效果
【核心答案】
架构是三段解耦:SSE/流式接收(网络层,拿到 token 就塞进队列)→ 缓冲队列(解耦网络抖动与渲染节奏,网络可能一次来 50 个字,也可能 200ms 没消息)→ rAF 驱动的消费者(渲染层,每帧从队列取固定数量字符追加到显示文本,制造匀速打字的观感)。这样做的价值是削峰填谷:直接把每个 chunk 都 setState 会导致高频重渲染(一秒几十上百次 React 渲染 + Markdown 重新解析 + 重排),队列让渲染频率与网络频率彻底解耦。性能优化的核心三条:批量 flush(一帧只 setState 一次,而不是每个 token 一次)、rAF 节流(对齐绘制,不做无效渲染)、增量渲染(只更新变化的那个消息节点,历史消息用 memo 冻结;Markdown 解析结果做块级缓存,已完成的块不重复解析)。
【展开】
整体数据流
fetch(SSE) 缓冲队列(Queue) rAF 消费者 React 状态
─────────────▶ ┌──────────────────┐ ─────────────▶ ┌────────────┐
chunk 到达 │ "你" "好" "," ...│ 每帧取 N 个 │ setState │
(频率不可控) └──────────────────┘ (16.7ms 一次) │ 一次 │
▲ └────────────┘
│ │
网络抖动被吸收 渲染频率恒定 ≤60/s核心实现(React)
import { useRef, useState, useCallback, useEffect } from 'react';
/**
* 打字机 Hook:把流式到达的文本,以恒定速度平滑输出。
* @param {number} charsPerFrame 每帧输出的字符数(决定打字速度)
* @param {number} maxQueue 队列长度阈值,超过则加速消费(避免落后太多)
*/
function useTypewriter({ charsPerFrame = 2, maxQueue = 200 } = {}) {
const [text, setText] = useState('');
const queueRef = useRef([]); // 待输出的字符队列
const bufferRef = useRef(''); // 已输出的完整文本(真相来源,避免闭包过期)
const rafRef = useRef(null);
const doneRef = useRef(false); // 流是否已结束
/** 生产者:SSE 收到内容时调用 */
const push = useCallback((chunk) => {
// 按「字符」入队。中文/emoji 要用 Array.from 按码点切,
// 直接 split('') 会把 emoji 的代理对拆坏,渲染出乱码
queueRef.current.push(...Array.from(chunk));
start();
}, []);
/** 标记流结束:队列消费完后才算真正完成 */
const finish = useCallback(() => { doneRef.current = true; }, []);
const start = useCallback(() => {
if (rafRef.current != null) return; // 已在运行
const loop = () => {
const q = queueRef.current;
if (q.length === 0) {
rafRef.current = null;
return; // 队列空了就停止循环,省资源
}
// 动态速度:队列积压太多说明模型输出比打字快,加速追上,
// 否则用户会看到「说完很久了字还在慢慢蹦」
const n = q.length > maxQueue
? Math.ceil(q.length / 10) // 积压严重时按比例加速
: charsPerFrame;
// ★ 关键:一帧只 setState 一次,把 n 个字符一次性拼接
// 而不是每个字符一次 setState(那样一秒会触发几百次渲染)
const take = q.splice(0, n).join('');
bufferRef.current += take;
setText(bufferRef.current);
rafRef.current = requestAnimationFrame(loop);
};
rafRef.current = requestAnimationFrame(loop);
}, [charsPerFrame, maxQueue]);
/** 跳过动画,立即输出全部(用户点「停止动画」时用) */
const flushAll = useCallback(() => {
bufferRef.current += queueRef.current.join('');
queueRef.current = [];
setText(bufferRef.current);
}, []);
const reset = useCallback(() => {
queueRef.current = [];
bufferRef.current = '';
doneRef.current = false;
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
rafRef.current = null;
setText('');
}, []);
useEffect(() => () => {
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
}, []);
return { text, push, finish, flushAll, reset,
isTyping: queueRef.current.length > 0 };
}接上 SSE
function ChatBox() {
const { text, push, finish, flushAll, reset } = useTypewriter({ charsPerFrame: 2 });
const abortRef = useRef(null);
const send = async (prompt) => {
reset();
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
signal: ctrl.signal,
});
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
// parseSSE 见第 8 题的完整实现
for await (const evt of parseSSE(res.body, ctrl.signal)) {
if (evt.data === '[DONE]') break;
try {
const delta = JSON.parse(evt.data)?.choices?.[0]?.delta?.content;
if (delta) push(delta); // 只入队,不直接 setState
} catch { /* 忽略非 JSON 的心跳帧 */ }
}
finish();
};
return (
<div>
<MessageBody text={text} />
<button onClick={() => abortRef.current?.abort()}>停止生成</button>
<button onClick={flushAll}>跳过动画</button>
</div>
);
}高频流式场景的性能问题与优化
问题一:每个 chunk 都 setState → 渲染风暴
模型可能一秒推 50~100 个 chunk。每个 chunk 一次 setState:
- React 触发 50~100 次渲染(React 18 的自动批处理只在同一事件循环内合并,SSE 的每次 chunk 是独立的宏任务/微任务,不会被批处理)
- 每次渲染都要重新解析 Markdown、重新 diff、重新排版
- 文本越长越慢(O(n) 的解析成本 × n 次渲染 = O(n²))
优化:rAF 批量 flush(上面的实现已经解决)。渲染频率被钳在 ≤ 60/s,且与文本长度无关。
问题二:Markdown 每次全量重新解析 → O(n²)
import { memo, useMemo, useRef } from 'react';
import { marked } from 'marked';
/**
* 增量 Markdown 渲染:
* 把文本按「块级边界」切分,已经稳定的块缓存解析结果,
* 只有最后一个「未完成的块」每帧重新解析。
* 这样把 O(n²) 降到 O(n) + O(尾块长度 × 帧数)。
*/
const MarkdownStream = memo(function MarkdownStream({ text }) {
const cacheRef = useRef(new Map()); // blockText -> html
const blocks = useMemo(() => {
// Markdown 的块级分隔是「空行」。最后一块可能还没写完。
const parts = text.split(/\n{2,}/);
return parts.map((raw, i) => {
const isLast = i === parts.length - 1;
if (!isLast && cacheRef.current.has(raw)) {
return { key: i, html: cacheRef.current.get(raw), stable: true };
}
const html = marked.parse(raw);
// 只缓存「已完成」的块(不是最后一块 = 后面已有空行 = 该块写完了)
if (!isLast) cacheRef.current.set(raw, html);
return { key: i, html, stable: !isLast };
});
}, [text]);
return (
<>
{blocks.map((b) => (
// 稳定块用 memo 冻结,React 不会重新 diff 它的 DOM
<StableBlock key={b.key} html={b.html} stable={b.stable} />
))}
</>
);
});
// stable 的块内容不再变化,memo 的比较函数直接跳过重渲染
const StableBlock = memo(
({ html }) => <div dangerouslySetInnerHTML={{ __html: html }} />,
(prev, next) => prev.stable && next.stable && prev.html === next.html
);问题三:历史消息跟着重渲染
// 会话列表:只有最后一条在流式更新,前面的必须冻结
const MessageList = ({ messages, streamingText }) => (
<>
{messages.map((m) => <HistoryMessage key={m.id} msg={m} />)}
<StreamingMessage text={streamingText} />
</>
);
// 历史消息用 memo + 稳定的 key,props 不变就完全不重渲染
const HistoryMessage = memo(({ msg }) => <MarkdownStream text={msg.content} />);问题四:自动滚动到底部导致的重排与"抢滚动"
/**
* 智能自动滚动:
* - 用户主动向上滚动查看历史时,停止自动跟随(否则用户根本看不了历史)
* - 用户滚回底部时,恢复跟随
* - 用 rAF 节流滚动操作,不要每次文本更新都 scrollTo
*/
function useAutoScroll(deps) {
const ref = useRef(null);
const followRef = useRef(true);
const rafRef = useRef(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const onScroll = () => {
// 距底部 < 50px 视为「在底部」,恢复跟随
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
followRef.current = atBottom;
};
el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll);
}, []);
useEffect(() => {
if (!followRef.current) return;
if (rafRef.current) return; // 一帧最多滚一次
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
const el = ref.current;
if (el) el.scrollTop = el.scrollHeight;
});
}, deps);
return ref;
}更省的做法:容器用
overflow-anchor: none+ CSSscroll-behavior: auto,或者用 flex 反转(flex-direction: column-reverse)让浏览器天然保持在底部——后者零 JS,但会带来选中文本方向、滚动条位置等细节问题,要权衡。
问题五:长会话的 DOM 累积
几百条消息 + 每条几千字,DOM 节点数轻松上万。解法:消息列表虚拟化(用 react-virtuoso,它对"底部对齐 + 动态高度 + 流式追加"的支持最好),或者对远离视口的历史消息做 content-visibility: auto。
问题六:代码块高亮的开销
highlight.js / shiki 对长代码块高亮是同步的重计算。流式过程中代码块每帧都在变长,每帧重新高亮会直接卡死。解法:流式期间代码块只渲染纯文本 <pre>,等这个代码块闭合(遇到结束的 ```)后才做一次高亮。这与下一题的"块级边界检测"是同一个思路。
【追问 & 加分点】
- 打字速度怎么定? 固定
charsPerFrame会有两个问题:模型快时字积压(说完很久还在蹦字)、模型慢时字断断续续。更好的做法是自适应速度:根据队列长度动态调整(上面代码里的q.length > maxQueue就是简化版),或者用 PID 式的平滑控制,目标是让队列长度稳定在一个小范围内。用户体感上,中文约 20~40 字/秒比较舒服。 - 中断(Stop 按钮)要做几件事? ①
AbortController.abort()断开 fetch 流;② 停止 rAF 消费循环;③ 把队列里已收到但还没显示的内容立即 flush 出来(用户已经"付费"拿到的 token 不应该丢);④ 通知后端停止生成(很多后端需要显式的取消请求来释放资源,仅断开连接不一定能及时释放)。 text每次都是新字符串,React 的 diff 成本如何? 纯文本节点的 diff 很便宜(字符串比较 +nodeValue赋值),真正贵的是 Markdown 解析成 HTML 后的元素树 diff。所以增量块缓存的收益远大于优化文本本身。如果用dangerouslySetInnerHTML,整块 innerHTML 重设会丢失用户的文本选中状态——这也是要做块级缓存的原因之一。- SSR / 流式 HTML 的替代方案:如果不需要富交互,服务端直接流式返回 HTML(
Transfer-Encoding: chunked)让浏览器原生增量渲染,是成本最低的"打字机",但无法做速度控制和中断。工程上 AI 对话几乎都用 SSE + 客户端渲染。
8. 手写 SSE 解析器与流式 Markdown 渲染
【核心答案】
SSE 的协议要点:UTF-8 文本流,以行为单位;字段格式是 field: value(冒号后的一个空格可选);合法字段只有 data、event、id、retry,以冒号开头的行是注释(常用作心跳);空行表示一个事件结束并派发;多个 data: 行用 \n 拼接。手写解析器的核心是跨 chunk 的缓冲——网络 chunk 的边界与行边界、事件边界完全无关,一个字符甚至一个 UTF-8 多字节序列都可能被切开,所以必须用 TextDecoder({stream:true}) 做流式解码,并保留未处理完的尾部字符串到下一个 chunk。流式渲染 Markdown 时,表格、代码块、列表这类块级结构必须等"块闭合"才渲染:维护一个待定缓冲区,用块级边界检测(代码块看成对的 ```、表格看连续 | 行是否被非表格行终止、段落看空行)判断当前尾块是否完整,未完整时降级为纯文本展示,完整后再整块解析。HTML 标签同理甚至更严格——半个 <div 或未闭合的标签直接插入会破坏 DOM 结构,必须缓冲到标签闭合,且必须做 XSS 净化。
【展开】
SSE 协议格式
: 这是注释行,常用于心跳保活(冒号开头)
event: message
id: 42
retry: 3000
data: {"choices":[{"delta":{"content":"你好"}}]}
← 空行 = 事件结束,派发
data: 第一行
data: 第二行
← 两个 data 用 \n 拼成 "第一行\n第二行"
data: [DONE]
规则细节(面试要能说准):
- 行分隔符可以是
\n、\r\n或\r(规范都支持)。 field: value中冒号后如果有一个空格,去掉;多余的空格保留。data字段可重复,多行用\n连接(最后不加\n)。id会被记住,重连时通过Last-Event-ID请求头发回。retry指定重连间隔(毫秒)。- 未知字段忽略。
- 空的
data(即事件里没有任何 data 行)不派发。
完整解析器(fetch + ReadableStream)
/**
* 解析 SSE 流,返回一个异步迭代器。
* @param {ReadableStream<Uint8Array>} stream fetch 的 response.body
* @param {AbortSignal} [signal]
* @yields {{event:string, data:string, id:string|null, retry:number|null}}
*/
async function* parseSSE(stream, signal) {
const reader = stream.getReader();
// ★ stream:true 是关键:一个 UTF-8 汉字占 3 字节,
// 完全可能被网络 chunk 从中间切开。stream 模式会把不完整的字节序列
// 留到下一次 decode,避免解出「�」乱码。
const decoder = new TextDecoder('utf-8');
let buffer = ''; // 跨 chunk 的行缓冲:保存最后一行未闭合的内容
let event = { event: 'message', data: [], id: null, retry: null };
const reset = () => { event = { event: 'message', data: [], id: null, retry: null }; };
// 中断时释放 reader,避免连接泄漏
const onAbort = () => reader.cancel().catch(() => {});
signal?.addEventListener('abort', onAbort);
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
// 流结束:flush 解码器残留字节,并处理最后一段没有以空行结尾的数据
buffer += decoder.decode();
if (buffer) {
const last = processLines(buffer.split(/\r\n|\r|\n/), event);
if (last) yield last;
} else if (event.data.length) {
yield toEvent(event);
}
break;
}
buffer += decoder.decode(value, { stream: true });
// ★ 按行切分,但【最后一段可能是不完整的行】,必须留回 buffer
const lines = buffer.split(/\r\n|\r|\n/);
buffer = lines.pop() ?? ''; // 最后一段不处理,等下个 chunk 补全
for (const line of lines) {
// 空行 = 事件边界,派发并重置
if (line === '') {
if (event.data.length > 0) { // 规范:没有 data 的事件不派发
yield toEvent(event);
}
reset();
continue;
}
// 冒号开头 = 注释(心跳),忽略
if (line.startsWith(':')) continue;
// 解析 field: value
const colon = line.indexOf(':');
let field, value;
if (colon === -1) {
field = line; // 无冒号:整行是字段名,值为空字符串
value = '';
} else {
field = line.slice(0, colon);
value = line.slice(colon + 1);
// 规范:冒号后【一个】空格要去掉,多余的保留
if (value.startsWith(' ')) value = value.slice(1);
}
switch (field) {
case 'event': event.event = value; break;
case 'data': event.data.push(value); break; // 多行 data 累积
case 'id':
// 规范:包含 NUL 字符的 id 要忽略
if (!value.includes('\0')) event.id = value;
break;
case 'retry': {
const n = Number(value);
if (Number.isInteger(n) && n >= 0) event.retry = n;
break;
}
default: break; // 未知字段忽略
}
}
}
} finally {
signal?.removeEventListener('abort', onAbort);
reader.releaseLock();
}
function toEvent(e) {
return {
event: e.event,
data: e.data.join('\n'), // ★ 多行 data 用 \n 连接
id: e.id,
retry: e.retry,
};
}
function processLines(lines, e) {
for (const line of lines) {
if (line === '' || line.startsWith(':')) continue;
const c = line.indexOf(':');
const f = c === -1 ? line : line.slice(0, c);
let v = c === -1 ? '' : line.slice(c + 1);
if (v.startsWith(' ')) v = v.slice(1);
if (f === 'data') e.data.push(v);
else if (f === 'event') e.event = v;
else if (f === 'id') e.id = v;
}
return e.data.length ? toEvent(e) : null;
}
}
// ---------- 使用 ----------
const ctrl = new AbortController();
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
body: JSON.stringify({ prompt }),
signal: ctrl.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!res.body) throw new Error('该环境不支持流式响应');
for await (const evt of parseSSE(res.body, ctrl.signal)) {
if (evt.data === '[DONE]') break;
const delta = JSON.parse(evt.data).choices?.[0]?.delta?.content;
if (delta) push(delta);
}为什么不直接用 EventSource?(必答)
EventSource 是浏览器内置的 SSE 客户端,但有三个致命限制:① 只支持 GET,无法把长 prompt 放 body(URL 长度有限且不适合放大文本);② 不能自定义请求头,没法带 Authorization;③ 自动重连行为不可控(AI 场景重连会重复生成,通常不想要)。所以 AI 对话场景一律用 fetch + ReadableStream 手写解析。
流式 Markdown:块级边界检测
问题:模型逐 token 输出表格时,中间态是这样的:
| 姓名 | 年龄直接丢给 Markdown 解析器,会渲染成一个残缺的表格 —— 而且下一帧又变成另一个残缺表格,视觉上疯狂闪烁跳动。
解法:把文本切成"已完成的块"和"待定的尾块",只对已完成的块做完整渲染,尾块降级处理。
/**
* 检测文本尾部是否处于一个「未闭合的块级结构」中。
* @returns {{ stable: string, pending: string, pendingType: string|null }}
* stable —— 可以安全地完整渲染的部分
* pending —— 尚未闭合,应降级为纯文本或暂不渲染的部分
*/
function splitStablePending(text) {
const lines = text.split('\n');
// ---- 1) 代码块:``` 必须成对 ----
// 从头扫描统计围栏数量,奇数说明当前处在未闭合的代码块中
let fenceOpenAt = -1;
let inFence = false;
let fenceMark = '';
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(/^\s*(`{3,}|~{3,})/);
if (!m) continue;
if (!inFence) { inFence = true; fenceMark = m[1][0]; fenceOpenAt = i; }
else if (m[1][0] === fenceMark) { inFence = false; fenceOpenAt = -1; }
}
if (inFence) {
// 代码块未闭合:闭合前的内容是稳定的,从围栏起始行开始都是 pending
return {
stable: lines.slice(0, fenceOpenAt).join('\n'),
pending: lines.slice(fenceOpenAt).join('\n'),
pendingType: 'code',
};
}
// ---- 2) 表格:连续的以 | 开头的行,直到遇到非表格行才算结束 ----
// 尾部如果正处在一段表格行里,说明表格可能还在生成
let i = lines.length - 1;
while (i >= 0 && lines[i].trim() === '') i--; // 跳过尾部空行
if (i >= 0 && /^\s*\|/.test(lines[i])) {
// 向上找到这段表格的起点
let start = i;
while (start - 1 >= 0 && /^\s*\|/.test(lines[start - 1])) start--;
const tableLines = lines.slice(start, i + 1);
// 表格「至少」要有表头行 + 分隔行(|---|---|) 才是合法表格。
// 且只要文本没有以空行结尾,就认为表格还可能继续追加行 → 判为 pending
const hasSeparator = tableLines.length >= 2 &&
/^\s*\|?[\s:-]*\|[\s:|-]*$/.test(tableLines[1]);
const closedByBlankLine = /\n\s*\n\s*$/.test(text);
if (!closedByBlankLine || !hasSeparator) {
return {
stable: lines.slice(0, start).join('\n'),
pending: tableLines.join('\n'),
pendingType: 'table',
};
}
}
// ---- 3) 普通段落:以空行结尾才算这一段写完 ----
// 找最后一个空行,它之后的内容都还可能继续写
let lastBlank = -1;
for (let j = lines.length - 1; j >= 0; j--) {
if (lines[j].trim() === '') { lastBlank = j; break; }
}
if (lastBlank === -1) {
return { stable: '', pending: text, pendingType: 'paragraph' };
}
return {
stable: lines.slice(0, lastBlank).join('\n'),
pending: lines.slice(lastBlank + 1).join('\n'),
pendingType: 'paragraph',
};
}判据的局限性(面试可主动说明):上面表格分支用的
closedByBlankLine + hasSeparator是工程启发式而非 Markdown 规范——规范里表格遇到任何非表格行就结束,不一定需要空行,所以这个判据会造成"表格其实已完整、但要等到模型输出下一个空行才切换为渲染"的轻微滞后。更可靠的方案是走解析器 token 流:把全文喂给 marked/markdown-it 的 lexer,检查最后一个 token 是否为完整的 table 节点,完整即渲染。字符串启发式胜在零依赖、每帧成本低,二者按场景取舍。
渲染策略:
import { memo, useMemo, useRef } from 'react';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
const StreamMarkdown = memo(function StreamMarkdown({ text, streaming }) {
const cache = useRef(new Map());
const { stable, pending, pendingType } = useMemo(
() => (streaming ? splitStablePending(text)
: { stable: text, pending: '', pendingType: null }),
[text, streaming]
);
// 稳定部分:缓存解析结果,只有内容真正增长时才重新解析新增的块
const stableHtml = useMemo(() => {
if (cache.current.has(stable)) return cache.current.get(stable);
const html = DOMPurify.sanitize(marked.parse(stable)); // ★ 必须净化,防 XSS
cache.current.set(stable, html);
return html;
}, [stable]);
return (
<div className="md">
<div dangerouslySetInnerHTML={{ __html: stableHtml }} />
{/* 待定部分的降级渲染策略:按类型区别对待 */}
{pending && (
pendingType === 'table'
// 表格未完成 → 显示纯文本 + 「正在生成表格」提示,
// 绝不渲染半截表格(会疯狂跳动)
? <pre className="pending-table">{pending}</pre>
: pendingType === 'code'
// 代码块未闭合 → 用 <pre> 展示原文,暂不做语法高亮
// (高亮是同步重计算,每帧做会卡死)
? <pre className="pending-code">{pending.replace(/^```\w*\n?/, '')}</pre>
// 普通段落 → 行内语法(粗体、链接)可以边生成边渲染,
// 因为它们不影响块级结构,最多闪一下 **
: <div dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(marked.parseInline(pending)) }} />
)}
{streaming && <span className="cursor">▋</span>}
</div>
);
});判断标准总结(这段是答案的核心洞察):
| 结构 | 完整性判据 | 未完成时的处理 |
|---|---|---|
| 代码块 | ``` 成对出现(且结束围栏长度 ≥ 起始围栏) | 纯文本 <pre>,闭合后再高亮 |
| 表格 | 有表头 + 分隔行,且后面跟了空行/非表格行 | 纯文本,显示"生成中" |
| 列表 | 遇到空行或缩进层级结束 | 可以边渲染(列表项增量追加视觉可接受) |
| 段落 | 遇到空行 | 可以边渲染(用 parseInline) |
| 标题 | 该行以换行结束 | 该行未换行时可以先当普通文本 |
| 数学公式 | $$ / \[ \] 成对 | 纯文本,闭合后再用 KaTeX 渲染 |
判断原则:会导致"块级结构重排"的,必须等闭合;只影响行内样式的,可以边生成边渲染。 因为块级结构变化会引起大范围重排和视觉跳动,行内样式变化只是局部闪一下。
HTML 标签要不要类似缓冲
要,而且要求更严格。 三个层面:
- 结构完整性:半个标签
<div class="直接插入会被浏览器的 HTML 解析器错误处理(可能吞掉后续内容,或自动补全成意料之外的结构)。用innerHTML赋值时,浏览器会做错误恢复——未闭合的标签会被自动闭合,但闭合位置往往不是你想要的,导致后续 token 到达时整个 DOM 结构重排跳动。所以必须缓冲到标签至少语法完整(<有对应的>)。
/** 检测文本尾部是否有未闭合的 HTML 标签片段 */
function hasIncompleteTag(text) {
const lastLt = text.lastIndexOf('<');
if (lastLt === -1) return false;
const lastGt = text.lastIndexOf('>');
// 最后一个 < 在最后一个 > 之后 → 有个标签只开了头
return lastLt > lastGt;
}
/** 检测是否有未闭合的成对标签(更严格) */
function hasUnclosedElement(html) {
const stack = [];
// void 元素不需要闭合
const VOID = new Set(['br','hr','img','input','meta','link','area','base',
'col','embed','source','track','wbr']);
const re = /<\/?([a-zA-Z][\w-]*)[^>]*?(\/?)>/g;
let m;
while ((m = re.exec(html))) {
const [full, name, selfClose] = m;
const tag = name.toLowerCase();
if (VOID.has(tag) || selfClose === '/') continue;
if (full.startsWith('</')) {
// 闭合标签:弹栈(宽松匹配,容忍不规范嵌套)
const idx = stack.lastIndexOf(tag);
if (idx !== -1) stack.splice(idx);
} else {
stack.push(tag);
}
}
return stack.length > 0;
}-
安全性:模型输出的 HTML 是不可信内容,可能包含
<script>、<img onerror=...>、javascript:协议的链接。必须用 DOMPurify 净化,且要注意——净化必须在内容完整后做。对半截 HTML 做净化,DOMPurify 的解析和你最终看到的可能不一致,存在被绕过的风险(比如<img src=x onerror=这个片段在净化时看起来无害,拼上后续内容后就是攻击)。 -
最佳实践:绝大多数 AI 对话产品直接禁用原始 HTML(
marked的配置里过滤掉 HTML 块,或 DOMPurify 白名单只放行有限的标签)。只允许 Markdown 语法,需要富展示时用自定义组件协议(比如模型输出```chart {...}代码块,前端识别后渲染成图表组件)。这样既安全又可控——能主动提出这一点是很好的加分。
【追问 & 加分点】
- SSE vs WebSocket vs 长轮询怎么选? SSE:单向(服务器→客户端)、基于 HTTP、自动重连(原生 EventSource)、天然穿透代理和 CDN、实现简单,AI 流式输出的标准选择。WebSocket:全双工、二进制友好、但需要独立协议升级、代理/防火墙兼容性差、要自己实现心跳和重连、服务端连接成本高。AI 对话是天然的"请求-流式响应"模式,不需要全双工,所以 SSE 更合适。长轮询是没有 SSE 时的兜底。
- SSE 的坑:① HTTP/1.1 下浏览器对同域名有 6 个并发连接限制,开多个 SSE 会把连接占满导致其他请求阻塞——HTTP/2 下没这个问题(多路复用),生产必须上 H2;② Nginx 等反向代理默认会缓冲响应,导致流式失效(表现为"等全部生成完才一次性显示"),必须设
proxy_buffering off(或响应头带X-Accel-Buffering: no);③ 压缩中间件(gzip)也会缓冲,流式响应要禁用压缩或用支持流式的压缩;④ 某些移动网络的运营商代理会缓冲,需要定期发注释行(: heartbeat\n\n)保活并冲刷缓冲区。 - 为什么
TextDecoder必须传{stream: true}? 网络 chunk 的边界是字节边界,一个中文字符(UTF-8 3 字节)完全可能被切成 2+1。不加stream: true时,decoder 会把不完整的字节序列替换成�并丢弃状态;加了之后它会把残留字节留到下次 decode 拼接。这是手写流式解析里最容易漏、上线才发现的 bug(英文测试全过,中文偶发乱码)。 - 错误处理与恢复:流中断时(网络断开、服务端 5xx),要区分"已经输出了部分内容"和"还没开始"。已有部分内容时保留并标记"生成中断",提供"继续生成"(带上已有内容和
Last-Event-ID让后端续写);还没开始时直接重试整个请求。另外 SSE 的 HTTP 状态码在响应头就确定了,流中途出错无法改状态码,所以后端约定要用一个event: error的事件来传递中途错误。
9. BFF 层 Redis 缓存与数据库一致性
【核心答案】
首先要明确:分布式系统里做不到强一致,目标是"最终一致 + 把不一致窗口压到足够小"。主流方案是 Cache Aside(旁路缓存):读时先查缓存,miss 则查库并回填;写时先更新数据库,再删除缓存(而不是更新缓存)。"先更库再删缓存"优于其他组合的原因是:先删缓存再更库时,删除到更新完成之间的读请求会把旧值回填进缓存,且这个旧值会一直存在到下次更新——不一致窗口很长;先更库再删缓存的不一致窗口只在"删除操作本身"这一瞬间。删而不是更新,是因为更新缓存在并发下会出现"旧值覆盖新值",且写多读少时是浪费。剩余的边界问题(读请求先于写请求读到旧值、但后于删除才回填)用延迟双删(更新后再延迟几百毫秒删一次)兜底;对一致性要求更高的场景用**订阅 binlog(Canal/Debezium)**异步删缓存,把删除动作从业务代码里解耦出来,并靠消息队列的重试保证最终删除成功。
【展开】
四种写策略的对比
| 组合 | 问题 | 结论 |
|---|---|---|
| 先删缓存 → 再更新库 | A 删缓存 → B 读 miss → B 查到旧值 → A 更新库 → B 回填旧值 → 缓存长期是旧值 | ❌ 不一致窗口长 |
| 先更新库 → 再删缓存 | 极端情况:B 先读 miss 查到旧值(还没回填)→ A 更新库 → A 删缓存 → B 才回填旧值。要求 B 的"查库到回填"跨越了 A 的整个"更新+删除",概率极低(读操作通常比写快得多) | ✅ 推荐 |
| 先更新库 → 再更新缓存 | A、B 并发更新:A 更库 → B 更库 → B 更缓存 → A 更缓存 → 缓存是 A 的旧值。且写多读少时白白计算 | ❌ |
| 先更新缓存 → 再更新库 | 库更新失败就彻底不一致,且同样有并发覆盖问题 | ❌ |
Cache Aside 标准实现
/**
* 读:Cache Aside + 空值缓存 + 单飞(防击穿)+ 随机 TTL(防雪崩)
*/
const inflight = new Map(); // key -> Promise,进程内单飞
async function getUser(id) {
const key = `user:${id}`;
// 1) 查缓存
const cached = await redis.get(key);
if (cached !== null) {
// 空值标记:防缓存穿透(查不存在的 id 时也缓存一个短 TTL 的空标记)
return cached === '__NULL__' ? null : JSON.parse(cached);
}
// 2) 单飞:同一进程内对同一 key 的并发 miss 只查一次库
// 跨进程还需要分布式锁(见下)
if (inflight.has(key)) return inflight.get(key);
const p = (async () => {
try {
// 3) 分布式互斥锁:防止多实例同时击穿到数据库(热点 key 保护)
const lockKey = `lock:${key}`;
const token = crypto.randomUUID();
const locked = await redis.set(lockKey, token, { NX: true, PX: 3000 });
if (!locked) {
// 没抢到锁:短暂等待后重查缓存(大概率此时已被持锁者回填)
await sleep(50);
const again = await redis.get(key);
if (again !== null) {
return again === '__NULL__' ? null : JSON.parse(again);
}
// 仍然没有:降级直接查库(避免锁持有者挂掉导致所有请求饿死)
}
try {
const row = await db.query('SELECT * FROM users WHERE id = ?', [id]);
// 4) 回填。TTL 加随机抖动,避免大量 key 同时过期造成雪崩
const ttl = row
? 300 + Math.floor(Math.random() * 60) // 5min ± 1min
: 60; // 空值只缓存 1 分钟
await redis.set(key, row ? JSON.stringify(row) : '__NULL__', { EX: ttl });
return row ?? null;
} finally {
// 用 Lua 保证「只删自己加的锁」(避免误删别人的锁)
if (locked) {
await redis.eval(
`if redis.call('get', KEYS[1]) == ARGV[1]
then return redis.call('del', KEYS[1]) else return 0 end`,
{ keys: [lockKey], arguments: [token] }
);
}
}
} finally {
inflight.delete(key);
}
})();
inflight.set(key, p);
return p;
}
/**
* 写:先更新数据库,再删除缓存 + 延迟双删
*/
async function updateUser(id, data) {
const key = `user:${id}`;
// 1) 先更新数据库(真相来源)
await db.query('UPDATE users SET ? WHERE id = ?', [data, id]);
// 2) 立即删除缓存
await deleteCacheWithRetry(key);
// 3) 延迟双删:覆盖「更新期间有读请求查到旧值、在删除之后才回填」的窗口。
// 延迟时间要大于「一次读请求从查库到回填的耗时」,
// 经验值 300~1000ms,主从架构下还要大于主从同步延迟。
// ⚠️ 不要 await(会阻塞响应),也不要用裸 setTimeout(进程重启会丢失),
// 生产上应投递到延迟队列(MQ 延迟消息 / Redis ZSET 定时任务)
scheduleDelayedDelete(key, 500);
}
/** 删除缓存必须保证成功,失败会导致长期不一致 */
async function deleteCacheWithRetry(key, retries = 3) {
for (let i = 0; i < retries; i++) {
try { await redis.del(key); return; }
catch (e) { if (i === retries - 1) {
// 重试仍失败:投递到 MQ 由消费者继续重试,并告警
await mq.publish('cache.delete.retry', { key });
logger.error({ key, err: e }, '缓存删除失败,已转入 MQ 重试');
} }
}
}更强的方案:订阅 binlog
业务代码 MySQL Canal/Debezium MQ 消费者
│ │ │ │ │
├── UPDATE ──────▶│ │ │ │
│ ├── 写 binlog ──────────▶│ │ │
│ │ ├── 解析变更 ────▶│ │
│◀── 返回成功 ─────┤ │ ├── 投递 ─────▶│
│ │ │ │ ├─ DEL cache优势(面试要点):
- 业务代码零侵入 —— 不用在每个写操作后手写删缓存,杜绝"新同事忘了删"这类问题。
- 可靠性由 MQ 保证 —— 消费失败自动重试,最终一定会删掉,比在业务代码里 try-catch 可靠得多。
- 天然处理"绕过应用直接改库"的情况 —— 运营在后台直接改 DB、数据修复脚本、其他服务写库,binlog 都能捕获。
- 顺序性 —— binlog 天然有序,按主键 hash 到同一个 MQ 分区可以保证同一行的变更顺序消费。
代价:多一套中间件要运维;有毫秒到秒级的同步延迟;需要处理消费幂等和积压。
一致性方案的选型
| 一致性要求 | 方案 | 不一致窗口 |
|---|---|---|
| 弱(商品描述、配置) | Cache Aside + TTL 过期 | 到 TTL 结束(分钟级) |
| 中(用户资料、列表) | Cache Aside + 先更库后删缓存 | 毫秒级 |
| 较强(库存展示、余额展示) | 上述 + 延迟双删 + binlog 兜底 | 亚毫秒级,且有兜底 |
| 强(扣款、扣库存) | 不用缓存,直接读库(或 SELECT ... FOR UPDATE);缓存只用于展示 | 无 |
最重要的一句话:真正需要强一致的操作(扣款、扣库存、下单)不应该依赖缓存。缓存的定位是"降低读压力",涉及资金和库存的决策必须回源到数据库并用事务/锁保证。把这条说出来,比背十种双删变体更能体现判断力。
三个经典缓存问题
| 问题 | 定义 | 解法 |
|---|---|---|
| 穿透 | 查询不存在的数据,缓存永远 miss,请求全打到库(常见于恶意攻击) | 空值缓存(短 TTL)+ 布隆过滤器 + 参数校验 |
| 击穿 | 某个热点 key 过期瞬间,大量并发同时回源 | 互斥锁 / 单飞(如上)+ 热点 key 逻辑过期不物理过期(后台异步刷新) |
| 雪崩 | 大量 key 同时过期 或 Redis 宕机,请求洪峰打到库 | TTL 加随机抖动 + 多级缓存(本地 + Redis)+ 熔断降级 + Redis 高可用(哨兵/集群) |
逻辑过期(热点 key 的更优解,值得展开):
// 缓存值里带一个逻辑过期时间,Redis 里的 key 本身【永不过期】
// { data: {...}, expireAt: 1735000000000 }
async function getHotKey(key, loader) {
const raw = await redis.get(key);
if (!raw) return loadAndSet(key, loader); // 冷启动
const { data, expireAt } = JSON.parse(raw);
if (Date.now() < expireAt) return data; // 未逻辑过期,直接返回
// 已逻辑过期:立即返回旧数据(不阻塞用户),同时异步触发刷新
// 用锁保证只有一个请求去刷新
if (await redis.set(`lock:${key}`, '1', { NX: true, PX: 10000 })) {
loadAndSet(key, loader).finally(() => redis.del(`lock:${key}`));
}
return data; // ★ 返回略旧的数据,但绝不阻塞、绝不击穿
}这是"用一点点新鲜度换取零击穿"的经典权衡,秒杀、首页推荐这类场景的标准做法。
【追问 & 加分点】
- 主从延迟带来的坑:更新走主库、读走从库时,删缓存后立即有读请求打到还没同步的从库,会把旧数据回填进缓存。解法:① 延迟双删的延迟时间必须 > 主从同步延迟;② 写后一段时间内该 key 的读强制走主库("写后读一致性");③ 用 binlog 方案(binlog 本身就在从库同步之后)。这是延迟双删真正存在的主要理由,很多人只背"防并发读回填",答出主从延迟会明显加分。
- 延迟双删的延迟时间怎么定? 不能拍脑袋。应该 = 一次读请求的 P99 耗时 + 主从同步的 P99 延迟 + 安全余量。可以通过监控实际测出来。太短没效果,太长会让第二次删除失去意义(期间已有大量请求读到旧值)。而且双删必须用可靠的延迟队列,进程内
setTimeout在实例重启/扩缩容时会丢。 - BFF 层的特殊性:BFF 缓存的通常是聚合后的视图数据(一个页面所需的多个上游数据拼装结果),失效更复杂——上游任何一个变了都要失效。常见做法是分层缓存:底层按实体缓存(
user:1、product:9),BFF 层按视图缓存但 TTL 很短(几十秒),或者用版本号/标签失效(给聚合缓存打上依赖实体的 tag,实体变更时按 tag 批量失效)。 - 多级缓存的一致性更难:本地缓存(进程内 LRU)+ Redis 的组合里,本地缓存无法被"删除"操作直达(多个实例各有一份)。解法是用 Redis 的 Pub/Sub 或 MQ 广播失效消息,各实例收到后清本地;或者本地缓存 TTL 设得很短(几秒)接受短暂不一致。这是高 QPS 场景的必经之路。
10. 并发操作数据库同一字段
【核心答案】
分三类手段,按冲突概率选:① 乐观锁(版本号 / CAS) —— 不加锁,更新时用 WHERE version = ? 校验,影响行数为 0 说明被别人改过,回滚重试。适合冲突概率低的场景,无锁开销、吞吐高。② 悲观锁 —— SELECT ... FOR UPDATE 在事务内先锁住行,其他事务阻塞等待。适合冲突频繁、重试代价高的场景,但持锁期间会阻塞、有死锁风险,必须控制事务时长并保证加锁顺序一致。③ 队列串行化 —— 把对同一资源的操作按 key 路由到同一个消费者顺序处理,把并发问题彻底变成串行问题,适合超高并发的热点(秒杀)。另外还有两个常被忽略的答案:原子操作(UPDATE t SET stock = stock - 1 WHERE id = ? AND stock >= 1,把"读-改-写"合并成数据库的一条原子语句,是最简单也最高效的方案,能用就优先用)和分布式锁(Redis/etcd,跨服务场景)。
【展开】
问题的本质:Lost Update
时刻 事务 A 事务 B
t1 SELECT stock → 10
t2 SELECT stock → 10
t3 UPDATE stock = 10 - 1 = 9
t4 UPDATE stock = 10 - 1 = 9 ← B 覆盖了 A
结果:卖出 2 件,库存只减了 1 → 超卖根因是"读-改-写"三步不是原子的。
方案零:原子更新(能用就用它)
-- ✅ 最优解:把判断和扣减合并成一条语句,由数据库的行锁保证原子性
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 100 AND stock >= 1;
-- 检查 affectedRows:0 表示库存不足或行不存在 → 扣减失败const [result] = await db.query(
'UPDATE product SET stock = stock - ? WHERE id = ? AND stock >= ?',
[qty, id, qty]
);
if (result.affectedRows === 0) throw new Error('库存不足');为什么这是最优:InnoDB 执行 UPDATE 时会对匹配的行加排他行锁,stock >= 1 的判断和 stock - 1 的写入在同一条语句内完成,不存在中间态。无需应用层重试,无需额外的锁管理,性能最好。
为什么面试里很多人答不出来:习惯性地想"先查再改"。能第一时间指出"这个场景根本不需要显式锁"是很强的信号。
局限:只适用于能表达成"基于当前值的算术运算 + 简单条件"的场景。如果更新逻辑复杂(需要调用外部服务、需要复杂计算),就要用下面的方案。
方案一:乐观锁(版本号 / CAS)
ALTER TABLE account ADD COLUMN version INT NOT NULL DEFAULT 0;
-- 1) 读取时把 version 一起读出来
SELECT id, balance, version FROM account WHERE id = 1; -- balance=100, version=5
-- 2) 更新时带上 version 作为条件
UPDATE account
SET balance = 80, version = version + 1
WHERE id = 1 AND version = 5;
-- affectedRows = 1 → 成功
-- affectedRows = 0 → 期间被别人改过,需要重新读取并重试/**
* 乐观锁 + 指数退避重试
* @param {number} id
* @param {(cur: object) => object} mutate 基于当前值计算新值(纯函数)
* @param {number} maxRetry
*/
async function updateWithOptimisticLock(id, mutate, maxRetry = 3) {
for (let attempt = 0; attempt < maxRetry; attempt++) {
// 1) 读当前值和版本号
const [row] = await db.query(
'SELECT id, balance, version FROM account WHERE id = ?', [id]
);
if (!row) throw new Error('记录不存在');
// 2) 计算新值(这一步可以做任意复杂的业务逻辑,甚至调外部服务)
const next = mutate(row);
// 3) 带版本号更新(CAS)
const [res] = await db.query(
'UPDATE account SET balance = ?, version = version + 1 WHERE id = ? AND version = ?',
[next.balance, id, row.version]
);
if (res.affectedRows === 1) return next; // 成功
// 4) 冲突:指数退避 + 随机抖动,避免多个重试者同步碰撞(惊群)
const backoff = 2 ** attempt * 20 + Math.random() * 20;
await sleep(backoff);
}
throw new Error('并发冲突,请重试'); // 重试耗尽,向上抛给业务决定
}要点:
- 版本号也可以用
updated_at时间戳,但精度不够时会误判(同一毫秒内的两次更新看起来 version 相同),生产建议用自增整数。 - 重试次数要有上限,且必须有退避 + 抖动。无退避的紧密重试在高冲突下会把 CPU 和数据库连接打满。
- 冲突率高时乐观锁会退化——大量重试反而比悲观锁更慢。监控冲突率,超过 10~20% 就该考虑换方案。
方案二:悲观锁
START TRANSACTION;
-- FOR UPDATE 加排他行锁,其他事务的 FOR UPDATE / UPDATE 会阻塞在这里
SELECT balance FROM account WHERE id = 1 FOR UPDATE;
-- 中间可以做任意业务判断
UPDATE account SET balance = balance - 20 WHERE id = 1;
COMMIT; -- 提交后释放锁async function transferWithPessimisticLock(fromId, toId, amount) {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
// ★ 关键:按 id 排序加锁,保证所有事务的加锁顺序一致 → 避免死锁
// A 转给 B、B 转给 A 同时发生时,若不排序会互相等待对方的锁
const [first, second] = fromId < toId ? [fromId, toId] : [toId, fromId];
await conn.query('SELECT balance FROM account WHERE id = ? FOR UPDATE', [first]);
await conn.query('SELECT balance FROM account WHERE id = ? FOR UPDATE', [second]);
const from = await conn.query('SELECT balance FROM account WHERE id = ?', [fromId]);
if (from.balance < amount) throw new Error('余额不足');
await conn.query('UPDATE account SET balance = balance - ? WHERE id = ?', [amount, fromId]);
await conn.query('UPDATE account SET balance = balance + ? WHERE id = ?', [amount, toId]);
await conn.commit();
} catch (e) {
await conn.rollback();
throw e;
} finally {
conn.release(); // 必须释放连接,否则连接池耗尽
}
}要点与坑:
FOR UPDATE必须走索引。走不到索引会退化成表锁(InnoDB 会锁住扫描到的所有行),并发直接归零。这是最常见的生产事故。- 事务要短。持锁期间的每一毫秒都在阻塞别人。绝对不要在事务里调用 HTTP 接口(外部服务超时会把锁持有几十秒)。
- 死锁:多个资源加锁时必须全局统一加锁顺序(如按主键升序)。MySQL 会自动检测死锁并回滚代价小的事务(报
Deadlock found),应用层要能捕获并重试。 FOR UPDATE NOWAIT/FOR UPDATE SKIP LOCKED(MySQL 8.0+):前者拿不到锁立即报错(避免长时间阻塞),后者跳过已锁定的行(做任务队列的利器)。- 隔离级别影响行为:RR(MySQL 默认)下
FOR UPDATE会加间隙锁(Gap Lock),可能锁住不存在的记录区间,影响并发插入。
方案三:队列串行化
/**
* 按资源 key 路由到同一分区,同一 key 的操作被顺序消费 → 天然无并发
* 适合秒杀这类「单点超高并发」场景
*/
// 生产者
await mq.send('stock-deduct', {
key: `product:${productId}`, // 分区键
partition: hash(productId) % PARTITIONS, // 同一商品永远落在同一分区
payload: { productId, userId, qty },
});
// 消费者:单线程顺序消费该分区,完全无并发冲突
async function consume(msg) {
const { productId, userId, qty } = msg.payload;
// ★ 幂等:MQ 至少投递一次,必须防止重复扣减
// 用「唯一请求 id + 唯一索引」或 Redis SETNX 做幂等标记
const idemKey = `deduct:${msg.id}`;
if (!(await redis.set(idemKey, '1', { NX: true, EX: 86400 }))) return; // 已处理过
// 这里可以放心地「读-改-写」,因为同一 productId 不会有并发
const stock = await getStock(productId);
if (stock < qty) return notifyFail(userId, '库存不足');
await setStock(productId, stock - qty);
await createOrder(userId, productId, qty);
}代价:从同步变异步,用户拿不到即时结果(需要轮询/推送订单状态);分区内串行,单分区吞吐受限;需要处理消息幂等、积压和顺序保证。
秒杀的完整套路(值得完整说出来):
- 前端限流:按钮防重复点击、验证码打散请求。
- 网关限流:令牌桶/漏桶,把明显超出库存的流量直接挡掉。
- Redis 预扣减:用 Lua 脚本原子地检查并扣减 Redis 里的库存(
DECR+ 判断),绝大部分请求在这一层就被拒绝,不打到数据库。 - MQ 削峰:预扣成功的请求投递到 MQ,异步落库创建订单。
- 数据库最终校验:
UPDATE ... WHERE stock >= n兜底,保证绝不超卖。
-- Redis 预扣减的 Lua 脚本:单线程执行,天然原子
local stock = tonumber(redis.call('GET', KEYS[1]))
local qty = tonumber(ARGV[1])
if stock == nil then return -1 end -- key 不存在
if stock < qty then return 0 end -- 库存不足
redis.call('DECRBY', KEYS[1], qty)
return 1 -- 扣减成功方案选型
| 场景 | 方案 |
|---|---|
| 简单的加减/条件更新 | 原子 UPDATE(首选) |
| 冲突概率低(< 10%)、更新逻辑复杂 | 乐观锁 + 重试 |
| 冲突频繁、必须成功、逻辑复杂 | 悲观锁(短事务 + 索引 + 统一加锁顺序) |
| 单点超高并发(秒杀) | Redis 预扣 + MQ 队列串行化 |
| 跨服务/跨数据源 | 分布式锁(Redisson / etcd)或分布式事务(TCC / Saga) |
【追问 & 加分点】
- Redis 分布式锁的正确姿势:
SET key value NX PX ttl(一条命令完成加锁+设过期,不能拆成 SETNX + EXPIRE,中间宕机会导致锁永不释放);value 用唯一 token,释放时用 Lua 脚本校验 token 再删(否则可能删掉别人的锁——自己的锁超时释放后被别人获取,自己执行完又去删);业务耗时可能超过 TTL 时需要看门狗自动续期(Redisson 的实现)。必须承认的一点:Redis 主从架构下,主节点宕机且锁还没同步到从节点时会丢锁,理论上不安全。RedLock 算法有争议(Martin Kleppmann 与 antirez 的著名论战),要绝对安全应该用 etcd/ZooKeeper 这类基于共识算法的方案,或者干脆让最终的一致性由数据库唯一约束保证。 - 幂等性是并发问题的另一半:网络重试、MQ 重投、用户重复点击都会导致同一操作执行多次。标准做法是唯一业务 id + 数据库唯一索引(重复插入直接报唯一键冲突,天然幂等),或 Token 机制(下单前先领一个一次性 token,提交时校验并消费)。只解决并发不解决幂等,系统照样出问题——能主动提这一点很加分。
- 热点行的性能问题:所有请求都更新同一行(比如一个爆款商品的库存),即使用了原子 UPDATE,行锁也会让所有请求排队,QPS 上不去。解法是分段库存(库存分桶):把 1000 库存拆成 10 个 100 的桶,请求随机路由到某个桶,把单行竞争降为 10 行竞争。代价是可能出现"总量还有但某个桶空了",需要做桶间调剂或重试其他桶。
- 数据库层面的兜底约束:无论应用层怎么做,数据库的约束是最后一道防线——
CHECK (stock >= 0)(MySQL 8.0.16+ 真正生效)或把字段设为UNSIGNED(扣成负数直接报错)。应用逻辑会有 bug,数据库约束不会。
11. 大数据量图表的聚合优化
【核心答案】
核心原则是:屏幕只有几千个像素,渲染超过像素数的点是纯粹的浪费。优化按层次是:① 降采样/聚合 —— 把 100 万点降到 1~2 千点,算法选 LTTB(Largest Triangle Three Buckets),它按"面积最大"的准则保留视觉特征(峰值、拐点不丢失),远优于等间隔抽样(会丢极值)和均值聚合(会削平毛刺);② 分层预聚合 —— 后端按时间粒度(秒/分/时/天)预先算好多套数据,前端按当前缩放级别请求对应粒度,这是时序数据库(Prometheus、InfluxDB、ClickHouse)的标准做法;③ 按视窗请求 —— 只请求当前 X 轴范围内的数据,缩放/平移时增量拉取;④ 渲染层优化 —— ECharts 开 large: true、sampling: 'lttb'、progressive 渐进渲染,数据量再大就换 Canvas/WebGL 渲染器;⑤ 数据传输优化 —— 用 TypedArray + dataset 的 source 直传,避免几十万个 JS 对象的创建和 GC。
【展开】
为什么必须聚合
一块 1920px 宽的屏幕,图表区域约 1600px。
折线图上 100 万个点 → 平均每个像素列要画 625 个点。
其中 624 个是完全不可见的重复绘制。
代价:
- 传输:100 万点的 JSON 约 20~40MB
- 解析:JSON.parse 长任务,主线程冻结数秒
- 内存:100 万个 {x, y} 对象 ≈ 数百 MB
- 渲染:Canvas 逐点 lineTo 也要几百毫秒到数秒
收益:0(肉眼看不出区别)降采样算法对比
| 算法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 等间隔抽样 | 每 N 个取 1 个 | 最简单、O(n) | 丢失极值,尖峰可能完全消失(监控场景致命) |
| 均值/最大最小聚合 | 每桶取 avg / (min,max) 两点 | 保留范围 | avg 削平毛刺;min-max 会把折线画成"毛毛虫" |
| LTTB | 每桶选一个与前后点构成三角形面积最大的点 | 视觉保真度最高,保留峰谷和拐点,输出点数可控 | O(n),实现稍复杂;不保证保留绝对最值 |
| Douglas-Peucker | 递归剔除偏离直线小于阈值的点 | 几何精度好 | O(n log n)~O(n²),输出点数不可控 |
| M4 | 每像素列取 first/last/min/max 四个点 | 像素级无损(渲染结果与全量完全一致) | 点数 = 4 × 像素列数,比 LTTB 多;适合数据库端聚合(可直接写成 SQL) |
面试怎么答:默认推荐 LTTB(点数可控、视觉最好);如果要求"渲染结果与全量像素级一致"(金融、科学可视化)用 M4;监控告警场景要保证极值不丢,用 min-max 聚合或 LTTB + 单独标注极值点。
LTTB 完整实现
/**
* LTTB (Largest Triangle Three Buckets) 降采样。
* 思路:把数据分成 threshold-2 个桶,每桶选一个点,
* 使 [上一个选中点, 当前候选点, 下一桶的平均点] 构成的三角形面积最大。
* 面积大 = 该点让折线"拐得更明显" = 视觉上更重要。
* 首尾两点始终保留。时间复杂度 O(n)。
*
* @param {Array<[number, number]>} data 已按 x 升序排列的 [x, y] 数组
* @param {number} threshold 目标点数(建议取图表像素宽度的 1~2 倍)
* @returns {Array<[number, number]>}
*/
function lttb(data, threshold) {
const n = data.length;
if (threshold >= n || threshold <= 2) return data; // 无需降采样
const sampled = [];
// 每个桶的宽度(首尾各占一个位置,所以是 threshold - 2 个桶)
const bucketSize = (n - 2) / (threshold - 2);
let a = 0; // 上一个被选中的点的下标
sampled.push(data[0]); // 首点必留
for (let i = 0; i < threshold - 2; i++) {
// ---- 1) 计算「下一个桶」的平均点,作为三角形的第三个顶点 ----
let avgX = 0, avgY = 0;
let avgStart = Math.floor((i + 1) * bucketSize) + 1;
let avgEnd = Math.floor((i + 2) * bucketSize) + 1;
avgEnd = Math.min(avgEnd, n);
const avgLen = avgEnd - avgStart;
for (let j = avgStart; j < avgEnd; j++) {
avgX += data[j][0];
avgY += data[j][1];
}
if (avgLen > 0) { avgX /= avgLen; avgY /= avgLen; }
// ---- 2) 在「当前桶」里找面积最大的点 ----
const rangeStart = Math.floor(i * bucketSize) + 1;
const rangeEnd = Math.min(Math.floor((i + 1) * bucketSize) + 1, n);
const [ax, ay] = data[a];
let maxArea = -1;
let maxIdx = rangeStart;
for (let j = rangeStart; j < rangeEnd; j++) {
// 三角形面积 = |叉积| / 2。除以 2 是常数,比较时可省略
const area = Math.abs(
(ax - avgX) * (data[j][1] - ay) - (ax - data[j][0]) * (avgY - ay)
);
if (area > maxArea) { maxArea = area; maxIdx = j; }
}
sampled.push(data[maxIdx]);
a = maxIdx; // 选中的点成为下一轮的「前一个点」
}
sampled.push(data[n - 1]); // 尾点必留
return sampled;
}
// ---------- 使用 ----------
const chartWidth = chartDom.clientWidth; // 例如 1200px
// 目标点数取像素宽度的 1~2 倍:再多肉眼也分辨不出
const points = lttb(rawData, Math.min(rawData.length, chartWidth * 2));
chart.setOption({ series: [{ data: points }] });分层预聚合(后端)
-- ClickHouse:用物化视图预聚合成多个粒度
CREATE MATERIALIZED VIEW metrics_1m
ENGINE = AggregatingMergeTree()
ORDER BY (metric_id, ts)
AS SELECT
metric_id,
toStartOfMinute(ts) AS ts,
avgState(value) AS avg_v,
minState(value) AS min_v, -- ★ 必须保留 min/max,否则告警毛刺会被抹平
maxState(value) AS max_v,
countState() AS cnt
FROM metrics_raw
GROUP BY metric_id, ts;
-- 同理再建 metrics_1h、metrics_1d/**
* 按查询的时间跨度自动选择合适的聚合粒度,
* 保证返回的点数始终在 500~2000 这个「够看又不卡」的区间。
*/
function pickGranularity(startTs, endTs, targetPoints = 1000) {
const span = endTs - startTs; // ms
const idealBucket = span / targetPoints; // 理想的每桶时长
const levels = [
{ name: 'raw', ms: 1000 }, // 原始(秒级)
{ name: '1m', ms: 60_000 },
{ name: '5m', ms: 300_000 },
{ name: '1h', ms: 3_600_000 },
{ name: '1d', ms: 86_400_000 },
];
// 选第一个「桶时长 >= 理想值」的粒度,保证点数不超过目标
return (levels.find((l) => l.ms >= idealBucket) ?? levels[levels.length - 1]).name;
}
// 缩放/平移时按当前视窗重新请求
chart.on('dataZoom', debounce(async () => {
const [start, end] = getCurrentXRange(chart);
const level = pickGranularity(start, end);
// 只请求视窗范围 + 适当的左右余量(预取,让平移更顺滑)
const pad = (end - start) * 0.2;
const data = await fetchMetrics({ start: start - pad, end: end + pad, level });
chart.setOption({ series: [{ data }] });
}, 300));ECharts 侧的渲染优化
const chart = echarts.init(dom, null, {
// 数据量大时 canvas 远优于 svg(svg 会创建几十万个 DOM 节点)
renderer: 'canvas',
});
chart.setOption({
// ① dataset + TypedArray:避免创建几十万个 JS 对象,大幅降低内存和 GC 压力
dataset: {
source: {
time: new Float64Array(times),
value: new Float32Array(values),
},
},
// ② dataZoom 的 filterMode 决定缩放时的数据处理方式
dataZoom: [{
type: 'inside',
// 'filter':过滤掉视窗外的数据(性能好,但会影响其他轴的范围计算)
// 'none' :不过滤(数据量大时慢)
filterMode: 'filter',
}],
series: [{
type: 'line',
// ③ 开启大数据模式:ECharts 内部走优化过的绘制路径
large: true,
largeThreshold: 2000, // 超过 2000 点自动启用
// ④ 内置降采样:'lttb' | 'average' | 'max' | 'min' | 'sum'
// 注意它是在【渲染前】按像素宽度采样,不减少内存中的数据量,
// 所以【不能替代后端聚合】,只是渲染层的最后一道优化
sampling: 'lttb',
// ⑤ 渐进渲染:分批绘制,避免一次性绘制造成的长任务
progressive: 2000, // 每批绘制 2000 个图形
progressiveThreshold: 5000, // 超过 5000 才启用渐进
// ⑥ 关闭无用的视觉元素:symbol 是最大的性能杀手之一
showSymbol: false, // 不画数据点(几十万个圆是灾难)
animation: false, // 大数据下动画毫无意义且很卡
lineStyle: { width: 1 },
emphasis: { disabled: true }, // 关闭 hover 高亮的额外计算
}],
// ⑦ tooltip 用 axis 触发 + 节流,避免逐点命中检测
tooltip: {
trigger: 'axis',
axisPointer: { type: 'line' },
},
});渲染器选择:
- SVG:几千个元素以内、需要 DOM 交互/无障碍/矢量导出时用。
- Canvas(ECharts 默认):几万到几十万点的通用选择。
- WebGL(
echarts-gl、deck.gl、regl):百万点级别的散点图/热力图,把绘制交给 GPU。
完整的分层策略(可以直接作为答案框架)
数据源 (1 亿点)
↓ ① 后端预聚合(物化视图/时序库 downsampling)→ 多粒度存储
↓ ② 按视窗 + 粒度查询 → 返回 ~1000~2000 点
↓ ③ 传输:二进制/TypedArray(比 JSON 小 3~5 倍且无需 parse 建对象)
↓ ④ 前端 Worker 中做二次 LTTB(若返回点数仍多于像素数)
↓ ⑤ ECharts: large + sampling + progressive + showSymbol:false
↓ ⑥ 交互:dataZoom 防抖 + 按新视窗重新请求(换粒度)
渲染 (~1000 点,满帧)【追问 & 加分点】
- 聚合会不会"骗人"? 会,而且这是必须主动说明的风险。均值聚合会把 CPU 的瞬时 100% 尖峰抹平成 40%,导致告警场景漏判。所以监控类图表的聚合必须同时保留 min/max(画成带状区域 + 均值线),或者在聚合数据上单独标注极值点。"聚合方式必须匹配业务语义" —— 均值适合趋势观察,min/max 适合异常检测,sum 适合流量统计,p99 适合延迟分析。这是数据可视化的专业判断。
- 前端聚合还是后端聚合? 优先后端 —— 前端聚合意味着 100 万点已经传输并解析过了,最贵的两步(网络 + parse)已经付出,聚合只省了渲染。前端聚合只在两种情况有意义:① 数据已经在内存里(离线分析、已加载的数据集反复缩放);② 后端不支持聚合的历史遗留接口。如果一定要在前端做,放进 Web Worker,别阻塞主线程。
- 数据传输格式:100 万个点的 JSON 约 20~40MB。改用 二进制格式(
ArrayBuffer+ 自定义布局,或 Protobuf/Arrow)可以降到 8MB(两个 Float32 数组),且免去 JSON.parse 这个长任务,直接new Float32Array(buffer)零拷贝拿到数据。Apache Arrow 在数据分析类前端里已经是标配。 - 实时流式图表的额外考量:不断追加新点时不要每次都
setOption全量数据(那是 O(n) 的 diff)。用 ECharts 的appendData(专为流式设计)或维护一个环形缓冲区(固定容量,新数据覆盖最旧的),保证内存和渲染成本恒定。刷新频率用 rAF 节流到 ≤ 60fps,数据到达再快也不多渲染。
反向链接(0)
还没有笔记链接到这里。在 Obsidian 里用 [[笔记名]] 引用它,这里就会出现,关系图谱上也会多出一条连线。