1. 为什么你的全选按钮会“杀死”浏览器?

我猜很多用 Vue 和 Element UI 的朋友都遇到过这个场景:一个权限配置页面,或者一个商品筛选器,需要展示几百上千个选项让用户勾选。你兴冲冲地用上了 el-checkbox-groupel-checkbox,功能很快就实现了,看起来一切完美。

直到测试同事或者某个“手欠”的用户,点了一下那个“全选”按钮。

页面瞬间卡住,浏览器标签页的标题旁边转起了小圈圈,整个界面像是被冻住了一样,好几秒甚至十几秒后才缓过来。如果你胆敢在滚动条上快速滑动,那种卡顿感更是让人抓狂。这可不是什么高端用户体验,用户只会觉得:“这系统真烂。”

问题出在哪?根源在于 DOM 渲染开销。无论是 Vue 还是 React,当你有上千个 el-checkbox 组件实例被同时渲染到页面上时,每一个都是真实的 DOM 节点。浏览器需要为每一个节点计算样式、布局、绘制,这会消耗巨量的内存和 CPU 资源。全选操作触发所有复选框状态更新,会引发大规模的响应式数据监听和视图重渲染,这个计算量是灾难性的。

我接手过一个后台管理系统,里面的角色权限配置有将近 3000 个细粒度权限点。最初的实现就是直接渲染 3000 个多选框,结果点全选直接导致页面假死超过 5 秒,Chrome 的内存占用飙升。这显然是不可接受的。传统的解决方案,比如分页,会打断用户的操作连续性,尤其是在需要跨页对比选择的时候,体验很割裂。

所以,我们需要一种方法,既能展示海量数据,又能保持操作的流畅性。这就是 虚拟滚动(Virtual Scrolling) 登场的时候了。它的核心思想非常聪明:只渲染用户看得见的那部分。无论你总共有 1 万条还是 10 万条数据,在屏幕上同时出现的可能就十几二十条。通过动态计算和占位,让滚动条的行为和渲染全部数据时一样,但实际创建的 DOM 节点数量极少。这样一来,性能瓶颈就被彻底打破了。

2. 虚拟滚动的核心原理:一场“视觉魔术”

理解虚拟滚动,你可以把它想象成剧院舞台的“换景”。舞台的窗口(可视区域)就那么大,但后面可能准备了几百个不同的布景(数据项)。虚拟滚动就是那个高效的舞台总监,它不会把几百个布景同时堆在台上,而是根据剧本(滚动位置),精准地把即将进入窗口的布景搬上来,把移出窗口的布景撤下去。

从技术层面拆解,一个基础的虚拟滚动列表需要这几个关键角色:

  1. 容器 (Container): 一个固定高度的 div,它决定了用户能看到的“窗口”有多大,并带有 overflow-y: auto 样式来产生滚动条。
  2. 滚动内容 (Scroll Content): 容器内的一个子 div,它的高度等于 所有数据项的总高度数据总数 * 单项高度)。这个元素本身没有实际内容,它唯一的作用就是“撑开”滚动条,让滚动条的长度比例和真实数据量匹配。
  3. 可视区域 (Viewport): 在滚动内容内部,还有一个真正承载内容的区域。这个区域的高度和容器一样,但它会通过 transform: translateY 在滚动内容内部上下移动。
  4. 渲染列表 (Rendered List): 位于可视区域内,是实际渲染的、用户当前能看到的少数几个数据项。

整个魔术的关键在于 动态计算。当用户滚动时,我们监听容器的 scrollTop 值。用这个值除以单项高度,就能知道当前应该从第几条数据开始显示(startIndex)。然后,根据容器高度和单项高度,计算出当前窗口最多能放下多少条数据(visibleCount)。最后,我们从总数据中切片,取出 data.slice(startIndex, startIndex + visibleCount) 这部分数据,渲染到可视区域内,同时通过 translateY 将可视区域定位到 startIndex * itemHeight 的位置。

这样,无论怎么滚动,DOM 中存在的节点数基本恒定在 visibleCount + 2 个左右(可能多一两个做缓冲),性能自然就上去了。把这个原理和 el-checkbox-group 结合,就是我们解决大数据量全选卡顿问题的技术基石。

