obsidian-computer
前端识记前端面试

08-scenarios

面向大厂 P6 前端面试的标准答案集。题目来源:字节 / 腾讯 / 快手真实面经。 每题结构:【核心答案】(30 秒口述版)→ 【展开】(原理 + 可运行代码)→ 【追问 & 加分点】。

最后更新 2026年8月29日

高频场景设计题

面向大厂 P6 前端面试的标准答案集。题目来源:字节 / 腾讯 / 快手真实面经。 每题结构:【核心答案】(30 秒口述版)→ 【展开】(原理 + 可运行代码)→ 【追问 & 加分点】。

目录


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>
  );
}

不定高的三个必须提到的坑:

  1. 滚动条长度会跳变:预估高度和真实高度差异大时,total 不断被修正,滚动条长度和位置都会跳。缓解手段:预估值尽量接近真实均值(可以用已实测项的移动平均动态更新预估值);只在必要时更新 total。
  2. 向上滚动时的位置漂移:向上滚动进入未测量区域,测量后高度变化会让当前视口内容"跳走"。标准修法是锚定(scroll anchoring):记住当前视口顶部那一项和它的偏移,修正高度后主动调整 scrollTop 补偿高度差。
  3. 异步内容改变高度:项内有图片、折叠面板、富文本,高度会在渲染后再变。用 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)

【追问 & 加分点】

  1. 为什么不用 content-visibility: auto 代替虚拟列表? content-visibility: auto 让浏览器跳过屏幕外元素的渲染工作(样式、布局、绘制),零 JS,是很好的轻量方案。但它不减少 DOM 节点数量——10 万个节点仍然存在于 DOM 树里,内存占用、querySelectorAll、事件冒泡、React reconciliation 的成本都还在。所以:几百到几千项用 content-visibility 够了;上万项必须虚拟化。
  2. 横向虚拟化 / 二维虚拟化:宽表格(几百列)需要同时对行和列虚拟化,思路一致但要处理固定列(fixed columns)——固定列不参与横向虚拟化,单独渲染并用 position: sticky 或独立的同步滚动层。
  3. 无障碍与可搜索性代价:虚拟列表里 Ctrl+F 搜不到未渲染的内容,屏幕阅读器也读不到。需要用 aria-setsize / aria-posinset 告知总数和位置,并提供额外的搜索入口。这是必须承认的 trade-off。
  4. 成熟方案:@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)

游标的两个额外好处(面试要说出来):

  1. 稳定性:翻页过程中有新数据插入,OFFSET 会导致某些记录重复出现或被跳过;游标基于排序键定位,不受插入影响。
  2. 可加密:把 (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 总量不变。有虚拟化就不需要分片——两者是替代关系而非叠加关系。分片适用于"必须全量渲染"的场景(比如打印、导出预览)。

【追问 & 加分点】

  1. 虚拟列表 + 无限滚动 + 不定高,三者叠加最难的点是什么? 是"新数据到达时的位置稳定性"。追加数据会改变 total 高度;如果同时有未测量项被测量,scrollTop 对应的内容会漂移。工程解法是 scroll anchoring:追加前记录锚点项(视口顶部第一项)的 id 和它相对视口的偏移,追加并重算位置后,把 scrollTop 调整回锚点应在的位置。
  2. IntersectionObserver 的哨兵为什么有时不触发? 常见三个原因:① 哨兵在内部滚动容器里但 root 没设为该容器(默认 root 是视口,容器滚动不产生 intersection 变化);② 哨兵 height: 0 且父元素 overflow: hidden 导致几何上永远不可见;③ 首屏数据不足一屏,哨兵一开始就在视口内 —— 这时 IO 会立即回调一次(这是正确行为,能自动补齐首屏,但要保证 loading 锁生效,否则会连续触发多次)。
  3. 要不要做"上拉加载"的双向无限滚动?(聊天记录场景)向上加载会改变上方内容高度,必须在插入前记录 scrollHeight,插入后用 scrollTop += (newScrollHeight - oldScrollHeight) 补偿,否则视口会跳。现代浏览器的 overflow-anchor: auto(默认开启)能自动处理部分情况,但跨浏览器不可靠,还是显式补偿更稳。
  4. 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>
  );
}

