Vue 生命周期:图表宽度为什么会偶发性量成 0
图表只画出一半、容器宽度偶发性拿到 0,这类 bug 很少是图表库本身有问题,更多时候是组件在不该初始化的时候初始化了。只要渲染时机、DOM 可见性和缓存激活顺序稍微错开一点,mounted 里量出来的尺寸就可能根本不可信。
这次看板页面的问题正好把几个生命周期边界同时暴露出来:组件挂载了,不代表容器已经准备好;keep-alive 缓存了,不代表重新显示时会再走一遍同样的钩子;数据请求、尺寸测量和资源清理如果都放错位置,现象就会变得又偶发又难复现。
下面会从“图表宽度为什么会变成 0”这个现场切进去,把 created、mounted、activated 和父子组件执行顺序一起理顺。
第一步:确认图表组件本身没写错
图表组件的骨架是这样的:
1export default { 2 name: 'TrendChart', 3 props: { 4 option: Object, 5 }, 6 mounted() { 7 this.chart = createChart(this.$refs.chartBox); 8 this.chart.setOption(this.option); 9 }, 10 beforeDestroy() { 11 if (this.chart) { 12 this.chart.dispose(); 13 this.chart = null; 14 } 15 }, 16};
单独跑这个组件,图表撑得满满的,没问题。说明 mounted 里拿 DOM 这个大方向没错——mounted 触发时,组件自己的 $el 已经是真实节点,created 阶段是拿不到的。我写了个最小例子确认这条边界:
1new Vue({ 2 el: '#app', 3 data: { msg: 'hi' }, 4 template: '<p>{{ msg }}</p>', 5 created() { console.log('created', this.$el); }, // undefined 6 mounted() { console.log('mounted', this.$el); }, // <p>hi</p> 7});
created 里数据和方法都准备好了,但 DOM 还没挂上去;mounted 才是真实节点进场的那一刻。这也是为什么图表这类要读真实尺寸、接第三方库的逻辑必须放 mounted,不能提前到 created——单独跑这个组件没出问题,恰恰印证了钩子放的位置是对的。问题只可能出在"单独跑"和"看板里跑"的差异上。
第二步:看板页面里的容器还没撑开
单独跑没事,套进看板页面就偶发出问题,那大概率是父容器的尺寸时机在捣鬼。看板这个页面结构大致是:外层一个可折叠的统计卡片区,图表在卡片展开之后的区域里。
1<template> 2 <div class="dashboard"> 3 <StatCards :collapsed="cardsCollapsed" /> 4 <TrendChart :option="chartOption" /> 5 </div> 6</template>
我加了行日志,在图表组件的 mounted 里打印 this.$refs.chartBox 的 clientWidth,复现问题的那次打出来是 0。父组件的宽度还没最终确定,图表组件的 mounted 就已经执行完了。
这里就得说到父子组件生命周期的执行顺序:挂载过程是父组件先进入 beforeCreate/created/beforeMount,然后子组件才走完自己整套 beforeCreate 到 mounted,最后父组件的 mounted 才触发。简单说就是父先开始,子先结束,父后收尾。图表作为子组件,它的 mounted 触发时父组件的 mounted 还没到,如果父组件在自己的 mounted 里还要做一次布局调整(这个看板恰好有个"根据统计卡片高度动态计算图表区域"的逻辑),子组件量尺寸的动作就踩在了父组件调整完成之前。
验证这个猜测很简单,在父组件和子组件的 mounted 里都打日志,看到的顺序果然是子组件先打、父组件后打。子组件读到的宽度,是父组件还没调整完时的中间态。
第三步:修法是让测量跟着数据走,而不是跟着 mounted 走
既然问题是"子组件的 mounted 跑得太早,父容器还没定型",那就不能死等 mounted 这一个时间点,得等真正该测量的那一刻。改法是把首次渲染的尺寸测量挪到 $nextTick 之后,而且是挪到父组件确定布局之后再通知子组件:
1// 父组件 2mounted() { 3 this.$nextTick(() => { 4 this.layoutReady = true; 5 }); 6},
1<TrendChart v-if="layoutReady" :option="chartOption" />
图表组件改成 v-if 控制之后再挂载,等父组件确认布局完成才创建,这时候量出来的宽度就是最终值。这里有个容易忽略的误区:mounted 并不代表"页面上所有跟这个组件相关的东西都定型了",它只保证这个组件自己的 DOM 进了页面。如果尺寸依赖的是父组件或者兄弟组件的异步渲染结果,在自己的 mounted 里去读,读到的就可能是半成品。$nextTick 解决的是"这一次响应式更新对应的 DOM 刷新完了没有",跟 mounted 解决的问题不是一回事,这次算是真正把两者的区别用实践焐热了。
顺手把请求时机也重新过了一遍。这个看板的统计数字是在 created 里发的请求,图表数据是在拿到统计数字之后再请求的,两个请求串行,纯逻辑处理,不牵扯 DOM,所以放在 created 没问题,比等到 mounted 更早发出去。真正要注意时机的,只有"请求回来之后要不要立刻操作 DOM"这一类——图表这里,数据请求可以早点在 created 发,但用数据初始化图表这个动作,必须等 DOM 真的进了页面才能做,这也是为什么整个组件要拆成"数据准备"和"图表创建"两段,分别挂在合适的钩子上。
第四步:换个标签页再切回来,图表却成了旧数据的截图
宽度的问题解决之后,我又照着 bug 复现步骤挨个点了一遍,结果在另一个地方绊了一跤:从看板标签页切到别的标签页,改了几个筛选条件再切回来,图表纹丝不动,还是切走前的数据,宽度这次是对的,数据却是旧的。
这个看板套了 keep-alive:
1<keep-alive> 2 <component :is="activeTabComponent" /> 3</keep-alive>
keep-alive 包住的组件,标签切走时不会真的销毁,再切回来也不会重新创建,所以 created 和 mounted 不会再跑一遍——图表组件自然也就不会重新拿数据。取而代之的是 activated 和 deactivated 这一对钩子:组件从缓存里被重新激活时触发 activated,被切到后台缓存起来时触发 deactivated。我最初的判断是"数据接口是不是被浏览器缓存了",查了半天请求记录发现请求根本没发出去,才反应过来是 keep-alive 把整个组件的初始化流程给绕过去了。
修法是把刷新逻辑从 created 挪一份到 activated:
1export default { 2 created() { 3 this.loadChartData(); 4 }, 5 activated() { 6 if (this.needRefresh) { 7 this.loadChartData(); 8 } 9 }, 10 deactivated() { 11 this.needRefresh = true; 12 }, 13};
但这里不能无脑每次 activated 都重新拉一遍数据,用户可能只是切个标签瞟一眼又切回来,图表要是每次都整个重刷,滚动位置、已经展开的图例状态全部丢失,体验反而更差。我加了个"脏标记":只有在 deactivated 期间外部筛选条件真的变了,才在 activated 时刷新,没变就直接用缓存的渲染结果。这个脏标记的判断放在筛选条件的 watch 里统一维护,不和具体某个组件的内部状态绑死,别的缓存页面也能照抄这套逻辑。
第五步:图表内部的自动轮询也得跟着 activated/deactivated 走
看板还有个需求,图表要每 30 秒自动刷新一次数据,做成了定时轮询。这个坑是我在验证 activated 修复方案时顺带发现的:标签页切走之后,轮询没有停,图表在后台缓存里还在偷偷发请求,用户压根看不到界面在动,纯粹浪费请求。
之前的写法是定时器只在 mounted 里建、beforeDestroy 里清:
1mounted() { 2 this.timer = setInterval(this.poll, 30000); 3}, 4beforeDestroy() { 5 clearInterval(this.timer); 6},
在 keep-alive 场景下,beforeDestroy 根本不会触发,因为组件没有被真正销毁,只是被缓存和切换。定时器要跟着"是否在前台可见"这个状态走,而不是跟着"组件是否存在"走,所以补上了 activated 开启、deactivated 暂停:
1activated() { 2 this.timer = setInterval(this.poll, 30000); 3}, 4deactivated() { 5 clearInterval(this.timer); 6 this.timer = null; 7},
这里也顺手把 beforeDestroy 的清理逻辑核对了一遍,团队里另一个页面之前踩过更隐蔽的坑:事件解绑传了两个不同的函数引用,看着对称实际解不掉。
1// 错误示范:两次 bind 生成两个不同的函数,removeEventListener 形同虚设 2mounted() { 3 window.addEventListener('resize', this.handleResize.bind(this)); 4}, 5beforeDestroy() { 6 window.removeEventListener('resize', this.handleResize.bind(this)); 7}
正确写法是把方法定义在 methods 里直接引用,两边传同一个函数:
1mounted() { 2 window.addEventListener('resize', this.handleResize); 3}, 4beforeDestroy() { 5 window.removeEventListener('resize', this.handleResize); 6}
这类监听器泄漏在单页应用里反复进出页面会越攒越多,症状是页面越用越卡,特别不好定位。我现在的习惯是:mounted(或者 activated)里建立了什么监听、定时器、第三方实例,beforeDestroy(或者 deactivated)里就对称地拆掉什么,两边一一对应地写,写的时候顺手核对一遍,就不会漏。
插曲:差点把 updated 也拖下水
排查父子挂载顺序那会儿,我一度怀疑是不是该在 updated 里补一次尺寸测量——反正数据一变、DOM 更新完就会触发,看起来像是个"保险丝"。写了两行才反应过来这个念头很危险:图表组件只要 option 变化就会触发 updated,如果测量逻辑也搭在这个钩子上,往后但凡有个无关的更新路过,都会顺带触发一次没必要的重排。
1// 差点这么干:只要是更新完成就顺手量一次 2updated() { 3 this.resizeChart(); 4}
updated 更适合处理"任意更新后都要同步的 DOM 任务"这类广谱逻辑,不适合当成某个具体状态变化的响应点。真正该做的是精确监听触发尺寸变化的那个来源——这里其实是筛选面板展开收起导致的父容器宽度变化,用 watch 盯住这个状态本身更直接:
1watch: { 2 panelExpanded() { 3 this.$nextTick(() => { 4 this.chart.resize(); 5 }); 6 }, 7},
顺带想起来 beforeUpdate 也有个类似的克制原则。它是数据已经变了、DOM 还没更新那一刻,用得少,但列表这类页面如果想要"内容刷新但视口不跳"的效果,可以在这里先记一下滚动位置,等 updated 里再恢复。这跟图表宽度的问题不是一回事,但道理相通:钩子选得准不准,看的是它对应的时机跟你要处理的状态变化是不是真的匹配,而不是"反正会触发就塞进去"。
收尾:把这几个钩子的边界重新钉一遍
这半天排查下来,能定位到问题,靠的不是记住了几个钩子的名字和顺序,而是想清楚每个钩子此刻能拿到什么、不能拿到什么。created 里数据和方法都好了,但 DOM 没有,适合发请求、做纯逻辑处理;mounted 里自己的 DOM 有了,但依赖别处异步渲染的尺寸未必定型,真要等"这一轮更新对应的 DOM 都刷新完",得用 $nextTick;updated 只适合处理"任意更新后都要同步的 DOM 任务",具体到某个字段变化该做什么,用 watch 表达更直接,塞进 updated 只会越堆越乱;beforeDestroy 要清理的不只是组件自身,任何在挂载期间向外部(window、定时器、第三方实例)注册过的东西,都得对称地收回来。
keep-alive 把"销毁重建"换成了"停用激活"之后,这套边界还得再往前挪一层:凡是写在 created/mounted/beforeDestroy 里、指望着组件重新创建才会重新触发的逻辑,套上 keep-alive 就全部落空,必须补一份对应到 activated/deactivated。这条我在这次排查之前只是听说过,这次是真吃了半天的排查成本才记牢。
那张画了一半的图,最后定位下来是两个独立问题叠在一起:父子组件挂载顺序导致的尺寸时机不对,加上 keep-alive 让数据刷新逻辑没被触发。改完之后运营那边再没提过这个问题。生命周期这套东西背下顺序不难,难的是分清楚每个检查点自己手里到底攥着什么资源、旁边组件的状态是不是也到位了——这道题在真实页面的组合场景里,比在孤立组件里麻烦得多。