做Vue3电商项目时,如何高效处理百万级SKU的搜索筛选加载和性能优化?
最近接了个朋友转的社区生鲜电商优化需求,之前用Vue2做了个架子,上线后一推节日活动,有优惠券叠加的时段,整个筛选页面卡得要死——下拉加载SKU列表的时候,滚动十多秒才出下一屏,选了十几个标签页(比如产地、品类、是否有机、满减、满赠)一起筛选,有时候要等半分钟以上,甚至直接白屏崩溃,后来整个重构到Vue3,调整了思路和技术方案,把SKU峰值(之前那次是120多万个已上架有效SKU,平台规则活动期间允许临时加挂新供货商的)的筛选从崩溃边缘拉到0.3秒内出结果,下拉加载流畅得像刷小红书图文,今天刚好有空整理下当时遇到的坑和解决方案,都是踩过雷之后实打实能用的东西。
从崩溃前的排查开始:Vue2过渡到Vue3时,SKU筛选加载的常见坑先别踩
之前那个架子虽然烂,但不是完全没法看,排查的时候我也把它当时的问题列了个清单,后来优化的时候刚好反过来用——朋友后来招了几个刚入行的前端实习生,拿这个清单当Vue3复杂项目入门避坑指南,反馈还不错。
第一个坑是照搬Vue2的双向绑定逻辑,没有合理拆分响应式状态,生鲜电商的筛选器标签页,有时候标签下的选项还挺多的,分类”有三级,每级少则几十个多则上千个SKU组;“价格区间”是动态生成的,有时候还要根据当前选中的其他条件(比如只看满39减10的水果)重新计算;“产地”按省份、地级市分层级,还有热门产地推荐在上面,那个架子把所有标签页的选项、当前选中状态、价格滑块的上下限、价格区间的动态列表、SKU列表的当前页码、每页条数、加载状态、错误提示……甚至还有搜索框的历史记录、联想词缓存,全部塞在一个大的data()对象里。
搬Vue3的时候,如果直接把这个大对象改成reactive(),问题只会更严重——你想想,每次用户选个分类标签,或者滑一下价格滑块,整个大对象的所有属性都会被Proxy代理监听一遍,哪怕那个属性和当前筛选结果、SKU加载完全没关系,比如历史记录、联想词缓存这些,之前Vue2还能靠Object.defineProperty的缺陷蒙混过关(比如深层嵌套的数组修改索引不会自动更新),但Vue3的Proxy是全代理,一点点风吹草动都会触发不必要的计算和视图更新,朋友那个架子崩溃的时候,我看了Chrome DevTools的Performance面板,红条全是“Long Task”,最长的一个超过了4000ms,里面大部分都是Vue的依赖收集和视图更新。
第二个坑是后端返回全量数据,前端自己处理筛选和分页,朋友的那个后端是刚毕业不久的Java实习生写的,可能为了省事,不管前端选了多少条件,不管当前在第几页,每次都返回全量的已上架有效SKU数据(后来我翻了下接口文档,是因为一开始产品经理说SKU可能只有几万条,全量返回加载更快——哪知道活动一来供货商加塞加塞加塞到120多万),你想想,120多万条JSON数据,每条大概有30-50个字段(比如SKU ID、商品名称、缩略图、原价、折扣价、满减满赠标识、库存、销量、评分、供货商信息、配送范围这些),压缩前大概有500MB,压缩后也有80-100MB,社区生鲜的用户很多都是在地铁、菜市场这种信号不稳定的地方用,别说加载80MB了,加载20MB都可能卡半天,甚至超时。
就算用户信号好,加载下来了,前端自己处理筛选和分页也是个大问题,当时那个架子用的是原生的filter()、sort()、slice(),120多万条数据,筛选一次大概要2-3秒,排序一次大概要1-2秒,加起来就是3-5秒,而且这3-5秒都是在主线程上跑的Long Task,主线程被占住了,用户选完标签之后什么都动不了,连点击按钮的反馈都没有,体验极差,实习生还搞了个防抖,说是怕用户选标签太快,触发多次请求——但全量数据已经加载下来了,防抖只会让用户觉得更卡,比如用户连续选了三个标签,要等防抖时间过了(当时设的是500ms)才会出结果,体验雪上加霜。
第三个坑是没有使用虚拟列表,不管有多少条SKU,都全部渲染到DOM里,那个架子一开始SKU只有几万条,分页设的是每页20条,好像没问题,但后来实习生为了让下拉加载更“丝滑”(其实是他不知道虚拟列表是什么),把分页改成了无限滚动,每次加载20条,但没有把已经滚出视口的SKU从DOM里移除,你想想,每次加载20条,滚动100次就是2000条,每条SKU的DOM元素大概有10-20个子元素(比如缩略图img、商品名称h4、原价span、折扣价span、满减满赠标签div、库存span、销量span、评分div、加入购物车button这些),2000条就是20000-40000个DOM元素,DOM元素多了之后,浏览器的重排重绘会非常慢,滚动的时候会出现明显的掉帧,甚至白屏。
Chrome DevTools的Layers面板里,当时那个架子的SKU列表是一个单独的层,但滚动的时候这个层的重绘面积特别大,占了整个视口的90%以上,Performance面板里,每滚动一帧,重排重绘的时间就超过了16ms(60fps的标准是每帧16ms以内),所以看起来特别卡。
第四个坑是没有使用Composition API的computed和watchEffect合理管理计算逻辑,而是用了watch加setTimeout手动触发更新,实习生搬Vue3的时候,把computed和watchEffect都丢了,直接用watch监听那个大的reactive()对象的多个属性,然后加个setTimeout延迟100ms再去执行筛选和分页,说是怕依赖收集的问题——其实是他对Composition API的响应式原理不熟悉,而且他没有把筛选、排序、分页这些逻辑拆成单独的函数,而是把所有逻辑都写在同一个watch回调里,代码看起来特别乱,维护起来也很麻烦。
避坑之后:百万级SKU搜索筛选加载的Vue3核心方案
说完了坑,现在说说我当时是怎么解决的,整个方案分为前端和后端两部分,前端主要是Vue3的Composition API优化、虚拟列表、搜索联想词和历史记录的本地存储+IndexedDB缓存、防抖节流的合理使用;后端主要是接口拆分、数据分页、索引优化、Redis缓存。
后端先搞搞,毕竟巧妇难为无米之炊——百万级SKU的后端优化方案
后端是基础,后端接口慢,前端再怎么优化也没用,当时那个架子的后端实习生Java水平还可以,就是经验不足,我花了半天时间跟他理了理接口和数据库的优化思路,他花了三天时间就改好了。
接口拆分,不要搞“万能接口”
之前那个架子的接口只有一个/api/sku/list,不管是搜索、筛选、排序、分页,参数全部塞在这个接口的请求体里,接口拆分之后,我让他拆成了四个接口:
/api/sku/search/suggest:搜索联想词接口,只需要接收搜索关键词的前3-5个字符,返回前10-20个热门联想词。/api/sku/filter/options:筛选器选项接口,只需要接收当前已选中的其他筛选条件(如果有的话),返回所有筛选器的可选选项(比如按当前选中的分类,重新计算价格区间、产地、销量、评分等)。/api/sku/list:SKU列表接口,只需要接收当前已选中的所有筛选条件、排序条件、当前页码、每页条数,返回当前页的SKU数据(只返回前端需要的字段,比如SKU ID、商品名称、缩略图、原价、折扣价、满减满赠标识、库存、销量、评分,其他字段比如供货商信息、配送范围这些,只有用户点击进入商品详情页的时候才单独请求)。/api/sku/count:SKU总数接口,只需要接收当前已选中的所有筛选条件,返回符合条件的SKU总数(可选,用于显示“共找到XX件商品”)。
数据分页,每次只返回当前页的20条数据
这个不用多说,是最基本的优化,社区生鲜的用户,每次下拉加载最多也就看个几十条,根本不需要全量数据。
然后是数据库索引优化,百万级SKU的查询要靠索引
数据库用的是MySQL,之前那个SKU表只有一个主键索引(SKU ID),其他字段比如商品名称、分类ID、折扣价、库存、销量、评分、满减满赠标识这些,都没有索引,查询的时候,MySQL只能做全表扫描,120多万条数据全表扫描一次大概要5-10秒,加上网络传输,前端的体验可想而知。
索引优化之后,我让他建了以下几个索引:
- 复合索引:
(category_id, discount_price, stock, is_on_sale):用于按分类、价格区间、库存、上架状态筛选,然后按价格排序。 - 复合索引:
(category_id, sales_volume DESC, is_on_sale):用于按分类、上架状态筛选,然后按销量排序。 - 复合索引:
(category_id, rating DESC, is_on_sale):用于按分类、上架状态筛选,然后按评分排序。 - 全文索引:
(product_name):用于商品名称的搜索(全文索引比LIKE '%xxx%'快很多,而且还能做分词搜索,比如搜索“有机苹果”,能找到所有名称里有“有机”或者“苹果”的商品,还能按相关性排序)。 - 普通索引:
(is_full_reduction, is_full_gift, is_on_sale):用于按满减满赠标识、上架状态筛选。
建索引之后,我用MySQL的EXPLAIN命令查了下查询计划,所有查询都走了索引,全表扫描的情况消失了,查询时间从5-10秒降到了0.01-0.05秒。
Redis缓存,热门筛选条件和热门联想词直接从缓存里取
节日活动期间,热门筛选条件(比如只看满39减10的水果、只看有机蔬菜、只看当天配送)和热门联想词(鸡蛋”、“牛奶”、“苹果”)的查询量特别大,甚至可能占到总查询量的80%以上,如果每次都查MySQL,MySQL的压力会特别大,甚至可能宕机。
Redis缓存优化之后,我让他把热门筛选条件的SKU总数、热门筛选条件的前10页SKU数据、热门联想词,都缓存到Redis里,缓存时间设为5-10分钟(根据活动的热度调整,热度越高缓存时间越短,比如秒杀活动期间设为1分钟),缓存失效的时候,不要让所有请求都去查MySQL,而是用一个“缓存击穿保护”机制,比如加个分布式锁,只有第一个请求去查MySQL,其他请求等待第一个请求的结果,然后直接从Redis里取。
分布式锁用的是Redis的SETNX命令,或者更简单一点,用Redisson这个Java库(Redisson的分布式锁实现得比较完善,有看门狗机制,防止锁提前过期)。
前端再搞搞,细节决定体验——百万级SKU的Vue3前端优化方案
后端优化好之后,前端的压力就小了很多,但还是要做一些优化,才能让体验更好。
合理拆分响应式状态,不要用大的reactive()对象
我把之前那个大的reactive()对象拆成了以下几个小的响应式对象/引用:
searchParams:reactive()对象,只包含搜索、筛选、排序相关的参数,比如搜索关键词、分类ID、产地、价格区间、满减满赠标识、排序方式(价格升序/降序、销量降序、评分降序、上架时间降序)。pagination:reactive()对象,只包含分页相关的参数,比如当前页码、每页条数、是否还有更多数据。loadState:reactive()对象,只包含加载状态相关的参数,比如SKU列表的加载状态(loading/loaded/error)、搜索联想词的加载状态、筛选器选项的加载状态。error:ref()引用,只包含错误提示信息。history:ref()引用,只包含搜索框的历史记录,最多保存10条,用localStorage存储(如果历史记录太多,可以用IndexedDB,但10条的话localStorage足够了)。suggestCache:Map()对象,用于缓存搜索联想词,key是搜索关键词的前3-5个字符,value是联想词列表,缓存时间设为5分钟(可以用一个定时器定期清理过期的缓存)。
拆分之后,每次用户修改searchParams或者pagination,只会触发和这些属性相关的计算和视图更新,不会触发和history、suggestCache这些属性相关的更新,Performance面板里的Long Task明显减少了。
使用Composition API的computed和watchEffect合理管理计算逻辑,不要用watch加setTimeout
我把筛选、排序、分页这些逻辑都拆成了单独的composable函数(比如useSearch、useFilter、usePagination、useVirtualList),然后在主组件里引用这些composable函数。
useFilter函数里,我用watchEffect监听searchParams,然后自动调用/api/sku/filter/options接口获取筛选器选项,同时把当前页码重置为1。watchEffect会自动追踪它内部使用的响应式状态,当这些状态发生变化时,它会自动重新执行,不需要手动指定依赖项,代码看起来更简洁,也不容易出错。
usePagination函数里,我用computed计算“是否还有更多数据”,当当前页的SKU数量小于每页条数时,就没有更多数据了,我还加了一个防抖,用于用户快速滑动滚动条的时候,避免触发多次请求——防抖时间设为200ms,刚好能让用户感受到“丝滑”,又不会触发太多无效请求。
然后是使用虚拟列表,只渲染视口内的SKU数据
虚拟列表的核心思路是:不管有多少条数据,只渲染视口内可见的那几十条(比如20-40条),当用户滚动的时候,把已经滚出视口的SKU从DOM里移除,把即将滚入视口的SKU添加到DOM里,这样DOM元素的数量就永远保持在几十条,浏览器的重排重绘会非常快,滚动的时候不会掉帧。
Vue3生态里有很多好用的虚拟列表组件,比如vue-virtual-scroller、vue-virtual-scroll-list、element-plus里的el-virtual-list(如果用element-plus的话),我当时用的是vue-virtual-scroller,因为它的API比较简单,文档也比较全,而且支持动态高度的列表项(虽然朋友的项目里SKU列表项的高度是固定的,但万一以后产品经理要改动态高度呢?还是选个支持的比较好)。
使用vue-virtual-scroller的时候,要注意以下几点:
- 列表项的高度最好是固定的,这样虚拟列表的计算会更准确,性能也会更好,如果列表项的高度是动态的,需要给每个列表项设置一个
key,并且提供一个getItemSize函数,用于获取每个列表项的高度。 - 要给虚拟列表设置一个固定的高度或者
max-height,并且设置overflow-y: auto,这样虚拟列表才能正常滚动。 - 要给虚拟列表的容器设置一个
padding-top和padding-bottom,用于模拟已经滚出视口的SKU的高度,这样滚动条的长度才会和真实的SKU列表长度一致。
然后是搜索联想词和历史记录的本地存储+IndexedDB缓存
搜索联想词的接口虽然已经很快了,但还是可以做个本地缓存,进一步减少请求次数,我用Map()对象缓存了搜索关键词的前3-5个字符对应的联想词列表,缓存时间设为5分钟,每次用户输入搜索关键词的时候,先检查缓存里有没有,如果有就直接用,如果没有就调用接口获取,然后存入缓存。
搜索历史记录最多保存10条,用localStorage存储,每次用户搜索成功之后,就把搜索关键词添加到历史记录的最前面,如果已经存在就把它移到最前面,如果超过10条就把最后一条删掉,历史记录的删除功能也很简单,就是清空localStorage里的对应字段。
如果搜索历史记录或者联想词缓存特别多(比如超过100条),localStorage就不够用了,因为localStorage的容量一般只有5MB左右,这时候可以用IndexedDB,IndexedDB的容量很大,一般有几百MB甚至几GB,而且支持异步操作,不会阻塞主线程。
防抖节流的合理使用,不要滥用
防抖和节流是前端优化里最常用的两个技巧,但不要滥用,否则会影响用户体验。
我当时的项目里,防抖用在了以下几个地方:
- 搜索框的输入:用户输入搜索关键词的时候,不要每输入一个字符就调用接口,而是等用户停止输入200ms之后再调用接口。
- 价格滑块的拖动:用户拖动价格滑块的时候,不要每拖动一像素就调用接口,而是等用户停止拖动200ms之后再调用接口。
节流用在了以下几个地方:
- 无限滚动的监听:用户滚动虚拟列表的时候,不要每滚动一像素就检查是否到达底部,而是每隔200ms检查一次。
- 窗口大小的改变:用户改变窗口大小的时候,不要每改变一像素就重新计算虚拟列表的高度,而是每隔200ms重新计算一次。
优化之后的效果:百万级SKU的筛选加载0.3秒内出结果,滚动丝滑
优化完成之后,我和朋友一起做了个压力测试,模拟了1000个并发用户,同时访问筛选页面,同时选满减满赠、热门分类、热门产地、价格区间这几个条件,同时下拉加载SKU列表。
压力测试的结果非常好:
- 后端接口的平均响应时间:
/api/sku/filter/options是0.02秒,/api/sku/list是0.05秒,/api/sku/search/suggest是0.01秒,/api/sku/count是0.01秒。 - 前端的筛选加载时间:从用户选完所有条件到看到第一屏SKU数据,平均是0.25秒,最长不超过0.3秒。
- 前端的滚动帧率:稳定在60fps左右,没有明显的掉帧。
- 前端的DOM元素数量:永远保持在30-40条左右(包括热门筛选条件的标签页、SKU列表的容器、虚拟列表的可见项)。
节日活动正式上线之后,用户的反馈非常好,之前的卡顿、白屏、崩溃问题都消失了,后台的销售额也比之前的节日活动提升了20%左右——产品经理说,这20%的提升,很大一部分是因为前端体验变好了,用户愿意多逛一会儿,多买几件商品。
Vue3百万级SKU搜索筛选加载的核心要点
我再总结一下Vue3百万级SKU搜索筛选加载的核心要点,方便大家以后遇到类似的问题时参考:
- 后端是基础:先搞后端的接口拆分、数据分页、索引优化、Redis缓存,不要把所有压力都推给前端。
- 合理拆分响应式状态:不要用大的
reactive()对象,把不同功能的响应式状态拆成单独的对象/引用,减少不必要的计算和视图更新。 - 使用Composition API的composable函数:把筛选、排序、分页、虚拟列表这些逻辑拆成单独的composable函数,提高代码的可复用性和可维护性。
- 使用虚拟列表:不管有多少条数据,只渲染视口内可见的那几十条,减少DOM元素的数量,提高浏览器的重排重绘效率。
- 使用本地存储+IndexedDB缓存:缓存搜索联想词、历史记录这些不常变化的数据,进一步减少请求次数。
- 合理使用防抖节流:不要滥用,否则会影响用户体验。
不管是Vue3还是其他前端框架,不管是电商项目还是其他复杂项目,性能优化的核心思路都是一样的:减少不必要的计算,减少不必要的请求,减少不必要的DOM操作,只要抓住了这个核心思路,再结合具体的项目需求,就能做出性能优秀、体验良好的前端项目。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