几个必须处理的工程细节

  1. table-layout: fixed + <colgroup> 固定列宽。默认的 auto 布局会根据全部内容计算列宽,块动态加载时列宽会不断跳变,视觉上非常糟;而且 auto 布局本身对大表格性能很差。
  2. 占位块的高度必须准。合并单元格的行高常常不一致(合并格内容多会撑高)。可以让后端在块元数据里返回该块的预估高度,或用已加载块的均值动态修正。
  3. 块回收(可选):块很多时,把远离视口的块卸载回占位状态(保留实测高度),控制 DOM 总量。回收要保守(保留前后各 2~3 块),否则来回滚动会频繁请求。
  4. 表头:<thead> + position: sticky; top: 0,表头也可能有多级合并(colspan),逻辑同上但表头一次性全加载。
  5. borderCollapse: collapse 与 sticky 的兼容问题:collapse 模式下 sticky 表头的边框会消失(边框归属于被滚走的单元格)。常见解法是改用 border-collapse: separate; border-spacing: 0 + 给单元格加 box-shadow 模拟边框。

【追问 & 加分点】

  1. 如果合并组超大(一个格子跨 1000 行)怎么办? 块的大小会被迫拉到 1000 行,失去懒加载意义。此时应该放弃原生 table 的 rowspan,改用绝对定位的自绘表格(div + position: absolute + 每个单元格显式 top/left/width/height),这样单元格与行解耦,可以只渲染视口内的部分——即使一个单元格逻辑上跨 1000 行,也只渲染它落在视口里的那一段(配合 position: sticky 让合并格的文字始终可见)。AG Grid、Handsontable 这类专业表格都是这个路子。
  2. 排序/筛选后合并关系怎么办? 合并通常基于"相同值连续出现"这一语义(比如按部门合并)。排序改变行顺序后,合并区间必须重新计算,不能沿用旧的元数据。所以合并元数据应该是由排序后的数据派生出来的(后端在返回排序结果时一并计算),而不是固定挂在原始数据上。
  3. 导出/打印:虚拟化的表格只有视口内的 DOM,直接 window.print() 或截 DOM 导出会丢数据。必须走"从数据层导出"的路径(后端生成 Excel,或前端用 SheetJS 从完整数据生成,合并信息用 !merges 字段表达)。
  4. 测试要点:合并边界的临界情况最容易出 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];
}

【追问 & 加分点】

  1. 什么情况下仍然必须用 scroll? 需要逐帧的连续位置时:视差滚动、滚动进度条、根据 scrollTop 连续插值的动画、滚动方向判断。这些用 IO 做不到(IO 只给状态变化点)。更现代的替代是 CSS Scroll-Driven Animations(animation-timeline: scroll() / view()),完全在合成线程运行,零 JS,性能最好,但浏览器支持面还在铺开。
  2. IO 不是"完全免费"的:它仍然依赖布局信息,观察上万个元素时浏览器内部计算也有成本;而且回调是在主线程执行的——回调里做重活一样会卡。IO 省掉的是"手写位置计算和高频触发",不是"所有开销"。
  3. 常见坑:① 忘了 unobserve/disconnect 造成内存泄漏(尤其 React 里元素卸载后 observer 还持有引用);② display: none 的元素永远不 intersect;③ 元素初始就在视口内时 IO 会立即触发一次回调(这是规范行为,用来告知初始状态),无限滚动场景要有并发锁;④ root 必须是目标元素的祖先,不是任意元素;⑤ 在跨域 iframe 里 rootBounds 为 null。
  4. 相关的观察者家族: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>
  );
}

三、骨架屏的三条设计原则(面试要点,不只是写代码)

  1. 结构对齐:骨架的块位置、数量、尺寸要和真实内容尽量一致。骨架是三行文字、内容是五行,切换时页面会跳 —— 骨架屏反而制造了 CLS。
  2. 不要过度动画:微光(shimmer)动画要柔和,动画本身也消耗合成资源;低端机上大面积 shimmer 反而掉帧。可以在 prefers-reduced-motion 下关闭。
  3. 有最短展示时长:如果数据 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 的做法,工程上最省。

