29、虚拟滚动的实现
虚拟滚动(Virtual Scrolling)是一种优化长列表性能的技术,通过只渲染可视区域内的列表项,而非一次性渲染全部数据,从而减少 DOM 节点数量和重绘重排开销。以下是其核心实现思路和关键步骤:
核心原理
- 可视区域计算:确定用户当前能看到的列表范围(基于容器高度、滚动位置)。
- 数据截取:从完整数据中,只提取可视区域内及前后少量缓冲(避免快速滚动时出现空白)的数据项。
- 定位偏移:通过容器内的滚动偏移(
padding或transform),让截取的数据项在视觉上 “对齐” 原始列表的滚动位置。 - 动态更新:监听滚动事件,实时更新可视区域数据和偏移量 我们以一个固定高度的滚动容器为例。
1. 基本结构
首先,需要创建一个固定高度的容器,它内部有一个“撑开高度”的占位元素和一个“实际内容”的列表。
html
复制下载运行
<div class="virtual-container" @scroll="handleScroll">
<!-- 这个 div 用于撑开整个容器的高度,其高度等于所有项目的总高度 -->
<div class="scroll-placeholder" :style="{ height: totalHeight + 'px' }"></div>
<!-- 这个 div 是实际渲染内容的“视口”,通过 transform 定位到可视区域 -->
<div class="visible-items" :style="{ transform: `translateY(${offsetY}px)` }">
<div
v-for="item in visibleData"
:key="item.id"
class="item"
:style="{ height: itemHeight + 'px' }"
>
{{ item.content }}
</div>
</div>
</div>
css
复制下载
.virtual-container {
height: 400px; /* 固定高度,产生滚动条 */
overflow-y: auto; /* 必须允许垂直滚动 */
border: 1px solid #ccc;
position: relative; /* 为内部绝对定位的子元素提供基准 */
}
.scroll-placeholder {
/* 这个元素没有实际内容,只用于撑开高度 */
}
.visible-items {
position: absolute; /* 绝对定位,使其脱离文档流,可以自由移动 */
top: 0;
left: 0;
width: 100%;
}
2. 关键计算属性
我们需要几个关键的计算属性来驱动虚拟滚动:
itemHeight: 每个列表项的高度(假设固定高度,如果是可变高度则复杂很多)。totalHeight: 所有项目的总高度,用于撑开滚动条。totalHeight = totalCount * itemHeight。visibleCount: 可视区域内能容纳的项目数量。visibleCount = Math.ceil(containerHeight / itemHeight)。通常会多渲染一些项目(例如上下多渲染 5 个),以防止滚动时出现空白。startIndex: 可视区域起始项目的索引。endIndex: 可视区域结束项目的索引。offsetY: 内容区域的垂直偏移量,用于将visible-items定位到正确的位置。
3. 滚动事件处理
当容器滚动时,触发 handleScroll 方法,核心是计算新的 startIndex。
javascript
复制下载
// 假设是 Vue 3 的 Composition API 实现
import { ref, computed, onMounted } from 'vue';
// 模拟所有数据
const allData = ref([]);
for (let i = 0; i < 10000; i++) {
allData.value.push({ id: i, content: `Item ${i}` });
}
const containerHeight = ref(400); // 容器高度
const itemHeight = ref(50); // 每项高度
const scrollTop = ref(0); // 滚动条位置
// 计算总高度
const totalHeight = computed(() => allData.value.length * itemHeight.value);
// 计算开始索引
const startIndex = computed(() => {
// 计算滚动到了第几个项目
let index = Math.floor(scrollTop.value / itemHeight.value);
// 为了平滑滚动,可以让开始索引向上多取几个,例如减5
return Math.max(0, index - 5);
});
// 计算结束索引
const endIndex = computed(() => {
// 结束索引 = 开始索引 + 可视数量 + 下方缓冲数量
let count = Math.ceil(containerHeight.value / itemHeight.value);
let index = startIndex.value + count + 5; // 下方也多渲染5个
return Math.min(allData.value.length, index);
});
// 计算可视区域的数据
const visibleData = computed(() => {
return allData.value.slice(startIndex.value, endIndex.value);
});
// 计算内容区域的偏移量
const offsetY = computed(() => {
return startIndex.value * itemHeight.value;
});
// 滚动事件处理函数
const handleScroll = (event) => {
scrollTop.value = event.target.scrollTop;
};
完整代码示例 (Vue 3)
这是一个简化但可运行的例子:
vue
复制下载
<template>
<div
ref="containerRef"
class="virtual-container"
@scroll="handleScroll"
>
<div class="scroll-placeholder" :style="{ height: totalHeight + 'px' }"></div>
<div class="visible-items" :style="{ transform: `translateY(${offsetY}px)` }">
<div
v-for="item in visibleData"
:key="item.id"
class="item"
:style="{ height: itemHeight + 'px', lineHeight: itemHeight + 'px' }"
>
{{ item.content }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
// 生成模拟数据
const allData = ref([]);
const generateData = () => {
for (let i = 0; i < 10000; i++) {
allData.value.push({ id: i, content: `列表项 #${i + 1}` });
}
};
// 配置项
const containerRef = ref(null);
const containerHeight = ref(0);
const itemHeight = ref(60);
const scrollTop = ref(0);
// 计算属性
const totalHeight = computed(() => allData.value.length * itemHeight.value);
const startIndex = computed(() => {
const index = Math.floor(scrollTop.value / itemHeight.value);
return Math.max(0, index - 3); // 向上缓冲3个
});
const endIndex = computed(() => {
const visibleCount = Math.ceil(containerHeight.value / itemHeight.value);
const index = startIndex.value + visibleCount + 3; // 向下缓冲3个
return Math.min(allData.value.length, index);
});
const visibleData = computed(() => {
return allData.value.slice(startIndex.value, endIndex.value);
});
const offsetY = computed(() => {
return startIndex.value * itemHeight.value;
});
// 方法
const handleScroll = (event) => {
scrollTop.value = event.target.scrollTop;
};
// 生命周期
onMounted(() => {
generateData();
// 获取容器实际高度
if (containerRef.value) {
containerHeight.value = containerRef.value.clientHeight;
}
});
</script>
<style scoped>
.virtual-container {
height: 400px;
overflow-y: auto;
border: 1px solid #e0e0e0;
position: relative;
}
.scroll-placeholder {
/* 仅用于撑开高度 */
}
.visible-items {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.item {
padding: 0 16px;
border-bottom: 1px solid #f0f0f0;
box-sizing: border-box;
}
</style>
进阶挑战与优化
上面的例子是基础实现,在实际生产中还会遇到更复杂的情况:
-
可变高度(Dynamic Height)
- 这是虚拟滚动中最复杂的问题。
- 解决方案:需要维护一个“位置索引”数组,记录每个项目的位置和累计高度。在滚动时,使用二分查找在这个数组中找到对应的
startIndex。 - 或者使用第三方库,它们内部已经处理了这种复杂性。
-
渲染大量 DOM
- 即使只渲染可视区域,如果单个项目非常复杂(如图片、复杂样式),也可能导致性能问题。
- 解决方案:可以使用
requestAnimationFrame对滚动事件进行节流,或者使用Intersection Observer来管理元素的渲染和销毁。
-
快速滚动白屏
- 如果滚动过快,计算和渲染跟不上,会出现短暂白屏。
- 解决方案:增加缓冲区(我们上面已经做了),或者使用
will-change: transformCSS 属性来提示浏览器进行优化。
推荐使用现有库
由于自己完整实现一个健壮的、支持可变高度的虚拟滚动器非常复杂,强烈建议在生产环境中使用成熟的库:
-
Vue 环境:
-
React 环境:
react-window(最流行)react-virtualized(功能更全,但体积更大)
这些库经过了大量优化和测试,能帮你处理各种边界情况,是更可靠的选择。
30、什么是主线程阻塞
主线程阻塞(Main Thread Blocking)是浏览器渲染和交互过程中,因主线 thread(主线程)被耗时任务占用,导致无法及时响应用户操作或执行渲染工作的现象。这会直接造成页面卡顿、交互延迟、加载缓慢等问题。
为什么主线程容易被阻塞?
浏览器的主线程是一个 “单线程”,负责处理以下核心任务:
- HTML 解析:将 HTML 字符串转换为 DOM 树;
- CSS 解析:将 CSS 转换为 CSSOM(CSS 对象模型);
- 布局(Layout) :计算 DOM 元素的位置和大小(回流 / 重排);
- 绘制(Paint) :将元素绘制到图层(重绘);
- 合成(Composite) :将图层合并为最终屏幕图像;
- JavaScript 执行:处理所有 JS 代码(包括事件回调、定时器等);
- 用户交互响应:如点击、滚动、输入等事件处理。
由于这些任务都在主线程串行执行,一旦某个任务耗时过长(通常超过 50ms,人眼可感知延迟),后续任务就会被 “阻塞”,导致页面无法及时更新或响应用户操作。
常见的主线程阻塞场景
-
长时间运行的 JavaScript
- 复杂计算(如大数据循环、递归、数学运算);
- 未优化的 DOM 操作(如频繁修改 DOM 导致多次回流 / 重排);
- 同步 AJAX 请求(会阻塞主线程直到请求完成)。
例:一段耗时 300ms 的循环会直接导致页面 300ms 内无法响应点击或滚动。
-
大量 DOM 操作
- 一次性插入 / 删除 thousands 级别的 DOM 节点;
- 频繁读写 DOM 属性(如
offsetHeight、scrollTop),触发浏览器强制同步布局(Force Synchronous Layout)。
-
大型资源加载与解析
- 未压缩的大体积 JS/CSS 文件:解析和执行会占用主线程;
- 同步加载的阻塞型脚本(
<script>标签默认阻塞 HTML 解析)。
-
频繁的重排(Reflow)和重绘(Repaint)
- 频繁修改影响布局的样式(如
width、top、display)会触发重排; - 修改不影响布局但影响视觉的样式(如
background、color)会触发重绘,虽比重排轻,但频繁触发仍会阻塞。
- 频繁修改影响布局的样式(如
主线程阻塞的表现
- 页面加载时白屏或渲染缓慢;
- 滚动时卡顿、不流畅(掉帧,帧率低于 60fps);
- 点击按钮、输入文字等交互无即时响应;
- 动画(如过渡、变换)卡顿或停滞。
如何检测主线程阻塞?
-
浏览器开发者工具
- Performance 面板:录制页面操作,查看主线程任务时间线,耗时过长的任务会被标记为长任务(Long Task,超过 50ms);
- Lighthouse:生成性能报告,检测主线程阻塞时间、长任务数量等指标。
-
性能 API通过
LongTasks API监听长任务:javascript
运行
const observer = new PerformanceObserver((list) => { list.getEntries().forEach((entry) => { console.log('长任务耗时:', entry.duration, 'ms'); }); }); observer.observe({ entryTypes: ['longtask'] });
如何避免主线程阻塞?
核心思路:减少主线程工作量、拆分耗时任务、将任务移至其他线程。
-
优化 JavaScript 执行
- 拆分长任务:用
setTimeout、requestIdleCallback或Promise将耗时操作拆分为多个小任务,避免单次阻塞; - 避免同步阻塞:用异步 API(如
fetch替代同步 AJAX)、Web Workers 处理复杂计算(脱离主线程)。
- 拆分长任务:用
-
优化 DOM 操作
- 批量操作 DOM:通过文档片段(
DocumentFragment)一次性插入多个节点; - 减少重排 / 重绘:使用
transform、opacity等属性(仅触发合成,不触发重排 / 重绘);读写 DOM 属性时先读再写,避免强制同步布局。
- 批量操作 DOM:通过文档片段(
-
优化资源加载
- 压缩 JS/CSS:通过 Terser、CSSNano 等工具减小体积;
- 异步加载脚本:用
<script async>或<script defer>避免阻塞 HTML 解析; - 代码分割:通过 Webpack/Vite 拆分代码,按需加载(如路由懒加载)。
-
利用浏览器多线程能力
- Web Workers:处理数据计算、文件解析等耗时任务,不阻塞主线程;
- Service Workers:在后台处理缓存、网络请求,与主线程独立。
总结
主线程阻塞的本质是 “单线程资源争夺”—— 耗时任务占用了本应用于渲染和交互的时间。前端优化的核心目标之一,就是通过合理的代码设计和工具链配置,让主线程保持 “轻量”,确保页面流畅响应。
31、Web Workers和Service Workers使用和区别
Web Workers 和 Service Workers 都是浏览器提供的 JavaScript 多线程 / 后台运行机制,但它们的设计目标、使用场景和工作方式有显著差异。以下是两者的核心区别及使用方式:
一、Web Workers(网页工作线程)
核心目标:解决主线程阻塞问题,将耗时的计算任务转移到独立线程执行,避免影响页面渲染和交互。
使用场景
- 复杂数据计算(如大数据排序、统计、数学建模);
- 大型文件解析(如 CSV、JSON 解析);
- 耗时的加密 / 解密操作;
- 任何可能阻塞主线程的 CPU 密集型任务。
核心特性
-
独立线程:运行在与主线程分离的后台线程,完全独立于主线程,不会阻塞页面渲染。
-
通信限制:与主线程通过 消息传递 通信(
postMessage+onmessage),数据传递是 拷贝机制(结构化克隆,无法直接共享内存,除了Transferable对象)。 -
环境限制:
- 无法访问 DOM(无
window、document对象); - 无法使用
alert、confirm等浏览器 API; - 可以使用
fetch、setTimeout等部分 API; - 受同源策略限制,脚本文件必须与主线程页面同源。
- 无法访问 DOM(无
-
生命周期:与创建它的页面绑定,页面关闭则 Worker 终止。
使用方式
-
主线程代码(main.js) :
javascript
运行
// 创建 Worker 实例(指定 Worker 脚本路径) const worker = new Worker('worker.js'); // 向 Worker 发送数据 worker.postMessage({ type: 'calc', data: [1, 2, 3, 4] }); // 接收 Worker 返回的结果 worker.onmessage = (e) => { console.log('计算结果:', e.data); // 输出:10 }; // 监听错误 worker.onerror = (err) => { console.error('Worker 错误:', err); }; // 终止 Worker(必要时手动关闭) // worker.terminate(); -
Worker 线程代码(worker.js) :
javascript
运行
// 接收主线程消息 self.onmessage = (e) => { if (e.data.type === 'calc') { // 执行耗时计算(此处示例为求和) const sum = e.data.data.reduce((a, b) => a + b, 0); // 向主线程发送结果 self.postMessage(sum); } }; // 关闭自身(可选) // self.close();
二、Service Workers(服务工作线程)
核心目标:作为浏览器与网络之间的代理,实现离线缓存、请求拦截、后台同步等功能,是 PWA(渐进式 Web 应用)的核心技术。
使用场景
- 离线访问(缓存静态资源,让页面在无网络时可打开);
- 请求拦截与缓存策略(如优先使用缓存,缓存未命中再请求网络);
- 后台同步(如网络恢复后自动同步本地待发数据);
- 推送通知(配合 Push API 实现服务器向客户端推送消息);
- 资源预加载(提前缓存可能需要的资源)。
核心特性
-
独立于页面:运行在浏览器后台,与页面生命周期完全分离(页面关闭后仍可运行)。
-
代理角色:可以拦截页面发出的所有网络请求(
fetch、XMLHttpRequest),自主决定请求的处理方式(返回缓存、请求网络、修改响应等)。 -
持久化缓存:通过
CacheStorageAPI 持久化缓存资源,缓存数据独立于浏览器普通缓存,需手动管理。 -
环境限制:
- 必须运行在 HTTPS 环境(本地开发
localhost除外); - 完全异步,无法使用同步 API(如
XMLHttpRequest同步请求); - 无 DOM 访问权限,无法直接操作页面元素;
- 生命周期包含:安装(install)、激活(activate)、闲置(idle)、销毁(terminate)等阶段。
- 必须运行在 HTTPS 环境(本地开发
使用方式
-
注册 Service Worker(主线程代码) :
javascript
运行
// 检查浏览器是否支持 Service Worker if ('serviceWorker' in navigator) { window.addEventListener('load', async () => { try { // 注册 Service Worker 脚本(scope 可选,限制作用范围) const registration = await navigator.serviceWorker.register('/sw.js', { scope: '/' // 表示控制全站资源 }); console.log('Service Worker 注册成功'); } catch (err) { console.error('Service Worker 注册失败:', err); } }); } -
Service Worker 脚本(sw.js) :
javascript
运行
// 定义需要缓存的静态资源 const CACHE_NAME = 'my-cache-v1'; const ASSETS_TO_CACHE = ['/', '/index.html', '/styles.css', '/app.js', '/logo.png']; // 1. 安装阶段:缓存静态资源 self.addEventListener('install', (event) => { // 等待缓存完成后再完成安装 event.waitUntil( caches.open(CACHE_NAME) .then((cache) => cache.addAll(ASSETS_TO_CACHE)) .then(() => self.skipWaiting()) // 强制激活新 SW(跳过等待) ); }); // 2. 激活阶段:清理旧缓存 self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then((cacheNames) => { return Promise.all( cacheNames.filter((name) => name !== CACHE_NAME) .map((name) => caches.delete(name)) // 删除旧版本缓存 ); }).then(() => self.clients.claim()) // 立即控制所有打开的页面 ); }); // 3. 拦截请求并处理(缓存优先策略) self.addEventListener('fetch', (event) => { event.respondWith( // 先从缓存中查找请求 caches.match(event.request) .then((response) => { // 缓存命中则返回缓存,否则请求网络 return response || fetch(event.request); }) ); });
三、核心区别对比
| 维度 | Web Workers | Service Workers |
|---|---|---|
| 设计目标 | 解决主线程阻塞(CPU 密集型任务) | 网络代理与离线缓存(PWA 核心) |
| 生命周期 | 与页面绑定,页面关闭则终止 | 独立于页面,后台长期运行(直至销毁) |
| 通信方式 | 与主线程通过消息传递(拷贝数据) | 与页面通过 postMessage 通信,可拦截请求 |
| 网络能力 | 可发起请求,但无拦截能力 | 可拦截所有请求,控制缓存与网络交互 |
| DOM 访问 | 无 | 无 |
| 运行环境 | 同源即可,HTTP/HTTPS 均可 | 必须 HTTPS(localhost 除外) |
| 典型场景 | 大数据计算、文件解析 | 离线缓存、请求拦截、后台同步、推送通知 |
总结
- Web Workers 是 “主线程的助手”,专注于分担 CPU 密集型任务,避免页面卡顿;
- Service Workers 是 “网络代理”,专注于离线体验和请求管理,是 PWA 的核心;
- 两者均运行在独立线程,无法直接操作 DOM,且都通过消息机制与主线程通信,但应用场景完全不同。