响应式数据可视化 Dashboard

在跨端开发领域,Uniapp 凭借其 "一套代码、多端部署" 的核心优势,已成为开发者首选的跨端解决方案。而 Vue3 作为当前最流行的前端框架之一,不仅带来了更优秀的性能表现,其推出的 <script setup> 语法糖更是彻底简化了组件开发流程,让代码结构更简洁、逻辑更清晰。

当 Uniapp 遇上 Vue3 语法糖,不仅能充分发挥跨端开发的高效性,还能享受 Vue3 带来的开发体验升级 ------ 更少的模板代码、更直观的 Composition API 调用、更便捷的组件通信方式,让跨端应用开发变得事半功倍。

一、项目初始化与技术栈选型

1.1 项目创建与基础配置

首先需要确保已安装最新版的 HBuilderX(Uniapp 官方推荐开发工具),或使用 Vue CLI 搭建 Uniapp 项目。本文采用 HBuilderX 可视化创建,步骤如下:

  1. 打开 HBuilderX → 新建 → 项目 → 选择 "Uniapp 项目";
  2. 填写项目名称(如 uniapp-vue3-dashboard),选择 "Vue3 + TypeScript" 模板,勾选 "启用 <script setup> 语法糖";
  3. 勾选需要适配的端(本文选择 H5、微信小程序、App 端);
  4. 点击创建,等待项目初始化完成。

1.2 核心技术栈选型

技术 / 工具 说明 核心作用
Uniapp 跨端开发框架 一套代码适配多端,统一页面路由、组件体系
Vue3 + <script setup> 前端框架与语法糖 简化组件开发,优化逻辑组织,提升开发效率
TypeScript 类型检查工具 提供类型约束,减少运行时错误,提升代码可维护性
Pinia 状态管理工具 替代 Vuex,轻量简洁,原生支持 Vue3
uView Plus UI 组件库 适配 Uniapp 的 Vue3 组件库,提供丰富 UI 组件
ECharts UniApp 版 图表可视化库 实现折线图、柱状图、饼图等数据可视化展示
PostCSS 样式处理工具 配合 px2rpx 插件实现多端自适应布局

1.3 项目目录结构说明

XML 复制代码
uniapp-vue3-dashboard/
├── api/                // 接口请求封装
│   ├── index.ts        // 请求拦截、响应拦截配置
│   └── dashboard.ts    // 数据看板相关接口
├── components/         // 公共组件
│   ├── ChartCard.vue   // 图表卡片组件
│   ├── StatisticCard.vue // 统计数字卡片组件
│   └── NavBar.vue      // 自定义导航栏组件
├── pages/              // 页面目录
│   ├── index/          // 首页(数据看板)
│   ├── detail/         // 详情页
│   └── mine/           // 个人中心
├── pinia/              // Pinia 状态管理
│   ├── index.ts        // Pinia 实例创建
│   └── modules/        // 模块划分
│       ├── user.ts     // 用户状态
│       └── dashboard.ts // 看板数据状态
├── static/             // 静态资源(图片、图标)
├── styles/             // 全局样式
│   ├── common.scss     // 公共样式变量
│   └── main.scss       // 全局样式入口
├── types/              // TypeScript 类型定义
│   └── index.ts        // 公共类型、接口定义
├── utils/              // 工具函数
│   ├── format.ts       // 数据格式化工具
│   └── adapter.ts      // 多端适配工具
├── App.vue             // 应用入口组件
├── main.ts             // 入口文件(初始化 Pinia、路由等)
├── manifest.json       // Uniapp 配置文件(多端配置、权限等)
├── pages.json          // 路由配置文件
└── tsconfig.json       // TypeScript 配置文件

1.4 基础配置实现

1.4.1 全局样式配置(styles/main.scss)
css 复制代码
// 引入公共样式变量
@import "./common.scss";

// 全局样式重置
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

