安得智联2

22 阅读12分钟

29、虚拟滚动的实现

虚拟滚动(Virtual Scrolling)是一种优化长列表性能的技术,通过只渲染可视区域内的列表项,而非一次性渲染全部数据,从而减少 DOM 节点数量和重绘重排开销。以下是其核心实现思路和关键步骤:

核心原理

  1. 可视区域计算:确定用户当前能看到的列表范围(基于容器高度、滚动位置)。
  2. 数据截取:从完整数据中,只提取可视区域内及前后少量缓冲(避免快速滚动时出现空白)的数据项。
  3. 定位偏移:通过容器内的滚动偏移(padding 或 transform),让截取的数据项在视觉上 “对齐” 原始列表的滚动位置。
  4. 动态更新:监听滚动事件,实时更新可视区域数据和偏移量 我们以一个固定高度的滚动容器为例。

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>

进阶挑战与优化

上面的例子是基础实现,在实际生产中还会遇到更复杂的情况:

  1. 可变高度(Dynamic Height)

    • 这是虚拟滚动中最复杂的问题。
    • 解决方案:需要维护一个“位置索引”数组,记录每个项目的位置和累计高度。在滚动时,使用二分查找在这个数组中找到对应的 startIndex
    • 或者使用第三方库,它们内部已经处理了这种复杂性。
  2. 渲染大量 DOM

    • 即使只渲染可视区域,如果单个项目非常复杂(如图片、复杂样式),也可能导致性能问题。
    • 解决方案:可以使用 requestAnimationFrame 对滚动事件进行节流,或者使用 Intersection Observer 来管理元素的渲染和销毁。
  3. 快速滚动白屏

    • 如果滚动过快,计算和渲染跟不上,会出现短暂白屏。
    • 解决方案:增加缓冲区(我们上面已经做了),或者使用 will-change: transform CSS 属性来提示浏览器进行优化。

推荐使用现有库

由于自己完整实现一个健壮的、支持可变高度的虚拟滚动器非常复杂,强烈建议在生产环境中使用成熟的库:

这些库经过了大量优化和测试,能帮你处理各种边界情况,是更可靠的选择。

30、什么是主线程阻塞

主线程阻塞(Main Thread Blocking)是浏览器渲染和交互过程中,因主线 thread(主线程)被耗时任务占用,导致无法及时响应用户操作或执行渲染工作的现象。这会直接造成页面卡顿、交互延迟、加载缓慢等问题。

为什么主线程容易被阻塞?

浏览器的主线程是一个 “单线程”,负责处理以下核心任务:

  • HTML 解析:将 HTML 字符串转换为 DOM 树;
  • CSS 解析:将 CSS 转换为 CSSOM(CSS 对象模型);
  • 布局(Layout) :计算 DOM 元素的位置和大小(回流 / 重排);
  • 绘制(Paint) :将元素绘制到图层(重绘);
  • 合成(Composite) :将图层合并为最终屏幕图像;
  • JavaScript 执行:处理所有 JS 代码(包括事件回调、定时器等);
  • 用户交互响应:如点击、滚动、输入等事件处理。

由于这些任务都在主线程串行执行,一旦某个任务耗时过长(通常超过 50ms,人眼可感知延迟),后续任务就会被 “阻塞”,导致页面无法及时更新或响应用户操作。

常见的主线程阻塞场景

  1. 长时间运行的 JavaScript

    • 复杂计算(如大数据循环、递归、数学运算);
    • 未优化的 DOM 操作(如频繁修改 DOM 导致多次回流 / 重排);
    • 同步 AJAX 请求(会阻塞主线程直到请求完成)。

    例:一段耗时 300ms 的循环会直接导致页面 300ms 内无法响应点击或滚动。

  2. 大量 DOM 操作

    • 一次性插入 / 删除 thousands 级别的 DOM 节点;
    • 频繁读写 DOM 属性(如 offsetHeightscrollTop),触发浏览器强制同步布局(Force Synchronous Layout)。
  3. 大型资源加载与解析

    • 未压缩的大体积 JS/CSS 文件:解析和执行会占用主线程;
    • 同步加载的阻塞型脚本(<script> 标签默认阻塞 HTML 解析)。
  4. 频繁的重排(Reflow)和重绘(Repaint)

    • 频繁修改影响布局的样式(如 widthtopdisplay)会触发重排;
    • 修改不影响布局但影响视觉的样式(如 backgroundcolor)会触发重绘,虽比重排轻,但频繁触发仍会阻塞。

主线程阻塞的表现

  • 页面加载时白屏或渲染缓慢;
  • 滚动时卡顿、不流畅(掉帧,帧率低于 60fps);
  • 点击按钮、输入文字等交互无即时响应;
  • 动画(如过渡、变换)卡顿或停滞。

如何检测主线程阻塞?

  1. 浏览器开发者工具

    • Performance 面板:录制页面操作,查看主线程任务时间线,耗时过长的任务会被标记为长任务(Long Task,超过 50ms);
    • Lighthouse:生成性能报告,检测主线程阻塞时间、长任务数量等指标。
  2. 性能 API通过 LongTasks API 监听长任务:

    javascript

    运行

    const observer = new PerformanceObserver((list) => {
      list.getEntries().forEach((entry) => {
        console.log('长任务耗时:', entry.duration, 'ms');
      });
    });
    observer.observe({ entryTypes: ['longtask'] });
    