3. 手把手封装一个带虚拟滚动的 el-checkbox 组件

光讲原理不够过瘾,我们来点实际的。下面我就基于 Element UI,带你一步步封装一个真正可用的 VirtualCheckbox 组件。我会把关键代码和设计思路都讲清楚,你可以直接拿去用,或者根据自己项目调整。

首先,我们规划一下组件的 Props,这是组件的对外接口:

props: {
  // 核心数据源,格式必须是 [{label: '名称', value: '唯一值'}, ...]
  options: {
    type: Array,
    default: () => []
  },
  // 初始就选中的值数组
  defaultChecked: {
    type: Array,
    default: () => []
  },
  // 可视区域的高度(像素),决定了组件显示多大
  viewH: {
    type: Number,
    default: 300
  },
  // 可视区域的宽度
  viewW: {
    type: Number,
    default: 250
  },
  // 每个选项行的高度(像素),必须和实际CSS高度一致!
  itemH: {
    type: Number,
    default: 32
  },
  // 是否显示顶部的搜索框
  showSearch: {
    type: Boolean,
    default: true
  }
}

组件内部的数据和计算属性是驱动虚拟滚动的引擎:

data() {
  return {
    keyword: '', // 搜索关键词
    checkedList: [], // 当前选中的值数组,与 el-checkbox-group 的 v-model 绑定
    searchOptions: [], // 经过搜索过滤后的总数据
    viewOptions: [], // 当前实际渲染在可视区域内的数据切片
    offsetY: 0, // 可视区域在滚动内容内的垂直偏移量(translateY的值)
    checkAll: false, // 全选按钮的选中状态
    isIndeterminate: false // 全选按钮的不确定状态(部分选中时)
  };
},
computed: {
  // 计算滚动内容的总高度,用于撑开滚动条
  scrollH() {
    return this.searchOptions.length * this.itemH;
  },
  // 计算可视区域内能容纳多少条数据
  visibleCount() {
    // 多加1-2条作为缓冲,防止滚动时出现空白
    return Math.ceil(this.viewH / this.itemH) + 2;
  },
  // 根据当前滚动位置,计算起始索引
  startIndex() {
    return Math.floor(this.offsetY / this.itemH);
  }
}

接下来是模板部分,这是视觉和交互的骨架:

<template>
  <div class="virtual-checkbox">
    <!-- 搜索框 -->
    <el-input
      v-if="showSearch"
      v-model="keyword"
      prefix-icon="el-icon-search"
      placeholder="输入关键词搜索..."
      @input="handleSearchInput"
      clearable
    >
    </el-input>

    <!-- 全选按钮 -->
    <el-checkbox
      v-model="checkAll"
      :indeterminate="isIndeterminate"
      @change="handleCheckAllChange"
      class="check-all"
    >
      全选
    </el-checkbox>

    <!-- 虚拟滚动容器 -->
    <div
      ref="scrollContainer"
      class="scroll-container"
      :style="{ width: viewW + 'px', height: viewH + 'px' }"
      @scroll="handleScroll"
    >
      <!-- 这个div用于撑开滚动条,高度等于所有数据的总高 -->
      <div class="scroll-content" :style="{ height: scrollH + 'px' }">
        <!-- 这是可视区域,通过translateY进行定位 -->
        <div
          class="viewport"
          :style="{ transform: `translateY(${offsetY}px)` }"
        >
          <!-- 真正渲染的复选框组 -->
          <el-checkbox-group v-model="checkedList" @change="handleCheckedChange">
            <el-checkbox
              v-for="item in viewOptions"
              :key="item.value"
              :label="item.value"
              class="checkbox-item"
              :style="{ height: itemH + 'px', lineHeight: itemH + 'px' }"
            >
              {{ item.label }}
            </el-checkbox>
          </el-checkbox-group>
        </div>
      </div>
    </div>
  </div>
</template>

3.1 实现虚拟滚动的动态切片

虚拟滚动的核心逻辑在滚动事件处理函数里。直接监听 scroll 事件并频繁更新 viewOptions 可能会导致性能问题,所以我们需要用 节流(throttle) 来优化。

import { throttle } from 'lodash';

created() {
  // 使用 lodash 的 throttle,确保滚动事件处理函数每16ms最多执行一次(约60fps)
  this.throttledScrollHandler = throttle(this.updateViewport, 16);
},

