教程:基于 React + Highcharts 构建一个单页应用程序、按需数据拉取与图表渲染

教程翻译:基于React + Highcharts构建一个单页应用程序、按需数据拉取与图表渲染

本篇教程将手把手搭建一套简易React单页应用:程序从静态JSON文件读取数据,结合Highcharts React完成图表绘制。本方案用来模拟单页应用(SPA)场景下向后端服务请求数据 的真实业务逻辑。

你将理解SPA的本质、按需请求数据为何是SPA的核心特性,以及Highcharts该如何适配这套开发模式。

一、什么是单页应用程序(SPA-Single Page Application )

传统网站的运行逻辑:用户点击链接、提交表单时,浏览器会向服务端发起请求,服务器返回一份全新的HTML页面。浏览器会销毁当前页面所有内容,从头渲染返回的新页面。

单页应用程序SPA的运行机制完全不同:浏览器初次加载页面资源后,整个应用不会发生整页刷新。用户操作页面时,由JavaScript完成视图的局部更新,仅和服务端产生少量交互。核心思路就是尽可能把业务逻辑放置在客户端运行,只按需请求当前视图必需的数据。

这也是SPA页面体验流畅的根本原因:无需重载整个网页,只在需要时拉取对应数据即可。

二、Highcharts如何适配单页应用程序SPA架构

Highcharts是运行在客户端的图表库,所有交互式图表直接在浏览器内完成渲染,天生契合SPA"局部更新、不整页刷新"的特性。

单页应用程序SPA初始化加载阶段,Highcharts会随项目代码一并被引入;后端接口返回新数据后,只需将最新数据传入图表组件,图表就会即时刷新展示,全程不会触发页面重载。

本教程落地的演示逻辑:

  1. 折线图表所属的页面一次性加载完成
  2. 用户选择指定年份
  3. 程序仅请求该年份对应的流量数据
  4. Highcharts接收新数据更新折线图,页面无任何刷新

补充说明:本演示体量很小,其实一次性全量加载所有数据会更简单。本次刻意采用按需拉取模式,是为了还原大型项目的真实场景:海量数据集、数据实时更新、后端筛选过滤、对接多份独立数据源等场景,都必须使用按需请求方案。

三、项目实现目标

基于Vite搭建React单页项目,展示2023/2024/2025三年的网站月度流量数据(自然流量、直接访问流量、外链引荐流量)。用户点击图表上方年份按钮即可切换数据,程序通过HTTP请求读取对应静态JSON文件。

代码中加入人工请求延迟模拟真实网络耗时;数据请求期间展示加载动画,完整还原「请求→加载→渲染」全流程。

在线预览地址:Stackblitz在线演示

成品效果:面积曲线图展示三年月度流量数据,顶部配置2023、2024、2025三个年份切换按钮

技术栈清单

  • Vite 8:极速项目构建与开发服务工具
  • React19 + TypeScript:页面UI开发,搭配类型校验提升代码健壮性
  • @highcharts/react v5.0:Highcharts官方React声明式组件封装
  • Vite静态资源目录:存放JSON数据文件,由服务直接托管访问

架构说明:使用静态JSON文件是为了聚焦「数据请求+图表渲染」核心逻辑,生产环境中只需把请求地址替换为真实后端接口即可,整体请求代码无需改动。

前置环境要求

  • 掌握React基础(组件、Props、State状态)
  • 安装Node.js 18及以上版本(自带npm包管理器)
  • 主流现代浏览器
  • 代码编辑器(推荐VS Code)

项目搭建步骤

步骤1:初始化Vite项目

在空白文件夹打开终端,执行命令:

bash 复制代码
npm create vite@latest

后续配置项:

项目名称填写 . 代表使用当前文件夹;框架选择React;语言变体选择TypeScript

弹出提示「是否使用npm安装依赖并启动服务」,选择Yes。工具会自动安装依赖并在5173端口启动开发服务。

若关闭终端后需要重启项目,执行:

bash 复制代码
npm run dev

步骤2:清理项目默认模板文件

