ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue渲染优化实战:用v-once和v-memo解决列表卡顿与图表闪烁

Vue渲染优化实战:用v-once和v-memo解决列表卡顿与图表闪烁 上个月在排查一个订单管理后台列表一千多行每五秒轮询一次数据页面卡到切Tab都掉帧。用 Performance 面板一录制发现 Vue 的渲染函数每次更新都在处理大量 VNode 对比而其中绝大部分内容根本没变化。当时第一反应就是上 v-memo。像这种“数据变了但大部分视图没变”的场景v-once 和 v-memo 就是 Vue 官方给的加速器。这篇博文不是单纯讲两个指令的用法而是把这些年在真实项目中靠渲染优化换回来的经验整理成一份可直接照抄的指南。你会搞清楚什么是不必要的渲染、v-once 和 v-memo 各自的底层逻辑、依赖数组怎么写才不会踩坑还会看到大型表格、markdown 长文、echart 图表这三个典型场景的完整改造过程。适合正在被列表卡顿、图表闪烁、长文本渲染慢折磨的 Vue 开发者Vue 2 和 Vue 3 的项目都适用。1. 内容整体设计与思路拆解1.1 先搞清楚“不必要的渲染”到底是什么Vue 的响应式运行机制说起来很简单状态变化组件重新执行 render 函数生成新的 VNode 树再和旧 VNode 树做 diff最后把差异更新到真实 DOM。问题就出在“状态变化”这件事被放大了。一个组件如果依赖了多个响应式数据其中任何一个数据变化整个组件的 render 都会重跑哪怕这份数据对应的视图只是一个角落里的数字。用一个生活化的例子你每次只需要打扫客厅但因为触发条件是“家里有人走动了”结果每次都得把整个屋子所有房间的 VNode 都翻一遍再确认其他房间确实没有脏东西。这就是不必要渲染的核心成本——不是页面视觉上闪了一下而是原本可以跳过的虚拟 DOM 创建、diff、对比被完整执行了一遍。以我排查的那个订单列表为例接口每 5 秒返回一次数据实际上只有几条订单的状态或金额改变了其他九百多行内容完全没动。但父组件拿到新数组之后整张列表的 VNode 全部重建并参与 diff单次更新耗时在 12ms 到 15ms 之间滚动时还会叠加长任务掉帧就不可避免。这个场景里优化空间极大但前提是能精确地告诉 Vue“这一块区域只有这些条件变化时才需要更新。”1.2 v-once 和 v-memo 的定位区别Vue 官方给这两个指令的定位完全不同。v-once 的意思是“只渲染一次之后我什么都不管”是彻底的静态化v-memo 的意思是“这次更新要不要跳过由我指定的依赖数组说了算”是条件化的记忆机制。维度v-oncev-memo引入版本Vue 2.0Vue 3.2更新机制节点标记后完全退出更新流程依赖数组全部相等时复用旧 VNode跳过 patch依赖控制无手动指定依赖数组适用场景完全静态的板块页脚、广告位、正文数据部分变化的大型列表、局部动态区域稳定性高但不可逆依赖数组漏写会出 bug对组件组件不会因 props 变化更新可控制组件接收 props 后的更新时机从选型逻辑上看如果一段内容从首次渲染后永远不变用 v-once 是最省事的。如果一段内容大部分时间不变但偶尔某个字段要刷新那就用 v-memo 把这个字段放进依赖数组。很多同学一上来就想“我全部用 v-memo 不就行了”真不是这样v-memo 本身有比较成本和内存占用用得不对反而更慢后面会说。1.3 什么时候才值得用这两个家伙我在项目里总结出了一个判断标准先把性能瓶颈定位到“组件 render 重跑的频率高”和“节点数量多”这两个条件上再考虑这两个指令。具体来说有三类场景收益最明显第一类是大型静态区域混在高频动态组件内部。比如图表页里折线图的数据每 100ms 更新一次但旁边有一大段指标说明和公司简介是固定内容。不隔离的话图表每次刷新都会让整块说明文字重新走一遍 diff这是典型的“连坐”。第二类是千行级表格或长列表数据轮询刷新但只有少数行发生变化。给每一行加上依赖字段的 v-memo可以让 Vue 只对比真正需要变化的行。第三类是渲染结果昂贵但内容本身低频变化的场景比如用 markdown-it 把一大段 markdown 转成 HTML 再 v-html 渲染。重新解析 markdown 的开销远超 DOM diff这时用 v-once 把渲染结果锁住非常划算。反过来说如果你的模板只有几十个节点或者数据本身就不是高频更新那这两个指令带来的收益基本感知不到。性能优化永远先测量再动手别在没定位到瓶颈之前就往模板里塞优化指令。2. 核心细节解析与实操要点2.1 v-once一口气渲染完就再也不管的指令v-once 的用法简单到不能再简单template div classprofile h1 v-once{{ systemName }}/h1 p{{ currentUserName }}/p /div /template上面的例子中systemName 这个数据在首次渲染时会被正常求值并显示但之后不管它怎么变页面上的 systemName 都不会再更新。currentUserName 不受影响该响应还是响应。内部机制说起来其实不复杂模板编译阶段v-once 会把这个节点标记成静态节点之后更新阶段里的 diff 直接跳过它。它作用于 v-once 所在元素的整个子树也就是说你给一个容器加了 v-once里面所有子元素都跟着一次性渲染完。这里要特别强调一点Vue 3 的编译器本身就会自动做静态提升纯静态的节点根本不需要你手动加 v-once。v-once 的价值在于那些“内容实际上包含动态绑定但你想在渲染后把它冻结”的场景。比如从接口拿回一段富文本渲染之后不希望它因为其他状态变化而重新被 patch这种就适合锁死。我把 v-once 用在页脚信息和活动规则弹层上。这些内容首次渲染成本不低但后续几乎不会变化锁死之后父组件再怎么频繁更新它们都不再参与任何 diff。2.2 v-memo按条件跳过更新的记忆化指令v-memo 是 Vue 3.2 才正式可用的它解决的是 v-once 太绝对的问题——v-once 一锁到底但真实业务里很多时候是“列表行数据大部分字段不变偶发字段变化要刷新”。基本写法如下template div v-foritem in list :keyitem.id v-memo[item.name, item.price, item.stock] span{{ item.name }}/span span{{ item.price }}/span span{{ item.stock }}/span /div /template当 v-memo 的依赖数组里每一项和上一次渲染时都相等Vue 就会直接复用上一次生成的 VNode跳过这一行的 patch连子节点的 diff 都不会执行。只有数组里某一项的值变了这一行才重新渲染。这个机制的本质是拿内存换 CPU。Vue 需要保存上一次的依赖数组值再在每次更新时逐一对比。对比的代价远小于整棵子树的 diff 时收益就出来了。需要注意v-memo 通常配合 v-for 使用但不代表只能用在列表里。一个独立的小区块只要你能明确列出它的响应式依赖也可以使用。不过我更推荐先在列表场景里用它的收益最直观。2.3 依赖数组怎么写才算对写 v-memo 最核心的就是依赖数组这个数组直接决定更新是“被正确触发”还是“被错误跳过”。我见过太多同事在这里翻车所以把几条经验放前面。一是模板里读到了哪个字段依赖数组里必须出现哪个字段。列表行模板里用了 item.name、item.price、item.status 三个字段v-memo 至少得写这三个。漏掉任何一个那个字段变化时视图就会不更新而且没有任何报错提示这是最危险的地方。二是依赖项尽量用基本类型。如果依赖数组里放的是对象比如 v-memo[item.info]而 item.info 内部属性变了但引用没变对比时 Vue 会发现“对象引用没变”直接跳过更新。这种 bug 排查起来相当隐蔽。正确做法是把对象内部可能变化的字段铺平写成 v-memo[item.info.title, item.info.desc]除非你确定整个对象会被整体替换。三是避免依赖数组里用方法调用。比如 v-memo[formatTime(item.createdAt)]formatTime 每次调用返回新值在不可控的情况下会让记忆失效优化效果直接归零。如果确实要格式化先把结果放到计算属性或字段里。四是依赖数组要克制不要图省事把整行所有字段都塞进去。如果所有字段都塞进去那每次数据变化都必然触发更新v-memo 等于白写还额外多了一次数组对比。依赖数组应该只包含“真正可能变化且影响视图的字段”。2.4 两者同时使用和边界v-once 和 v-memo 可以嵌套使用但实际意义有限。外层 v-once 已经把整个子树冻结了内层 v-memo 不会再被触发因为外层根本不会走到更新逻辑。反过来外层 v-memo 控制某个区块的更新内层用 v-once 锁死其中某个完全静态的子区块这样组合是有意义的。还有一个容易踩的边界是 v-for、v-if、v-memo 之间的优先级。Vue 3 中 v-if 的优先级高于 v-forv-memo 必须放在 v-for 所在元素上才能按行记忆。如果你把 v-memo 放到 v-for 的外层容器上那它记忆的是整个列表依赖数组里任何一项变化都会让整个列表重新渲染粒度就没了。另外注意 v-memo 也能用在组件上comp v-memo[propsData] /此时 v-memo 控制的是父组件把这个组件的 VNode 传给它的时机。组件内部如果有自己的响应式状态内部更新仍然会正常进行v-memo 并不会把组件变成静态的。这个语义和 v-once 作用于组件时完全不同v-once 是连组件内部更新都不再触发。3. 实操过程与核心环节实现3.1 场景一千行级表格的按需刷新先说背景。一个订单管理表格接口轮询返回最近 1000 条订单每条订单有订单号、客户、金额、状态、创建时间。状态偶尔变化金额偶尔变化但订单号和创建时间永远不变。优化前每次轮询整个表格更新。改造分三步走。第一步先给表格每一行加上稳定的业务主键 :key。这步很关键Vue 需要利用 key 来复用同一行的 VNodev-memo 才能在行级别做记忆。没有稳定 keyv-memo 的命中率会大打折扣。第二步把 v-memo 加到行元素上template tr v-fororder in tableData :keyorder.id v-memo[order.status, order.amount] td{{ order.no }}/td td{{ order.customerName }}/td td{{ order.amount }}/td td span :class{ highlight: order.status pending } {{ statusText(order.status) }} /span /td td{{ order.createTime }}/td /tr /template我在模板里读取的响应式字段主要是 status 和 amount所以依赖数组写这两个就足够。order、createTime 这些字段不会变化放进去反而增加数组对比长度。第三步用 Performance 面板验证效果。我在项目里优化前单次轮询刷新整个表大概要 12ms优化后命中 v-memo 的行直接复用旧 VNode单次更新掉到 3ms 以内。注意不同机器差异很大这个数字只能说明量级。这里有个经验如果同一时间只有少量行变化v-memo 收益非常明显。但如果每 5 秒几乎全部行都变化v-memo 的优势就被抹平了这种场景应该先和后端协商增量数据接口。3.2 场景二markdown-it 渲染大量文字时怎么配合markdown-it 长文渲染是很多内容型产品会碰到的痛点。一段 100KB 的 markdown每次父组件因为某个点赞数变化而重渲染时VNode diff 是小事真正耗性能的是 markdown-it 重新解析。而且如果模板里直接template div classdoc-content v-htmlcompiledContent / /templatecompiledContent 每次 render 都会重新执行的话开销会非常夸张。优化做法是让解析结果只在原始 markdown 变化时才重新计算渲染结果只在 markdown 未变化时被复用。两步走第一步用计算属性缓存解析结果script setup import MarkdownIt from markdown-it const md new MarkdownIt({ html: true }) const compiledContent computed(() { return md.render(props.markdownText) }) /script template div classdoc-content v-htmlcompiledContent / /template第二步如果这个 markdown 正文在首次渲染后完全不更新直接在容器上加 v-once渲染结果彻底冻结。如果正文偶尔从接口刷新但频率很低则用 v-memo[props.markdownText] 来控制正文区域的更新时机。这里要特别提醒如果 markdown 来源是用户输入或外部接口v-html 输出前必须做 XSS 过滤和标签白名单处理别把安全优化和性能优化混在一起。生产环境里这是底线问题。首帧渲染成本是 v-once 和 v-memo 都无法回避的。它们只能优化“之后”的更新不能减少首次解析耗时。如果首帧都卡更适合的方案是构建期预渲染、服务端渲染或把解析结果持久化到缓存里。3.3 场景三echart 图表闪烁与静态信息隔离echart 闪烁的问题很多人问过。我排查过的案例里大量闪烁的根本原因不是 echart 本身而是图表所在的 Vue 组件被父级高频更新连坐导致 canvas 元素被异常重建或重复初始化。典型的错误写法是这样整个页面是一个组件图表数据 timer 每秒更新十几次同一个模板里股票名称、公司简介、指标说明全混在一起。图表数据一变整个模板更新canvas 容器被重新 patchechart 实例原有的状态丢失图表重新渲染视觉上就是闪烁。正确拆解第一刀是组件隔离。把高频图表拆成子组件父组件只是传数据script setup import KLineChart from ./KLineChart.vue const chartData ref({}) let timer onMounted(() { timer setInterval(() { chartData.value fetchData() }, 100) }) /script template div classpage div classstock-info v-once h2{{ stockName }}/h2 p{{ stockDesc }}/p /div KLineChart :datachartData / /div /template股票名称和公司简介属于完全静态的内容直接 v-once。KLineChart 子组件内部通过 watch 监听 data 变化再手动调用 echartsInstance.setOption 更新而不是通过模板重渲染来重建图表。第二刀是图表自身用 shallowRef 管理 echarts 实例避免实例被响应式代理包裹。这一步虽然不属于 v-memo 范畴但非常重要。echart 实例内部有大量方法、事件、缓存如果被 Vue 的递归代理包进去每次访问和修改都会产生额外开销。至于 v-memo 在图表场景里还有没有用有但通常用在图表外层的包装组件上比如一个同时展示多张图表和指标卡片的 dashboard每张卡片的配置项变化频率不一致可以用 v-memo 控制卡片级别的更新。单纯解决 echart 闪烁先把组件隔离和实例管理做好比直接上指令更有效。3.4 用 Performance 工具量化验证优化效果性能优化最怕“感觉快了”。没有任何数据支撑的优化很容易被下一次需求变更推翻。我一般分三步做量化。第一步在优化前录制一次完整操作。打开 Chrome DevTools 的 Performance 面板勾选 Web Vitals点击录制模拟一次轮询更新或滚动然后停止。找到对应的 Long Task 和渲染函数调用记录耗时。第二步用 performance.now 在关键路径埋点。比如在父组件拿到新数据之后打一个标记在组件 updated 钩子再打一个标记计算差值performance.mark(data-updated) // 模拟数据更新后触发 Vue 更新 await nextTick() performance.mark(dom-updated) performance.measure(vue-update-cost, data-updated, dom-updated)第三步优化后重复同样的录制流程对比同一操作路径的耗时。我习惯每种场景测五次取中位数避免噪声影响判断。注意一定要在生产模式构建下测试开发模式下 Vue 的响应式和组件初始化会有额外的警告和代理开销测出来的数据不能反映真实上线表现。我自己的经验是如果优化前后差异只有零点几毫秒说明这个问题根本不在 Vue 渲染上很可能在接口数据量、第三方库内部逻辑或浏览器布局绘制阶段。这时代码里再加 v-memo 都是自欺欺人。4. 常见问题与排查技巧实录4.1 v-memo“失灵”更新永远不出现这个坑我踩过而且踩得很深。给一个商品列表加了 v-memo 之后点击“收藏”按钮收藏状态死活不刷新。第一反应是 v-memo 有 bug后来一行行检查模板才发现模板里用了 item.isCollected但 v-memo 依赖数组写成了 v-memo[item.name, item.price]漏掉了 isCollected。这是 v-memo 最容易犯的错误。Vue 不会因为漏写依赖而警告你因为从它的视角看v-memo 说“这些字段没变就不更新”它严格遵守了这个约定。所以排查方向就是把模板里所有插值、指令绑定、事件处理中读取的响应式字段全部列出来逐一对照依赖数组。还有一种“失灵”是依赖数组里放了引用类型。回调数据时重新赋值了数组对象但其中某项 item 的引用没变只改了 item 内部的属性。v-memo 对比的是数组项本身引用没变就被判定为没变化。这种问题的排查思路是看看数据源是整体替换还是局部修改局部修改场景下必须把依赖项铺平到具体属性。4.2 v-once 之后数据变了页面怎么都不更新很多人第一次用 v-once 会以为它只是“减少渲染频率”结果发现数据更新后界面完全没反应赶紧回来翻文档。v-once 的语义就是用远不更新换性能它不是优化而是冻结。如果出现“用了 v-once 之后想更新但更新不了”解法很简单把 v-once 从元素上移除换成 v-memo 并指定依赖数组。v-once 适合内容确实不会再变的场景比如写死的页脚、静态公告、导入的富文本正文。只要内容还有一点点更新的可能就不要用 v-once。顺便提醒一个细节v-once 影响的是它所在元素的子树父组件和其他兄弟节点不受影响。如果你看到整块区域都不更新确认一下是不是把 v-once 加到了公共容器上导致里面本应更新的子模块也被冻结了。4.3 和动画、第三方库打架v-memo 配合 transition-group 时我碰到过动画失效。原因在于 transition-group 进入和离开动画依赖 VNode 的新增和移除状态而 v-memo 复用了旧 VNode导致组件认为没有新增或移除动画钩子不触发。解决方案是避免在动画列表中同时使用 v-memo或者只在列表项的纯内容区域使用 v-memo动画容器保持原样。另一个常见冲突是 v-memo 和第三方 UI 库的组件结合。比如 el-table 本身做了列渲染优化你再强行套 v-memo反而可能干扰它内部的更新逻辑。实测下来对成熟组件库的表格优先用组件自身提供的高性能模式和虚拟滚动比硬上 v-memo 靠谱。至于 echart 闪烁前面已经说过它往往不是 Vue 更新问题而是 canvas 重建、resize 监听重复绑定、容器尺寸变化导致的。排查顺序是先用 Performance 看有没有长任务和布局抖动再检查 echarts 实例是否被反复 init 或 dispose最后再看 Vue 层面是否需要 v-once 或组件隔离。4.4 性能排查速查表现象优先排查方向对应方案列表滚动卡顿行级更新是否连坐v-memo 按行控制更新大段富文本/文档渲染慢解析是否每次都执行computed 缓存 v-html图表闪烁canvas 实例是否被重建组件隔离 setOption 更新整块内容永不更新却不该重渲染是否包含动态依赖v-once 锁死数据没变但视图重绘响应式粒度是否过大shallowRef、拆分组件数据变了但视图不更新v-memo 依赖数组漏字段对照模板补全依赖这张表我贴在了团队前端项目的 wiki 里每次有人提“页面卡”“图表闪”就先对着查一遍。大部分问题都能在十分钟内定位。5. 别把渲染优化用错地方分清视图渲染与图形渲染5.1 v-once/v-memo 管的是组件更新不是画布绘制有时候前端同学会把“渲染慢”的所有锅都甩给 Vue。但我会先问一个问题这个渲染发生在哪一层v-once 和 v-memo 管的是 Vue 组件的 VNode 更新与 DOM patch属于视图更新层。另一类渲染比如 liltoon 卡通渲染、Unity Shader 的 NPR 风格化渲染、Impeller 渲染引擎这类图形渲染完全不是一回事。它们发生在 GPU 管线、Shader 编译、光栅化阶段由 WebGL、Canvas 或原生渲染引擎负责。你不可能靠 v-memo 让一个卡通角色的描边渲染得更快也不可能用 v-once 让 canvas 里的粒子系统减少重绘。这两层经常被混淆是因为浏览器工具里的“渲染”指标既能反映 JavaScript 计算也能反映绘制和合成。排查时先确认耗时集中在哪个阶段如果 Performance 面板里 Scripting 阶段耗时高v-memo 这类优化有意义如果 Painting 或 Rendering 阶段耗时高问题在样式计算和绘制层要考虑减少重绘区域、提升合成层、优化 Canvas 绘制逻辑。5.2 视图更新层还有哪些更底层的优化手段v-once 和 v-memo 只是响应式视图更新层的一部分。实际项目里我会按场景组合使用这些手段。shallowRef 可以避免对深层对象的递归响应式代理。比如一个图表配置对象内部结构几百层但实际只有某个配置项会被修改用 shallowRef 包裹后修改整层引用时才触发更新。配合上 v-memo能解决大部分“数据没变但视图总在更新”的问题。markRaw 标记的对象不会被转换为响应式代理。echart 实例、第三方地图实例、大型类库实例都应该用 markRaw 标记掉避免 Vue 对不需要响应式跟踪的对象做无谓的代理成本。这也解释了为什么我的 KLineChart 组件里实例管理得好图表更新才不卡。组件拆分是容易被忽略的基础优化。把大组件拆成多个粒度合理的子组件本身就是让 Vue 的更新范围最小化。v-memo 解决的问题本质上是“更新范围已经无法再小连子组件内部某些行都不想让它动”。先做组件拆分再用指令做精细化控制顺序不要反过来。5.3 什么时候该用别的方案而不是 v-memov-memo 不是万能药。数据量非常大、窗口内只展示几十条的列表应该用虚拟滚动而不是给几千个 DOM 节点加 v-memo。虚拟滚动从根源上解决了“不可见节点也要渲染”的问题v-memo 做的只是让它们少更新几次。交互复杂的模块比如一个可拖拽、可编辑、可撤销的富文本编辑器手动控制更新时序往往比用 v-memo 更可靠。v-memo 在控制更新时机时会把子组件内部的状态变化也一并忽略掉一旦和编辑器自身的状态管理纠缠很容易出现数据对不上视图的诡异 bug。长列表配合 v-memo 还要留意内存。Vue 需要保存每个节点的 memo 依赖数组旧值如果列表持续增长且依赖项很多这部分内存占用会上升。我在无限滚动列表上实测过如果更新频率非常低v-memo 收益有限反而白白增加内存。给优化方案做取舍时把“更新频率”和“节点数量”两个变量放在一起考虑比单看节点数量更准确。最后再分享一个小技巧v-memo 依赖数组里的字段顺序会影响比较效率把最容易变化的字段放在最前面。这样大多数情况下对比到第一项就发现不等可以直接跳出后续比较省下一点点时间。一次更新省不了多少但列表行数上去之后累计效果还是能感知到的。真正的优化都是在这种细节里一点一点抠出来的。
返回列表