【追问 & 加分点】

  1. 原生 loading="lazy" 和 IntersectionObserver 怎么选? 原生零成本、浏览器可以做更聪明的调度(考虑网络状况),首选;但提前量不可控(各浏览器的触发距离不一,且随网络状况变化)、无法做骨架状态管理、无法做失败重试。生产上常见是两者叠加:img 上加 loading="lazy" 兜底,外层用 IO 控制骨架和状态机。首屏 LCP 图片绝对不能加 loading="lazy",那会推迟请求、直接拉高 LCP。
  2. decoding="async" 的作用:图片解码(decode)本身是有成本的,大图解码可能占用几十毫秒主线程。decoding="async" 让浏览器在后台线程解码,不阻塞渲染。想更精细可以用 img.decode() API 手动等解码完成再插入 DOM,彻底消除"插入瞬间的卡顿"。
  3. ErrorBoundary 抓不到什么? 事件处理函数里的错误、异步代码(setTimeout、Promise)里的错误、SSR 期间的错误、以及 ErrorBoundary 自身的错误。这些要靠 window.onerror / unhandledrejection 全局兜底。React 19 提供了 onCaughtError / onUncaughtError 根级回调,是更好的统一上报入口。
  4. 弱网/离线场景:可以读 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 小时后可能少走十几秒到几十秒;后台标签页下差得更多

三个原因逐条说清:

  1. HTML 规范只保证"不早于":setInterval(fn, 1000) 的语义是"至少 1000ms 后把 fn 加入任务队列",实际执行时间取决于主线程什么时候空闲。
  2. 误差累积:每次的迟到量不会被后续补偿,而是继续叠加。
  3. 后台节流: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)。要补充三点才完整:

  1. rAF 的频率跟随显示器刷新率,不是固定 60Hz。120Hz 屏(iPhone ProMotion、高刷安卓、MacBook Pro)上约 8.3ms,144Hz 上约 6.9ms。所以绝不能假设 rAF 一定是 16.7ms 来做时间计算——这正是"用时间戳差值而不是帧计数"的另一个理由。
  2. rAF 的回调参数就是一个高精度时间戳(DOMHighResTimeStamp,相对 performance.timeOrigin),可以直接用它做增量计算:requestAnimationFrame((t) => { const dt = t - lastT; ... })。
  3. 实际间隔可能远大于 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);
  }
};

【追问 & 加分点】

  1. 服务端时间校正为什么必要? 客户端 Date.now() 依赖用户系统时间,用户改时间就能让秒杀倒计时归零。标准做法:页面加载时从后端拿一次服务器时间戳(或直接读响应的 Date 头),算出 offset = serverTime - clientTime,之后所有计算用 Date.now() + offset。更严谨的还要扣掉网络往返延迟的一半((t4-t1-(t3-t2))/2,NTP 的思路)。但最终判定必须以服务端为准——前端倒计时只是展示,到点后的下单请求由服务端校验时间。
  2. 多个倒计时怎么办?(商品列表里每个卡片一个倒计时)绝不能开 N 个 rAF/interval。正确做法是一个全局调度器(单例 ticker),维护一个订阅列表,一个 rAF 循环里统一计算并批量通知,把 N 个循环降到 1 个。这是列表页性能的关键,也是很能体现工程素养的回答。
  3. 毫秒级显示的场景:需要显示到 10ms(如竞拍、抢票)时,interval 设为 0 让它每帧更新。此时要特别注意渲染成本:用 textContent 直接改 DOM 而不是走 React setState;数字用等宽字体(font-variant-numeric: tabular-nums)避免宽度跳动导致布局抖动。
  4. 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 + CSS scroll-behavior: auto,或者用 flex 反转(flex-direction: column-reverse)让浏览器天然保持在底部——后者零 JS,但会带来选中文本方向、滚动条位置等细节问题,要权衡。

问题五:长会话的 DOM 累积

几百条消息 + 每条几千字,DOM 节点数轻松上万。解法:消息列表虚拟化(用 react-virtuoso,它对"底部对齐 + 动态高度 + 流式追加"的支持最好),或者对远离视口的历史消息做 content-visibility: auto。

问题六:代码块高亮的开销