// 全局字体设置(适配多端)
body,
uni-page-body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  font-size: $font-size-base; // 从 common.scss 引入的变量
  color: $text-color-primary;
  background-color: $bg-color-primary;
}

// 自定义滚动条样式(H5 端)
::-webkit-scrollbar {
  width: 4px;
  height: 4px;
}

::-webkit-scrollbar-thumb {
  background-color: $border-color-light;
  border-radius: 2px;
}

// 适配小程序、App 端的安全区域
.page-container {
  padding-bottom: env(safe-area-inset-bottom);
  min-height: 100vh;
}
1.4.2 Pinia 初始化(pinia/index.ts)
TypeScript 复制代码
import { createPinia } from 'pinia'
import { App } from 'vue'

// 创建 Pinia 实例
const pinia = createPinia()

// 导出安装函数,在 main.ts 中使用
export function installPinia(app: App) {
  app.use(pinia)
}

export default pinia
1.4.3 接口请求封装(api/index.ts)
TypeScript 复制代码
import axios, { AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
import { showToast } from '@/utils/uni-api' // 封装的 uni.showToast

// 创建 axios 实例
const service = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL, // 从环境变量获取基础地址
  timeout: 10000, // 请求超时时间
  headers: {
    'Content-Type': 'application/json;charset=utf-8'
  }
})

// 请求拦截器:添加 token、设置请求头等
service.interceptors.request.use(
  (config: AxiosRequestConfig) => {
    // 从 Pinia 获取用户 token(后续会实现 user 模块)
    const token = uni.getStorageSync('token')
    if (token && config.headers) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error: AxiosError) => {
    return Promise.reject(error)
  }
)

// 响应拦截器:统一处理响应结果、错误提示等
service.interceptors.response.use(
  (response: AxiosResponse) => {
    const res = response.data
    // 假设后端约定:code=200 为成功,其他为错误
    if (res.code !== 200) {
      showToast(res.message || '请求失败', 'error')
      return Promise.reject(res)
    }
    return res.data // 直接返回响应体中的 data 字段
  },
  (error: AxiosError) => {
    // 网络错误、超时等处理
    const message = error.message || '网络异常,请稍后重试'
    showToast(message, 'error')
    return Promise.reject(error)
  }
)

// 封装请求方法:get、post、put、delete
export const request = {
  get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
    return service.get(url, config)
  },
  post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
    return service.post(url, data, config)
  },
  put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
    return service.put(url, data, config)
  },
  delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
    return service.delete(url, config)
  }
}

export default service
1.4.4 TypeScript 类型定义(types/index.ts)
TypeScript 复制代码
// 响应数据通用类型
export interface ApiResponse<T = any> {
  code: number
  message: string
  data: T
}

// 统计卡片数据类型
export interface StatisticItem {
  id: string
  title: string
  value: number
  unit: string
  icon: string
  color: string
  trend: 'up' | 'down' | 'flat' // 趋势:上升、下降、平稳
  trendValue: number // 趋势值(百分比)
}

// 折线图数据类型
export interface LineChartData {
  dates: string[] // X轴日期
  series: {
    name: string
    data: number[] // Y轴数据
    color: string
  }[]
}

// 饼图数据类型
export interface PieChartData {
  name: string
  value: number
  color: string
}[]

// 柱状图数据类型
export interface BarChartData {
  categories: string[] // X轴分类
  series: {
    name: string
    data: number[] // Y轴数据
    color: string
  }[]
}

二、核心功能实现:响应式数据看板

2.1 状态管理设计(pinia/modules/dashboard.ts)

使用 Pinia 管理看板数据,实现数据缓存、全局共享,避免重复请求:

TypeScript 复制代码
import { defineStore } from 'pinia'
import { request } from '@/api'
import { 
  StatisticItem, 
  LineChartData, 
  PieChartData, 
  BarChartData 
} from '@/types'