Vite自动生成的演示文件大多无用,按需清理:

  1. 清空public文件夹内部所有文件(保留文件夹本体,后续存放数据JSON)
  2. 删除src/assets整个文件夹
  3. 保留App.tsx文件(后续替换内部代码);将演示地址中的App.cssindex.css源码覆盖项目内原有文件,本篇教程不展开样式编写说明。

步骤3:安装Highcharts React依赖

执行命令安装官方React封装包:

bash 复制代码
npm install @highcharts/react

步骤4:编写业务数据JSON文件

无需搭建独立后端服务,直接在Vite的public目录存放JSON文件。Vite会将public下所有文件托管在项目根路径,也就是说:public/data/traffic-2025.json 访问地址为 /data/traffic-2025.json

新建文件:public/data/traffic-2025.json

json 复制代码
{
    "id": "2025",
    "year": 2025,
    "series": [
        {
            "name": "Organic",
            "data": [
                8423, 9234, 10456, 9845, 12123, 14567, 16234, 15123, 13456,
                11234, 10345, 9123
            ]
        },
        {
            "name": "Direct",
            "data": [
                5234, 5834, 6567, 6789, 8012, 8945, 10123, 9234, 8567, 6456,
                5234, 5123
            ]
        },
        {
            "name": "Referral",
            "data": [
                1867, 2145, 2734, 2956, 3234, 3789, 4123, 3845, 3234, 2567,
                2145, 1945
            ]
        }
    ]
}

参照上述格式,补齐traffic-2024.jsontraffic-2023.json两份文件的数据(数据可从演示仓库复制)。

这类JSON文件就是本项目的数据层;开发阶段由Vite托管访问,项目打包部署后会随同前端资源一并输出。

步骤5:TypeScript类型定义

新建src/types.ts,约束流量数据的结构,编辑器自动提示字段、编译阶段拦截书写错误:

typescript 复制代码
export interface TrafficSeries {
    name: string;
    data: number[];
}
export interface TrafficData {
    id: string;
    year: number;
    series: TrafficSeries[];
}

步骤6:封装通用数据请求Hook

新建src/hooks/useFetch.ts,统一管理接口请求、加载状态、异常捕获逻辑

typescript 复制代码
import { useState, useEffect } from "react";

interface UseFetchResult<T> {
    data: T | null;
    loading: boolean;
    error: string | null;
}
// 模拟网络请求延时1秒
const SIMULATED_DELAY_MS = 1000;

export function useFetch<T>(url: string): UseFetchResult<T> {
    const [data, setData] = useState<T | null>(null);
    const [loading, setLoading] = useState<boolean>(true);
    const [error, setError] = useState<string | null>(null);

    useEffect(() => {
        // 发起新请求时保留旧数据,切换年份时原有图表不会直接消失
        setLoading(true);
        setError(null);
        let cancelled = false;

        const fetchData = async () => {
            try {
                const response = await fetch(url);
                if (!response.ok) {
                    throw new Error(`网络请求异常:状态码${response.status}`);
                }
                const json: T = await response.json();
                // 模拟真实网络耗时,上线项目需要删除该延时代码
                await new Promise((resolve) => setTimeout(resolve, SIMULATED_DELAY_MS));

                if (!cancelled) {
                    setData(json);
                    setLoading(false);
                }
            } catch (err) {
                if (!cancelled) {
                    setError(
                        err instanceof Error
                            ? err.message
                            : "发生未知请求错误"
                    );
                    setLoading(false);
                }
            }
        };
        fetchData();

        // 组件卸载时中断异步请求,避免已销毁组件修改状态引发警告
        return () => {
            cancelled = true;
        };
    }, [url]);

    return { data, loading, error };
}

Hook设计要点:

  1. 重新请求数据时保留上一次的图表数据,新数据返回前页面依旧展示旧图表,搭配样式做变暗虚化提示即可
  2. 通过cancelled标识,解决组件卸载后异步回调修改state的报错问题
  3. 人工延时仅用于演示加载状态,正式环境务必移除

