Vue 生命周期:图表宽度为什么会偶发性量成 0

图表只画出一半、容器宽度偶发性拿到 0,这类 bug 很少是图表库本身有问题,更多时候是组件在不该初始化的时候初始化了。只要渲染时机、DOM 可见性和缓存激活顺序稍微错开一点,mounted 里量出来的尺寸就可能根本不可信。

这次看板页面的问题正好把几个生命周期边界同时暴露出来:组件挂载了,不代表容器已经准备好;keep-alive 缓存了,不代表重新显示时会再走一遍同样的钩子;数据请求、尺寸测量和资源清理如果都放错位置,现象就会变得又偶发又难复现。

下面会从“图表宽度为什么会变成 0”这个现场切进去,把 createdmountedactivated 和父子组件执行顺序一起理顺。

第一步:确认图表组件本身没写错

图表组件的骨架是这样的:

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.chartBoxclientWidth,复现问题的那次打出来是 0。父组件的宽度还没最终确定,图表组件的 mounted 就已经执行完了。

这里就得说到父子组件生命周期的执行顺序:挂载过程是父组件先进入 beforeCreate/created/beforeMount,然后子组件才走完自己整套 beforeCreatemounted,最后父组件的 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 包住的组件,标签切走时不会真的销毁,再切回来也不会重新创建,所以 createdmounted 不会再跑一遍——图表组件自然也就不会重新拿数据。取而代之的是 activateddeactivated 这一对钩子:组件从缓存里被重新激活时触发 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 都刷新完",得用 $nextTickupdated 只适合处理"任意更新后都要同步的 DOM 任务",具体到某个字段变化该做什么,用 watch 表达更直接,塞进 updated 只会越堆越乱;beforeDestroy 要清理的不只是组件自身,任何在挂载期间向外部(window、定时器、第三方实例)注册过的东西,都得对称地收回来。

keep-alive 把"销毁重建"换成了"停用激活"之后,这套边界还得再往前挪一层:凡是写在 created/mounted/beforeDestroy 里、指望着组件重新创建才会重新触发的逻辑,套上 keep-alive 就全部落空,必须补一份对应到 activated/deactivated。这条我在这次排查之前只是听说过,这次是真吃了半天的排查成本才记牢。

那张画了一半的图,最后定位下来是两个独立问题叠在一起:父子组件挂载顺序导致的尺寸时机不对,加上 keep-alive 让数据刷新逻辑没被触发。改完之后运营那边再没提过这个问题。生命周期这套东西背下顺序不难,难的是分清楚每个检查点自己手里到底攥着什么资源、旁边组件的状态是不是也到位了——这道题在真实页面的组合场景里,比在孤立组件里麻烦得多。