// 定义看板数据存储
export const useDashboardStore = defineStore('dashboard', {
  state: () => ({
    // 统计卡片数据
    statisticData: [] as StatisticItem[],
    // 折线图数据(访问量趋势)
    visitLineData: {} as LineChartData,
    // 饼图数据(用户来源分布)
    sourcePieData: [] as PieChartData,
    // 柱状图数据(各模块使用占比)
    moduleBarData: {} as BarChartData,
    // 加载状态
    loading: false
  }),
  getters: {
    // 计算总访问量(从折线图数据汇总)
    totalVisits(): number {
      return this.visitLineData.series?.[0]?.data.reduce((sum, curr) => sum + curr, 0) || 0
    }
  },
  actions: {
    // 重置所有数据
    resetData() {
      this.statisticData = []
      this.visitLineData = {} as LineChartData
      this.sourcePieData = []
      this.moduleBarData = {} as BarChartData
    },
    // 获取所有看板数据(批量请求,优化性能)
    async fetchAllDashboardData() {
      try {
        this.loading = true
        // 并行请求多个接口,提升加载速度
        const [statisticRes, lineRes, pieRes, barRes] = await Promise.all([
          request.get<StatisticItem[]>('/dashboard/statistic'),
          request.get<LineChartData>('/dashboard/visit-line'),
          request.get<PieChartData>('/dashboard/source-pie'),
          request.get<BarChartData>('/dashboard/module-bar')
        ])
        // 赋值到状态
        this.statisticData = statisticRes
        this.visitLineData = lineRes
        this.sourcePieData = pieRes
        this.moduleBarData = barRes
      } catch (error) {
        console.error('获取看板数据失败:', error)
        this.resetData() // 失败时重置数据
      } finally {
        this.loading = false
      }
    }
  },
  // 持久化配置:将数据缓存到本地存储,页面刷新不丢失
  persist: {
    enabled: true,
    strategies: [
      {
        key: 'dashboard_data',
        storage: uni.getStorageSync('isH5') ? localStorage : uni.getStorageSync, // 适配 H5 和小程序/App
        paths: ['statisticData', 'visitLineData', 'sourcePieData', 'moduleBarData'] // 需要持久化的字段
      }
    ]
  }
})

2.2 公共组件开发

2.2.1 统计数字卡片(components/StatisticCard.vue)

实现数据展示、趋势指示、多端适配的统计卡片:

html 复制代码
<template>
  <view class="statistic-card" :style="{ backgroundColor: bgColor }">
    <!-- 图标区域 -->
    <view class="card-icon" :style="{ backgroundColor: item.color + '20' }">
      <uni-icons :type="item.icon" :color="item.color" size="24"></uni-icons>
    </view>
    <!-- 数据区域 -->
    <view class="card-content">
      <view class="card-title">{{ item.title }}</view>
      <view class="card-value">
        <span class="value-text">{{ formatNumber(item.value) }}</span>
        <span class="value-unit">{{ item.unit }}</span>
      </view>
      <!-- 趋势区域 -->
      <view class="card-trend" :class="['trend-' + item.trend]">
        <uni-icons 
          :type="item.trend === 'up' ? 'arrowup' : item.trend === 'down' ? 'arrowdown' : 'minus'" 
          :color="getTrendColor(item.trend)" 
          size="14"
        ></uni-icons>
        <span class="trend-value">{{ item.trendValue }}%</span>
      </view>
    </view>
  </view>
</template>

<script setup lang="ts">
import { defineProps, computed } from 'vue'
import { StatisticItem } from '@/types'
import { formatNumber } from '@/utils/format'

// 定义组件 props
const props = defineProps<{
  item: StatisticItem // 统计数据项
  bgColor?: string // 卡片背景色(默认白色)
}>()

// 计算趋势颜色
const getTrendColor = computed(() => {
  switch (props.item.trend) {
    case 'up':
      return '#00b42a' // 上升-绿色
    case 'down':
      return '#f53f3f' // 下降-红色
    case 'flat':
      return '#86909c' // 平稳-灰色
    default:
      return '#86909c'
  }
})