如何避免主线程阻塞?

核心思路:减少主线程工作量拆分耗时任务将任务移至其他线程

  1. 优化 JavaScript 执行

    • 拆分长任务:用 setTimeoutrequestIdleCallback 或 Promise 将耗时操作拆分为多个小任务,避免单次阻塞;
    • 避免同步阻塞:用异步 API(如 fetch 替代同步 AJAX)、Web Workers 处理复杂计算(脱离主线程)。
  2. 优化 DOM 操作

    • 批量操作 DOM:通过文档片段(DocumentFragment)一次性插入多个节点;
    • 减少重排 / 重绘:使用 transformopacity 等属性(仅触发合成,不触发重排 / 重绘);读写 DOM 属性时先读再写,避免强制同步布局。
  3. 优化资源加载

    • 压缩 JS/CSS:通过 Terser、CSSNano 等工具减小体积;
    • 异步加载脚本:用 <script async> 或 <script defer> 避免阻塞 HTML 解析;
    • 代码分割:通过 Webpack/Vite 拆分代码,按需加载(如路由懒加载)。
  4. 利用浏览器多线程能力

    • Web Workers:处理数据计算、文件解析等耗时任务,不阻塞主线程;
    • Service Workers:在后台处理缓存、网络请求,与主线程独立。

总结

主线程阻塞的本质是 “单线程资源争夺”—— 耗时任务占用了本应用于渲染和交互的时间。前端优化的核心目标之一,就是通过合理的代码设计和工具链配置,让主线程保持 “轻量”,确保页面流畅响应。

31、Web Workers和Service Workers使用和区别

Web Workers 和 Service Workers 都是浏览器提供的 JavaScript 多线程 / 后台运行机制,但它们的设计目标、使用场景和工作方式有显著差异。以下是两者的核心区别及使用方式:

一、Web Workers(网页工作线程)

核心目标:解决主线程阻塞问题,将耗时的计算任务转移到独立线程执行,避免影响页面渲染和交互。

使用场景

  • 复杂数据计算(如大数据排序、统计、数学建模);
  • 大型文件解析(如 CSV、JSON 解析);
  • 耗时的加密 / 解密操作;
  • 任何可能阻塞主线程的 CPU 密集型任务。

核心特性

  1. 独立线程:运行在与主线程分离的后台线程,完全独立于主线程,不会阻塞页面渲染。

  2. 通信限制:与主线程通过 消息传递 通信(postMessage + onmessage),数据传递是 拷贝机制(结构化克隆,无法直接共享内存,除了 Transferable 对象)。

  3. 环境限制

    • 无法访问 DOM(无 windowdocument 对象);
    • 无法使用 alertconfirm 等浏览器 API;
    • 可以使用 fetchsetTimeout 等部分 API;
    • 受同源策略限制,脚本文件必须与主线程页面同源。
  4. 生命周期:与创建它的页面绑定,页面关闭则 Worker 终止。

使用方式

  1. 主线程代码(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();
    
  2. 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 实现服务器向客户端推送消息);
  • 资源预加载(提前缓存可能需要的资源)。

核心特性

  1. 独立于页面:运行在浏览器后台,与页面生命周期完全分离(页面关闭后仍可运行)。

  2. 代理角色:可以拦截页面发出的所有网络请求(fetchXMLHttpRequest),自主决定请求的处理方式(返回缓存、请求网络、修改响应等)。

  3. 持久化缓存:通过 CacheStorage API 持久化缓存资源,缓存数据独立于浏览器普通缓存,需手动管理。

  4. 环境限制

    • 必须运行在 HTTPS 环境(本地开发 localhost 除外);
    • 完全异步,无法使用同步 API(如 XMLHttpRequest 同步请求);
    • 无 DOM 访问权限,无法直接操作页面元素;
    • 生命周期包含:安装(install)、激活(activate)、闲置(idle)、销毁(terminate)等阶段。

使用方式

  1. 注册 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);
        }
      });
    }
    
  2. 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 WorkersService Workers
设计目标解决主线程阻塞(CPU 密集型任务)网络代理与离线缓存(PWA 核心)
生命周期与页面绑定,页面关闭则终止独立于页面,后台长期运行(直至销毁)
通信方式与主线程通过消息传递(拷贝数据)与页面通过 postMessage 通信,可拦截请求
网络能力可发起请求,但无拦截能力可拦截所有请求,控制缓存与网络交互
DOM 访问
运行环境同源即可,HTTP/HTTPS 均可必须 HTTPS(localhost 除外)
典型场景大数据计算、文件解析离线缓存、请求拦截、后台同步、推送通知

总结

  • Web Workers 是 “主线程的助手”,专注于分担 CPU 密集型任务,避免页面卡顿;
  • Service Workers 是 “网络代理”,专注于离线体验和请求管理,是 PWA 的核心;
  • 两者均运行在独立线程,无法直接操作 DOM,且都通过消息机制与主线程通信,但应用场景完全不同。