highlight.js / shiki 对长代码块高亮是同步的重计算。流式过程中代码块每帧都在变长,每帧重新高亮会直接卡死。解法:流式期间代码块只渲染纯文本 <pre>,等这个代码块闭合(遇到结束的 ```)后才做一次高亮。这与下一题的"块级边界检测"是同一个思路。

【追问 & 加分点】

  1. 打字速度怎么定? 固定 charsPerFrame 会有两个问题:模型快时字积压(说完很久还在蹦字)、模型慢时字断断续续。更好的做法是自适应速度:根据队列长度动态调整(上面代码里的 q.length > maxQueue 就是简化版),或者用 PID 式的平滑控制,目标是让队列长度稳定在一个小范围内。用户体感上,中文约 20~40 字/秒比较舒服。
  2. 中断(Stop 按钮)要做几件事? ① AbortController.abort() 断开 fetch 流;② 停止 rAF 消费循环;③ 把队列里已收到但还没显示的内容立即 flush 出来(用户已经"付费"拿到的 token 不应该丢);④ 通知后端停止生成(很多后端需要显式的取消请求来释放资源,仅断开连接不一定能及时释放)。
  3. text 每次都是新字符串,React 的 diff 成本如何? 纯文本节点的 diff 很便宜(字符串比较 + nodeValue 赋值),真正贵的是 Markdown 解析成 HTML 后的元素树 diff。所以增量块缓存的收益远大于优化文本本身。如果用 dangerouslySetInnerHTML,整块 innerHTML 重设会丢失用户的文本选中状态——这也是要做块级缓存的原因之一。
  4. 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 标签要不要类似缓冲

要,而且要求更严格。 三个层面:

  1. 结构完整性:半个标签 <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;
}
  1. 安全性:模型输出的 HTML 是不可信内容,可能包含 <script>、<img onerror=...>、javascript: 协议的链接。必须用 DOMPurify 净化,且要注意——净化必须在内容完整后做。对半截 HTML 做净化,DOMPurify 的解析和你最终看到的可能不一致,存在被绕过的风险(比如 <img src=x onerror= 这个片段在净化时看起来无害,拼上后续内容后就是攻击)。

  2. 最佳实践:绝大多数 AI 对话产品直接禁用原始 HTML(marked 的配置里过滤掉 HTML 块,或 DOMPurify 白名单只放行有限的标签)。只允许 Markdown 语法,需要富展示时用自定义组件协议(比如模型输出 ```chart {...} 代码块,前端识别后渲染成图表组件)。这样既安全又可控——能主动提出这一点是很好的加分。

【追问 & 加分点】

  1. SSE vs WebSocket vs 长轮询怎么选? SSE:单向(服务器→客户端)、基于 HTTP、自动重连(原生 EventSource)、天然穿透代理和 CDN、实现简单,AI 流式输出的标准选择。WebSocket:全双工、二进制友好、但需要独立协议升级、代理/防火墙兼容性差、要自己实现心跳和重连、服务端连接成本高。AI 对话是天然的"请求-流式响应"模式,不需要全双工,所以 SSE 更合适。长轮询是没有 SSE 时的兜底。
  2. SSE 的坑:① HTTP/1.1 下浏览器对同域名有 6 个并发连接限制,开多个 SSE 会把连接占满导致其他请求阻塞——HTTP/2 下没这个问题(多路复用),生产必须上 H2;② Nginx 等反向代理默认会缓冲响应,导致流式失效(表现为"等全部生成完才一次性显示"),必须设 proxy_buffering off(或响应头带 X-Accel-Buffering: no);③ 压缩中间件(gzip)也会缓冲,流式响应要禁用压缩或用支持流式的压缩;④ 某些移动网络的运营商代理会缓冲,需要定期发注释行(: heartbeat\n\n)保活并冲刷缓冲区。
  3. 为什么 TextDecoder 必须传 {stream: true}? 网络 chunk 的边界是字节边界,一个中文字符(UTF-8 3 字节)完全可能被切成 2+1。不加 stream: true 时,decoder 会把不完整的字节序列替换成 � 并丢弃状态;加了之后它会把残留字节留到下次 decode 拼接。这是手写流式解析里最容易漏、上线才发现的 bug(英文测试全过,中文偶发乱码)。
  4. 错误处理与恢复:流中断时(网络断开、服务端 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

优势(面试要点):

  1. 业务代码零侵入 —— 不用在每个写操作后手写删缓存,杜绝"新同事忘了删"这类问题。
  2. 可靠性由 MQ 保证 —— 消费失败自动重试,最终一定会删掉,比在业务代码里 try-catch 可靠得多。
  3. 天然处理"绕过应用直接改库"的情况 —— 运营在后台直接改 DB、数据修复脚本、其他服务写库,binlog 都能捕获。
  4. 顺序性 —— 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;   // ★ 返回略旧的数据,但绝不阻塞、绝不击穿
}

这是"用一点点新鲜度换取零击穿"的经典权衡,秒杀、首页推荐这类场景的标准做法。