// 默认背景色
const bgColor = computed(() => props.bgColor || '#ffffff')
</script>

<style scoped lang="scss">
.statistic-card {
  display: flex;
  align-items: center;
  padding: 16px;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  margin-bottom: 12px;

  .card-icon {
    width: 48px;
    height: 48px;
    border-radius: 16px;
    display: flex;
    align-items: center;
    justify-content: center;
    margin-right: 16px;
  }

  .card-content {
    flex: 1;

    .card-title {
      font-size: $font-size-sm;
      color: $text-color-secondary;
      margin-bottom: 4px;
    }

    .card-value {
      display: flex;
      align-items: baseline;
      margin-bottom: 4px;

      .value-text {
        font-size: $font-size-lg;
        font-weight: 600;
        color: $text-color-primary;
      }

      .value-unit {
        font-size: $font-size-base;
        color: $text-color-secondary;
        margin-left: 4px;
      }
    }

    .card-trend {
      display: flex;
      align-items: center;
      font-size: $font-size-xs;

      .trend-value {
        margin-left: 4px;
      }

      &.trend-up {
        color: #00b42a;
      }

      &.trend-down {
        color: #f53f3f;
      }

      &.trend-flat {
        color: #86909c;
      }
    }
  }
}
</style>
2.2.2 图表卡片组件(components/ChartCard.vue)

统一图表容器样式,适配不同图表类型(折线图、饼图、柱状图):

html 复制代码
<template>
  <view class="chart-card">
    <!-- 卡片头部 -->
    <view class="card-header">
      <view class="header-title">{{ title }}</view>
      <view class="header-action" @click="handleRefresh">
        <uni-icons type="refresh" color="#86909c" size="16"></uni-icons>
      </view>
    </view>
    <!-- 图表容器(适配多端高度) -->
    <view class="chart-container" :style="{ height: chartHeight + 'px' }">
      <!-- 插槽:传入具体图表组件 -->
      <slot name="chart"></slot>
    </view>
  </view>
</template>

<script setup lang="ts">
import { defineProps, emit, defineEmits } from 'vue'

// 定义组件 props
const props = defineProps<{
  title: string // 卡片标题
  chartHeight?: number // 图表高度(默认 240px)
}>()

// 定义组件事件
const emit = defineEmits<{
  (e: 'refresh'): void // 刷新事件
}>()

// 默认图表高度
const chartHeight = props.chartHeight || 240

// 处理刷新点击
const handleRefresh = () => {
  emit('refresh')
}
</script>

<style scoped lang="scss">
.chart-card {
  background-color: #ffffff;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  padding: 16px;
  margin-bottom: 16px;

  .card-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 16px;

    .header-title {
      font-size: $font-size-base;
      font-weight: 500;
      color: $text-color-primary;
    }

    .header-action {
      cursor: pointer;
    }
  }

  .chart-container {
    width: 100%;
    overflow: hidden;
  }
</style>

2.3 首页数据看板实现(pages/index/index.vue)

整合所有组件,实现响应式布局、数据加载、多端适配:

html 复制代码
<template>
  <view class="page-container">
    <!-- 自定义导航栏 -->
    <nav-bar title="数据看板" :show-back="false"></nav-bar>

    <!-- 加载中提示 -->
    <uni-loading-mask :show="dashboardStore.loading" text="加载中..."></uni-loading-mask>

    <!-- 统计卡片区域(2列布局,适配多端) -->
    <view class="statistic-list">
      <view class="statistic-row" v-for="(row, rowIndex) in statisticRows" :key="rowIndex">
        <statistic-card 
          v-for="(item, index) in row" 
          :key="item.id" 
          :item="item"
          :bgColor="index % 2 === 0 ? '#f0f9ff' : '#fef7fb'"
        ></statistic-card>
      </view>
    </view>

    <!-- 图表区域 -->
    <view class="chart-list">
      <!-- 访问量趋势折线图 -->
      <chart-card title="访问量趋势" @refresh="fetchDashboardData">
        <template #chart>
          <line-chart :data="dashboardStore.visitLineData"></line-chart>
        </template>
      </chart-card>

      <!-- 双列图表布局(饼图 + 柱状图) -->
      <view class="chart-row">
        <!-- 用户来源分布饼图 -->
        <chart-card title="用户来源分布" :chartHeight="200" @refresh="fetchDashboardData">
          <template #chart>
            <pie-chart :data="dashboardStore.sourcePieData"></pie-chart>
          </template>
        </chart-card>

        <!-- 模块使用占比柱状图 -->
        <chart-card title="模块使用占比" :chartHeight="200" @refresh="fetchDashboardData">
          <template #chart>
            <bar-chart :data="dashboardStore.moduleBarData"></bar-chart>
          </template>
        </chart-card>
      </view>
    </view>
  </view>
</template>

<script setup lang="ts">
import { onMounted, computed } from 'vue'
import { useDashboardStore } from '@/pinia/modules/dashboard'
import NavBar from '@/components/NavBar.vue'
import StatisticCard from '@/components/StatisticCard.vue'
import ChartCard from '@/components/ChartCard.vue'
import LineChart from '@/components/charts/LineChart.vue'
import PieChart from '@/components/charts/PieChart.vue'
import BarChart from '@/components/charts/BarChart.vue'

// 获取 Pinia 实例
const dashboardStore = useDashboardStore()

// 初始化:获取看板数据
onMounted(() => {
  // 优先使用缓存数据,无缓存则请求接口
  if (dashboardStore.statisticData.length === 0) {
    fetchDashboardData()
  }
})

// 重新获取看板数据
const fetchDashboardData = () => {
  dashboardStore.fetchAllDashboardData()
}

// 统计卡片分两行显示(每行2个)
const statisticRows = computed(() => {
  const rows = []
  // 将一维数组转为二维数组(每行2个元素)
  for (let i = 0; i < dashboardStore.statisticData.length; i += 2) {
    rows.push(dashboardStore.statisticData.slice(i, i + 2))
  }
  return rows
})
</script>

<style scoped lang="scss">
// 统计卡片布局:H5/PC 端2列,小程序/App 端自适应
.statistic-list {
  padding: 16px;

  .statistic-row {
    display: flex;
    gap: 12px;
    margin-bottom: 12px;

    // 响应式:屏幕宽度小于768px时,单列布局
    @media (max-width: 767px) {
      flex-direction: column;
    }

    statistic-card {
      flex: 1; // 平分宽度
    }
  }
}

// 图表区域布局
.chart-list {
  padding: 0 16px 16px;

  .chart-row {
    display: flex;
    gap: 16px;
    margin-top: 16px;

    // 响应式:屏幕宽度小于768px时,单列布局
    @media (max-width: 767px) {
      flex-direction: column;
    }

    chart-card {
      flex: 1; // 平分宽度
    }
  }
}
</style>

2.4 图表组件实现(以折线图为例)

html 复制代码
<template>
  <view class="line-chart">
    <!-- ECharts 容器 -->
    <view ref="chartRef" class="chart-dom"></view>
  </view>
</template>

<script setup lang="ts">
import { defineProps, onMounted, onUnmounted, watch, ref } from 'vue'
import * as echarts from 'echarts'
import { LineChartData } from '@/types'
import { debounce } from '@/utils/format'

// 定义组件 props
const props = defineProps<{
  data: LineChartData // 折线图数据
}>()

// ECharts 实例引用
const chartRef = ref<HTMLElement | null>(null)
let chartInstance: echarts.ECharts | null = null

