题干与适用场景
一个产品动态信息流最多有 100,000 条逻辑数据。游标接口每次返回 50 条。文字换行、图片加载或详情展开后,单张卡片高度可能在 48 到 240 像素之间。用户可以筛选信息流、展开卡片并填写行内回复草稿。键盘和读屏用户需要连续浏览,焦点与位置信息都不能丢失。
请设计渲染区间、动态高度测量、增量加载、状态归属、滚动锚定、焦点行为、无障碍语义和验证方案,并说明何时应改用更简单的分页或普通列表。
100,000 条、每页 50 条和高度范围都是面试假设,不是通用产品指标。这道题适合高级前端、UI 基础设施和 Web 性能岗位,核心考察浏览器渲染与交互设计,因此归类为 frontend。
面试官考察点
第一,候选人能否拆开四个容易混淆的职责:分页决定客户端已经加载哪些记录;虚拟化决定哪些已加载记录拥有 DOM 节点;稳定身份决定状态属于哪条业务记录;无障碍设计决定局部 DOM 如何表达完整逻辑集合。四者需要分别设计。
第二,候选人能否从行高约束推导区间算法。等高行可以直接通过偏移量计算;动态高度需要估算值、实测值、累计偏移,以及估算修正后的补偿策略。高质量回答应指出“视口上方内容变高导致滚动跳动”,并保住一个逻辑锚点。
第三,候选人能否避免状态串行。被复用的可视槽位不代表业务记录。草稿、选中、展开和待完成变更都需要稳定记录 ID,并由能跨越行卸载的状态容器持有。排序、筛选和过期分页响应也不能让数据或状态继续绑定旧索引。
第四,候选人能否保住无障碍能力。只挂载可见卡片后,大部分逻辑集合都不在 DOM 中。高质量回答会保留原生或信息流语义,公开逻辑位置与集合大小,正确维护忙碌状态,保留含焦点的卡片,并提供可操作的加载方式。
第五,候选人能否验证行为,而不是只承诺“滚动流畅”。回答应先定义目标设备与场景,再测量挂载节点数、帧与长任务、布局偏移或锚点误差、重复请求、内存、焦点保留以及读屏输出。
回答前需要澄清的问题
- 普通列表是否真的达不到性能目标? 如果产品最多展示几十条简单数据,普通渲染或分页更易维护,也更容易保障无障碍。先测量,再增加窗口化。
- 行高是固定、有限几档,还是任意变化? 固定高度允许常数时间区间计算;动态高度需要估算与测量。无限制的嵌入内容或延迟媒体会加大滚动修正难度,可能需要约束卡片布局。
- 接口是否返回总数,还是只有下一页游标? 已知总数可以表达完整逻辑集合大小;未知总数会改变滚动条预期和无障碍元数据。游标分页也意味着未加载索引不能随机访问。
- 用户是否必须跳到任意数据? 大量行尚未测量时,按索引跳转只能先到估算位置。按记录 ID 跳转可能需要服务端定位接口或另一种分页能力。
- 哪些行状态必须跨卸载保留? 临时悬停可以丢弃;回复草稿、选中、展开、校验错误和乐观更新通常应放在行组件外,并以记录 ID 为键。
- 适用哪种键盘模型和语义? 阅读型信息流、可选择列表框和可编辑网格拥有不同角色与键盘约定。交互方式决定语义模式,视觉布局不能代替这个判断。
- 数据变化时应遵循什么滚动约定? 筛选后通常回到顶部;向前插入历史数据时通常保留当前第一条可见记录;实时追加时只有用户原本就在末尾才自动跟随。
- 性能验收目标是什么? 需要说清目标设备、数据形态、滚动动作和预算。桌面环境中的短占位行不能代表移动设备上含图片与控件的信息流。
30 秒回答框架
“我会把数据加载、虚拟化和行状态分开。游标加载器每次拉取 50 条,并负责去重;虚拟化器只渲染可见区间和少量缓冲行,把这些行定位在代表已加载数据总高度的容器里。固定行高直接用偏移量计算,动态行高先估算、布局后测量,再更新累计偏移。视口上方高度变化时,我会保留第一条可见记录及其行内偏移,防止画面跳动。每行用记录 ID,草稿和选中状态放到行外。活动信息流会使用 feed 与 article 语义,表达逻辑位置、集合大小和忙碌状态,并且绝不卸载含焦点的卡片。我会测试快速滚动、图片与展开高度变化、筛选竞态、前插和追加、键盘与读屏导航、节点数量、长任务、内存和锚点误差。如果普通渲染已达标,我会保留更简单的列表。”
分步骤深入解答
第一步:区分已加载记录与已挂载行
数据层维护明确状态:有序记录 ID、按 ID 索引的记录、下一页游标、请求状态和是否还有更多数据。虚拟化器接收有序的已加载 ID 与滚动容器,不负责游标、重试、草稿或服务端变更。
游标接口初始只允许访问 50 条记录。虚拟化已加载前缀,并随着新页到达增加逻辑高度。除非接口支持按索引访问,或返回带稳定位置的占位数据,否则不能假设第 73,421 条已在本地。可见区间末端接近已加载数量时可以触发请求,但加载器仍负责合并请求和识别数据末尾。
这层分离能防住两类常见错误:无限加载不断保留旧页 DOM 节点;虚拟化器每次重算小区间时重复发起同一请求。
第二步:先推导固定行高区间,再处理动态高度
等高行可以通过 scrollTop、视口高度和行高得到第一条与最后一条可见索引。缓冲区向两端扩展,再限制在已加载数量内。一个具有完整高度的内部画布产生滚动条,挂载行根据逻辑偏移定位。
function getFixedRange({
scrollTop,
viewportHeight,
rowHeight,
loadedCount,
overscanRows,
}) {
const visibleStart = Math.floor(scrollTop / rowHeight);
const visibleEnd = Math.ceil(
(scrollTop + viewportHeight) / rowHeight,
);
return {
start: Math.max(0, visibleStart - overscanRows),
end: Math.min(loadedCount, visibleEnd + overscanRows),
totalHeight: loadedCount * rowHeight,
};
}渲染从 start 开始、到 end 之前结束的左闭右开区间。索引为 i 的行从 i * rowHeight 开始。根据滚动容器更新区间,让框架批量渲染,并避免在一次滚动回调中反复交替读取布局和写入样式。
设计能够约束为统一行高或少量已知规格时,这个方案更合适。它可预测、成本低,也容易恢复滚动位置。不能因为组件库提供动态测量,就默认引入动态测量。
第三步:测量动态高度并维护累计偏移
动态行高把乘法换成尺寸模型。未测量行先使用符合真实卡片设计的估算高度。实测高度按记录 ID 保存,不能绑定到被复用的 DOM 槽位。某条记录的起始偏移等于此前尺寸之和,因此查找第一条可见记录需要在累计偏移中搜索。
简单前缀数组容易解释,但一行高度变化后可能需要更新很多后续偏移。成熟虚拟化库或带索引的前缀和结构可以降低搜索与更新成本。面试回答的关键不变量是:每个渲染行的逻辑起点都来自同一份尺寸存储,画布总高度等于所有实测或估算尺寸之和。
行完成布局后,用 ResizeObserver 或虚拟化库的测量钩子读取尺寸。能提前获得图片宽高时先预留空间;否则图片加载会产生一次新测量。忽略尺寸未变化的通知,批量应用修正,并在行卸载时解除观察。容器宽度、字体指标、展开状态或内容变化可能改变高度时,需要重新测量。
估算出现误差是正常情况。更新偏移时没有明确“用户应继续看到哪一处”的策略,才会造成失败。
第四步:保留逻辑滚动锚点
应用尺寸变更前,记录第一条可见记录的 ID,以及其顶部到视口顶部的距离。更新测量值与累计偏移后,找到这条记录的新起点,再调整滚动偏移,让同一个行内位置留在原来的视觉位置。
锚点上方某一行增加 30 像素时,滚动偏移通常也要增加 30 像素;锚点下方变化则无需补偿。用户正在拖动滚动条,或者产品明确导航到另一条数据时,应根据交互约定延迟或跳过补偿。
浏览器会为普通布局变化执行滚动锚定。虚拟化器常使用合成画布与绝对定位行,浏览器的原生行为未必能保住所需逻辑记录。必须实测两者组合。一个容器里的补偿由浏览器或虚拟化器其中一方负责;手动锚定作为权威策略时,应在该容器关闭原生锚定,避免重复修正。
向前插入历史记录也使用同一规则:先捕获当前锚点,插入并测量新记录,再恢复锚点。实时追加记录时,只有用户原本处于产品定义的“最新位置”阈值内才跟随末尾;其余情况显示“有新动态”操作,不主动移动视口。
第五步:根据单行工作量和滚动行为调整缓冲区
缓冲区通过预先挂载视口外的数据来隐藏渲染延迟。过少可能在快速滚动时露出空白;过多会重新引入虚拟化原本要消除的 DOM、布局、内存和副作用成本。动态高度场景中,按像素设置缓冲距离通常比固定行数稳定,因为五条紧凑行和五条展开行代表的距离与工作量不同。
从较小值开始,在目标设备上记录性能轨迹,再根据证据调整。需要考虑滚动方向与速度、单行渲染成本、图片解码和框架更新延迟。缓冲区不能替代昂贵行内容的缓存、媒体尺寸预留、过期副作用取消或同步布局读取削减。
维护严格的节点数不变量:挂载行只包括可见区间、缓冲区和少量明确固定的行,例如当前焦点行。内存分析应证明离开该集合的旧行组件、观察器和事件监听器能够被回收。
第六步:加载分页时处理竞态与重复记录
从逻辑区间末端触发加载,避免依赖可能被回收的哨兵节点。每个游标最多同时存在一个请求。记录每次请求对应的游标,按稳定 ID 对返回记录去重,并在服务端表示没有下一游标时停止。
筛选或排序会创建新的查询代次。能取消旧请求时就取消,并拒绝代次不匹配的延迟响应。清空有序 ID 和游标,根据产品约定重置滚动位置,同时清理依赖布局或内容的测量值。新顺序复用旧偏移会把正确高度贴到错误位置。
加载占位需要稳定的逻辑含义。一个“下一页加载中”占位不能冒充 50 条无障碍记录。如果接口给出可按索引访问的已知总量,产品也决定使用逐行占位,才能为它们分配稳定位置并在替换时保持身份。始终展示重试与末尾状态;请求失败后留下永久空白尾部,会让用户误以为已经到底。
第七步:让记录身份和重要状态脱离复用行
框架 key 与虚拟化器 item key 都使用记录的稳定 ID。分页前插、筛选重排或去重发生时,数组索引不再安全。一个复用槽位此刻显示记录 A,之后可能显示记录 B;绑定在槽位上的状态会把草稿、勾选或乐观结果带到另一条记录。
把需要保留的 UI 状态存成以记录 ID 为键的映射:草稿文本、展开、选中、校验状态和待完成变更。行挂载后读取自己的状态片段,并通过能跨卸载存在的持有者写回。只有明确允许丢弃的展示细节才适合放在行内局部状态。
也不能永久保存所有临时值。产品丢弃一个查询时清理相关状态,限制草稿保留范围,并让乐观状态与服务端结果对账。目标是按业务身份归属状态,不是建立无限增长的客户端缓存。
第八步:把无障碍设计纳入区间算法
根据交互选择语义。阅读型信息流可以为容器使用 feed 语义,每张卡片使用带可访问名称的 article 语义。每个挂载 article 表达从 1 开始的逻辑位置;总数已知时表达逻辑总量,未知时使用该模式规定的未知集合大小。多步更新期间将信息流标记为忙碌,完成或失败后都必须清除该状态。
不能卸载包含 DOM 焦点的元素。即使焦点记录落在普通缓冲区外,也将它加入挂载区间;或者在回收前通过明确导航动作移动焦点。键盘命令前往下一条逻辑记录时,先确保它已加载并挂载,滚入视口后再聚焦。自定义按键需要清楚说明,并提供可靠方式移动到信息流之前和之后。
虚拟化还会影响页内查找、浏览器文字选择、打印和读屏浏览,因为未挂载内容不在 DOM 中。需要逐项确认产品需求。搜索可能需要服务端控件;打印可以使用独立分页视图;显式“加载更多”按钮可以作为自动加载之外的可操作路径。
最后必须用纯键盘和真实读屏软件测试。检查属性只能证明标记存在,不能证明焦点、播报、加载与导航能够协同工作。
第九步:验证故障矩阵,不只看一次顺滑滚动
准备确定性测试数据,覆盖短长文本、带尺寸的延迟图片、展开、缺失媒体和重复 ID。执行慢速与快速滚轮滚动、类似触控的惯性滚动、拖动滚动条、窗口缩放、页面缩放、字体加载、请求进行中的筛选、前插、追加、加载失败、重试和数据末尾。
记录全过程的挂载行与 DOM 节点数量。用性能轨迹检查帧、长任务、脚本、布局、绘制和意外的重复测量。滚动经过多页后检查峰值内存与保留内存。对比高度变化前后的顶部逻辑锚点,并断言游标与记录 ID 没有重复。
交互测试中,把焦点放入一行,再用指针和键盘滚动、展开其上方的行、加载下一页并切换筛选条件。确认焦点落点,以及草稿是否仍属于正确 ID。使用读屏软件确认卡片名称、逻辑位置与集合大小、忙碌状态切换、前后导航、重试和末尾状态。
验收目标应来自产品与目标设备。报告实测轨迹和不变量是否被破坏,不能宣称虚拟化天然保证某个帧率。
高质量示范回答
“我会把信息流分成三层。数据层持有有序 ID、记录、下一游标、请求代次和去重;虚拟化器只接收已加载 ID,负责可见区间、测量和定位;草稿、展开、选中与变更状态放在以记录 ID 为键的映射里,因此行卸载不会删除或移动它们。
固定行高时,可见区间直接通过偏移量计算,画布高度等于已加载数量乘以行高。这些卡片会在 48 到 240 像素之间变化,所以我会先估算、再测量挂载卡片,并维护累计偏移。第一条可见记录及其行内偏移构成滚动锚点。锚点上方测量值变化时,我按差值修正滚动偏移。一个容器只选择一种补偿责任方,避免原生与手动滚动锚定同时调整。
渲染区间包含少量经过测量的缓冲距离和当前焦点卡片。区间末端接近已加载数量时,加载器只请求一次下一游标。筛选变化会开启新代次,尽可能取消旧请求,拒绝延迟结果,并重置顺序与测量值。记录 ID 作为 item key,索引不持有任何业务状态。
无障碍方面,这个活动流使用 feed 与 article 语义。挂载 article 表达逻辑位置与已知总量,分页插入期间信息流表达忙碌状态,随后清除。我不会卸载含焦点的 article。键盘导航会先加载并挂载下一条逻辑记录,再移动焦点,同时保留明确的重试、末尾和加载更多路径。
我会先确认普通渲染确实达不到目标预算,再用固定数据组合测试动态高度、延迟媒体、展开、快速滚动、缩放、筛选竞态、前插、追加和网络失败。记录挂载节点数、长任务、布局与绘制、保留内存、锚点误差、重复请求与 ID、草稿身份、键盘焦点和读屏输出。如果分页普通列表满足同样要求,我会选择它,因为正确性与无障碍故障面更小。”
常见错误
- 把无限加载当成虚拟化 → 旧分页仍不断增加 DOM 节点,滚动越远性能越差 → 让挂载区间独立于已加载数据,并保持有界。
- 使用数组索引作为 item key → 前插、筛选和去重会把草稿与选中状态移动到别的记录 → 按稳定记录 ID 管理行和外部状态。
- 把平均高度当成精确高度 → 偏移误差不断积累,最终出现空白区间或跳动 → 测量动态行,并只维护一份累计尺寸模型。
- 修改测量值时没有锚点 → 视口上方内容改变后,用户正在阅读的位置被推走 → 保留第一条可见记录及其行内偏移。
- 让原生与手动锚定同时补偿 → 视口可能移动两次 → 实测容器行为,并只指定一个补偿责任方。
- 固定多渲染大量缓冲行 → 展开卡片重新带来沉重 DOM 与布局工作 → 按目标设备上的像素距离和渲染成本调节。
- 每次区间更新都发请求 → 重复渲染会请求同一游标并交错合并结果 → 按游标合并请求,并拒绝过期查询代次。
- 把草稿留在行组件中 → 卸载会删除草稿,槽位复用还可能串到别的记录 → 把重要状态提升到按 ID 管理的持有者。
- 卸载焦点所在行 → 键盘与辅助技术上下文突然消失 → 固定焦点项,或在回收前明确移动焦点。
- 只播报已挂载子集的位置 → 读屏用户得到错误的集合大小和顺序 → 表达逻辑位置与已知或未知的逻辑集合大小。
- 设置忙碌状态后不清除 → 辅助技术可能一直看不到已完成更新 → 所有成功和失败路径都清除忙碌状态。
- 只优化帧率 → 焦点、草稿、竞态、内存与滚动位置仍可能错误 → 同时验证性能与交互不变量。
追问及应对
追问一:如果每行都严格是 56 像素高,方案如何简化?
删除动态测量及其修正路径。根据 56 像素直接计算区间与偏移,通过乘法得到已加载总高度,用索引加行内偏移恢复位置。稳定 ID、加载竞态、焦点和无障碍处理仍需保留;固定高度只简化几何计算,不解决身份与交互。先验证性能,无需引入约束已经不需要的前缀和结构。
追问二:游标接口无法返回总数时怎么办?
只虚拟化已加载前缀,新页到达后增加画布高度。不能虚构未加载索引或最终滚动条长度。使用信息流模式规定的未知集合大小,播报加载与末尾状态,并保留显式加载更多或重试控件。服务端没有按索引查询、记录定位或稳定估算约定时,任意按索引跳转就不可用。
追问三:用户正在中间阅读,系统向前插入历史记录,如何避免跳动?
插入前记录第一条可见记录 ID 及其相对视口偏移。加入历史 ID,估算或测量新行并重算累计偏移,然后修正滚动偏移,让捕获记录回到原视觉位置。分页边界按 ID 去重。媒体稍后加载继续改变高度时,对锚点上方差值应用同一规则。
追问四:用户拖动滚动条后,焦点卡片距离当前位置已有 5,000 条,还要一直挂载吗?
先定义交互约定。固定一个焦点卡片的 DOM 成本有界,但焦点留在离视口很远的元素上也会让用户困惑。明确的滚动条跳转过程中,可以把焦点移动到信息流容器或新位置的可见卡片并给出播报,再解除旧卡片固定。普通指针滚动或测量变化不能悄悄销毁焦点。最终行为需要与键盘和读屏用户共同验证。
追问五:产品要求浏览器页内查找并打印全部 100,000 条,单个虚拟 DOM 能满足吗?
局部 DOM 无法让浏览器查找尚未挂载的文字,也无法把它们作为普通页面内容打印。把它们拆成独立需求:提供服务端搜索,找到记录后跳转并挂载;提供带明确上限的分页导出或打印视图。临时挂载 100,000 张交互卡片只会冻结页面。
追问六:如何测试虚拟化器,避免只依赖截图?
先用确定输入验证纯固定区间和累计偏移不变量,再在真实浏览器做集成场景。断言挂载 ID 覆盖可见区间与缓冲区,总尺寸符合尺寸模型,测量后逻辑锚点误差处于约定范围,每个游标只请求一次,草稿在重排后仍跟随 ID。还要加入键盘与读屏检查,因为 DOM 几何断言无法验证焦点和播报。