主题
大屏项目搭建与图表(二)
本页对应 大屏项目搭建与图表,共 7 段视频,约 1 小时 24 分。
学习目标
- 能够将年度收入图表按业务需求完成配置改造,正确绑定接口返回的 X 轴、Y 轴数据,并实现奇偶行渐变色等业务样式。
- 能够基于官方示例完成园区产业分布环形图渲染,替换业务数据并保证图表在数据返回后正确初始化。
- 能够识别并处理图表初始化与异步数据获取的依赖关系,说明
ref响应式数据在函数传参时传递对象与传递.value的区别。 - 能够按“定位独立业务逻辑—定义
useXxx组合函数—返回数据与方法—在组件中调用并解构”的流程完成逻辑拆分,并保证拆分前后页面效果一致。 - 能够使用入口
index.js对拆分后的组合函数进行统一中转导出,并在业务组件中正确按需导入使用。 - 能够调用 3D 模型解析库完成模型加载与渲染,使用 loading 条件渲染降低等待焦虑,并通过 Network 面板验证模型请求与渲染结果。
知识点
一、先准备可用的渲染节点和初始化时机
为什么需要:ECharts 初始化时必须拿到一个真实、宽高可用的 DOM 节点。饼图要挂到页面某个 div 上,如果节点还没渲染出来就 echarts.init,图表不会出现,甚至直接报错。
怎么想:不要一上来就写配置项,先按官方环形图 demo 走通“节点 → 实例 → setOption”的流程。转写稿里也是先找官方环形图示例,复制完整代码,再改业务配置。
怎么做:模板中准备一个带 ref 的 div,例如 pieChart,脚本中用 const pieChart = ref(null) 接收;初始化函数里通过 echarts.init(pieChart.value) 实例化。初始化应放在 mounted 之后,并且最好等依赖数据返回后再执行。
易错点:DOM 节点没有宽高、ref 名字与模板不一致、在 setup 顶层直接初始化,都会导致图表不显示。转写稿特别强调:图表的初始化操作必须放到 mounted 钩子里做,因为那里才能保证节点可用。
二、配置项先描述“图表长什么样”,再替换业务数据
为什么需要:ECharts 的配置项决定颜色、图例、提示框、半径、圆心、标签等。业务数据只是 series.data 的一部分,不能把样式和数据混在一起写死。
怎么想:先把官方 demo 的配置项搬过来,保留结构;再把与业务无关的静态数据替换成接口返回的数据。本节笔记中,颜色数组、legend、tooltip、series 的 radius、center、label 都是样式配置,真正的数据在 data: parkIndustry。
怎么做:从 parkInfo.value 中解构出 parkIndustry,赋给饼图 series[0].data。图例图标用 rect,设置 itemHeight、itemWidth;环形图用 radius: ['55%', '60%'],圆心用 center: ['50%', '40%'] 调整整体位置;label.show 设为 false,让标签不显示。
易错点:把接口数据直接写进 color 或 legend;忘记 data 必须是数组;tooltip.formatter 中 params 字段拼错;环形图半径写成一个值就变成普通饼图。转写稿提到,配置项里很多参数可以现查现改,重点是找到绑定数据的位置。
三、封装初始化函数,并调用 echarts.init 与 setOption
为什么需要:把初始化逻辑封装成函数,便于控制调用时机,也避免代码散落。实例化和设置配置项是 ECharts 渲染的两个核心步骤。
怎么想:函数内部先取业务数据,再准备 pieOption,最后 echarts.init 得到实例,调用 setOption 完成渲染。以下代码为笔记原样保留:
javascript
// 渲染园区产业分布图表
const pieChart = ref(null)
const initPieChart = () => {
const { parkIndustry } = parkInfo.value
const pieOption = {
color: [
'#00B2FF', '#2CF2FF', '#892CFF', '#FF624D', '#FFCF54', '#86ECA2'],
legend: {
itemGap: 20,
bottom: '0',
icon: 'rect',
itemHeight: 10, // 图例icon高度
itemWidth: 10, // 图例icon宽度
textStyle: {
color: '#c6d1db',
},
},
tooltip: {
trigger: 'item'
},
series: [
{
name: '园区产业分析',
type: 'pie',
radius: ['55%', '60%'], // 设置内圈与外圈的半径使其呈现为环形
center: ['50%', '40%'], // 圆心位置, 用于调整整个图的位置
tooltip: {
trigger: 'item',
formatter: (params) => {
return `${params.seriesName}</br><div style='display:flex;justify-content: space-between;'><div>${params.marker}${params.name}</div><div>${params.percent}%</div></div>`
}
},
label: {
show: false,
position: 'center',
},
data: parkIndustry,
},
],
}
const myPieChart = echarts.init(pieChart.value)
myPieChart.setOption(pieOption)
}怎么做:在数据准备好后调用 initPieChart()。如果页面需要自适应,还可以在窗口变化时调用实例的 resize,但本节笔记未包含,不要额外编造。
易错点:echarts.init 重复执行可能产生多个实例;setOption 前没有拿到 parkIndustry;函数名与模板 ref 不一致。转写稿提醒,初始化函数只负责首次渲染,不会因为数据变化自动重跑。
四、保证数据返回后再初始化,避免“只渲染一次”的坑
为什么需要:饼图依赖 parkInfo.value.parkIndustry。如果接口还没返回就执行 initPieChart,解构会失败或拿到空数据。转写稿中调试时出现“刷新没出来、控制台报错”,本质就是异步数据与初始化顺序问题。
怎么想:不能只在函数里加可选链就以为能解决。模板里写可选链有效,是因为模板会随响应式数据重新渲染;但 mounted 里的初始化函数只执行一次,数据后来变了也不会自动重新执行。
怎么做:把“获取园区数据”的异步操作完成后再调用 initPieChart,保证 parkInfo.value 已有完整数据;或者用 watch 监听 parkInfo,数据到位后重新初始化/更新图表。转写稿也提到可以用 watch 监听数据变化,再执行初始化。
易错点:在 mounted 中先调初始化、后调接口;以为 parkInfo.value?.parkIndustry 能自动出图;传参时把响应式对象写成 .value 导致失去响应式。核心原则是:有先后依赖时,先保证数据返回,再做图表初始化。
本节的核心不是“多写一个函数”,而是把可视化大屏里与同一业务相关的数据、请求方法从组件中抽离出来,形成可复用、可维护的逻辑单元。下面按“为什么需要 → 怎么想 → 怎么做 → 易错点”展开。
从组件拆分到逻辑拆分
为什么需要: 组件拆分能复用模板和样式,但有些逻辑只是纯 JS,比如“切换布尔值”“请求园区概况数据”。如果为了复用一段 JS 就拆组件,会增加组件嵌套和通信成本。
怎么想: 把属于同一业务的 JS 代码放到一个小函数中维护,再把组件需要的数据和方法 return 出去。命名以 use 开头,后面接业务名,这是组合式函数的常见规范。
怎么做: 先找到独立业务逻辑:数据定义 + 操作数据的方法。例如园区概况业务,就是 parkInfo 和 getParkInfo。把它们从组件脚本中挪到 useInitParkInfo 里。
易错点: 不要把模板、样式也硬塞进逻辑函数;逻辑函数只负责 JS 逻辑,不负责渲染结构。
useInitParkInfo 的职责边界
为什么需要: 组件里如果同时写取数、图表初始化、条件渲染,代码会越来越长,排查问题时要来回翻。抽成 useInitParkInfo 后,园区概况数据的获取只在一个地方维护。
怎么想: 这个函数只做两件事:声明响应式数据 parkInfo,提供异步方法 getParkInfo;请求成功后把 res.data 赋给 parkInfo.value。组件要用什么,就 return 什么。
怎么做: 保留本节笔记代码:
javascript
import { ref } from 'vue'
import { getParkInfoAPI } from '@/apis/park'
function useInitParkInfo () {
const parkInfo = ref({})
const getParkInfo = async () => {
const res = await getParkInfoAPI()
parkInfo.value = res.data
}
return {
parkInfo,
getParkInfo
}
}
export { useInitParkInfo }易错点: parkInfo 是 ref,在函数内部赋值要写 parkInfo.value;return 时返回 parkInfo 本身,不要返回 parkInfo.value,否则响应式会丢失。
在组件里调用:先执行,再解构
为什么需要: 逻辑抽出去后,组件仍然要使用 parkInfo 和 getParkInfo,所以必须把函数返回的对象接回来。
怎么想: useInitParkInfo 是一个函数,调用它才会返回 { parkInfo, getParkInfo };然后用解构赋值拿到需要的数据和方法。
怎么做: 在组件中导入 useInitParkInfo,执行 const { parkInfo, getParkInfo } = useInitParkInfo(),再在需要的地方调用 getParkInfo()。转写稿中拆分完第一块逻辑后刷新页面,效果和拆分前一致,说明抽离没有改变业务行为。
易错点: 只写 useInitParkInfo 不加小括号,拿到的是函数引用/函数体,不是返回对象,无法解构。转写稿里专门强调“函数必须调一下”,这是纯 JS 问题,不是 Vue 问题。
依赖传递与响应式:传 ref,不传 .value
为什么需要: 拆分年度收入图表逻辑时,内部也要用 parkInfo。但 parkInfo 在另一个 use 函数作用域里,内部函数直接拿不到。
怎么想: 通过函数参数把依赖传进去。useInitParkInfo 返回的是 parkInfo 这个 ref 对象,所以传参时传 parkInfo,不要传 parkInfo.value。
怎么做: 在需要依赖的函数上设计形参,调用时把 parkInfo 作为实参传入;内部继续通过 parkInfo.value 读取数据。这样数据变化后仍能保持响应式关联。
易错点: 传参时加了 .value,传下去的是当时的值,后续接口返回新数据也不会同步,响应式特性丢失。另一个易错点是异步顺序:图表初始化依赖接口数据时,要保证先取数、后初始化,不能只在 mounted 里初始化一次,再指望可选链自动重渲染。
入口文件做中转导出
为什么需要: 拆成多个 use 模块后,组件里逐个写具体路径导入很啰嗦,也不利于统一管理。
怎么想: 新建一个 index.js 作为模块入口,先把子模块函数导入,再统一导出;业务组件从入口文件导入即可。
怎么做: 在 index.js 中导入各个 use 函数,再以对象方式统一导出;组件里改为从入口导入。转写稿中这样做完刷新页面不受影响,只是做了一层中转。
易错点: 导入导出方式要匹配:对方按需导出,这边就按需导入;对方默认导出,这边就默认导入。对不上会找不到模块。
先明确本节目标:把年度收入柱状图抽成组合式函数
为什么需要:年度收入图表不是孤立的一坨代码,它依赖 parkInfo 里的 parkIncome 数据,还要在组件挂载后拿到可用的 DOM 节点。如果全部写在组件里,模板、数据请求、图表初始化会混在一起;抽成 useInitBartChart 后,组件只负责调用和组合。
怎么想:把“年度收入图表”看成一个独立业务逻辑:它需要外部传入 parkInfo,内部维护 barChart 节点引用,暴露 initBarChart 给组件在合适时机调用。注意原代码函数名是 useInitBartChart,这里保留原拼写,不擅自改成 BarChart。
怎么做:先保留本节笔记代码,再围绕它补使用前提:模板里要有 ref="barChart" 的容器,且容器必须有宽高;parkInfo 要是响应式对象,函数内部通过 parkInfo.value.parkIncome 取数据。
javascript
import * as echarts from 'echarts'
import { ref } from 'vue'
function useInitBartChart (parkInfo) {
const barChart = ref(null)
// 渲染年度收入2d分析图表
const initBarChart = () => {
const { parkIncome } = parkInfo.value
const barOptions = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
grid: {
// 让图表占满容器
top: '10px',
left: '0px',
right: '0px',
bottom: '0px',
containLabel: true,
},
xAxis: [
{
type: 'category',
axisTick: {
alignWithLabel: true,
show: false,
},
data: parkIncome.xMonth,
},
],
yAxis: [
{
type: 'value',
splitLine: {
show: false,
},
},
],
series: [
{
name: '园区年度收入',
type: 'bar',
barWidth: '10px',
data: parkIncome.yIncome.map((item, index) => {
const color =
index % 2 === 0
? new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0.23, color: '#74c0f8' },
{ offset: 1, color: 'rgba(116,192,248,0.00)' },
])
: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0.23, color: '#ff7152' },
{ offset: 1, color: 'rgba(255,113,82,0.00)' },
])
return { value: item, itemStyle: { color } }
}),
},
],
textStyle: {
color: '#B4C0CC',
},
}
const myBarChart = echarts.init(barChart.value)
myBarChart.setOption(barOptions)
window.addEventListener('resize', () => {
myBarChart.resize()
})
}
return {
barChart,
initBarChart
}
}
export { useInitBartChart }易错点:不要只复制 barOptions 就完事,必须确认 barChart.value 已拿到真实 DOM;不要把 parkInfo 写成普通对象,否则内部 .value 取不到;xMonth、yIncome 字段要和后端返回一致。
业务数据替换:X 轴绑 xMonth,Y 轴绑 yIncome
为什么需要:官方示例里的数据是静态的,业务里必须换成接口返回的年度收入数据,否则图表只是“样子货”。
怎么想:转写稿里明确说,X 轴数据来自 parkIncome.xMonth,Y 轴数据来自 parkIncome.yIncome。Y 轴不是直接写数组,而是 map 一遍,因为每根柱子要按奇偶行定制不同渐变色。
怎么做:先保证接口数据已经拿到,再执行 initBarChart。转写稿的调试过程是:一开始直接初始化,页面不显示并报错,因为异步数据还没返回;后来改成先调数据接口,数据写入 parkInfo 后,再调用图表初始化。也就是“先数据、后渲染”。
易错点:只在模板里写 ?. 不能解决图表初始化问题。模板会随响应式数据重新渲染,但 initBarChart 只在 mounted 首次执行时读一次数据;数据后到,图表函数不会自动重跑。需要保证数据返回后再初始化,或用 watch 监听数据变化后重新初始化。
双色渐变:用 index % 2 === 0 区分奇偶柱
为什么需要:设计图里柱子是两种颜色交替,并且是自上而下的渐变,不是普通纯色。
怎么想:转写稿解释 index % 2 === 0 是偶数行判断:余数为 0 走蓝色渐变,否则走橙红色渐变。两种颜色都用 echarts.graphic.LinearGradient 从上到下渐变,底部透明度为 0。
怎么做:在 yIncome.map 中返回 { value: item, itemStyle: { color } },把颜色挂到每根柱子上。注意 barWidth: '10px' 控制柱子宽度,grid 用 containLabel: true 防止标签被裁掉。
易错点:不要把 color 写成字符串数组;渐变色要 new echarts.graphic.LinearGradient;奇偶判断写错会导致颜色节奏不对。
初始化时机、resize 与导出:组件里只做组合
为什么需要:图表容器必须有宽高,且初始化要发生在 DOM 可用、数据可用之后;窗口变化时图表还要自适应。
怎么想:转写稿反复强调“初始化操作放到 mounted 里”,因为要拿宽高可用的 DOM。若数据是异步的,就把“取数据”和“初始化图表”按先后依赖排好;也可以 watch 数据变化后重新执行初始化。
怎么做:组件中调用 const { barChart, initBarChart } = useInitBartChart(parkInfo),在数据返回后调用 initBarChart()。resize 监听里调用 myBarChart.resize()。拆分多个 use 函数时,转写稿还建议用 index.js 做统一中转导出,业务组件从入口文件导入。
易错点:传 parkInfo 时不要传 parkInfo.value,否则失去响应式;initBarChart 是函数引用,组件里要加括号执行;resize 监听如果页面销毁,最好移除,避免重复绑定。
为什么还要再抽一层:组件拆分不够用
前面做大屏时,我们把页面拆成了组件。组件拆分的好处很清楚:一是复用,el-button 这种一次定义、处处使用;二是可维护,把一个上千行的 .vue 拆成若干小的 .vue,找东西、看懂都容易。但组件本质上是「模板 + 脚本 + 样式」三件套绑在一起,这带来两个绕不开的问题。
第一是粒度。组件只支持整体复用,不支持「只复用一段纯 JS 逻辑」。比如一个布尔值来回切换,可能在 A 处控制 loading、在 B 处控制弹窗显隐,切换动作一样,但业务含义不同,你没法为它单独抽一个组件。第二是通信成本。组件一旦嵌套,父子之间就要传值,渲染顺序、异步时序没吃透,很容易出现「数据传过来了,那边却没变」的坑。
所以这一节做的是基于逻辑的拆分:把属于同一个业务的 JS 代码(数据定义 + 操作数据的方法)收进一个小函数里,再把组件要用的数据和方法以对象形式 return 出去。命名上按行业惯例用 use 打头,后面接业务名,比如 useInitPieChart。
怎么做,四步走:找到独立的业务逻辑 → 定义 use 打头的函数 → 函数内部 return 出数据和方法的对象 → 在组件的 setup 里执行该函数并解构。
易错点:useXxx() 一定要加小括号。不调用拿到的是函数体引用,压根拿不到内部 return 的对象,解构必然失败。
从官方 demo 到业务参数:饼图怎么改
为什么需要:ECharts 的配置项成百上千,硬背不现实,正确姿势是先跑通官方 demo,再按业务改配置。
怎么想:找到「环形图」示例,把完整代码整段复制过来,只改两处——渲染节点和 series.data。其余 color、legend、tooltip、radius、center 都属于「样子」的配置,跟后端数据无关。
怎么做:准备一个宽高可用的 DOM 节点并挂 ref,echarts.init(pieChart.value) 完成实例化,setOption 完成渲染,再补一个 resize 监听。
javascript
import { ref } from 'vue'
import * as echarts from 'echarts'
function useInitPieChart (parkInfo) {
// 渲染园区产业2d分布图表
const pieChart = ref(null)
const initPieChart = () => {
const { parkIndustry } = parkInfo.value
const pieOption = {
color: [
'#00B2FF', '#2CF2FF', '#892CFF', '#FF624D', '#FFCF54', '#86ECA2'],
legend: {
itemGap: 20,
bottom: '0',
icon: 'rect',
itemHeight: 10, // 图例icon高度
itemWidth: 10, // 图例icon宽度
textStyle: {
color: '#c6d1db',
},
},
tooltip: {
trigger: 'item'
},
series: [
{
name: '园区产业分析',
type: 'pie',
radius: ['55%', '60%'], // 设置内圈与外圈的半径使其呈现为环形
center: ['50%', '40%'], // 圆心位置, 用于调整整个图的位置
tooltip: {
trigger: 'item',
formatter: (params) => {
return `${params.seriesName}</br><div style='display:flex;justify-content: space-between;'><div>${params.marker}${params.name}</div><div>${params.percent}%</div></div>`
}
},
label: {
show: false,
position: 'center',
},
data: parkIndustry,
},
],
}
const myPieChart = echarts.init(pieChart.value)
myPieChart.setOption(pieOption)
window.addEventListener('resize', () => {
myPieChart.resize()
})
}
return {
pieChart,
initPieChart
}
}
export { useInitPieChart }易错点:echarts.init 的节点必须宽高可用,所以整个初始化必须放在 onMounted 之后;data 直接绑定接口返回的 parkIndustry 数组,不要再手工拼一份静态数据。
参数怎么传:传 ref,不传 .value
为什么需要:这个函数依赖 parkInfo,而 parkInfo 是上一个 useXxx 函数返回的响应式对象。函数内部作用域拿不到外部变量,只能通过形参传进来。
怎么做:函数签名写成 useInitPieChart(parkInfo),组件里调用时把整个 parkInfo 传进去。
易错点:传参时不要加 .value。加了 .value 传进去的是当时那一份初始值,后续接口数据变化,图表拿到的永远是旧值,响应式就丢了。要传的是 ref 对象本身,函数内部再用 parkInfo.value 取。
调用时机:先拿数据,再初始化
为什么需要:这是最容易翻车的地方。initPieChart 写在 onMounted 里,而它只会在组件初始化时执行一次,onMounted 不具备「数据变化后重新执行」的机制。如果此时接口还没返回,parkInfo.value 是空的,解构就会报「读取 undefined 的属性」。
怎么想:这里的问题跟框架无关,是同步异步的时序问题。两个操作有先后依赖,就必须保证第一个完成后再做第二个。
怎么做:把接口调用和图表初始化都放进 onMounted,用 await 串起来——先等数据回来并存入 parkInfo,再调 initPieChart。
易错点:别指望在函数里加可选链 ?. 解决问题。模板里加 ?. 之所以有效,是因为模板和响应式数据绑定,数据一变整块模板重新渲染;而初始化函数没有这个机制,加问号只是把报错吞掉,图还是不出来。想走另一条路,可以用 watch 监听数据变化后重新执行一次初始化。
统一出口:用 index.js 做中转
为什么需要:拆成三个模块后,组件里要写三行 import,路径长、名字多。
怎么做:在 composable 目录下新建 index.js,先把子模块逐个导入,再以对象形式统一导出;组件里直接从 index 导入即可。
易错点:导入导出方式必须对上——对方是默认导出,你这边就得默认导入;对方是按需导出,你这边就得按需导入,对不上就找不到。
这一节要解决什么问题
为什么需要:当页面里同时有“获取园区数据、渲染年度收入柱状图、渲染产业分布饼图、渲染 3D 模型”等逻辑时,如果全部堆在 setup 里,代码会越来越长,数据、方法、渲染时机混在一起,后面很难维护。
怎么想:把“同一业务相关的数据 + 操作数据的方法”收进一个以 use 开头的小函数里,组件只负责把这些逻辑组合回来。这就是基于逻辑的拆分,也就是组合式函数。
怎么做:先找独立业务逻辑;定义 useXxx 函数;在函数内部把组件要用的数据和方法 return 出去;组件里执行该函数并解构使用。
易错点:不要以为只有模板能拆,纯 JS 的布尔切换、接口请求、图表初始化都可以拆;命名按惯例用 use 打头;组件要用什么就 return 什么。
先保证数据回来,再初始化图表
为什么需要:图表初始化依赖接口返回的 parkInfo。如果接口还没回来就执行 initBarChart(),图表内部拿不到 X 轴、Y 轴数据,就会报错或空白。
怎么想:转写稿里反复强调,onMounted 只会在组件初始化时执行一次,图表初始化函数不会因为数据后来变化就自动重新执行。模板里写可选链能恢复,是因为模板和响应式数据关联,会重新渲染;但 initBarChart() 这个函数没有自动重跑机制。
怎么做:在 onMounted 里先 await getParkInfo(),等数据写入 parkInfo 后,再调用两个初始化函数。本节笔记代码如下,保留原样:
javascript
<script setup>
import { onMounted } from 'vue'
import { useInitParkInfo, useInitBartChart, useInitPieChart, useInit3d } from './composiables'
// 获取园区数据
const { parkInfo, getParkInfo } = useInitParkInfo()
// 渲染年度收入分析2d图表
const { initBarChart, barChart } = useInitBartChart(parkInfo)
// 渲染园区产业分布2d图标
const { initPieChart, pieChart } = useInitPieChart(parkInfo)
// 调度图表渲染
onMounted(async () => {
// 保证数据先回来
await getParkInfo()
initBarChart()
initPieChart()
})易错点:不要试图只在初始化函数里给 parkInfo 加可选链就解决。转写稿的调试结论是:可选链只能让报错消失,但初始化函数已经执行完了,图表仍然出不来。也可以在 watch 里监听数据变化后重新执行初始化,但本节采用“先等数据,再初始化”的方式。
组合式函数拆分时的传参与调用
为什么需要:把逻辑拆到 useInitBarChart(parkInfo)、useInitPieChart(parkInfo) 后,图表函数内部还要使用园区数据,这就涉及跨函数传参。
怎么想:数据来自 useInitParkInfo() 的返回值,图表函数需要它,就用参数传进去。转写稿特别提醒:传的是 ref 对象本身,不要加 .value。
怎么做:组件里先执行 useInitParkInfo() 并解构出 parkInfo、getParkInfo;再把 parkInfo 作为实参传给图表组合函数;图表函数内部接收后使用。
易错点:第一,调用 useXxx() 必须加小括号,不写小括号拿到的是函数引用,不是内部 return 的对象,无法解构。第二,传参不要写成 parkInfo.value,否则传下去的是当时的值,数据后续变化会失去响应式关联。第三,函数内部依赖外部数据时,优先用参数传,不要硬跨作用域拿。
入口文件做统一中转导出
为什么需要:拆成多个组合式函数后,组件里如果分别从不同文件导入,路径会越来越散。
怎么想:转写稿给的做法是新增一个入口文件,先把各子模块导入,再统一导出;业务组件从入口文件导入即可。
怎么做:在 composiables 目录下用入口文件统一管理导出,组件里仍然按需解构 useInitParkInfo、useInitBarChart、useInitPieChart。
易错点:模块导入导出方式要对应:对方按需导出,这边就按需导入;对方默认导出,这边就默认导入。对不上就会找不到模块成员。
初始化时机与 3D、loading 的配合
为什么需要:2D 图表要求渲染节点宽高可用,3D 模型还要求 canvas 节点可用,并且模型接口和解析都需要时间。
怎么想:所有初始化都放在 onMounted 之后做;3D 模型加载前先打开 loading,加载完成后再关闭,减少等待焦虑。
怎么做:组合函数里暴露 init3d 一类初始化方法;组件中准备 loading 的布尔 ref,调用加载前设为 true,模型加载完成回调里设为 false。
易错点:loading 只是控制显示隐藏,不会真的计算模型进度;转写稿里的进度条是用很长动画时间模拟等待。真正要保证的是:节点可用、接口数据可用、模型地址可用,再执行初始化。
从组件拆分说起:它解决了什么,又卡在哪里
为什么需要:当页面越写越大,所有模板、脚本、样式都堆在一个 .vue 文件里,找数据、找方法、找结构都会变难。组件拆分的直接目的,是把一个大的 .vue 拆成多个小的 .vue,从而获得复用和可维护性。
怎么想:组件本质上可以粗略理解为 HTML + JS + CSS 三部分组合。拆成多个组件后,哪里需要就在哪里引入,像按钮、弹窗这类通用 UI 就能一次封装、多处使用;同时,维护某一块功能时,进入对应的小组件即可,不必在上千行代码里来回翻找。
怎么做:按照页面结构或功能块拆成父子组件,再组合成一棵组件树。转写稿里强调,这种思路就是“基于组件的拆分”。
易错点:组件一旦从一个变成多个,就必然形成嵌套关系,父子之间要传值、接收、再渲染,通信成本会增加。更关键的是,组件拆的是“三合一”的 .vue,它不擅长只复用一段纯 JS 逻辑。比如一个布尔值在 true/false 之间切换,可能今天控制 loading,明天控制某个元素的显示隐藏,切换逻辑相同,但业务含义不同;如果只为这段纯 JS 抽组件,反而不自然。
逻辑拆分要补的缺口:只拆 JS
为什么需要:组件拆分解决了结构和 UI 的复用,但对“同一类业务逻辑”的复用不够细。逻辑拆分要补的,正是组件拆分粒度不够细、组件通信成本高的问题。
怎么想:笔记明确写了:基于逻辑拆分解决的是“逻辑复用 + 可维护性”,拆分的对象只有 JS。也就是说,不再把模板、样式一起搬走,而是把某块业务相关的响应式数据和修改数据的方法集中到一个函数里。
怎么做:把“属于同一个业务逻辑的所有代码”找出来,包括响应式数据、获取数据的方法、修改数据的方法,以及它们之间的调度关系。转写稿中拆分“园区概况数据”时,就是把数据定义和获取数据的方法一起移入 use 函数;拆分“年度收入图渲染”时,则把图表初始化相关逻辑移入另一个 use 函数。
易错点:逻辑拆分后,对原生 JS 函数的要求会变高。转写稿里反复调试的几个问题都不是框架问题,而是函数问题:函数不调用,拿不到内部 return 的对象;跨作用域用不到上游数据,要通过函数形参传进去;传参时如果传了 .value,传下去的是值,不是响应式对象,后续数据变化就不会再联动。这里的“科选练”即可选链,“monkey”即 mounted,要按真实含义理解。
通用拆分四步:先找业务,再封装 use,再导出,最后解构
为什么需要:如果没有固定流程,第一次拆逻辑很容易把不相关的代码也搬走,或者拆完后组件里仍然残留业务逻辑。
怎么想:笔记给出的四步就是判断标准:先按业务边界找代码,再封装,再导出,最后在组件中组合回来。组件里的 setup 不负责写业务,只负责把拆出去的逻辑重新组合,这也是组合式 API 的体现。
怎么做:
- 找到组件中属于同一业务逻辑的所有代码:响应式数据 + 修改数据的方法。
- 定义一个以
use打头的方法,把第一步的业务逻辑放进去。 - 在
use函数内部,把组件中要用到的数据或方法以对象方式导出。 - 在组件
setup语法糖中调用该函数,配合解构赋值拿到数据和方法,再在模板或后续逻辑中使用。
易错点:拆分后要先刷新验证效果是否和原来一致。转写稿中拆第一块后页面“一动不动”,说明拆分正确;拆第二块时出现 DOM 获取不到,是因为模板里绑定的 ref 节点还在组件中使用,必须把该 ref 也导出并解构回来。不要以为拆出去就与组件无关,组件仍然要用到的数据、方法、DOM 引用,都要通过 return 暴露。
调试与易错点:函数调用、作用域传参、响应式传递
为什么需要:逻辑拆分看似只是“搬代码”,实际最容易在函数调用和响应式传递上出错。
怎么想:先判断这个 use 函数返回的是对象还是函数体。只写函数引用不写小括号,拿到的是函数本身,不是内部 return 的对象;必须调用后才能解构。再判断依赖关系:下游 use 函数需要上游数据时,不能靠“它们写在同一个文件里”就认为能拿到,作用域不允许,应该通过形参传入。
怎么做:在组件中执行 use 函数并解构;需要上游数据时,把上游返回的响应式对象作为实参传给下游函数;调试时先注释掉耗时的 3D 渲染等逻辑,避免每次刷新都重新加载,干扰判断。
易错点:传参时不要随手加 .value。转写稿明确说,传响应式对象才能保持响应式;传了 .value 就是传初始值,第一次是什么后面就固定是什么。另一个小优化是入口文件中转:各子模块按需导出,组件里按需导入,导入导出方式必须对应;可以新增 index.js 先导入三个子模块再统一导出,业务里直接从 index 引入,减少路径和名字分散。
代码示例
示例一:把「取园区数据」抽成组合式函数,并用 index.js 做统一中转导出
js
// composables/useInitParkInfo.js
import { ref } from 'vue'
import { getParkInfoAPI } from '@/apis/park'
function useInitParkInfo () {
const parkInfo = ref({})
const getParkInfo = async () => {
const res = await getParkInfoAPI()
parkInfo.value = res.data
}
return {
parkInfo,
getParkInfo
}
}
export { useInitParkInfo }js
// composables/index.js —— 统一入口,只做中转
import { useInitParkInfo } from './useInitParkInfo'
import { useInitBartChart } from './useInitBartChart'
import { useInitPieChart } from './useInitPieChart'
export {
useInitParkInfo,
useInitBartChart,
useInitPieChart
}关键行与易错点
- 函数名必须以
use打头(useInitParkInfo),这是组合式函数的行业约定。 - 函数内部要同时放「响应式数据 + 操作数据的方法」(
parkInfo和getParkInfo),不能只搬数据不搬方法。 - 必须
return { ... }一个对象出去,组件里才能解构;只export函数不return,组件里拿到的是undefined。 - 组件里调用时一定要加小括号:
useInitParkInfo()才是对象,写useInitParkInfo只是函数引用,无法解构。 index.js中转后,业务侧统一from './composables'导入,不要再从每个子文件单独引;注意导出方式要对齐——子模块是「按需导出」,index.js也必须是按需导出,若写成export default就对不上了。
示例二:把「年度收入柱状图」抽成组合式函数(含 resize 与双色渐变)
js
// composables/useInitBartChart.js
import * as echarts from 'echarts'
import { ref } from 'vue'
function useInitBartChart (parkInfo) {
const barChart = ref(null)
// 渲染年度收入2d分析图表
const initBarChart = () => {
const { parkIncome } = parkInfo.value
const barOptions = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
grid: {
// 让图表占满容器
top: '10px',
left: '0px',
right: '0px',
bottom: '0px',
containLabel: true
},
xAxis: [
{
type: 'category',
axisTick: {
alignWithLabel: true,
show: false
},
data: parkIncome.xMonth
}
],
yAxis: [
{
type: 'value',
splitLine: {
show: false
}
}
],
series: [
{
name: '园区年度收入',
type: 'bar',
barWidth: '10px',
data: parkIncome.yIncome.map((item, index) => {
const color =
index % 2 === 0
? new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0.23, color: '#74c0f8' },
{ offset: 1, color: 'rgba(116,192,248,0.00)' }
])
: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0.23, color: '#ff7152' },
{ offset: 1, color: 'rgba(255,113,82,0.00)' }
])
return { value: item, itemStyle: { color } }
})
}
],
textStyle: {
color: '#B4C0CC'
}
}
const myBarChart = echarts.init(barChart.value)
myBarChart.setOption(barOptions)
window.addEventListener('resize', () => {
myBarChart.resize()
})
}
return {
barChart,
initBarChart
}
}
export { useInitBartChart }关键行与易错点
- 形参
parkInfo必须传ref 对象本身,不能写parkInfo.value。传.value传进去的是当次快照,之后接口返回新数据也不会跟着变,响应式就丢了。 parkInfo.value解构出parkIncome,xMonth绑 X 轴、yIncome绑 Y 轴——这是与后端数据真正相关的两处,其余全是样式配置。index % 2 === 0判断偶数行用蓝色渐变,否则用红色渐变,所以柱子是两种颜色;itemStyle要跟着value一起return,只返回数字就没有渐变。radius、center、color这类都是样式项,可以照着设计稿现查现改,不要试图背下来。echarts.init(barChart.value)里的节点必须宽高可用,所以这一步只能放在onMounted之后执行。barChart这个 ref 变量名要和模板里ref="barChart"完全一致,名字对不上就init到null上。
示例三:组件里只做「组合」,用 await 保证「数据先回来、再渲染图表」
vue
<template>
<div class="chart-wrap">
<div class="bar-chart" ref="barChart"></div>
<div class="pie-chart" ref="pieChart"></div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useInitParkInfo, useInitBartChart, useInitPieChart } from './composables'
// 获取园区数据
const { parkInfo, getParkInfo } = useInitParkInfo()
// 渲染年度收入分析2d图表
const { initBarChart, barChart } = useInitBartChart(parkInfo)
// 渲染园区产业分布2d图表
const { initPieChart, pieChart } = useInitPieChart(parkInfo)
// 调度图表渲染
onMounted(async () => {
// 保证数据先回来
await getParkInfo()
initBarChart()
initPieChart()
})
</script>
<style scoped>
.bar-chart,
.pie-chart {
width: 100%;
height: 100%;
}
</style>关键行与易错点
setup里几乎不写业务逻辑,只做「调 use 函数 + 解构 + 调度」,这就是组合式 API 的用法。useInitBartChart(parkInfo)传的是parkInfo(ref 对象),不是parkInfo.value。- 初始化必须放在
onMounted里:此时模板里的ref="barChart"/ref="pieChart"节点才存在且宽高可用,提前调echarts.init会拿到null。 - 两个
use函数里用到了parkInfo.value.parkIncome/parkIndustry,所以必须await getParkInfo()之后再调initBarChart()/initPieChart()。这两个操作有先后依赖,不加await就会报「访问了 undefined 的属性」。 - 不要指望在图表配置里加可选链
?.能解决问题:initBarChart只在onMounted里执行一次,数据后来变了它也不会重新执行,?.只是让报错消失、图还是出不来。模板里写?.管用是因为模板会随响应式数据重渲染,图表初始化函数没有这个机制。 - 如果确实想监听数据变化再重绘,要用
watch监听parkInfo,在回调里重新执行initBarChart(),而不是靠可选链。
常见错误
1. 图表空白,控制台报 Cannot read properties of undefined (reading 'xMonth')
- 现象:
initBarChart执行时parkInfo.value.parkIncome为undefined,页面无图表。 - 原因:
getParkInfo()是异步的,onMounted里两个函数平铺调用,初始化抢在数据返回之前执行。 - 修法:
await getParkInfo()之后再调initBarChart()/initPieChart(),用同步语义保证"数据先到、图表后画"。
2. 给 parkInfo.value?.parkIncome 加了可选链,报错没了但图还是不出现
- 现象:控制台干净,图表区域依旧空白。
- 原因:
initXxxChart只在onMounted里跑一次,首次拿到的是空数据,函数不会因响应式数据变化而重新执行;可选链只是掩盖了报错,没有制造第二次渲染。 - 修法:删掉可选链"续命"的写法,改为
await后再初始化;确实需要跟随数据变化的,用watch(parkInfo, () => initXxxChart())显式触发。
3. echarts.init() 报 Initialize failed: invalid dom 或图表尺寸为 0
- 现象:初始化拿不到节点,或图渲染成一条线/看不见。
- 原因:
ref未在模板上正确绑定(名字对不上、层级写错),或容器没有显式宽高。 - 修法:核对模板
ref="barChart"与脚本const barChart = ref(null)名称一致;初始化放进onMounted;容器补width/height。
4. 拆分后组件里 const { parkInfo, getParkInfo } = useInitParkInfo 解构出 undefined
- 现象:解构后调用
getParkInfo()报 "not a function"。 - 原因:只写了函数引用,没有调用,拿到的是函数体而不是内部
return的对象。 - 修法:
useInitParkInfo()必须带括号执行,再解构。
5. 组合函数里数据不更新,图表永远画的是第一次的数据
- 现象:父组件数据已刷新,子逻辑里读到的还是旧值。
- 原因:调用
useInitBartChart(parkInfo.value)时传了.value,把ref拆成了普通值,响应式丢失。 - 修法:传
ref对象本身(useInitBartChart(parkInfo)),在函数内部用parkInfo.value.xxx取值。
6. 拆分时漏把模板用到的 ref 节点导出
- 现象:图表容器没渲染出来,报找不到 DOM。
- 原因:只
return了数据和init方法,模板里绑定的barChart/pieChart没在解构里取回来。 - 修法:凡是模板或初始化里要用的,一律
return出去并在组件中解构。
7. resize 监听越挂越多
- 现象:切换页面/热更新后页面卡顿,
resize时图表闪烁。 - 原因:
window.addEventListener('resize', ...)写在初始化函数里,每次调用都新增一个匿名监听且从不移除。 - 修法:
onUnmounted中removeEventListener(回调抽成具名函数),或改用ResizeObserver并在卸载时disconnect。
8. 3D 模型反复重渲染导致页面卡死
- 现象:改一行代码热更新,模型重新下载解析,浏览器假死。
- 原因:模型体积大、解析耗时,而调试期每次更新都会重跑
init3DModel。 - 修法:调试无关逻辑时先注释掉 3D 初始化;Network 面板确认模型请求确实发出后再判断问题。
练习
补全响应式重渲染:把年收入柱状图改成
watch方案——监听parkInfo,数据变化后重新setOption,并与await方案对比"重复初始化实例"带来的问题,给出避免重复echarts.init的处理。补一个组合函数:参照
useInitPieChart的写法,抽出一个useInit3DModel(),要求内部包含ref节点、init3DModel方法、加载状态loading的开关,并在组件中通过解构使用。统一入口改造:在
composables/index.js中把已有的三个use函数统一中转导出,然后把组件里三行import合并成一行;再说明"按需导出 / 按需导入"不匹配时会出现什么报错。给 loading 加最小展示时长:当前 loading 靠 CSS 1000s 动画假装在走。请改成"模型加载完成且已展示满 800ms 才关闭",用
Promise+setTimeout实现,说明为什么需要这个兜底。清理副作用:为柱状图、饼图各写一份
onUnmounted清理逻辑(移除 resize 监听 +dispose实例),并说明不dispose会有什么后果。
课堂画面






