简介:这是一份面向前端学习者与房地产数据应用开发者的完整前端源码包,围绕房地产大数据分析平台的界面呈现与交互需求,提供可参考的工程实践样本。压缩包共2000个文件,以1378个JavaScript源码为主,辅以467个Markdown说明、147个JSON配置与少量HTML、XML及TXT文件,整体约18.08MB,目录结构便于按模块查阅。已有105人学习,适合希望了解大型数据平台前端组织方式的中级开发者。源码覆盖组件化开发、数据可视化图表接入、HTTP接口交互、全局状态管理、响应式布局与模块打包等环节,也能看到测试调试和代码规范相关的工程配置。读者可从中梳理数据密集型应用的前端分层、接口调用与状态流转思路,并对照自身项目查漏补缺;对房地产数据分析场景感兴趣者,还可借此理解业务信息如何转化为直观图表与交互界面。
1. 从一份前端组代码说起:这套房地产大数据平台到底在解决什么问题
接手这份「房地产大数据分析平台——前端组代码」的时候,我最先打开的不是首页,而是src/views/market目录。一个数据平台靠不靠谱,看它怎么组织指标,比看它怎么画图表准得多。这套前端组代码覆盖市场行情、楼盘详情、区域对比、成交走势和后台权限五块,图表密、表格宽、筛选条件多,是典型的中后台数据看板形态。适合两类人:手上正接同类看板需求、想找现成骨架改的前端;以及要从零搭数据平台、又不想在图表自适应和按钮权限上反复踩坑的开发者。代码里最值钱的部分是那些不太显眼的封装:统一的请求层、能自适应宽度的图表组件、一套按后端菜单生成路由的逻辑。
2. 技术选型与工程结构:把前端组代码拆到目录这一层
拿到一份别人写的中后台代码,先看目录比先跑命令划算。目录能告诉你作者的分层习惯,也能提前暴露哪些地方是硬编码、哪些地方留了扩展口。这一套的目录结构相当规矩,api和views严格按业务域对齐,是后面所有改造能顺利进行的前提。
2.1 技术栈清单与选型理由
先把用到的库摆出来,后面所有代码都建立在这套组合上:
| 层次 | 选型 | 在这套代码里的实际作用 |
|---|---|---|
| 框架 | Vue 3 +<script setup> | 单文件组件里写组合式逻辑,图表和表格的状态都收在 setup 内 |
| 语言 | TypeScript | 接口返回结构全部有类型,改字段时编译期就能报警 |
| 构建 | Vite | 冷启动秒开,本地代理直接写在vite.config.ts |
| 路由 | Vue Router 4 | history 模式,配合后端菜单动态addRoute |
| 状态 | Pinia | 登录态、当前城市、字典缓存、权限码四块状态 |
| 组件库 | Element Plus | 表格、表单、日期范围选择器、分页,占界面 70% |
| 图表 | ECharts | 折线、柱状、饼图、地图散点,全部走一个封装组件 |
| 请求 | Axios | 统一拦截器处理 token、城市维度、错误提示 |
| 时间 | Day.js | 按月聚合的 X 轴、环比同比计算 |
选 Vue 3 而不是别的,主要原因是这个项目里图表和表格的状态耦合很重:切换城市要同时刷新四个图表和一张宽表,用响应式去驱动比手动setOption省事太多。Element Plus 的表格虽然在大数据量下需要额外处理,但它的筛选、排序、分页 API 足够稳定,前端组不用自己造轮子。ECharts 挑大梁没有悬念,房地产数据离不开地图和堆叠柱状图。
要留意的是 TypeScript 在这套代码里不是摆设。api/types.ts里把PageResult、TrendPoint、DistrictStat全部定义成接口,views层引用这些类型,一旦后端改字段名,vue-tsc直接报错,比上线后看到一片空白强得多。
2.2 目录结构与模块边界
展开后的目录大致是这样:
src/ ├── api/ # 按业务域拆的接口函数,只做请求不发散 │ ├── market.ts # 行情:走势、排行、对比 │ ├── project.ts # 楼盘:列表、详情、竞品 │ ├── region.ts # 区域:下钻、热力 │ ├── system.ts # 登录、用户、菜单 │ └── types.ts # 所有响应体类型定义 ├── components/ # 跨页面复用的通用件 │ ├── BaseChart/ # ECharts 封装 │ ├── FilterBar/ # 城市 + 时间 + 业态的统一筛选条 │ └── StatCard/ # 指标卡 ├── composables/ # useTable / useChartTheme / useDict ├── directives/ # v-permission 按钮级权限 ├── layouts/ # 后台骨架:侧边菜单 + 顶栏 + 面包屑 ├── router/ # 静态路由 + 动态路由生成 ├── stores/ # user / dict / app ├── styles/ # 变量、重置、ECharts 配色 ├── utils/ # request.ts、format.ts、export.ts └── views/ ├── dashboard/ # 首页总览 ├── market/ # 市场行情 ├── project/ # 楼盘详情 ├── region/ # 区域分析 └── system/ # 用户、角色、菜单这套划分的关键在于api和views的对应关系:一个页面目录基本对应一个api文件,接口函数只负责拼参数、发请求、返回带类型的数据,不做任何数据加工。数据加工放在composables里,页面组件只负责渲染。这样的好处是写新页面时有明确的三步走:先在api里加函数,再在composables里做聚合,最后在views里拼 UI。坏处是文件数量多,新手第一天容易在market.ts和market/index.vue之间来回找。
components里的FilterBar值得单独提一句。房地产平台的筛选条件高度重复——城市、区域、时间范围、新房还是二手房——如果每个页面各写一遍,改一个默认值要动十几个文件。代码里的做法是把筛选条抽成组件,通过v-model双向绑定一个 query 对象,页面拿到 query 后自己决定怎么请求。这种「筛选与渲染解耦」的写法在中后台里非常实用。
2.3 本地启动与环境变量
跑起来的门槛不高,Node 18 以上即可:
# 安装依赖,锁文件是 pnpm-lock.yaml,用 pnpm 更稳 pnpm install # 开发模式,默认 5173 端口 pnpm dev # 打包,产物在 dist/ pnpm build # 本地预览打包结果,验证路由 history 模式 pnpm preview环境变量放在根目录的.env.development和.env.production,命名统一带VITE_前缀:
# .env.development VITE_APP_TITLE=房产数据平台 VITE_API_BASE_URL=/api VITE_PROXY_TARGET=http://127.0.0.1:8080VITE_API_BASE_URL之所以写成/api而不是完整域名,是为了走 Vite 的代理,避开本地开发的跨域问题。代理配置在vite.config.ts里:
// vite.config.ts import { defineConfig, loadEnv } from 'vite' import vue from '@vitejs/plugin-vue' import { fileURLToPath, URL } from 'node:url' export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd()) return { plugins: [vue()], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, server: { port: 5173, proxy: { // 只有以 /api 开头的请求才转发,静态资源仍然走 Vite 自己 '/api': { target: env.VITE_PROXY_TARGET, changeOrigin: true, // 后端接口本身不带 /api 前缀,这里重写掉 rewrite: (path) => path.replace(/^\/api/, '') } } } } })changeOrigin: true会把请求头里的 Host 改成目标地址,很多内网网关靠 Host 做路由,少了这一行就是 404。rewrite是否需要取决于后端有没有统一的/api前缀,这套代码的后端没有,所以要去掉。这两点是最常见的本地起不来的原因,后面避坑章节还会展开。
2.4 别名、规范与提交约束
@指向src的别名在vite.config.ts和tsconfig.json里必须同时配置,只配一边的后果是编辑器不报错但编译报错:
// tsconfig.json 片段 { "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }代码规范上用的是 ESLint + Prettier,规则里关掉了vue/multi-word-component-names,因为views下的页面组件大量用index.vue命名。这个选择有争议,但对按目录组织的项目来说,market/index.vue比MarketIndex.vue更好找。提交前跑一次pnpm lint,能挡掉大部分低级问题。
3. 数据看板核心链路:一个楼盘指标从接口到图表渲染的全过程
一个指标从后端到屏幕,要过四道关:请求层拼参数、类型层定义结构、图表层渲染、交互层响应用户切换。这四道关里任何一道写松了,后面改需求都会难受。这一章按顺序拆开看。
3.1 请求层:从 axios 实例到业务 api 函数
utils/request.ts是整个前端的数据入口,拦截器里做了三件事:注入登录态、透传城市维度、统一处理业务码。
// src/utils/request.ts import axios from 'axios' import { ElMessage } from 'element-plus' import { useUserStore } from '@/stores/user' const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000, headers: { 'Content-Type': 'application/json' } }) // 请求拦截:登录态 + 城市维度 service.interceptors.request.use((config) => { const user = useUserStore() if (user.token) config.headers.Authorization = `Bearer ${user.token}` // 平台所有查询都带城市维度,放 header 里避免每个 api 函数重复传 if (user.cityCode) config.headers['X-City-Code'] = user.cityCode return config }) // 响应拦截:后端统一包装 { code, data, msg } service.interceptors.response.use( (res) => { const body = res.data if (body.code === 0) return body.data if (body.code === 401) { useUserStore().logout() return Promise.reject(body) } ElMessage.error(body.msg || '接口返回异常') return Promise.reject(body) }, (err) => { // 超时单独提示,报表类接口超时是高频问题 const tip = err.code === 'ECONNABORTED' ? '查询超时,请缩小时间范围' : '网络异常,请稍后重试' ElMessage.error(tip) return Promise.reject(err) } ) export default servicebaseURL走环境变量,本地是/api走代理,线上是网关域名。timeout给 15 秒是折中值,行情类接口通常 300ms 内返回,但成交明细的聚合查询偶尔会跑到 8 秒以上,所以导出类接口会单独传timeout: 60000覆盖。响应拦截器直接把body.data返回出去,好处是业务函数里不用每次都.data.data,代价是拿不到原始响应头,如果哪天要做文件下载就得另开一个实例。
有了实例,api层只写薄薄一层:
// src/api/market.ts import request from '@/utils/request' import type { PageResult, TrendPoint, DistrictStat } from './types' export interface TrendQuery { startMonth: string // 格式 2023-01 endMonth: string type: 'new' | 'second' // 新房 / 二手房 } // 成交均价走势,返回按月聚合的点数组 export const getPriceTrend = (params: TrendQuery) => request.get<any, TrendPoint[]>('/market/price-trend', { params }) // 区域成交排行,分页返回 export const getDistrictRank = (params: { page: number; size: number }) => request.get<any, PageResult<DistrictStat>>('/market/district-rank', { params }) // 新房二手房对比,一次拿两组 export const getCompare = (params: { month: string }) => request.get<any, { newAvg: number; secondAvg: number; ratio: number }>( '/market/compare', { params } )request.get<any, TrendPoint[]>里的两个泛型参数是 Axios 的固定签名,第一个是响应体原始类型,第二个才是函数实际返回的类型。因为拦截器已经把body.data剥出来了,所以第二个泛型写业务类型,编辑器里res[0].month就能直接提示。这个写法新人第一次看会懵,但在整个项目里统一之后,改接口字段时能省掉大量排查。
3.2 图表封装:BaseChart 收口 ECharts 的初始化与自适应
ECharts 在这个项目里出现了二十多次,如果每个页面都写echarts.init和window.addEventListener('resize'),代码会迅速失控。代码里有一个BaseChart组件专门收口这件事:
<!-- src/components/BaseChart/index.vue --> <template> <div ref="el" class="base-chart" :style="{ height }"></div> </template> <script setup lang="ts"> import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue' import * as echarts from 'echarts' const props = withDefaults(defineProps<{ option: echarts.EChartsOption height?: string autoResize?: boolean }>(), { height: '320px', autoResize: true }) const el = ref<HTMLDivElement>() // 用 shallowRef:ECharts 实例内部结构很深,深度代理会拖慢渲染甚至报错 const chart = shallowRef<echarts.ECharts>() let ro: ResizeObserver | null = null const render = () => { if (!chart.value) return // notMerge 传 true,切换指标时不残留旧 series chart.value.setOption(props.option, true) } onMounted(() => { chart.value = echarts.init(el.value!) render() if (props.autoResize) { // ResizeObserver 盯容器,比 window.resize 精准,侧边栏折叠也能捕获 ro = new ResizeObserver(() => chart.value?.resize()) ro.observe(el.value!) } }) watch(() => props.option, render, { deep: true }) onBeforeUnmount(() => { ro?.disconnect() chart.value?.dispose() // 必须 dispose,否则切页面后实例常驻内存 }) </script>三个细节值得说。第一,shallowRef而不是ref:ECharts 实例上挂着大量 DOM 引用和内部对象,Vue 的深度响应式代理会给它套一层 Proxy,轻则卡顿,重则某些内部方法拿不到正确的this,图表直接白屏。第二,用ResizeObserver而不是监听window.resize,因为后台布局里侧边菜单可以折叠,窗口尺寸没变但容器宽度变了,只监听 window 的话图会一直保持旧宽度。第三,dispose()放在onBeforeUnmount里,这是内存泄漏最常见的来源。
setOption的第二个参数notMerge设成true,意思是完全替换而不是合并。默认的合并模式在同一个组件里切换不同指标时会出问题——比如从「成交套数柱状图」切到「均价折线图」,旧的柱状 series 会留在图上,出现柱线叠加的怪画面。设成true每次全量替换,配合watch深度监听option,切换指标就很干净。
3.3 宽表格:分页、筛选与导出的组合
房地产数据的表格特点是列多、数字多、还要能导出。代码里的做法是抽一个useTable组合式函数,把列表、总数、加载态、查询对象、翻页、重置全部收进去:
// src/composables/useTable.ts import { reactive, ref } from 'vue' import type { PageResult } from '@/api/types' export function useTable<T>( fetcher: (q: Record<string, any>) => Promise<PageResult<T>>, initQuery: Record<string, any> = {} ) { const list = ref<T[]>([]) const total = ref(0) const loading = ref(false) const query = reactive({ page: 1, size: 20, ...initQuery }) const load = async () => { loading.value = true try { const res = await fetcher({ ...query }) list.value = res.records total.value = res.total } finally { loading.value = false // finally 保证异常时不会一直转圈 } } // 改筛选条件后必须回到第一页,否则会查到空页 const search = () => { query.page = 1; load() } const reset = () => { Object.assign(query, { page: 1, size: 20, ...initQuery }) load() } return { list, total, loading, query, load, search, reset } }页面里用起来就三行:
// src/views/market/district-rank.vue 片段 const { list, total, loading, query, load, search, reset } = useTable(getDistrictRank, { rankType: 'amount' }) onMounted(load)finally里关 loading 这个细节很关键。早期版本写在then里,接口一报错转圈就停不下来,用户只能刷新页面,血泪经验。search里强制回第一页也是必须的:用户在第 8 页把区域从「全市」改成「某区」,结果只有 2 页,不改 page 就会看到空表格,还以为是数据没出来。
导出走的是前端xlsx拼表再file-saver下载。这里有个折中:导出当前页还是导出全部。代码里保留了两种按钮,导出全部会单独调一个size: 5000的请求。这个上限是拍脑袋定的,超过 5000 行建议走后端异步导出,前端拼表会明显卡顿。
3.4 地图下钻与区域联动
区域分析页是整个平台交互最重的地方。地图上展示各区的成交热度,点某个区下降一层,右侧图表跟着刷新。实现上分三块:地图数据、点击事件、联动状态。
地图本身用的 GeoJSON 存放在assets/map/下,按城市分文件,echarts.registerMap注册一次就能复用。点击事件通过chart.on('click', params => ...)拿到区域名,然后触发cityCode + districtCode的查询。这里不需要额外的事件总线,因为BaseChart已经把实例封装了,但页面拿不到实例。代码里的做法是给BaseChart加一个@ready事件,把实例抛出来给父组件用:
<!-- 父组件用法 --> <BaseChart :option="mapOption" height="520px" @ready="onChartReady" /> <script setup lang="ts"> const currentDistrict = ref<string>('') const onChartReady = (chart: echarts.ECharts) => { chart.on('click', (params: any) => { if (params.componentType !== 'series') return currentDistrict.value = params.name loadDistrictDetail(params.name) // 触发右侧图表刷新 }) } </script>下钻时要注意地图层级切换必须重新registerMap,并且setOption要带notMerge: true,否则上一层的散点会残留在新地图上。另外下钻深度建议限制在两级(市 → 区),再往下数据颗粒度不够,做三层下钻的用户体验反而更差。
4. 状态与权限:登录态、动态菜单和按钮级权限怎么落地
中后台的权限设计千篇一律又处处是坑。这套代码的思路是:登录拿 token 和权限码,菜单由后端下发,路由动态注册,按钮用指令控制。
4.1 Pinia 用户态:token、城市与权限码
// src/stores/user.ts import { ref } from 'vue' import { defineStore } from 'pinia' import { login as loginApi, getProfile } from '@/api/system' import type { MenuNode, LoginForm } from '@/api/types' export const useUserStore = defineStore('user', () => { const token = ref(localStorage.getItem('rt_token') || '') const cityCode = ref('310100') // 当前城市,默认主城区 const menus = ref<MenuNode[]>([]) const perms = ref<string[]>([]) // 形如 market:export const login = async (form: LoginForm) => { const data = await loginApi(form) token.value = data.token // 存 localStorage:刷新页面不丢登录态 localStorage.setItem('rt_token', data.token) } // 刷新后菜单和权限是空的,必须重新拉一次 const fetchProfile = async () => { const data = await getProfile() menus.value = data.menus perms.value = data.perms cityCode.value = data.defaultCity || cityCode.value } const logout = () => { token.value = '' perms.value = [] menus.value = [] localStorage.removeItem('rt_token') } return { token, cityCode, menus, perms, login, fetchProfile, logout } })城市放在 store 里而不是每个页面自己维护,是这套代码里很聪明的一处设计。切换城市后,请求拦截器自动带X-City-Code,所有页面重新请求就自动切了城市,不用挨个传参。perms存的是扁平字符串数组,形如market:export、project:edit,判断时用includes,够用且好读。
4.2 路由守卫:动态菜单转路由的两个坑
// src/router/guard.ts import router from './index' import { useUserStore } from '@/stores/user' import { buildDynamicRoutes } from './dynamic' router.beforeEach(async (to) => { const user = useUserStore() if (to.meta.public) return true // 登录页、错误页放行 if (!user.token) { return { path: '/login', query: { redirect: to.fullPath } } } // 刷新页面后 store 是空的,补一次 profile if (!user.menus.length) { await user.fetchProfile() buildDynamicRoutes(user.menus) // 关键:重新导航一次,否则首次进入会命中 404 return { ...to, replace: true } } return true })return { ...to, replace: true }这一行是动态路由的经典写法。addRoute注册完成后,当前这次导航是拿不到新路由的,必须重新触发一次。少了这行,用户刷新页面会看到 404,但从别的页面点进去又正常,属于第一眼很难定位的问题。replace: true是为了不在历史记录里留一条无效记录。
buildDynamicRoutes负责把后端菜单树摊平成路由配置:
// src/router/dynamic.ts import type { RouteRecordRaw } from 'vue-router' import type { MenuNode } from '@/api/types' const modules = import.meta.glob('../views/**/*.vue') export function buildDynamicRoutes(menus: MenuNode[]) { const walk = (nodes: MenuNode[], parentPath = '') => { nodes.forEach((node) => { if (node.children?.length) { walk(node.children, `${parentPath}/${node.path}`) return } // 后端只给组件路径字符串,这里映射到真正的懒加载模块 const key = `../views/${node.component}.vue` if (!modules[key]) { console.warn('[route] 组件文件不存在:', key) return } router.addRoute('layout', { path: node.path, name: node.name, component: modules[key], meta: { title: node.title, icon: node.icon, keepAlive: node.cache } }) }) } walk(menus) }import.meta.glob是 Vite 提供的批量导入能力,它会把这批.vue全部打成懒加载 chunk。这里加了一行console.warn,是因为后端配错组件路径是极高频的事,没有这行警告就只能看到白屏然后去翻网络面板。
4.3 按钮级权限与字典缓存
按钮权限用一个自定义指令解决:
// src/directives/permission.ts import type { Directive } from 'vue' import { useUserStore } from '@/stores/user' export const permission: Directive<HTMLElement, string | string[]> = { mounted(el, binding) { const need = ([] as string[]).concat(binding.value) const user = useUserStore() const has = need.some((p) => user.perms.includes(p)) // 直接摘掉节点,不要用 display:none if (!has) el.parentNode?.removeChild(el) } }用法是<el-button v-permission="'market:export'">导出</el-button>。之所以移除节点而不是隐藏,是因为隐藏的按钮仍然能被脚本触发 click,权限校验等于没做。当然前端权限只是体验层,真正的拦截必须在后端,这一点不能含糊。
字典缓存是为了减少重复请求。新房/二手房、面积段、价格区间这些枚举在十几个页面都会用到,stores/dict.ts里做了一次内存缓存加 localStorage 持久化,首次进入拉全量,之后直接读缓存。缓存加了 12 小时过期,避免后端改了枚举前端还显示旧值。
5. 踩坑与排查:前端组血泪经验汇总
下面这几条都是这套代码在真实联调和上线过程中遇到过的,症状看起来玄学,原因都很具体。
5.1 图表在 Tab 切换后变成一条线或者空白
现象:从「成交走势」切到「区域排行」再切回来,图表的宽度变成 0,或者所有柱子挤在左边。
原因:display: none的容器宽度是 0,ECharts 在初始化时读到 0 宽,就会按 0 计算画布尺寸。切回来时容器有宽度了,但实例里存的还是 0。
解决:Tab 切换后手动调一次resize()。如果用el-tabs,可以在tab-change事件里延迟一帧再 resize:
const onTabChange = () => { // nextTick 之后再 resize,此时容器已有实际宽度 nextTick(() => chartRef.value?.resize()) }更彻底的做法是把lazy加在 Tab 面板上,或者用v-if控制图表组件,切回来重新挂载。这套代码选了前者,懒加载加 resize,改动最小。
5.2 成交金额显示成科学计数法或末位对不上
现象:总价显示1.2345678901234568e+9,或者前端求和的结果和后端报表差几十块。
原因:JavaScript 的Number是双精度浮点数,超过 2^53 的整数会丢精度。成交金额动辄十亿级,用Number存必然出问题。
解决:金额字段从一开始就让后端返回字符串(单位元或分),前端只做展示,不做运算。确实需要排序或求和时,用整数分计算再格式化:
// 后端返回字符串,避免 JSON.parse 阶段就丢精度 const formatAmount = (raw: string, unit: 'yuan' | 'fen' = 'yuan') => { const fen = unit === 'fen' ? BigInt(raw) : BigInt(raw) * 100n const wan = Number(fen / 1000000n) / 100 // 转万元,保留两位 return wan >= 10000 ? `${(wan / 10000).toFixed(2)} 亿` : `${wan.toFixed(2)} 万` }BigInt在这个场景里是必须的,parseFloat加toFixed解决不了精度问题,只是把误差藏起来。
5.3 刷新页面 404,从菜单点进去却正常
现象:本地pnpm dev一切正常,部署到测试环境后按 F5 刷新任意子路由,直接白屏 404。
原因:Router 用的是 history 模式,URL 是/market/trend这样的真实路径。服务器找不到这个文件,又没有配置回退到index.html,就返回 404。开发环境 Vite 自动处理了这件事,所以本地看不出来。
解决:在部署层加回退规则。Nginx 的写法是:
location / { root /usr/share/nginx/html; index index.html; # 找不到文件就交给前端路由 try_files $uri $uri/ /index.html; }pnpm preview能在本地模拟这个行为,上线前跑一遍能提前发现。如果不想动服务器配置,另一个选择是改成 hash 模式,代价是 URL 里多个#。
5.4 代理配置了但接口还是 404 或跨域
现象:本地请求/api/market/price-trend返回 404,或者浏览器控制台报 CORS。
原因通常有两个:一是rewrite写错了,把不该去的/api前缀也去掉了;二是baseURL配成了完整域名,请求根本没走代理。前者表现为 404,后者表现为跨域。
解决:先看网络面板里请求的实际 URL。如果 URL 是http://127.0.0.1:8080/market/...,说明代理生效且 rewrite 正确;如果是http://localhost:5173/api/...且返回 404,说明 rewrite 没匹配上;如果直接指向了后端域名,说明baseURL没走环境变量。排查顺序固定成「看 URL → 看 rewrite → 看 baseURL」,比乱改配置快得多。
5.5 切页面后定时器和监听还在跑
现象:首页有个 30 秒自动刷新的轮询,进详情页待一会儿再回首页,发现接口请求频率翻倍了。
原因:setInterval在组件卸载时没有清理。Vue 不会自动帮你清,路由切换只是卸载了 DOM。
解决:所有副作用成对出现。轮询用onUnmounted清理,事件监听用onBeforeUnmount移除,ResizeObserver和IntersectionObserver记得disconnect。
let timer: number | null = null onMounted(() => { load() timer = window.setInterval(load, 30000) }) onUnmounted(() => { if (timer) { clearInterval(timer); timer = null } })判断有没有泄漏有个简单办法:在浏览器 Performance 面板录一段「首页 → 详情 → 首页」的操作,看内存曲线是否阶梯式上升。上升就说明有东西没释放。
6. 二次开发:换数据源、加指标页、抽公共组件的三条改造路径
这份代码拿来二次开发,最常见的三种诉求是:后端换了要改接口、业务要加新指标页、以及想把图表主题统一。三条路径的改动量差别很大,先给个对照:
| 改造目标 | 主要改动文件 | 预估工作量 | 风险点 |
|---|---|---|---|
| 换后端数据源 | utils/request.ts、api/*.ts、api/types.ts | 半天到两天 | 响应包装结构不同,拦截器要重写 |
| 新增指标页 | api/xxx.ts、views/xxx/index.vue、后端菜单配置 | 一天一页 | 忘记配菜单导致路由 404 |
| 统一图表主题 | styles/chart-theme.ts、components/BaseChart | 两小时 | 老页面硬编码的 color 数组要一起清 |
换数据源那条,最省事的做法是只动拦截器。如果新后端的返回结构不是{ code, data, msg },就把响应拦截器里的判断换掉,业务层一行不用改:
// 换后端时只改这一段,api 层和页面层保持不动 service.interceptors.response.use( (res) => { const body = res.data // 新后端约定:{ success: true, result: any, message: string } if (body.success) return body.result ElMessage.error(body.message || '请求失败') return Promise.reject(body) }, (err) => Promise.reject(err) )这里的前提是api/types.ts里的字段名和新后端对得上。如果字段命名风格不同(比如驼峰换下划线),加一层映射函数比在每个页面里改res.avg_price划算:
// src/api/adapter.ts const snakeToCamel = (obj: any): any => { if (Array.isArray(obj)) return obj.map(snakeToCamel) if (obj && typeof obj === 'object') { return Object.fromEntries( Object.entries(obj).map(([k, v]) => [ k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()), snakeToCamel(v) ]) ) } return obj }新增指标页的流程建议固化成三步,别跳步:第一步在api里加函数并补类型,第二步在views下建目录写页面,第三步去后端菜单表插一条记录,component字段填market/new-metric这样的相对路径。第三步最容易忘,忘了就是登录后看不到菜单,然后在代码里翻半天。
图表主题统一这件事,我的建议是尽早做。这套代码里各个页面自己写了 color 数组,同一张「成交均价」在首页是蓝色、在行情页是绿色,用户会以为是两组不同的数据。抽一个chart-theme.ts导出统一色板,在BaseChart初始化时echarts.registerTheme注册,之后所有图表自动一致。改的时候把老页面的硬编码 color 一起删掉,留着会覆盖主题。
最后说个验证习惯。改完任何一处接口或路由,我都会强制走一遍「登录 → 进首页 → 切城市 → 下钻一个区 → 导出一次 → 按 F5 刷新」,这六个动作覆盖了 token、城市维度、动态路由、导出和刷新补数据五条链路,任何一条断了都会暴露。有次偷懒跳过了刷新那步,上线后才发现动态路由没做二次导航,用户按 F5 全是 404,被追着修了两小时。从那以后我每次交付前都强制走一遍这套动作,不管改动多小。希望帮到你。
本文还有配套的精品资源,点击获取