【追问 & 加分点】

  1. 主从延迟带来的坑:更新走主库、读走从库时,删缓存后立即有读请求打到还没同步的从库,会把旧数据回填进缓存。解法:① 延迟双删的延迟时间必须 > 主从同步延迟;② 写后一段时间内该 key 的读强制走主库("写后读一致性");③ 用 binlog 方案(binlog 本身就在从库同步之后)。这是延迟双删真正存在的主要理由,很多人只背"防并发读回填",答出主从延迟会明显加分。
  2. 延迟双删的延迟时间怎么定? 不能拍脑袋。应该 = 一次读请求的 P99 耗时 + 主从同步的 P99 延迟 + 安全余量。可以通过监控实际测出来。太短没效果,太长会让第二次删除失去意义(期间已有大量请求读到旧值)。而且双删必须用可靠的延迟队列,进程内 setTimeout 在实例重启/扩缩容时会丢。
  3. BFF 层的特殊性:BFF 缓存的通常是聚合后的视图数据(一个页面所需的多个上游数据拼装结果),失效更复杂——上游任何一个变了都要失效。常见做法是分层缓存:底层按实体缓存(user:1、product:9),BFF 层按视图缓存但 TTL 很短(几十秒),或者用版本号/标签失效(给聚合缓存打上依赖实体的 tag,实体变更时按 tag 批量失效)。
  4. 多级缓存的一致性更难:本地缓存(进程内 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);
}

代价:从同步变异步,用户拿不到即时结果(需要轮询/推送订单状态);分区内串行,单分区吞吐受限;需要处理消息幂等、积压和顺序保证。

秒杀的完整套路(值得完整说出来):

  1. 前端限流:按钮防重复点击、验证码打散请求。
  2. 网关限流:令牌桶/漏桶,把明显超出库存的流量直接挡掉。
  3. Redis 预扣减:用 Lua 脚本原子地检查并扣减 Redis 里的库存(DECR + 判断),绝大部分请求在这一层就被拒绝,不打到数据库。
  4. MQ 削峰:预扣成功的请求投递到 MQ,异步落库创建订单。
  5. 数据库最终校验: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)

【追问 & 加分点】

  1. Redis 分布式锁的正确姿势:SET key value NX PX ttl(一条命令完成加锁+设过期,不能拆成 SETNX + EXPIRE,中间宕机会导致锁永不释放);value 用唯一 token,释放时用 Lua 脚本校验 token 再删(否则可能删掉别人的锁——自己的锁超时释放后被别人获取,自己执行完又去删);业务耗时可能超过 TTL 时需要看门狗自动续期(Redisson 的实现)。必须承认的一点:Redis 主从架构下,主节点宕机且锁还没同步到从节点时会丢锁,理论上不安全。RedLock 算法有争议(Martin Kleppmann 与 antirez 的著名论战),要绝对安全应该用 etcd/ZooKeeper 这类基于共识算法的方案,或者干脆让最终的一致性由数据库唯一约束保证。
  2. 幂等性是并发问题的另一半:网络重试、MQ 重投、用户重复点击都会导致同一操作执行多次。标准做法是唯一业务 id + 数据库唯一索引(重复插入直接报唯一键冲突,天然幂等),或 Token 机制(下单前先领一个一次性 token,提交时校验并消费)。只解决并发不解决幂等,系统照样出问题——能主动提这一点很加分。
  3. 热点行的性能问题:所有请求都更新同一行(比如一个爆款商品的库存),即使用了原子 UPDATE,行锁也会让所有请求排队,QPS 上不去。解法是分段库存(库存分桶):把 1000 库存拆成 10 个 100 的桶,请求随机路由到某个桶,把单行竞争降为 10 行竞争。代价是可能出现"总量还有但某个桶空了",需要做桶间调剂或重试其他桶。
  4. 数据库层面的兜底约束:无论应用层怎么做,数据库的约束是最后一道防线——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 点,满帧)