// 初始化图表
const initChart = () => {
  if (!chartRef.value || !props.data.dates || props.data.series.length === 0) return

  // 创建 ECharts 实例
  chartInstance = echarts.init(chartRef.value)

  // 图表配置项
  const option = {
    tooltip: {
      trigger: 'axis',
      axisPointer: {
        type: 'shadow'
      },
      textStyle: {
        fontSize: 12
      },
      padding: 10
    },
    grid: {
      left: '10%',
      right: '5%',
      top: '15%',
      bottom: '15%',
      containLabel: true
    },
    xAxis: {
      type: 'category',
      data: props.data.dates,
      axisLine: {
        lineStyle: {
          color: '#e5e6eb'
        }
      },
      axisLabel: {
        fontSize: 11,
        color: '#86909c',
        rotate: 30 // X轴标签旋转30度,避免重叠
      },
      splitLine: {
        show: false
      }
    },
    yAxis: {
      type: 'value',
      axisLine: {
        show: false
      },
      axisLabel: {
        fontSize: 11,
        color: '#86909c',
        formatter: (value: number) => {
          // 数值格式化:大于1000时显示为千分位
          return value >= 1000 ? (value / 1000).toFixed(1) + 'k' : value
        }
      },
      splitLine: {
        lineStyle: {
          color: '#f2f3f5'
        }
      },
      boundaryGap: [0, '10%']
    },
    series: props.data.series.map(series => ({
      name: series.name,
      type: 'line',
      data: series.data,
      smooth: true, // 平滑曲线
      symbol: 'circle', // 标记点样式
      symbolSize: 4,
      lineStyle: {
        width: 2,
        color: series.color
      },
      itemStyle: {
        color: series.color,
        borderWidth: 1,
        borderColor: '#ffffff'
      },
      areaStyle: {
        // 填充区域渐变
        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
          {
            offset: 0,
            color: series.color + '80'
          },
          {
            offset: 1,
            color: series.color + '10'
          }
        ])
      },
      emphasis: {
        itemStyle: {
          symbolSize: 6
        }
      }
    }))
  }

  // 设置配置项并渲染图表
  chartInstance.setOption(option)

  // 监听窗口大小变化,自适应图表(防抖处理)
  const resizeHandler = debounce(() => {
    chartInstance?.resize()
  }, 300)

  window.addEventListener('resize', resizeHandler)

  // 组件卸载时移除监听
  onUnmounted(() => {
    window.removeEventListener('resize', resizeHandler)
  })
}

// 监听数据变化,重新渲染图表
watch(
  () => props.data,
  (newVal) => {
    if (newVal.dates && newVal.series.length > 0) {
      if (chartInstance) {
        // 已有实例,更新配置项
        chartInstance.setOption({
          xAxis: { data: newVal.dates },
          series: newVal.series.map(series => ({
            name: series.name,
            data: series.data,
            lineStyle: { color: series.color },
            itemStyle: { color: series.color },
            areaStyle: {
              color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                { offset: 0, color: series.color + '80' },
                { offset: 1, color: series.color + '10' }
              ])
            }
          }))
        })
      } else {
        // 无实例,初始化图表
        initChart()
      }
    }
  },
  { deep: true } // 深度监听对象变化
)

// 组件挂载时初始化图表
onMounted(() => {
  initChart()
})

// 组件卸载时销毁图表实例
onUnmounted(() => {
  chartInstance?.dispose()
  chartInstance = null
})
</script>

<style scoped lang="scss">
.line-chart {
  width: 100%;
  height: 100%;

  .chart-dom {
    width: 100%;
    height: 100%;
  }
}
</style>

三、多端适配与性能优化

3.1 多端适配方案

Uniapp 本身提供了完善的多端适配能力,结合 Vue3 语法糖,我们可以从以下几个维度实现精细化适配:

3.1.1 样式适配
  1. rpx 自动转换 :Uniapp 支持将 rpx 自动转换为对应端的单位(H5 转 px,小程序转 rpx,App 转 dp),开发时优先使用 rpx 作为长度单位;

  2. 媒体查询 :针对不同屏幕尺寸(如 H5 端的 PC 大屏和移动端小屏),使用 @media 查询编写差异化样式,如首页的统计卡片在大屏时 2 列布局,小屏时单列布局;

  3. 条件编译 :针对特定端的样式差异,使用 Uniapp 条件编译语法:

    css 复制代码
    /* #ifdef H5 */
    // H5 端专属样式(如滚动条、导航栏高度)
    .header {
      height: 60px;
    }
    /* #endif */
    
    /* #ifdef MP-WEIXIN */
    // 微信小程序专属样式(如胶囊按钮适配)
    .header {
      height: 48px;
      padding-top: 10px;
    }
    /* #endif */
    3.1.2 功能适配
  4. API 适配 :Uniapp 提供了 uni 前缀的跨端 API,但部分 API 存在端差异,需使用条件编译处理:

    TypeScript 复制代码
    // 保存图片到本地(适配 H5 和小程序)
    const saveImage = async (url: string) => {
      try {
        /* #ifdef H5 */
        // H5 端:创建 a 标签下载
        const link = document.createElement('a')
        link.href = url
        link.download = '图表.png'
        link.click()
        /* #endif */
    
        /* #ifdef MP-WEIXIN || APP-PLUS */
        // 小程序/App 端:使用 uni API
        await uni.downloadFile({ url })
        await uni.saveImageToPhotosAlbum({ filePath: res.tempFilePath })
        uni.showToast({ title: '保存成功' })
        /* #endif */
      } catch (error) {
        uni.showToast({ title: '保存失败', icon: 'none' })
      }
    }
  5. 组件适配 :部分组件在不同端的表现不一致,可通过 uni.createSelectorQuery() 等 API 获取设备信息,动态调整组件属性:

    TypeScript 复制代码
    // 动态设置图表高度(根据屏幕宽度适配)
    const setChartHeight = () => {
      uni.createSelectorQuery().in(getCurrentInstance()).select('.chart-container').boundingClientRect(rect => {
        if (rect) {
          // 图表高度 = 屏幕宽度 * 0.6(保持宽高比)
          chartHeight.value = rect.width * 0.6
        }
      }).exec()
    }

    3.2 性能优化策略

    3.2.1 数据层面优化
  6. Pinia 持久化:将看板数据持久化到本地存储,页面刷新后无需重新请求接口,提升加载速度;

  7. 并行请求 :使用 Promise.all 并行请求多个接口,减少请求等待时间(如 2.1 中 fetchAllDashboardData 方法);

  8. 数据缓存与过期策略 :对高频访问但不常变化的数据(如统计卡片数据),设置缓存过期时间,避免过度请求:

    TypeScript 复制代码
    // 在 Pinia actions 中添加缓存过期逻辑
    async fetchAllDashboardData() {
      // 检查缓存是否过期(假设缓存有效期为10分钟)
      const cacheTime = uni.getStorageSync('dashboard_cache_time')
      const now = Date.now()
      if (cacheTime && now - cacheTime < 10 * 60 * 1000) {
        // 缓存未过期,直接使用本地数据
        return
      }
    
      // 缓存过期,重新请求接口
      try {
        this.loading = true
        const [statisticRes, lineRes, pieRes, barRes] = await Promise.all([/* 接口请求 */])
        // 更新状态
        this.statisticData = statisticRes
        // ...其他数据赋值
        // 记录缓存时间
        uni.setStorageSync('dashboard_cache_time', now)
      } catch (error) {
        // 错误处理
      } finally {
        this.loading = false
      }
    }
    3.2.2 渲染层面优化
  9. 组件懒加载:对非首屏组件(如详情页、个人中心),使用 Uniapp 的路由懒加载:

TypeScript 复制代码
// pages.json
{
  "pages": [
    {
      "path": "pages/index/index",
      "style": { "navigationBarTitleText": "数据看板" }
    },
    {
      "path": "pages/detail/detail",
      "style": { "navigationBarTitleText": "详情页" },
      "lazyCodeLoading": "requiredComponents" // 懒加载
    }
  ]
}
  1. 虚拟列表 :如果看板数据量较大(如统计卡片超过 20 个),使用 uni-virtual-list 组件实现虚拟滚动,减少 DOM 节点数量:

    html 复制代码
    <uni-virtual-list 
      :height="500" 
      :item-height="100" 
      :items="dashboardStore.statisticData"
    >
      <template #default="{ item }">
        <statistic-card :item="item"></statistic-card>
      </template>
    </uni-virtual-list>
  2. ECharts 优化

    • 使用 debounce 处理窗口 resize 事件,避免频繁触发图表重绘;
    • 组件卸载时销毁 ECharts 实例,释放内存;
    • 减少图表动画复杂度,提升渲染性能。
  3. 按需引入组件:uView Plus、ECharts 等库支持按需引入,避免全量打包导致体积过大:

3.2.3 打包优化
TypeScript 复制代码
// 按需引入 ECharts 模块(减少打包体积)
import * as echarts from 'echarts/core'
import { LineChart, PieChart, BarChart } from 'echarts/charts'
import { CanvasRenderer } from 'echarts/renderers'

// 注册所需模块
echarts.use([LineChart, PieChart, BarChart, CanvasRenderer])
  1. 代码压缩与 tree-shaking :在 manifest.json 中开启代码压缩:

    TypeScript 复制代码
    // manifest.json
    {
      "h5": {
        "optimization": {
          "treeShaking": true,
          "minify": true,
          "splitChunks": true
        }
      }
    }

    四、常见问题与解决方案

    4.1 Vue3 语法糖适配问题

    问题 1:<script setup> 中无法使用 this

    解决方案 :Vue3 组合式 API 中无需 this,直接通过导入的模块、props、ref 等访问数据:

    TypeScript 复制代码
    // 错误写法(Vue2 语法)
    this.fetchData()
    
    // 正确写法(Vue3 语法糖)
    import { fetchData } from '@/api'
    fetchData()
    
    // 访问 Pinia 状态
    const store = useDashboardStore()
    console.log(store.statisticData)

    4.3 性能问题

    问题:页面加载缓慢、卡顿

    排查方向

  2. 检查是否有大量同步请求,改为并行请求;

  3. 检查图表是否过多或数据量过大,考虑分页加载或简化图表;

  4. 检查是否有内存泄漏(如未销毁 ECharts 实例、未移除事件监听);

  5. 使用 Uniapp 开发者工具的 "性能分析" 功能,定位耗时操作。

相关推荐
smile_Iris1 小时前
Day 26 常见的降维算法
开发语言·算法·kotlin
光影少年1 小时前
web3学习路线
前端·学习·前端框架·web3
克喵的水银蛇1 小时前
Flutter 状态管理:Provider 入门到实战(替代 setState)
前端·javascript·flutter
鹏多多1 小时前
flutter-使用url_launcher打开链接/应用/短信/邮件和评分跳转等
android·前端·flutter
王铁柱子哟-1 小时前
如何在 VS Code 中调试带参数和环境变量的 Python 程序
开发语言·python
小飞侠在吗1 小时前
vue3 中的 ref 和 reactive
前端·javascript·vue.js
0思必得01 小时前
[Web自动化] 开发者工具控制台(Console)面板
前端·javascript·python·自动化·web自动化·开发者工具
zhixingheyi_tian1 小时前
TestDFSIO 之 热点分析
android·java·javascript
weixin_307779131 小时前
Jenkins Bootstrap 5 API插件:现代化Jenkins界面的开发利器
开发语言·前端·网络·bootstrap·jenkins