methods: {
  handleScroll(event) {
    this.throttledScrollHandler(event);
  },

  updateViewport(event) {
    const scrollTop = event.target.scrollTop;
    // 计算新的偏移量。这里用取整保证偏移量是 itemH 的整数倍,避免子像素渲染问题
    this.offsetY = Math.floor(scrollTop / this.itemH) * this.itemH;
    // 根据新的起始索引,切片出需要渲染的数据
    this.viewOptions = this.searchOptions.slice(
      this.startIndex,
      this.startIndex + this.visibleCount
    );
  }
}

这里有个细节:offsetY 的计算我做了取整处理。这是因为 translateY 使用整数像素值性能更好,浏览器渲染时不需要处理子像素,能减少不必要的重绘。虽然理论上 scrollTop 除以 itemH 可能得到小数,但我们对齐到最近的整数倍高度,视觉上几乎无法察觉差异,却换来了更好的性能。

3.2 让全选/反选在虚拟世界里依然高效

全选功能在虚拟滚动中需要特殊处理。因为我们并不渲染所有数据,所以不能简单地依赖 el-checkbox-group 去绑定一个包含所有 value 的数组,那样在数据量巨大时,数组操作本身就会很慢。

我的思路是:维护一个选中状态的映射,而不是一个庞大的数组。但为了兼容 el-checkbox-groupv-model(它期望一个数组),我们依然用 checkedList 数组,但通过计算属性来优化其与完整数据的同步。

methods: {
  handleCheckAllChange(checked) {
    if (checked) {
      // 全选:将 searchOptions 中所有项的 value 存入 checkedList
      this.checkedList = this.searchOptions.map(item => item.value);
    } else {
      // 取消全选:清空 checkedList
      this.checkedList = [];
    }
    this.isIndeterminate = false;
    this.emitChangeEvent();
  },

  // 单个选项变化时,更新全选按钮的状态
  handleCheckedChange() {
    const checkedCount = this.checkedList.length;
    const totalCount = this.searchOptions.length;
    this.checkAll = checkedCount === totalCount;
    this.isIndeterminate = checkedCount > 0 && checkedCount < totalCount;
    this.emitChangeEvent();
  },

  emitChangeEvent() {
    // 对外抛出事件,传递当前选中的值和完整数据对象
    this.$emit('change', {
      checkedValues: this.checkedList,
      checkedItems: this.options.filter(item => this.checkedList.includes(item.value))
    });
  }
}

注意,handleCheckAllChange 中操作的是 this.searchOptions(过滤后的数据),而不是原始的 this.options。这符合用户直觉:搜索后点全选,应该只选中搜索出来的这些项。

3.3 集成实时搜索过滤

搜索功能与虚拟滚动结合时,需要重置滚动位置并重新计算渲染切片。为了提升体验,我们使用 防抖(debounce) 来避免用户每输入一个字符就触发一次过滤计算。

import { debounce } from 'lodash';

created() {
  // 创建防抖搜索函数,延迟300毫秒执行
  this.debouncedSearch = debounce(this.performSearch, 300);
},

methods: {
  handleSearchInput() {
    this.debouncedSearch();
  },

  performSearch() {
    const keyword = this.keyword.trim().toLowerCase();
    if (keyword === '') {
      // 关键词为空,恢复显示所有数据
      this.searchOptions = [...this.options];
    } else {
      // 过滤数据,这里对 label 进行大小写不敏感的模糊匹配
      this.searchOptions = this.options.filter(item =>
        item.label.toLowerCase().includes(keyword)
      );
    }

    // 搜索后,重置滚动位置到顶部
    this.$nextTick(() => {
      if (this.$refs.scrollContainer) {
        this.$refs.scrollContainer.scrollTop = 0;
      }
      this.offsetY = 0;
      // 重新计算并更新可视区域的数据
      this.viewOptions = this.searchOptions.slice(0, this.visibleCount);
      // 搜索后,全选状态需要根据新的数据重新计算
      this.handleCheckedChange();
    });
  }
}

这里用 $nextTick 确保 DOM 更新后再操作滚动位置,是一个很实用的 Vue 技巧。搜索后重置滚动条,能让用户立刻看到过滤结果,体验更连贯。