原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 大屏项目-年度收入统计-按照业务修改图表 | 10:05 |
| 2 | 大屏项目-产业分布图表渲染 | 14:15 |
| 3 | 大屏项目-基于逻辑的拆分实现 | 21:58 |
| 4 | 拆分时问题说明 | 04:23 |
| 5 | 大屏项目-借助入口文件做中转导出 | 03:36 |
| 6 | 大屏项目-3D模型渲染 | 18:28 |
| 7 | 大屏项目-使用loading减少等待焦虑 | 11:45 |
小结
- 图表初始化必须发生在两件事之后:DOM 可用(
onMounted)和数据已返回(await)。二者缺一,报错形式不同但结果都是"没图"。 - 可选链只能消除报错,不能制造渲染时机。初始化函数不会被响应式系统自动重跑,这是它和模板渲染最本质的区别。
- 模板里的
?.之所以管用,是因为模板整体参与响应式追踪;脚本里的?.只是一次性取值。两者不可混用同一套心智模型。 - 组合式拆分的四步是固定套路:找齐同一业务的数据 + 方法 → 定义
use开头函数 → 以对象return→ 组件中调用并解构。少任何一步都会出问题。 - 跨组合函数共享数据,靠"传参 + 形参接收"解决作用域问题;传的是
ref对象而不是.value,否则响应式当场失效。 - 拆分降低的是组件通信成本,抬高的是对原生 JS(函数调用、作用域、引用传递)的理解成本——这笔账要提前认。
- 副作用(
resize监听、图表实例、3D 引擎)必须与生命周期配对管理,否则热更新和路由切换会持续放大问题。 - 大体积资源(3D 模型)体验优化的核心不是"让它变快",而是"让等待可见"——loading 是产品层面的解法,不是技术层面的遮掩。