ARTICLE DETAIL

资讯详情

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

Vue 1.x 性能优化实战:从响应式原理到列表渲染优化

Vue 1.x 性能优化实战:从响应式原理到列表渲染优化 接手过几个 Vue 1.x 时期的老项目之后我对性能优化这四个字的理解跟刚入行时完全不一样了。那时候网上铺天盖地的文章都在讲 jQuery 时代怎么减少 DOM 操作到了 Vue 1.x 这里很多人直觉以为框架帮我们管了 DOM性能问题就不存在了。等真正上线跑起来才会发现页面卡起来一点都不含糊改一个筛选条件整张表格要白屏两秒钟初始化一个几万行的列表浏览器直接转圈给你看。这篇文章不是给你念 API 文档而是围绕 Vue 1.x 的性能优化核心来拆。我把这几年在老项目里踩过的坑、试过的方法、实测有效的方案连同底层原理一起讲清楚。文章会先带你弄清楚 Vue 1.x 响应式系统的成本到底在哪里再讲 Watcher 数量为什么会拖垮页面、列表渲染应该怎么做最后落到工程构建和加载优化上。无论你是刚接手存量项目的新人还是想给老系统做最后一轮性能抢救的负责人这篇文章都能给你一套可以直接落的思路。1. 先搞清楚 Vue 1.x 的性能瓶颈在哪儿很多人做优化上来就改代码改半天没效果连问题方向都没找对。所以在讲具体优化手段之前我觉得非常有必要先把 Vue 1.x 的性能模型讲清楚。它的瓶颈跟 Vue 2、Vue 3 有本质区别原因就藏在它的响应式实现里。1.1 依赖追踪是双刃剑Vue 1.x 的核心是依赖追踪Dependency Tracking底层用Object.defineProperty把 data 里的每个属性转换成 getter/setter。模板里用到哪个属性渲染的时候就会访问它触发 getter把当前组件的订阅者Watcher注册到这个属性的依赖列表里数据一变setter 就会通知所有订阅了这个属性的 Watcher 去更新。这个机制非常巧妙理论上只有用到了某个数据的组件才会因为该数据的变化而重新渲染。但要注意一个容易被忽略的事实——每个属性的 getter/setter 都是有成本的而且这个成本是初始化时一次性付出的。举个例子。你定义一个订单对象const order { id: 1001, customer: { name: 张三, address: { city: 杭州, district: 西湖区 } }, products: [ { name: 衣服, price: 199 }, { name: 鞋子, price: 399 } ] }当 Vue 实例创建时它会递归遍历这个对象把id、customer、name、address、city、district以及数组里的每一项name、price全部走一遍defineReactive。嵌套层级越深、数据量越大初始化时消耗的时间就越长。一个巨大的静态字典表几百个城市、几千个 SKU如果直接塞进 data白屏时间就会明显增加。1.2 初始化时 defineProperty 的开销这个开销到底有多大我用一个实际场景说明。某项目里有一份全国行政区划数据大约 3000 多个对象嵌套三层。放在 data 里后接口还没请求页面就已经卡了一秒多。后来我把这份数据用Object.freeze处理了一下再放进 data初始化耗时直接降了一个数量级。关键在于Vue 1.x 的 observer 在遍历对象时会判断Object.isFrozen(obj)如果是冻结对象就跳过 defineReactive。这意味着如果某份数据只用于展示、后续不会修改就可以放心大胆地 freeze 掉省掉一大笔响应式初始化开销。注意Object.freeze是浅冻结还是深冻结它本身是浅冻结深层对象仍然可以修改。你要是想整棵树都跳过响应式需要做一次深冻结或者保证这份数据只有顶层被读取。实际项目里我一般写个deepFreeze工具函数来处理。不过要切记冻结就意味着以后不能通过this.xxx newValue去更新它一旦改了数据视图不会变。所以冻结的适用范围是纯静态配置、词典、枚举说明这类数据业务数据别乱冻结否则调试的时候会怀疑人生。1.3 新增属性与数组变更的陷阱Vue 1.x 的defineReactive只在初始化时对已存在的属性生效之后给对象新增一个属性这个属性是没有 getter/setter 的。如果你直接this.order.shippingInfo { ... }然后期待视图自动更新那大概率会失败。解决办法是使用Vue.set或vm.$setVue.set(this.order, shippingInfo, { company: 顺丰, code: SF123 })$set会调用 observer 的observe方法把新属性也转成响应式的。但要注意这个操作同样会触发一次defineReactive频繁使用也会积累开销。更常见的性能陷阱是数组Vue 1.x 重写了一些数组方法push、splice 等每次调用它们都会立即通知依赖更新而且是同步的——不像 Vue 2 有 nextTick 队列合并更新。你在循环里 push 100 条数据它就真真切切地通知 100 次。所以老项目里常见的循环里逐个 push、逐个修改的写法在 Vue 1.x 里等于自己给自己制造卡顿。我会在第 3 章专门讲怎么处理这里先把原理记住响应式系统不是免费的它有初始化开销、有更新通知开销用得好和用得差性能差距可以到数倍。2. 模板绑定数量Watcher 太密是卡顿的首凶刚接触 Vue 1.x 时我一度以为一个组件只对应一个 Watcher模板里几十个绑定应该都是这个 Watcher 在做。实际上完全不是这样。Vue 1.x 的模板编译结果里每一处插值{{ }}和每一条v-bind、v-on指令都会形成独立的数据订阅。也就是说模板里的绑定越多页面里存活的订阅者就越多。2.1 每个插值和指令都是一个订阅者拿一个常见的订单行来算一笔账tr td{{ order.id }}/td td{{ order.customer.name }}/td td{{ order.amount | formatMoney }}/td td :classorder.statusClass{{ order.statusText }}/td td clickshowDetail(order)查看/td /tr这一行里就有 5 个绑定插值 3 个、class 1 个、事件 1 个也就是至少 5 个订阅者。如果列表有 1000 行模板里会有 5000 个订阅者。当某个公共数据变化时Vue 需要遍历所有依赖了它的订阅者然后逐一执行更新函数。这个链条一旦太长主线程就会被占住。减少订阅者数量是所有 Vue 1.x 优化里收益最直接的一步。我常用的手段有两个第一把模板里重复出现的复杂表达式抽到computed属性里。比如上面那个order.amount | formatMoney如果直接用过滤器Vue 1.x 会在每次依赖变化时重新执行过滤器要是把格式化逻辑放进computed不仅模板里只留一个简单绑定而且 computed 本身带缓存只有order.amount变化时才重新计算。第二合理利用计算属性的缓存性。很多团队喜欢在模板里写statusText这种映射逻辑比如span{{ order.status 1 ? 待付款 : order.status 2 ? 已付款 : 已取消 }}/span这样写会生成一个相对复杂的表达式订阅每次order.status变化都要重新求值而且可读性很差。改成 computedcomputed: { statusText() { const map { 1: 待付款, 2: 已付款, 3: 已取消 } return map[this.order.status] } }模板只留{{ order.statusText }}一个绑定、一次计算依赖还更清晰。2.2 用 computed 给模板瘦身你可能会担心 computed 本身不也是 Watcher 吗是不是就多了一个订阅者 这里要理解 computed 和普通表达式的区别。computed 属性确实有自己的 Watcher但它是惰性的只有访问它的时候才计算而且它会缓存计算结果。模板里绑定statusText和绑定一大段三元表达式虽然都只有一个订阅者但前者只在status变化时重算后者在组件重渲染时往往会被反复求值。如果这段表达式的计算量再大一点比如在 v-for 里对某个数组做 filter差出来的性能就不是一点点。我在真实项目里见过最典型的写法是ul li v-foritem in visibleItems(product.list){{ item.name }}/li /ulvisibleItems是一个方法。在 Vue 1.x 里方法调用跟 computed 不一样它不会做结果缓存。组件每次更新方法都要重新执行哪怕传进来的product.list根本没变。优化方法很简单把这个计算挪到 computed或者直接在 data 层维护一份已经过滤好的列表。2.3 慎用 deep watch 和复杂过滤器Vue 1.x 中$watch支持一个deep选项用来深度监听对象内部的变化。这个功能好用是好用但要明白它的实现方式它会对整个对象做递归遍历把对象里每一个嵌套属性的变化都收集到一个 Watcher 上。如果你的 watch 对象层级深、体量大任何内部属性的变化都会触发这个 Watcher 的回调非常容易造成改一个小字段整个大列表重新计算的情况。能用 computed 解决的就不要用 deep watch能用普通 watch 监听某个具体字段的就不要整个对象 watch。实在要监听一个深度嵌套对象的多个字段考虑把这些字段拍平到同一层或者通过 computed 返回一个精简后的依赖状态。另外Vue 1.x 提供了很多内置过滤器比如filterBy、orderBy、limitBy。这些过滤器在数据量小的时候很好用但它是每次渲染时对数组重新计算的机制。列表一旦上了几百条再用filterBy就会明显感到卡。性能敏感场景里我会一律把它们替换成computed 手动排序过滤把这个计算从每次渲染都执行变成依赖变化时才执行。3. 列表渲染从 v-for 到虚拟滚动的实战优化Vue 1.x 的列表渲染是性能重灾区。列表大、更新频繁、复用逻辑不明确三个问题叠加页面基本就卡死了。这一章我讲我自己实际用过的三种方案以及各自的适用场景。3.1 track-by 的设计和用法Vue 1.x 里v-for渲染列表时如果没有给元素指定track-by它在更新列表时只能靠索引来猜测哪些节点可以复用。这个猜的过程在列表发生排序、过滤、插入时会非常吃力——猜错了就重建 DOM重建几十个节点肯定比复用现有节点慢得多。track-by的作用就是告诉 Vue 1.x这一行数据唯一标识是哪个字段你用这个字段去对比新旧列表能复用就复用。 比如订单列表tr v-fororder in orderList track-byid这样当列表里的某个订单状态变化时Vue 只要更新那一行而不是整张表格。还有一种是track-by$index它告诉 Vue 按数组下标复用。这个适合纯静态展示列表比如只渲染一次、之后不再增删的报表行。如果列表会做排序、过滤、倒序用$index很容易出问题因为下标对不上数据了。我接手过一个老项目筛选功能卡就卡在没写track-by。每次切换筛选条件Vue 1.x 都是先把整个列表的 DOM 拆掉再重建500 行数据就卡得不行。加了track-byorderId之后切换筛选只更新有变化的那几行肉眼可见地流畅了一个档次。提醒一句加上track-by后如果列表行内部有输入框、复选框这类带状态的元素而且这些状态没有存到 data 里那复用时会发现状态没跟着数据走。解决办法是把这些临时状态也存到对应的数据对象上千万别依赖 DOM 里的东西。3.2 大数据列表的三种方案当列表数据超过 1000 条时光靠track-by是不够的。原因很简单Vue 1.x 的指令系统会为每一行创建若干 Watcher1000 行 × 每行 5 个绑定 5000 个 Watcher光初始化就够喝一壶的。这时候需要换思路不是怎么让渲染更快而是怎么让渲染更少。方案一分页。这是最简单粗暴也最有效的。把 5000 条数据切成 50 页每页显示 100 条每次只渲染一页。缺点是要加翻页交互但后端如果支持分页前端压力直接下降一个数量级。方案二前端分批渲染增量渲染。如果接口只能一次返回全量数据可以在拿到数据后不要立刻塞进列表而是先把数据存起来用一组定时器或者requestAnimationFrame分批把数据 push 进渲染数组比如每帧渲染 50 条。这样页面能先出来一部分用户感知上会好很多不至于白屏转圈。// 伪代码示意把 totalList 分成每批 50 条 let index 0 const batchSize 50 const timer setInterval(() { if (index totalList.length) { clearInterval(timer) return } this.visibleList this.visibleList.concat(totalList.slice(index, index batchSize)) index batchSize }, 0)注意这个方案里我用的是slice切片而不是concat追加其实 index 处理好就行。核心是不要让 5000 条数据在同一个 tick 里一次性渲染。方案三虚拟滚动。这个方案最优雅但实现成本也最高。它的原理是只渲染可视区域内的那一小部分行比如视口高度能显示 20 行就只渲染 30 行滚动时动态替换渲染的数据。Vue 1.x 时代没有特别成熟的轮子需要自己写或者改造 jQuery 插件。如果列表行高是固定的实现起来其实不算难外层一个overflow: auto的容器内层用一个撑起总高度的占位 div绝对定位里渲染可视行。我自己在移动端项目里用过一次虚拟滚动数据量是 3000 条效果非常稳。但要注意虚拟滚动下每个行的高度必须一致或者你能精确算出每个行的偏移量否则会出现滚动错位。3.3 数组替换比逐条 push 更省前面说 Vue 1.x 的响应式数组方法每次调用都会同步通知更新。这意味着如果你在循环里写this.list.push(item)每一句 push 都会触发一次依赖更新。虽然最终 DOM 更新可能不会每次真的变更但指令系统的 update 链路是实打实走了一遍的。正确的姿势是先在普通数组里把数据组装好最后一次性赋值给this.list。const newList [] for (let i 0; i 1000; i) { newList.push(createRow(i)) } this.list newList // 只触发一次更新同理修改列表里的字段也别一个个改。如果你要对 1000 行的某个字段统一追加一个后缀先 map 出一个新的数组再整体替换而不是在循环里this.list[i].name xxx suffix。后者每改一个字段都会触发通知前者只触发一次。这个思路放在对象上也是一样的多个字段要同时变化时尽量一次性构造一个新的对象赋值而不是连续this.a 1; this.b 2; this.c 3。Vue 1.x 没有帮你合并这些同步更新。4. 工程阶段的体积与加载优化到这一章我们已经把运行时的主要卡点处理得差不多了。接下来聊的是页面还没跑起来阶段的优化——打包体积和加载速度。很多老项目性能不好其实是首屏体积太大、资源太多造成的跟代码写得怎么样没多大关系。4.1 模板预编译老项目最容易漏掉的一步Vue 1.x 有两种使用方式一种是直接在 HTML 里写 template 字符串运行时由框架内部的编译器解析另一种是用 Vue Loader 把.vue文件里的模板预编译成 render 函数。两者的性能差距非常大。运行时编译意味着用户打开页面的那一刻浏览器要先执行一遍 HTML 模板解析器把模板字符串转换成 AST再生成代码执行。这个过程少则几十毫秒多则几百毫秒而且是一个串行过程。预编译则把这个工作转移到了构建阶段浏览器端只需要执行编译好的 render 函数省掉了运行时解析的时间。所以如果你接手的老项目还在用 CDN 引入的vue.js全量包并且在new Vue({ template: ... })里写模板那你的优化空间里绝对有一块是改造为预编译。做法其实不复杂项目里加上 vue-loader 和 webpack 的 Vue 插件把组件拆成.vue文件模板写在template标签里构建时自动完成预编译。如果项目已经用了vue-router和单文件组件那大概率已经是预编译模式了这步可以跳过。4.2 Webpack 分包和路由懒加载Vue 1.x 时代常用的构建工具是 webpack 1 / 2。很多人打包的时候不分包所有代码打成一个巨大的 bundle.js。我用 Chrome 的 Network 面板看过一个 1.2MB 的 JS 文件在弱网环境加载时白屏时间奔着五秒去。这个体积里大多数是框架代码、第三方库业务代码占比反而不高。首要优化是提取 vendor。用CommonsChunkPlugin把 vue、vue-router、vue-resource或 axios、lodash 这些几乎不变化的依赖单独打成vendor.js利用浏览器缓存后续业务代码更新时只下载业务包vendor 部分直接命中缓存。new webpack.optimize.CommonsChunkPlugin({ name: vendor, minChunks: Infinity })注意CommonsChunkPlugin提取 vendor 的配置要放在所有 loader 和插件都声明完、在执行顺序靠后的位置并且入口文件里要把包含第三方依赖的 chunk 手动声明进 vendor不然可能提取不出来或者把业务代码也卷进去。另一个大杀器是路由懒加载。Vue 1.x 配合 vue-router 使用 webpack 的异步组件语法可以让路由对应的页面在需要时才加载。写法是const Home resolve require([../pages/Home.vue], resolve) const router new VueRouter({ routes: [ { path: /, component: Home } ] })这样一来首屏只加载首页组件其他页面等路由跳转时才发起 JS 请求。对于有几十个页面的后台管理系统这个优化能把首屏脚本体积砍掉一半以上。4.3 数据请求与渲染的节奏控制首屏加载慢不只跟体积有关还跟数据请求的时机有关。老项目常见的问题是进入页面就并发请求一堆数据每个请求回来都触发一次视图更新互相抢占主线程用户看到的就是页面一会儿白、一会儿半渲染、一会儿又闪一下。我的习惯是给数据请求分个优先级首屏必须的数据比如列表第一页的内容、用户基本信息在路由进入时立即请求。首屏可选的数据比如字典缓存、配置项、其他 Tab 的内容放到 requestIdleCallback 或者 setInterval 延迟加载。用户触发后才需要的数据比如点击详情弹窗时的数据等用户点击再请求。另外一个细节是请求回来的数据不要立刻全部塞进 data。如果接口返回的是一个巨大的嵌套对象而页面只用到其中几个字段可以先做一次数据清洗再交给 Vue避免没必要的defineReactive递归。const raw await api.getOrderDetail(id) this.orderDetail normalize(raw)normalize里只提取模板用得到的字段顺手把深层嵌套结构调整成扁平的。这样既减少了初始化时间也让模板绑定更直白。5. 老项目的典型优化案例一个页面的前后对比前面几章讲的是方法论这一章用一个我实际做过的订单列表页面来串一遍。这个页面不算极端复杂但非常典型筛选条件三个、列表最多 5000 行、每行 6 个字段、带一个状态标签和一个按钮。优化前我测了一下打开页面到列表完整渲染要 2.8 秒切换一个筛选条件要 1.9 秒。5.1 页面现状和性能采样方法开始优化之前我先做了一次性能采样确认问题到底出在哪。方法很朴素打开 Chrome DevTools 的 Performance 面板录制从路由跳转到页面完全渲染的过程。在mounted里插入console.time(page-render)在nextTick回调里console.timeEnd(page-render)得到页面渲染耗时。在每次切换筛选条件的事件回调里也用console.time包一下看筛选逻辑本身耗时。采样结果page-render约 2.8 秒其中一个下拉筛选变化到列表更新结束约 1.9 秒。看 Performance 面板的 Main 线程可以发现大部分时间花在 Recalculate Style 和 Vue 的指令更新函数上说明问题主要出在列表 DOM 太多和更新粒度太粗。5.2 按优先级执行的优化步骤我按收益从大到小执行了下面几步第一步改分页。因为后端支持分页我把接口从一次返回全量 5000 条改成每次返回 100 条前端加了分页器。这一步直接让首屏渲染的数据量从 5000 行降到了 100 行渲染耗时肉眼可见地缩小了一个量级。第二步加 track-by。列表行的唯一标识是orderId我在v-for上加了track-byorderId。切换筛选时Vue 不再重建所有行只更新数据有变化的那部分。第三步模板瘦身。把每行里的order.statusText和order.amountText提前放到 computed 里模板里不再调用过滤器。这一步让每个行在重渲染时少做了两次格式化运算。第四步冻结静态字典。页面上有一些状态枚举、城市列表和业务数据无关。我把它们deepFreeze后放进 data减少了初始化时的 defineProperty 开销。第五步懒加载弹窗组件。页面里有一个订单详情弹窗组件一开始放在路由页面组件里同步加载。我改成点击查看按钮时通过异步组件方式动态加载首屏体积又小了一点。5.3 优化结果与踩坑提醒优化后的效果很直观指标优化前优化后收益首屏列表渲染约 2.8 秒约 0.4 秒明显改善切换筛选条件约 1.9 秒约 0.2 秒明显改善首屏 JS 体积1.6MB0.9MB下降明显新增 Watcher 数量约 30000 个约 1200 个下降明显这个项目里收益最大的毫无疑问是分页加载因为它直接砍掉了 4900 行数据带来的所有成本。但如果没有track-by即使分页到 100 行筛选时仍然会出现整表重建优化效果会打折扣。所以这几件事是叠加起作用的单独做其中一两件效果不会这么完整。中间也踩过两个坑顺便提醒大家分页后记得处理筛选与分页状态的联动。如果筛选条件变化要从第一页开始重新请求而不是停留在当前页码。否则用户筛选后看到的是第 5 页的空结果体验很怪。加 track-by 之后列表行的输入框状态要注意。那会儿列表行里有一个备注输入框用户输入的内容没有立刻存进 data。加了 track-by 后排序会导致输入框内容错乱。解决方式是给每行的备注字段加上v-model绑定让输入内容进到数据对象里行复用时就自然保留。这些做完之后我在代码里把所有 Vue 1.x 专用优化 的注释都留了下来给后面接手的人看免得他们觉得这些写法很奇怪又改回去。毕竟一段代码如果不是靠性能数据驱动修改很容易在后续重构时被当成历史遗留问题清掉。最后说句实在话Vue 1.x 的性能优化核心就四个方向——削依赖、减 Watcher、控渲染、预编译。但比这更重要的是你得先建立一个性能成本意识每写一个模板绑定、每请求一份大字典、每 push 一条数据都知道背后 Vue 在做多少事。这个意识有了Vue 2 和 Vue 3 时代碰到的性能问题你同样能快速定位。
返回列表