4. 性能优化进阶:那些我踩过的“坑”和解决方案

第一个版本跑起来后,我在万级数据量下测试,发现全选后,再取消勾选其中几个选项,界面还是会卡顿一下。用 Chrome 的 Performance 工具分析,发现瓶颈不在渲染,而在我们之前提到的 checkedList 数组的差异计算上。

checkedList 从包含 10000 个值的数组(全选状态),变成包含 9995 个值的数组(取消5个),Vue 的响应式系统会触发侦听器。如果我们在 watch 里或者某个方法里需要对变化前后的数组做对比(比如找出具体取消了哪几项),简单的 concatfilter 操作在万级数组上就会产生明显的延迟。

优化点一:使用 Set 进行高效集合运算 对于查找两个数组的差异(对称差集),使用 Set 数据结构比纯数组操作快得多。

// 优化前:查找从全选状态中取消的那几项(性能差)
const allValues = this.searchOptions.map(i => i.value);
const removedValues = allValues.filter(v => !this.checkedList.includes(v));

// 优化后:使用 Set
const allValueSet = new Set(this.searchOptions.map(i => i.value));
const checkedSet = new Set(this.checkedList);
const removedValues = [...allValueSet].filter(v => !checkedSet.has(v));

优化点二:避免在模板或计算属性中进行重型计算 不要在模板里写 options.filter(...).map(...) 这种针对大数据量的链式调用。应该把结果缓存到计算属性中,或者只在数据变化时计算一次。

// 不好的做法:在模板中直接过滤
// <div v-for="item in options.filter(o => checkedList.includes(o.value))">

// 好的做法:使用计算属性
computed: {
  selectedItems() {
    // 如果 checkedList 很大,这里用 Set 优化
    const checkedSet = new Set(this.checkedList);
    return this.options.filter(item => checkedSet.has(item.value));
  }
}

优化点三:非响应式数据分离 对于纯粹用于展示、不需要触发视图更新的静态配置数据,可以将其放在 data 函数外部,或者使用 Object.freeze() 处理,避免 Vue 为其添加不必要的响应式开销。

const staticConfig = Object.freeze({
  maxHeight: 500,
  itemHeight: 32
});

export default {
  data() {
    return {
      // ... 其他响应式数据
      config: staticConfig // 引用冻结对象,减少开销
    };
  }
};

优化点四:组件销毁时的清理 我们使用了 lodashthrottledebounce,它们会生成内部定时器。如果组件销毁前不清理,可能会导致内存泄漏。好在 lodash 的函数提供了 .cancel() 方法。

beforeDestroy() {
  if (this.throttledScrollHandler && this.throttledScrollHandler.cancel) {
    this.throttledScrollHandler.cancel();
  }
  if (this.debouncedSearch && this.debouncedSearch.cancel) {
    this.debouncedSearch.cancel();
  }
}

5. 在你的项目中引入并使用这个组件

封装好了,怎么用呢?非常简单。假设你把组件文件保存为 components/VirtualCheckbox.vue

首先,在需要用到的页面引入并注册:

<template>
  <div class="permission-manager">
    <h3>角色权限配置(共 {{ totalCount }} 项)</h3>
    <VirtualCheckbox
      ref="virtualCheckboxRef"
      :options="permissionList"
      :default-checked="defaultPermissions"
      :view-h="400"
      :item-h="36"
      @change="onPermissionChange"
    />
    <div>
      已选中:{{ selectedCount }} 项
      <el-button @click="save">保存配置</el-button>
    </div>
  </div>
</template>

<script>
import VirtualCheckbox from '@/components/VirtualCheckbox.vue';

export default {
  components: { VirtualCheckbox },
  data() {
    return {
      permissionList: [], // 从后端API获取
      defaultPermissions: [1, 2, 5], // 初始选中的权限ID
      selectedValues: []
    };
  },
  computed: {
    totalCount() {
      return this.permissionList.length;
    },
    selectedCount() {
      return this.selectedValues.length;
    }
  },
  created() {
    this.fetchPermissions();
  },
  methods: {
    async fetchPermissions() {
      // 模拟从后端获取数据
      const mockData = [];
      for (let i = 1; i <= 5000; i++) {
        mockData.push({
          value: i,
          label: `系统权限点_${i}_管理用户数据_${Math.random().toString(36).substr(2, 5)}`
        });
      }
      this.permissionList = mockData;
    },
    onPermissionChange({ checkedValues, checkedItems }) {
      this.selectedValues = checkedValues;
      console.log('选中的完整对象:', checkedItems);
    },
    save() {
      // 调用API,提交 this.selectedValues
      console.log('提交的权限ID:', this.selectedValues);
    }
  }
};
</script>

