项目背景
103项目CPC卡使用模块在原有的表格展示数据的基础之上,增加环状饼图图表展示,采用tab切换的方式实现
问题描述
图表无法设置宽高,导致饼图无法渲染
具体代码
// 入口页
<el-tabs type="card" class="cts_flex_tabs height-calc">
<el-tab-pane label="CPC卡使用情况">
<CtsTable :table-id="tableId"
:version="version"
ref="cpcCtsTable"
class="cpc-use-table"
:spanMethod="spanMethod"
:loading="loading"
:table-data="tableData"
:add-row-class-name="addRowClassName"
:columns="columns" />
</el-tab-pane>
<el-tab-pane label="CPC卡库存情况分析">
<cpc-inventory ref="cpcInventoryRef" />
</el-tab-pane>
</el-tabs>
// cpc-inventory组件
<div class="cpc-inventory" ref="inventoryRef">
<div class="pie-chart" ref="pieChart"></div>
</div>
<style lang="less" scoped>
.cpc-inventory {
height: 100%;
width: 100%;
.pie-chart {
height: 100%;
width: 100%;
}
}
</style>
页面在初始化时会同时加载两个tab,但是CPC卡库存情况分析
图表tab中的元素具体的宽高值是无法获取到的(display: none隐藏了),如下图:
在onMounted
钩子函数中无论是否使用nextTick
,打印出的inventoryRef
的offsetHeight
都是0,尽管设置了元素的宽高均为100%
,但是由于元素没有渲染出来,导致无法获取实际高度,而是高度为0。原本是想通过获取到父元素inventoryRef
的具体宽高值,然后设置图表容器pieChart
的宽高从而实现图表的正常渲染。
注意:页面初始化时,CPC卡库存情况分析
图表组件中的onMounted
会执行,但此时无法获取父元素宽高。
解决方案
采用ResizeObserver
ResizeObserver
是浏览器提供的一个 API,用于监听 DOM 元素尺寸的变化(如宽度、高度、边框、内边距等)。它比传统的 window.resize
或 MutationObserver
更高效,专门用于监测元素的尺寸变化。
代码实现
<div class="cpc-inventory" ref="inventoryRef">
<div class="pie-chart" ref="pieChart"></div>
</div>
const setupResizeObserver = () => {
if (!inventoryRef.value) return;
if (resizeObserver.value) {
resizeObserver.value.disconnect();
}
resizeObserver.value = new ResizeObserver((entries) => {
for (const entry of entries) {
const { height } = entry.contentRect;
if (height > 0 && !chart) {
initPieChart();
}
}
});
resizeObserver.value.observe(inventoryRef.value);
};
onMounted(() => {
setupResizeObserver();
});
onBeforeUnmount(() => {
if (resizeObserver.value) {
resizeObserver.value.disconnect();
}
});
代码解释:
在页面初始化时,钩子函数onMounted
中调用setupResizeObserver
函数,通过resizeObserver.value.observe(inventoryRef.value)
监听inventoryRef
元素,当切换tab到CPC卡库存情况分析
图表展示组件时,此时能够监测到inventoryRef
元素有了宽高值,那么由于设置了宽高为100%
,因此子元素pieChart
也就有了宽高,就可以进行图表的渲染,至此也就解决了上述的问题。
问题回顾
上面的是解决方案中的一种,还有另外一种方案:为两个tab添加name属性,在图表组件中添加v-if判断,代码如下:
<el-tabs type="card" class="cts_flex_tabs height-calc" v-model="activeTab" @click="...">
<el-tab-pane label="CPC卡使用情况" name="use">
<CtsTable :table-id="tableId"
:version="version"
ref="cpcCtsTable"
class="cpc-use-table"
:spanMethod="spanMethod"
:loading="loading"
:table-data="tableData"
:add-row-class-name="addRowClassName"
:columns="columns" />
</el-tab-pane>
<el-tab-pane label="CPC卡库存情况分析" name="inventory">
<cpc-inventory ref="cpcInventoryRef" v-if="activeTab == 'inventory'" />
</el-tab-pane>
</el-tabs>
这种方案是通过v-if
判断,使得cpc-inventory
组件的钩子函数onMounted
在页面初始化加载时不会执行,只有切换到此tab时才会执行。然后我们可以在nextTick
中进行图表初始化渲染。此方案的劣势是用户从表格切换到图表时,每次都要执行图表的初始化过程。方案1只有初次加载图表时才会进行初始化过程,后续切换只是完成数据更新,用户无法看到图标初始化过程。
注意
Node.js 或特殊环境不支持或者无法识别**ResizeObserver
**,会有报错信息:Cannot find name 'ResizeObserver'. Did you mean 'resizeObserver'?
解决方案:
-
确保
tsconfig.json
包含 DOM 类型(推荐){ "compilerOptions": { "lib": ["dom", "es6"] // 确保包含 "dom" } }
-
有时方案1没生效,则需要安装
@types/resize-observer-browser
(适用于某些特殊环境)npm install --save-dev @types/resize-observer-browser # 或 yarn add --dev @types/resize-observer-browser
在页面中引入
<script> .... import ResizeObserver from "resize-observer-polyfill"; .... </script>
适用场景
表格展示与图表展示通过tab组件切换(或者通过v-show
),而不是通过v-if
的方式动态设置显示与隐藏
完整代码分享
// 父组件
<el-tabs type="card" class="cts_flex_tabs height-calc">
<el-tab-pane label="CPC卡使用情况">
<CtsTable :table-id="tableId"
:version="version"
ref="cpcCtsTable"
class="cpc-use-table"
:spanMethod="spanMethod"
:loading="loading"
:table-data="tableData"
:add-row-class-name="addRowClassName"
:columns="columns" />
</el-tab-pane>
<el-tab-pane label="CPC卡库存情况分析">
<cpc-inventory ref="cpcInventoryRef" />
</el-tab-pane>
</el-tabs>
...
// 查询获取列表数据
listRefresh(pageIndex = 1) {
this.loading = true
let params = {
...api.getCpcUseInfoReportList,
data: {
...this.searchForm,
startHour: parseInt(this.searchForm.startHour.split(':')[0], 10),
endHour: parseInt(this.searchForm.endHour.split(':')[0], 10),
},
}
request(params)
.then((data) => {
this.loading = false
this.isBtnLoader = false
this.tableData = data.data
this.$refs.cpcInventoryRef.updateData(this.tableData, this.searchForm.cityCode);
})
.catch((error) => {
this.loading = false
this.isBtnLoader = false
this.$message.error(error.message)
})
},
// 图表组件
<template>
<div class="cpc-inventory" ref="inventoryRef">
<div class="pie-chart" ref="pieChart"></div>
</div>
</template>
<script lang='ts'>
import useCommon from "@/hooks/use-common";
import {
ref,
reactive,
defineComponent,
onMounted,
computed,
toRefs,
watch,
onBeforeUnmount,
} from "@vue/composition-api";
import useResizeSearch from "@/hooks/use-resizeSearch";
import api from "@/api";
import request from "@/axios/fetch";
import * as echarts from "echarts";
import ResizeObserver from "resize-observer-polyfill";
export default defineComponent({
name: "CpcInventory",
components: {},
props: {},
setup(props, { emit }) {
const { proxy } = useCommon(); // 作为this使用
const { isXLCol } = useResizeSearch();
const pieChart = ref(null);
let chart = null;
const inventoryRef = ref(null);
const resizeObserver = ref(null);
const chartData = ref([]); // 饼图数据
const centerShowInfo = reactive({
name: "浙江省中心",
kc: 0,
pb: 0,
});
const setCenterShwoInfo = ({ city, inventory, matching }) => {
centerShowInfo.name = city;
centerShowInfo.kc = inventory;
centerShowInfo.pb = matching;
};
const initPieChart = () => {
if (pieChart.value) {
chart = echarts.init(pieChart.value);
const option = {
tooltip: {
trigger: "item",
renderMode: "html", // 启用 HTML 模式
formatter: ({ data }) => {
return `
<div style="font-size: 14px;">
<p style="font-weight: bold; margin: 0 0 5px 0;">${data.name}</p>
<p style="margin: 2px 0;">库存: ${data.value}</p>
<p style="margin: 2px 0;">配比: ${data.pb}</p>
<p style="margin: 2px 0;">建议调入量: ${data.jytrl}</p>
</div>
`;
},
// 定义富文本样式
rich: {
name: {
fontSize: 16,
fontWeight: "bold",
color: "#333",
padding: [0, 0, 5, 0],
},
value: {
color: "#666",
fontSize: 14,
},
ratio: {
color: "#ff9900",
fontSize: 14,
},
suggest: {
color: "#ff0000",
fontSize: 14,
fontWeight: "bold",
},
},
},
legend: {
orient: "vertical",
bottom: "bottom",
},
series: [
{
name: "",
type: "pie",
radius: ["30%", "40%"],
data: chartData.value,
padAngle: 0,
// value 字段是必需的,用于确定扇形区域的大小。
// 可以通过 formatter 自定义标签内容,展示其他字段。
label: {
show: true,
position: "outside", // 标签显示在外部
formatter: function ({ data }) {
if (data.jytrl > 0) {
return `{name|${data.name}}\n{value|库存: ${data.value}} {ratio|配比: ${data.pb}} {desc|建议调入量: ${data.jytrl}}`;
} else if (data.jytrl < 0) {
return `{negativeName|${data.name}}\n{negativeValue|库存: ${data.value}} {negativeRatio|配比: ${data.pb}} {negativeDesc|建议调入量: ${data.jytrl}}`;
}
return `{zeroName|${data.name}}\n{zeroValue|库存: ${data.value}} {zeroRatio|配比: ${data.pb}} {zeroDesc|建议调入量: ${data.jytrl}}`;
},
rich: {
name: {
fontSize: 14,
fontWeight: "bold",
lineHeight: 24,
color: "#F56C6C",
},
value: {
fontSize: 12,
color: "#F56C6C",
lineHeight: 20,
},
ratio: {
fontSize: 12,
color: "#F56C6C",
lineHeight: 20,
},
desc: {
fontSize: 12,
color: "#F56C6C",
lineHeight: 20,
},
negativeName: {
fontSize: 14,
fontWeight: "bold",
lineHeight: 24,
color: "#67C23A",
},
negativeValue: {
fontSize: 12,
color: "#67C23A",
lineHeight: 20,
},
negativeRatio: {
fontSize: 12,
color: "#67C23A",
lineHeight: 20,
},
negativeDesc: {
fontSize: 12,
color: "#67C23A",
lineHeight: 20,
},
zeroName: {
fontSize: 14,
fontWeight: "bold",
lineHeight: 24,
color: "#333",
},
zeroValue: {
fontSize: 12,
color: "#333",
lineHeight: 20,
},
zeroRatio: {
fontSize: 12,
color: "#333",
lineHeight: 20,
},
zeroDesc: {
fontSize: 12,
color: "#333",
lineHeight: 20,
},
},
},
labelLine: {
show: true, // 显示引导线
},
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: "rgba(0, 0, 0, 0.5)",
},
},
},
],
graphic: {
type: "text",
left: "center",
top: "center",
style: {
text: [
`{title|${centerShowInfo.name}}`,
`{value|库存: ${centerShowInfo.kc}}`,
`{desc|配比: ${centerShowInfo.pb}}`,
].join("\n"), // 用 \n 换行
rich: {
// 定义每一行的样式
title: {
fontSize: 24,
fontWeight: "bold",
color: "#333",
lineHeight: 36,
textAlign: "center",
},
value: {
fontSize: 16,
color: "#333",
lineHeight: 30,
textAlign: "center",
},
desc: {
fontSize: 16,
color: "#333",
lineHeight: 30,
textAlign: "center",
},
},
},
},
};
chart.setOption(option);
}
};
const updateData = (val, isSingleCity) => {
if (val.length) {
chartData.value = val
.filter((ele) => ele.city)
.map((item) => ({
value: item.inventory,
name: item.city,
pb: item.matching,
jytrl: item.suggest,
}));
if (isSingleCity) {
// 单区域
const totalInfo = val.find((ele) => ele.area == "合计");
setCenterShwoInfo(totalInfo);
} else {
// 全域
const totalInfo = val.find((ele) => ele.area == "总计");
setCenterShwoInfo({ ...totalInfo, city: "浙江省中心" });
}
} else {
chartData.value = [];
setCenterShwoInfo({ city: "", inventory: 0, matching: 0 });
}
if (chart) {
chart.clear();
initPieChart();
}
};
const setupResizeObserver = () => {
if (!inventoryRef.value) return;
if (resizeObserver.value) {
resizeObserver.value.disconnect();
}
resizeObserver.value = new ResizeObserver((entries) => {
for (const entry of entries) {
const { height } = entry.contentRect;
if (height > 0 && !chart) {
initPieChart();
}
}
});
resizeObserver.value.observe(inventoryRef.value);
};
onMounted(() => {
setupResizeObserver();
});
onBeforeUnmount(() => {
if (resizeObserver.value) {
resizeObserver.value.disconnect();
}
if (chart) {
chart.dispose();
chart = null;
}
});
return {
pieChart,
inventoryRef,
updateData,
};
},
});
</script>
<style lang="less" scoped>
.cpc-inventory {
height: 100%;
width: 100%;
.pie-chart {
height: 100%;
width: 100%;
}
}
</style>