ARTICLE DETAIL

资讯详情

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

Vue 1.x性能优化:从响应式原理到track-by实战

Vue 1.x性能优化:从响应式原理到track-by实战 第一次在项目里用Vue 1.x做后台管理系统时我一度以为是框架出了Bug一个表单页面几十个输入框每次敲一个字符都感觉卡半拍。后来把模板里的绑定数统计了一下才发现一个页面竟然躺着两千多个Watcher这才意识到Vue 1.x所谓的“性能问题”根源几乎都在它的响应式模型上。Vue 1.x虽然已经是老古董但至今还有不少老项目在维护。这篇文章我想好好聊聊Vue 1.x的性能优化从Observer、Watcher、模板编译这些底层机制讲起再落到track-by、组件拆分、Object.freeze这些可落地的实操手段上。如果你正在维护Vue 1.x老项目或者想从原理上理解前端框架为什么快、为什么卡这篇文章应该能帮到你。1. 先搞清楚Vue 1.x的运行时模型想要优化Vue 1.x不能上来就套“最佳实践”得先弄明白它在运行时到底做了什么。Vue 1.x的架构和Vue 2.x、3.x有一个本质区别它的数据更新是“细粒度依赖追踪”而不是虚拟DOM diff。这个区别决定了它的性能瓶颈在哪里也决定了优化的方向。1.1 数据劫持与Observer每一个属性都有代价Vue 1.x的响应式核心是Object.defineProperty。在组件初始化阶段Vue会递归遍历data对象里的所有属性把它们重新定义为带getter和setter的访问器属性这个过程就是Observer的“依赖收集前哨战”。这里有个很容易被忽略的事实递归遍历本身就是一笔巨大的开销。假设你的data里有500个属性其中200个是嵌套对象那么初始化时Vue要执行上千次defineProperty操作。在低端移动设备上这个过程的耗时非常明显尤其是嵌套了三层以上的配置数据时。我用一个生活类比来解释defineProperty相当于给每个箱子里的每件物品都贴上追踪标签。搬家时如果你把几百个箱子里的所有东西都拆开、贴标签、再放回去那光是做标记就得花上很久。Vue 1.x初始化data就是这样一个过程而且它没有后续的虚拟DOM diff做缓冲所以这个初始化的时间成本会直接打在用户身上。实测中我遇到过接口返回一个多层对象前端直接用this.allData response.data塞进data的情况。那个对象有四个层级每层都有几十个字段。页面打开时模块初始化阶段就占了首屏耗时的一大半。原因很简单Vue把整个对象里所有用不到的子字段全部observe了一遍。很多字段模板里根本没用但初始化时照样付出代价。所以在Vue 1.x里第一原则是没有进入模板使用的数据就不要放进data。放进data意味着它会被递归、被劫持、被追踪无论你用不用它。1.2 Watcher粒度精确更新背后的代价Vue 1.x采用细粒度的Watcher模型。什么意思呢模板里的每个绑定表达式都会创建一个独立的Watcher实例。{{ item.name }}是一个Watcherv-bind:titleitem.title是另一个Watcher。一个页面有100个绑定就是100个Watcher1000个绑定就是1000个Watcher。这和Vue 2.x完全不同。2.x的Watcher粒度是“组件级别”——每个组件只有一到两个渲染Watcher数据变化时通过虚拟DOM diff来找出具体要更新的节点。而Vue 1.x没有这个机制它的Watcher直接作用于真实DOM节点数据一变对应的指令更新函数立刻执行。听起来很精确对不对但代价是内存和初始化时间的线性增长。每个Watcher实例都需要维护自己的依赖集合、回调函数、指令绑定信息。创建Watcher的过程中还要完成“依赖收集”当访问某个数据属性时当前Watcher会被记录为该属性的订阅者。绑定越多这个依赖关系图中的边就越多启动时消耗的时间也就越长。Vue 2.x把性能优化的核心方向从“减少Watcher数量”扭转成了“减少不必要的diff计算”本质上就是因为大家发现Watcher数量多到一定程度后内存和初始化开销已经完全失控了。所以在Vue 1.x项目里控制Watcher总量就是控制性能生命线。你不需要背下来这个结论只需要记住一个判断方式打开页面后模板里有多少个{{ }}插值、多少个:绑定、多少个v-if或v-for背后就有多少个Watcher在运行。数量级一旦上千必须开始警惕。1.3 模板编译与指令系统浏览器端编译要避免Vue 1.x有完整版和运行时版之分。完整版的vue.js自带模板编译器能在浏览器里把template字符串编译成渲染逻辑。如果你在页面里用script标签直接引入Vue 1.x并且传的是template选项而不是render函数那么浏览器每次加载页面时都要现场做一次模板编译。这个编译过程不是单纯的正则替换它要解析模板语法、识别指令、处理表达式、生成渲染函数。模板越大编译时间越长。虽然编译结果会被缓存但首次加载的编译成本无法避免。生产环境里一定要用构建工具比如Webpack配vue-loader提前把模板编译好浏览器只加载编译后的JS。这既是性能优化也是体积优化——运行时版比完整版小非常多。我在项目里见过有人直接在网上用bootcdn引了个完整版vue.js然后写了一大坨模板首屏有个明显白屏期查了半天才发现是浏览器端模板编译在拖后腿。另外Vue 1.x模板中的指令最终会被解析成“指令描述符”每个指令描述符又和具体的DOM节点关联。所以模板里指令越密集解析时期生成的描述对象也越多这就是为什么下面要讲“模板优化不能只看Watcher数量”的原因。2. 响应式数据层面的优化理解了运行时模型之后优化的抓手其实就清晰了既然瓶颈在数据初始化和Watcher数量上那就从数据源头入手。2.1 控制data的规模和层级这个字段我建议你贴到团队规范里data里只放需要参与视图渲染的值只放模板直接或间接用到的字段。其他数据放到组件实例的非响应式属性上或者放到模块级变量里。为什么强调“只放需要的”因为Vue 1.x对data的处理是无差别的。它不会区分哪些数据被模板使用哪些数据完全闲置。只要是data里的普通对象它就会递归赋予响应式能力。实际操作时我习惯在接口返回数据后做一层清洗this.list response.data.map(function (item) { return { id: item.id, name: item.name, status: item.status } })只保留渲染所需的字段把_raw原始数据放到一个非响应式的Map里var rawMap {} // 在created钩子里把完整对象存进去 rawMap[item.id] response.data这样既保留了完整数据用于后续操作又不让Vue把那些几十个字段全部observe一遍。第二点是控制层级。尽量把数据扁平化不要搞出this.user.profile.address.city这种四级甚至五级链。层级越深初始化时递归的次数越多每次访问时的依赖路径也越长。遇到深层对象我通常会在拿到数据后就地展平// 原始对象 var user { id: 1, profile: { address: { city: Shanghai } } } // 展平后 this.userId user.id this.userCity user.profile.address.city模板里只出现userCity而不是user.profile.address.city。这一步把“多次深层属性访问”变成了“一次浅层属性访问”既提高了访问速度也降低了初始化时的递归深度。补充一个容易踩的坑不要用Vue.set给已经初始化的对象动态添加新属性来规避初始化开销。Vue.set虽然能添加响应式属性但它同样要调用defineProperty而且还会重新触发依赖通知。如果要在初始化后给对象加字段而这个字段不需要响应式就干脆用普通赋值或者挂在this上。2.2 Object.freeze冻结静态数据在我做过的Vue 1.x项目里有一类数据特别值得单独处理下拉选项配置、枚举映射、权限常量表、状态字典这类数据一旦定义就永远不会变但模板又确实需要读取它们。如果直接放进dataVue 1.x会一个不漏地把它们全部变成响应式数据每个属性都会加入依赖追踪。白白占用内存和初始化时间。我用过最有效的手段就是Object.freezevar statusMap Object.freeze({ active: 启用, disabled: 禁用, expired: 已过期 }) new Vue({ data: { statusMap: statusMap, list: [] } })被Object.freeze冻结的对象属性变成不可写、不可配置。Vue 1.x在遍历这个对象时无法对内部属性做访问器劫持于是这部分数据就不会被纳入响应式系统。你依然可以在模板里读取statusMap.active但它不会建立Watcher依赖也不会在数据结构中参与依赖收集。有人可能会担心冻结后Vue会不会报错。实测下来不会。Vue 1.x的Observer对无法执行defineProperty的属性有保护逻辑最差的后果就是这部分数据退化为普通属性读取完全不影响运行。我在一个大型后台系统里验证过把一份包含几百条枚举配置的对象冻结后页面首次渲染时间在低端机上差出几十毫秒越大的静态数据差异越明显。这个优化看起来简单但效果极其稳定。因为绝大多数管理后台都会有大量的字典表数据而且这些字典表往往又大又不会变属于“完全不应该被响应式系统处理”的典型数据。2.3 计算属性与watch的正确选择Vue 1.x的计算属性computed是自带缓存的。它只有在响应式依赖发生变化时才会重新计算多次访问直接取缓存结果。这个特性在优化中很值钱因为模板里常见的那种“同一个计算结果在多处使用”的情况用computed可以合并计算次数而模板表达式则会把每次渲染都当作一次新计算。举个例子一个列表需要同时展示fullName和fullNameInitial。如果在模板里分别写span{{ firstName lastName }}/span span{{ (firstName lastName).charAt(0) }}/span这两个表达式各自是独立的Watcher同一份数据要计算两遍。改成computedcomputed: { fullName: function () { return this.firstName this.lastName }, fullNameInitial: function () { return this.fullName.charAt(0) } }模板里span{{ fullName }}/span span{{ fullNameInitial }}/span第一个computed只会计算一次第二个computed直接复用第一个的结果。整体计算量减少Watcher的依赖范围也更清晰。watch则要克制使用。Vue 1.x的watch可以监听路径字符串比如user.name也可以监听整个对象。监听整个对象时Vue需要建立对对象内部所有属性的依赖这个开销接近一次micro-observer。所以如果只是关心某个子字段一定要写具体路径不要图省事直接监听对象// 不推荐监听整个user对象所有子字段变化都会触发 watch: { user: function () { // do something } } // 推荐只监听需要的字段 watch: { user.name: function () { // do something } }我在实际项目中还养成了一个习惯能用computed表达的计算不用watch手动维护结果。watch本质上是面向“副作用”的比如数据变化后触发请求、写入缓存、同步外部对象。如果你用watch来重新计算一个展示数据很容易出现中间状态不一致的情况而且watch回调里再赋值别的响应式数据又会引发额外一轮更新性能反而更差。3. 模板与列表渲染的优化数据层面的优化做完接下来是模板层面。模板是Watcher的诞生地模板写得不好前面数据优化得再干净也扛不住。3.1 track-by让列表复用有据可依Vue 1.x的v-for列表在数据变化时会尽量复用已经渲染出来的DOM节点和对应的Watcher。默认情况下这种复用是基于数组索引的。问题在于如果列表发生重排、插入、删除而每个节点的身份对应关系完全靠索引维持Vue就搞不清楚“哪条数据对应哪个DOM节点”了。最坏的情况下它会销毁一批节点再重新创建一批造成成倍的性能损失。解决办法是给v-for加上track-by指定一个唯一标识字段li v-foritem in list track-byid {{ item.name }} - {{ item.price }} /li这样当数组因为排序、过滤、删除而发生变化时Vue能精准判断哪些节点可以原地复用哪些节点需要移动哪些节点需要删除。它会把“销毁重建”变成“节点移动”和“局部文本更新”性能差距非常大。我踩过的坑是track-by的字段必须是稳定且唯一的不能是索引。如果你指定了track-by$index那等于告诉Vue“用数组位置来区分节点”这是在强迫它退化成默认行为完全失去了track-by的意义。另外如果列表项是简单字符串或数字不是对象Vue 1.x本身可以按值来识别这时候不写track-by问题也不大但凡是对象数组尽量都写上。3.2 v-if与v-show切换频率决定选择这个选择题很多初学者容易搞反。Vue 1.x里v-if是条件渲染条件为假时节点不会渲染出来v-show则始终渲染节点只是用CSS的display控制显示隐藏。在Vue 1.x的体系中v-if的切换会创建或销毁对应的指令Watcher和DOM节点。也就是说从一个分支切到另一个分支时整个子树的Watcher、虚拟节点、事件监听器都要重新建立。这个过程比改一个CSS属性要重得多。高频切换的场景比如Tab切换、弹层显示、折叠面板用v-show几乎总是更优的。反过来如果某个区域渲染成本很高比如一个复杂的图表组件而且用户大概率不会打开它那就应该用v-if延迟渲染避免首屏白白付出构建成本。我遇到过一个典型的例子列表页里有个“详情”弹层里面放了一个很大的表格和一堆联动逻辑。一开始用的v-show结果首屏就卡顿因为弹层里的内容在页面加载时就已经全部渲染了。改成v-if之后只有用户真正点开详情时才开始渲染首屏耗时直接降了一截。一句话总结就是渲染成本低的场景用v-show切换频率高渲染成本高的场景用v-if首次进入的成本需要把优先级提起来。3.3 组件拆分把Watcher关进笼子里前面说了Vue 1.x的Watcher数量等于模板里的绑定数量。那么问题来了如果一个列表项内部特别复杂里面有十来个字段需要展示还有几个计算逻辑怎么办如果全部写在父模板的v-for里面那列表有多少行父级模板就会有多少组绑定。500个列表项乘以每项15个绑定就是7500个Watcher挂在父级渲染上下文中。任何一项数据变化即便只是某个字段的开关状态变了Vue也要遍历一遍这个庞大的Watcher集合来确认到底是谁依赖了它。更好的做法是把列表项拆成独立组件my-list-item v-foritem in list track-byid :itemitem /my-list-itemmy-list-item组件内部再去处理自己的字段展示和计算逻辑。这样父模板里只有一层v-for的绑定具体的Watcher都被关到了子组件自己的作用域里。数据变化时父级只要根据track-by更新组件引用子组件内部自己决定哪些字段需要更新。这样做带来两个直接好处第一父级模板的绑定数量从“列表项总数乘以每个项内部的绑定数”降为“列表项总数”Watcher数量大幅下降第二排查问题的时候你不再需要在一个几百行的模板里找某个字段到底在哪绑定、被谁依赖组件边界把数据流和更新范围都隔离清楚了。我当时优化那个长列表页面最见效的动作就是把表格行里那一大坨交互逻辑抽成了一个table-row组件主模板瞬间清爽了很多。3.4 模板里不要堆复杂表达式这里再补一个细节Vue 1.x模板中的表达式最终会被编译成一个求值函数。表达式越复杂这个函数每次执行时的耗时就越长。虽然Watcher只会在依赖变化时触发求值但如果这个表达式涉及方法调用、数组遍历、多层属性访问而且页面上绑定了很多处累计消耗依然不可小觑。我见过最夸张的写法是模板里直接调用后端格式化方法div{{ formatDate(item.createdAt, yyyy-MM-dd) }}/div div{{ formatStatus(item.status) }}/div然后formatDate和formatStatus内部还有一堆规则判断。这样的函数每放在模板里一次就相当于创建了一个Watcher每处渲染都要重新执行格式化逻辑。正确的做法是让数据在进入data之前就已经是格式化好的展示值或者用一个计算属性统一算好computed: { displayRows: function () { return this.list.map(function (item) { return { displayTime: formatDate(item.createdAt, yyyy-MM-dd), displayStatus: formatStatus(item.status) } }) } }模板只负责{{ displayTime }}、{{ displayStatus }}这样的简单取值。这样做还有一个额外好处当list没有变化时displayRows会直接走缓存不会重复执行格式化方法。4. 性能问题定位与排查实录优化手段讲了不少但真正到了项目里第一步往往是“定位问题在哪”。Vue 1.x的调试工具远不如后来的Vue Devtools那么顺手排查性能问题得有自己的一套土办法。4.1 数据更新方式与Vue.set先说一个容易和性能问题混淆的坑Vue 1.x的数据变更检测有两处和Vue 2.x类似的限制。第一直接通过索引修改数组元素this.list[0] newValue不会触发视图更新。有人会把“视图没更新”误判成“性能问题”其实不是性能问题是检测机制压根没覆盖到这个操作。正确处理方式是使用Vue.set(this.list, 0, newValue)或者用支持响应式的方法this.list.$set(0, newValue)。这里顺带说一句Vue 1.x对数组的push、pop、splice这些方法做了包裹所以用原生数组方法修改数组是没问题的但索引赋值不算。第二初始化之后给对象添加新属性this.obj.newField x这个新属性不会变成响应式的。如果模板里依赖了它你会发现数据变了但DOM不更新。这个也不是性能问题而是响应式基准。为什么我要在性能优化文章里提这两个因为在优化过程中我经常看到有人为了“让老项目跑得更快”把Vue.set用得满天飞高频触发响应式更新反而把性能搞得更差。正确姿势是在初始化data时就把所有需要的字段声明白不要等运行时再动态注入响应式属性如果某个字段真的不需要响应式就直接普通赋值不要用Vue.set去制造没必要的依赖。4.2 长列表卡顿排查实例这里我完整复盘一个我之前优化过的真实场景。一个数据表格组件渲染500行数据每行10列列里有文本、有状态图标、有操作按钮还有一些联动CheckBox。页面打开后要等两秒多才能交互点一下排序浏览器直接进入“无响应”状态。排查的第一步我在new Vue()前后各打了一个时间戳var t0 Date.now() new Vue({ data: initialData, created: function () { console.log(data生效耗时, Date.now() - t0) } })打印出来一看光数据初始化阶段就花了600多毫秒。然后我检查了data发现每行数据是从接口返回的一整个大对象包含几十个字段其中有大量历史记录、日志描述、扩展字段。这些字段根本不在表格里展示。Vue 1.x把它们全部observe了这才是首屏慢的元凶。第二步做了字段裁剪每行只保留渲染需要的那几个字段剩余的放到一个非响应式Map里页面打开速度立刻有改善。接着检查了Watcher数量表格行内每个单元格有好几处绑定算下来一行接近50个Watcher500行就是25000个。这个量级在1.x的细粒度模型下已经非常夸张了。第三步做的事情是单元格绑定能合并的合并、能抽成组件的抽出去、复杂计算改到computed里。同时给v-for加上了track-byid。最后又做了一个列表替换的优化排序和过滤时不在原数组上逐行修改字段而是先在一个普通数组里完成排序再一次性把整个排序结果赋值给listthis.list sortedList.slice()这样做的原理是Vue 1.x看到list整体被替换只需要根据track-by做一次列表更新逻辑利用已有节点MobileUpdate而不是触发500行x每行N个Watcher的逐条更新。实测下来这个改动对排序操作的流畅度提升非常明显。最终优化结果首屏在低端机上从两秒多压到800毫秒左右排序操作不再无响应整个表格的交互流畅度回到了可以接受的水平。4.3 构建层面的基础优化不能漏框架层面的优化做完还要检查一遍构建和部署的基础项。Vue 1.x生产环境有几个必须做到的用Vue.config.debug false关闭调试模式。Vue 1.x在debug模式下会输出大量警告信息还会执行额外的断言逻辑这些都会拖慢运行速度。生产环境务必确认关掉。使用压缩后的JS文件同时把process.env.NODE_ENV换成production。使用运行时版本而不是完整版避免浏览器端模板编译。关闭不必要的Vue.config.silent不对silent反而应该打开能挡住无用日志。这些虽然不直接改变框架内部的算法复杂度但能减少运行时兜底的额外工作。尤其是模板预编译前面说过省掉的是浏览器端一大块编译时间。4.4 常见问题速查表症状可能原因优化手段首屏初始化慢data嵌套过深、属性过多字段裁剪、扁平化、Object.freeze静态数据列表操作卡顿Watcher数量过多、缺少track-by拆分列表项组件、加track-by、整体替换数组输入框敲字卡顿输入绑定的Watcher过多、事件处理过多合并表达式、减少模板绑定、用computed收口页面响应慢但CPU不高大量无意义的依赖收集和Watcher调度降低数据层级、减少响应式属性数量数据变化后视图不更新索引赋值/新增属性未响应式化用数组方法或Vue.set尽量初始化时声明完整字段模板编译耗时明显浏览器端使用完整版Vue改用构建工具预编译模板这张表基本覆盖了Vue 1.x老项目里最常见的性能问题。遇到问题的时候先对照症状和原因别一上来就整体重写组件。我个人在实际维护Vue 1.x老项目中的体会是性能优化不是套用几条“最佳实践”就行了核心是先看清楚自己的数据规模和Watcher数量再有针对性地裁剪。哪怕Vue 1.x现在已经不主流了但把这一套响应式模型的底子吃透回头看Vue 2.x、Vue 3.x的优化思路会特别轻松因为后续版本本质上都在解决1.x暴露出的问题Watcher太多、初始化太重、依赖收集太粗。如果你正维护一个Vue 1.x老项目不要急着整体重写先用文中的方法把数据规模和Watcher数量压下来多数卡顿都能明显缓解如果依然不够再考虑对重交互模块用新版框架做渐进式重构。
返回列表