你甚至可以通过 ref 调用组件内部的方法,比如提供一个“清空”按钮:

<el-button @click="$refs.virtualCheckboxRef.reset()">清空选择</el-button>

在组件内部,我们需要暴露这个 reset 方法:

// 在 VirtualCheckbox.vue 的 methods 中
methods: {
  reset() {
    this.keyword = '';
    this.checkedList = [...this.defaultChecked];
    this.searchOptions = [...this.options];
    this.$refs.scrollContainer.scrollTop = 0;
    this.offsetY = 0;
    this.viewOptions = this.searchOptions.slice(0, this.visibleCount);
    this.handleCheckedChange();
    this.$emit('change', this.getCheckedData());
  }
}

6. 样式调整与最佳实践建议

为了让组件看起来更协调,一些基础的样式是必要的。这里我建议使用 SCSS 或 Less,利用深度选择器来覆盖 Element UI 默认样式,同时保持样式的作用域。

<style lang="scss" scoped>
.virtual-checkbox {
  border: 1px solid #e4e7ed;
  border-radius: 4px;
  padding: 12px;
  background: #fff;

  .check-all {
    margin: 10px 0;
    padding-left: 8px;
  }

  .scroll-container {
    border: 1px solid #dcdfe6;
    border-radius: 4px;
    overflow-y: auto;
    position: relative;

    .scroll-content {
      position: relative;
    }

    .viewport {
      position: absolute;
      top: 0;
      left: 0;
      right: 0;
      will-change: transform; /* 提示浏览器优化 transform 动画 */
    }

    /* 深度选择器,影响内部的 el-checkbox 样式 */
    ::v-deep .el-checkbox-group {
      .checkbox-item {
        padding: 0 12px;
        margin: 0;
        display: flex;
        align-items: center;
        border-bottom: 1px solid #f5f7fa;
        box-sizing: border-box;

        &:hover {
          background-color: #f5f7fa;
        }

        .el-checkbox__label {
          white-space: nowrap;
          overflow: hidden;
          text-overflow: ellipsis;
          flex: 1;
        }
      }
    }
  }
}
</style>

最后,分享几个我总结的 最佳实践

  1. 精确测量 itemH:这个值必须和实际渲染的复选框行高(包括 padding、border)完全一致。差一个像素,滚动时就会出现错位或闪烁。最好用浏览器开发者工具量一下。
  2. 数据格式标准化:确保传入的 options 每个对象都有唯一的 valuevalue 最好使用数字或简单的字符串,不要用复杂的对象,这会影响 Vue 的 key 管理和对比效率。
  3. 分页仍是备选:虚拟滚动解决了渲染性能,但如果你的数据真的多达数万条,一次性从后端加载也可能导致网络延迟和前端内存占用高。对于极端场景,可以考虑“虚拟滚动 + 分页加载”的混合模式。
  4. 提供加载状态:如果数据是异步加载的,在 options 为空或加载时,应该在组件内显示一个“加载中”的占位符,提升用户体验。
  5. 做好无障碍访问:如果你的项目对可访问性有要求,需要为虚拟滚动列表添加适当的 ARIA 属性,例如 role="listbox"aria-label 等,确保屏幕阅读器能正确识别和播报。

我自己在好几个中后台项目里都用上了这套方案,从最初的全选卡顿到现在的流畅操作,效果提升是立竿见影的。有一次,产品经理特意跑过来说:“哎,这次权限页面怎么这么顺滑了?” 那种感觉,比解决了什么复杂算法题还有成就感。技术优化很多时候就是这样,不是为了炫技,就是为了让用户用着更舒服,让产品体验更上一层楼。希望这个组件和这些思路,也能帮你解决实际项目中的痛点。

更多推荐