【追问 & 加分点】

  1. 聚合会不会"骗人"? 会,而且这是必须主动说明的风险。均值聚合会把 CPU 的瞬时 100% 尖峰抹平成 40%,导致告警场景漏判。所以监控类图表的聚合必须同时保留 min/max(画成带状区域 + 均值线),或者在聚合数据上单独标注极值点。"聚合方式必须匹配业务语义" —— 均值适合趋势观察,min/max 适合异常检测,sum 适合流量统计,p99 适合延迟分析。这是数据可视化的专业判断。
  2. 前端聚合还是后端聚合? 优先后端 —— 前端聚合意味着 100 万点已经传输并解析过了,最贵的两步(网络 + parse)已经付出,聚合只省了渲染。前端聚合只在两种情况有意义:① 数据已经在内存里(离线分析、已加载的数据集反复缩放);② 后端不支持聚合的历史遗留接口。如果一定要在前端做,放进 Web Worker,别阻塞主线程。
  3. 数据传输格式:100 万个点的 JSON 约 20~40MB。改用 二进制格式(ArrayBuffer + 自定义布局,或 Protobuf/Arrow)可以降到 8MB(两个 Float32 数组),且免去 JSON.parse 这个长任务,直接 new Float32Array(buffer) 零拷贝拿到数据。Apache Arrow 在数据分析类前端里已经是标配。
  4. 实时流式图表的额外考量:不断追加新点时不要每次都 setOption 全量数据(那是 O(n) 的 diff)。用 ECharts 的 appendData(专为流式设计)或维护一个环形缓冲区(固定容量,新数据覆盖最旧的),保证内存和渲染成本恒定。刷新频率用 rAF 节流到 ≤ 60fps,数据到达再快也不多渲染。

反向链接(0)

还没有笔记链接到这里。在 Obsidian 里用 [[笔记名]] 引用它,这里就会出现,关系图谱上也会多出一条连线。

评论

关系图谱

01-js-core02-browser03-network04-css-html05-react06-vue07-engineering08-scenarios09-handwritten-js10-algorithms11-ai-frontend前端识记前端面试

On this page

高频场景设计题目录1. 虚拟列表完整原理与实现【核心答案】【展开】原理拆解定高虚拟列表(原生 JS,完整可运行)React 版(定高,含 key 复用)不定高的处理:预估高度 + 实测校正【追问 & 加分点】2. 表格懒加载 / 滚动分页【核心答案】【展开】基础实现(React Hook,可直接用)后端配合:游标分页常规性能要点10 万条下拉加载仍卡顿:三层叠加优化【追问 & 加分点】3. 合并单元格表格的滚动懒加载【核心答案】【展开】为什么行不能随便切数据结构设计合并元数据的预处理(从原始 merges 生成 hidden 标记)渲染逻辑几个必须处理的工程细节【追问 & 加分点】4. 为什么用 IntersectionObserver 替代 scroll 监听【核心答案】【展开】对比scroll 方案的问题(代码对比)API 关键参数三个常见用法React 封装【追问 & 加分点】5. 骨架屏 + 视口懒加载 + 错误兜底的加载体系【核心答案】【展开】整体设计一、图片加载体系二、组件懒加载体系(含 chunk 加载失败重试)三、骨架屏的三条设计原则(面试要点,不只是写代码)四、骨架屏的生成方式(可选加分)【追问 & 加分点】6. 精确倒计时组件【核心答案】【展开】setInterval 为什么漂移正确实现:时间戳差值 + rAF 驱动React 版本追问:60 帧下 rAF 的间隔是多少追问:页面切后台怎么办【追问 & 加分点】7. AI 对话打字机效果【核心答案】【展开】整体数据流核心实现(React)接上 SSE高频流式场景的性能问题与优化【追问 & 加分点】8. 手写 SSE 解析器与流式 Markdown 渲染【核心答案】【展开】SSE 协议格式完整解析器(fetch + ReadableStream)流式 Markdown:块级边界检测HTML 标签要不要类似缓冲【追问 & 加分点】9. BFF 层 Redis 缓存与数据库一致性【核心答案】【展开】四种写策略的对比Cache Aside 标准实现更强的方案:订阅 binlog一致性方案的选型三个经典缓存问题【追问 & 加分点】10. 并发操作数据库同一字段【核心答案】【展开】问题的本质:Lost Update方案零:原子更新(能用就用它)方案一:乐观锁(版本号 / CAS)方案二:悲观锁方案三:队列串行化方案选型【追问 & 加分点】11. 大数据量图表的聚合优化【核心答案】【展开】为什么必须聚合降采样算法对比LTTB 完整实现分层预聚合(后端)ECharts 侧的渲染优化完整的分层策略(可以直接作为答案框架)【追问 & 加分点】