步骤7:封装单条面积曲线公共组件

新建src/charts/TrafficAreaSplineSeries.tsx,统一管理曲线样式、渐变填充、拐点样式,解耦重复配置

typescript 复制代码
import { Highcharts } from "@highcharts/react";
import { AreaSplineSeries } from "@highcharts/react/series/AreaSpline";

interface TrafficAreaSplineSeriesProps {
    name: string;
    data: number[];
    color: string;
}

export function TrafficAreaSplineSeries({
    name,
    data,
    color
}: TrafficAreaSplineSeriesProps) {
    return (
        <AreaSplineSeries
            name={name}
            data={data}
            color={color}
            options={{
                marker: {
                    fillColor: Highcharts.color(color).brighten(-0.1).get(),
                    lineColor: Highcharts.color(color).brighten(0.8).get(),
                    lineWidth: 1
                },
                fillColor: {
                    linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },
                    stops: [
                        [0, color],
                        [1, color + "00"]
                    ]
                }
            }}
        />
    );
}

该组件接收名称、数组数据、基础颜色三个入参;依托Highcharts内置颜色方法自动计算拐点配色,实现从上到下透明渐变填充效果。

步骤8:编写图表主容器组件

新建src/charts/TrafficChart.tsx,组装坐标轴、图例、悬浮提示、导出、无障碍等全套图表配置

typescript 复制代码
import {
    Chart,
    Title,
    Subtitle,
    XAxis,
    YAxis,
    Tooltip,
    Legend,
    PlotOptions
} from "@highcharts/react";
import type { TrafficData } from "../types";
import { TrafficAreaSplineSeries } from "./TrafficAreaSplineSeries";
import { Exporting } from "@highcharts/react/modules/Exporting";
import { Accessibility } from "@highcharts/react/modules/Accessibility";

interface TrafficChartProps {
    data: TrafficData;
}
// 三条曲线固定配色
const colors = ["#47abc9", "#5064e7", "#7c31e6"];

export default function TrafficChart({ data }: TrafficChartProps) {
    const categories = ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"];

    return (
        <Chart
            colors={colors}
            containerProps={{ style: { width: "100%", height: "420px" } }}
        >
            <Title>{data.year}年月度网站流量统计</Title>
            <Subtitle>各渠道访问访客量</Subtitle>
            <XAxis categories={categories} crosshair={{}}>月份</XAxis>
            <YAxis max={20000}>访客数量</YAxis>
            <Tooltip shared valueDecimals={0} valueSuffix=' 位访客' />
            <Legend enabled />
            <PlotOptions series={{ marker: { enabled: false } }} />
            {data.series.map((s, i) => (
                <TrafficAreaSplineSeries
                    key={s.name}
                    name={s.name}
                    data={s.data}
                    color={colors[i]}
                />
            ))}
            {/* 图表导出、无障碍适配模块 */}
            <Exporting />
            <Accessibility />
        </Chart>
    );
}

Highcharts React基于JSX声明式写法,父组件传入最新data,图表自动响应式更新;原生JS版本需要手动调用setDatachart.update等API刷新,React封装大幅简化了维护成本。

步骤9:在App.tsx串联所有逻辑(入口组件)

替换src/App.tsx全部代码,绑定年份切换按钮、请求状态、图表渲染、加载/异常兜底视图

typescript 复制代码
import { useState } from "react";
import { useFetch } from "./hooks/useFetch";
import TrafficChart from "./charts/TrafficChart";
import type { TrafficData } from "./types";
import "./App.css";

const YEARS = ["2025", "2024", "2023"];

