Vue3 实战封装
🎯 引言
前面的例子都把 D3 代码直接写在业务组件里,画一个图就要复制一遍比例尺、坐标轴、事件绑定,重复又难维护。学会这篇文章,你能理清 D3 和 Vue 的分工原则,把图表封装成可复用的 Vue3 组件,并处理好数据更新和卸载清理,完成从“会画”到“会用”的最后一跃。
🧱 D3 和 Vue 的分工原则
D3 和 Vue 都想操作 DOM,如果让它们管同一批元素,就会打架。先看一个错误示范:
<template>
<!-- Vue 用 v-for 渲染了柱子 -->
<svg>
<rect v-for="d in list" :key="d.day" :height="d.value" />
</svg>
</template>
<script setup>
// 同时 D3 又来修改这批柱子
d3.selectAll('rect').attr('fill', 'red');
</script>
结果:Vue 下次更新时会按自己的虚拟 DOM 把 D3 改的内容全部覆盖掉,D3 删掉的元素 Vue 也不知道——两边互相干扰,画面错乱。
社区的共识是一句话:
Vue 只负责渲染空容器,容器内部的图形全部交给 D3。
正确姿势就三条:
- 模板里只写一个带
ref的空<svg>,不写任何v-for。 - 容器内部的
rect、path等图形,全由 D3 创建和更新。 - D3 的初始化代码放在
onMounted里,这时容器才真实存在。
🛠 封装 BarChart 组件
按这个原则,把比例尺、坐标轴、数据绑定全部收进组件,对外只暴露一个 data 属性:
<template>
<svg ref="svgRef" class="chart"></svg>
</template>
<script setup>
import { onMounted, ref, watch } from 'vue';
import * as d3 from 'd3';
const props = defineProps({
data: {
type: Array,
required: true,
},
});
const svgRef = ref(null);
const render = () => {
const margin = { top: 20, right: 20, bottom: 30, left: 40 };
const width = 480 - margin.left - margin.right;
const height = 280 - margin.top - margin.bottom;
// 先清空再重画,保证不会重复叠加
const root = d3.select(svgRef.value);
root.selectAll('*').remove();
const svg = root
.append('g')
.attr('transform', `translate(${margin.left}, ${margin.top})`);
const xScale = d3
.scaleBand()
.domain(props.data.map((d) => d.day))
.range([0, width])
.padding(0.2);
const yScale = d3
.scaleLinear()
.domain([0, d3.max(props.data, (d) => d.value)])
.range([height, 0])
.nice();
svg.selectAll('rect')
.data(props.data)
.join('rect')
.attr('x', (d) => xScale(d.day))
.attr('y', (d) => yScale(d.value))
.attr('width', xScale.bandwidth())
.attr('height', (d) => height - yScale(d.value))
.attr('fill', '#5470c6');
svg.append('g')
.attr('transform', `translate(0, ${height})`)
.call(d3.axisBottom(xScale));
svg.append('g').call(d3.axisLeft(yScale));
};
onMounted(render); // 挂载后画第一次
// 数据变化时自动重画
watch(() => props.data, render, { deep: true });
</script>
<style scoped>
.chart {
width: 480px;
height: 280px;
}
</style>
整个组件就四个部分,对照着看就清楚了:
defineProps:声明接收的data。render:完整的画图逻辑(前几篇学过的内容原样搬进来)。onMounted(render):挂载后画第一次。watch:数据变了自动重画,deep: true保证数组内部修改也能监听到。
使用时一行搞定:
<template>
<BarChart :data="list" />
</template>
<script setup>
import { ref } from 'vue';
import BarChart from '@/components/BarChart.vue';
const list = ref([
{ day: '周一', value: 120 },
{ day: '周二', value: 200 },
{ day: '周三', value: 150 },
]);
</script>
render 里用了最朴素的“清空重画”策略:selectAll('*').remove() 删掉所有图形再重新渲染。简单可靠,绝大多数场景性能完全够用。追求丝滑动画时,可以换成动画篇学的“重新绑定数据 + transition”增量更新。下面就是这个封装思路的最终效果——入场生长动画、刷新数据的更新过渡、悬停提示框,全部具备:
💡 Vue 中使用 D3 的常见坑
| 坑 | 原因 | 对策 |
|---|---|---|
| 图形不显示 | 在 onMounted 前初始化,容器不存在 | 画图代码放进 onMounted |
| 数据变了图没变 | 没有监听 props 变化 | 用 watch 监听并在回调里重画 |
| 图形越画越多、重叠 | 重画前没清空旧图形 | 渲染前先 selectAll('*').remove() |
| 画面错乱 | Vue 和 D3 同时操作同一批节点 | 模板只留空容器,内部全交给 D3 |
| 组件卸载后报错 | window 上的事件监听没移除 | 在 onBeforeUnmount 里移除监听 |
D3 不像 ECharts 有 dispose 方法,组件卸载时 DOM 会被 Vue 一并移除,一般不需要额外清理。唯一要记住的是:如果你在 window、document 上绑了监听(如 resize),必须在 onBeforeUnmount 里移除。
🧾 小节总结
- 分工原则:Vue 渲染空容器,D3 管理容器内部的图形,互不越界。
- 同一批节点只能由一方管理,混用必乱。
- 封装四部曲:
defineProps接数据、render画图、onMounted首绘、watch重画。 - 重画策略:简单场景“清空重画”,追求动画用“重新绑定 + transition”。
- D3 无需 dispose,但 window 上的事件监听要在卸载时手动移除。
❓ 知识问答
Q1:父组件数据是异步获取的,初始是空数组怎么办?
在 render 开头加保护:if (!props.data.length) return;。watch 会在数据到达后自动触发重画。
Q2:一个页面放多个 BarChart 会互相影响吗?
不会。每个组件实例有自己的 svgRef,D3 的选择都基于这个容器,天然隔离。
Q3:为什么不直接用 v-for 渲染柱子,非要 D3?
简单静态图表确实可以。但 D3 的比例尺、坐标轴、动画、缩放是成套体系,v-for 只能解决“画出来”,解决不了“专业地画”。
Q4:社区有现成的 D3 + Vue 封装库吗?
有一些,但 D3 的魅力就在于完全自定义,实际项目大多按本课的方式自己封装。掌握原理后,看任何封装库都能一眼看穿。
🧪 小练习
基于本课的 BarChart 组件完成一个“数据看板”:
- 页面上放一个
BarChart和一个“刷新数据”按钮。 - 点击按钮生成 5 条随机数据并赋值给
list,观察图表自动重画。 - 进阶:把“清空重画”换成“重新绑定数据 + transition”,观察柱子平滑升降。
<template>
<!-- 请在这里编写代码 -->
</template>
<script setup>
// 请在这里编写代码
</script>
🎉 恭喜你已经完整掌握 D3 的核心技能啦!从数据绑定到比例尺、坐标轴、图形生成器、动画交互,再到 Vue3 封装,你已经具备用 D3 实现定制化可视化的能力。D3 的世界很大,力导向图、地图、树图都在向你招手,快去探索吧!