export default function App() {
    const [selectedYear, setSelectedYear] = useState<string>("2025");
    // 根据选中年份拼接请求地址,自动发起请求
    const { data, loading, error } = useFetch<TrafficData>(`/data/traffic-${selectedYear}.json`);
    const hasChartData = data !== null;

    return (
        <div className='page'>
            <header className='header'>
                <h1>网站月度流量数据看板</h1>
            </header>

            {/* 年份切换按钮区 */}
            <div className='controls'>
                <span className='label'>选择年份:</span>
                {YEARS.map((year) => (
                    <button
                        key={year}
                        onClick={() => setSelectedYear(year)}
                        className={`yearBtn ${selectedYear === year ? "active" : ""}`}
                    >
                        {year}
                    </button>
                ))}
            </div>

            <div className='chartWrapper'>
                {/* 存在历史数据时,旧图表持续展示,请求中添加虚化样式 */}
                {hasChartData && (
                    <div className={`chartContent ${loading ? "chartLoading" : ""}`}>
                        <TrafficChart data={data} />
                    </div>
                )}
                {/* 请求加载动画 */}
                {loading && (
                    <div className='loading'>
                        <div className='spinner' />
                        <p>正在模拟请求服务器数据......</p>
                    </div>
                )}
                {/* 请求错误兜底 */}
                {error && (
                    <div className='error'>
                        <p>⚠️ 数据加载失败:{error}</p>
                        <p className='errorHint'>请确认Vite服务正常运行,且public/data目录下JSON文件存在</p>
                    </div>
                )}
                {/* 初始无数据空白状态 */}
                {!loading && !error && !hasChartData && (
                    <div className='emptyState'>
                        <p>暂无流量数据</p>
                    </div>
                )}
            </div>
        </div>
    );
}

运行逻辑说明:

  1. 点击年份按钮修改selectedYear状态,触发useFetch重新发起对应文件请求
  2. 刷新数据期间旧图表保留展示,搭配chartLoading样式做虚化变暗处理,同时展示加载动画
  3. 只有项目初次加载、无任何数据时才会渲染空白占位页面
    整体流程就是标准SPA交互模型:用户操作 → 状态变更 → 精准拉取所需数据 → 组件局部刷新图表,全程无页面重载。

步骤10:运行项目查看效果

浏览器访问 http://localhost:5173,页面默认加载2025年流量图表。点击顶部年份按钮,图表会虚化等待1秒左右,随后自动切换为对应年份数据。

整体方案总结

文件/模块 核心作用
public/data JSON文件 Vite托管的静态数据源
useFetch 自定义Hook 统一管理请求、加载、错误状态
人工延时代码 演示加载交互效果,生产环境删除
TrafficAreaSplineSeries 封装曲线样式,复用配置代码
TrafficChart 接收数据源,以声明式渲染Highcharts图表
App主组件 串联用户操作、数据请求、图表展示整套流程

整套架构没有整页刷新,仅按需获取原始数据,所有视图渲染、图表更新逻辑都在浏览器客户端完成。

项目上线改造要点

  1. 删除useFetch中的人工延时代码
  2. 将JSON文件请求地址替换为真实后端接口地址
  3. 增加请求缓存机制,避免重复发起相同请求

需要我把配套的 App.css 完整样式代码一并补充给你吗?

相关推荐
必须会一定会24 分钟前
Agent Plugins 1.0实战:plugin.json、skills、mcp.json目录结构与迁移
开发语言·人工智能·ai编程
St_rive40 分钟前
Page Object设计模式
java·开发语言·设计模式
wp123_11 小时前
硬件元器件笔记|IPX8 防水 Type‑C 母座安费诺 124018802112A 与 TONEVEE TY48086‑24A 分析
c语言·开发语言·笔记
wuyk5551 小时前
4.树:一对多的层次数据结构
开发语言·数据结构·stm32·单片机
剪刀石头布啊1 小时前
javascript手动实现继承
前端
Elias不吃糖2 小时前
Langfuse 入门:Trace、Prompt、Dataset、Experiment、Evaluator
前端·python·prompt·langfuse
luj_17682 小时前
桥牌思维启示:系统设计的模块化架构
c语言·开发语言·c++·经验分享·算法
vipbic2 小时前
一个前端的 9 天重构:我是怎么用 Codex 重做航栈的
前端·javascript·后端
caimouse3 小时前
ReactOS 图形系统分析(16):字符串对象 — STROBJ(string.c)
c语言·开发语言