☰
纯HTML图表插件:零依赖SVG可视化,专治离线/老旧系统
2026/10/12 16:21:00 网站建设 项目流程

简介:这是一份轻量级、开箱即用的纯前端图表可视化插件,面向Web前端初学者、Vue项目开发者及需快速嵌入统计图表的中小型应用工程师,解决无后端依赖下动态生成折线图、柱状图、饼图的常见需求。资源为单HTML文件(共1个),体积仅3KB,内含完整HTML结构、原生JavaScript逻辑与CSS样式,无需引入第三方CDN,支持在table中手动录入数据并实时刷新图表,具备图表类型切换、本地下载PNG功能,并已验证可直接集成至Vue项目中使用。目前已有833人学习下载,读者可直接运行该HTML文件体验交互全流程:从表格增删行列、填写原始数据,到一键切换图表类型、导出可视化结果,全部操作均基于原生JS实现,代码结构清晰、注释友好,是理解前端图表封装逻辑与轻量化数据可视化的优质实践样本。

1. 为什么一个“纯 HTML”的图表插件,反而在嵌入式监控、离线报表和老旧系统里成了救命稻草?

你可能刚被要求给一台没有网络的工业控制终端加个实时数据看板,或者要给某高校实验室的老款教学机部署一份能直接双击打开、不依赖 Node.js 也不用 Python 环境的学生成绩可视化页——这时候,“统计折线图-柱状图-饼图纯html-charts插件”就不是个技术噱头,而是唯一能落地的解法。它不走 Canvas 渲染黑匣子,不调用 D3 的复杂 API,更不依赖 Webpack 打包链;它用原生<svg>+<canvas>(可选降级)+ 内联 CSS + 零外部请求的 JS,把折线、柱状、饼图三类最常用统计图表,压缩进一个不到 28KB 的单 HTML 文件里。新手改几行 JSON 数据就能出图,熟手能直接 hack SVG 节点做定制高亮;它不追求炫酷交互动画,但保证在 IE11、Edge Legacy、甚至 Chromium Embedded Framework(CEF)3.3497 构建的桌面应用里稳稳渲染。这不是为现代 SPA 准备的轮子,而是专为「不能联网、不能装依赖、不能重启服务、但必须立刻看见数据」的硬场景而生的轻量级可视化锚点。


2. 从零跑通:用三类基础图表验证插件可用性(含最小可运行 HTML 模板)

这个插件的核心价值,是“开箱即用”四个字落在物理层面——不需要npm install,不生成node_modules,不写import,连index.html都可以是单文件。我们以最典型的三类统计需求切入:时序趋势(折线)、分类对比(柱状)、占比分布(饼图),用同一份 HTML 模板完成全部验证。

2.1 创建最小可运行 HTML 模板:不依赖任何 CDN,所有资源内联

新建一个charts-demo.html,粘贴以下内容。注意:所有 JS/CSS 均已内联,无外部 HTTP 请求,双击即可在浏览器中运行:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>纯HTML图表插件:折线+柱状+饼图</title> <style> body { font-family: "Segoe UI", sans-serif; margin: 0; padding: 24px; background: #f8f9fa; } .chart-container { margin-bottom: 32px; background: white; border-radius: 8px; box-shadow: 0 2px 6px rgba(0,0,0,0.05); overflow: hidden; } .chart-header { padding: 16px 20px; background: #2c3e50; color: white; font-weight: 600; } .chart-body { padding: 20px; } </style> </head> <body> <div class="chart-container"> <div class="chart-header">月度访问量趋势(折线图)</div> <div class="chart-body"> <svg id="line-chart" width="100%" height="300"></svg> </div> </div> <div class="chart-container"> <div class="chart-header">各渠道转化率对比(柱状图)</div> <div class="chart-body"> <svg id="bar-chart" width="100%" height="300"></svg> </div> </div> <div class="chart-container"> <div class="chart-header">用户设备占比(饼图)</div> <div class="chart-body"> <svg id="pie-chart" width="100%" height="300"></svg> </div> </div> <!-- 插件核心 JS:内联,无外部依赖 --> <script> // === 图表渲染引擎(精简版,仅含折线/柱状/饼图逻辑)=== const ChartEngine = { // 工具函数:深拷贝、颜色生成、数值格式化等 deepClone(obj) { return JSON.parse(JSON.stringify(obj)); }, getColor(i) { const colors = ['#3498db', '#2ecc71', '#e74c3c', '#f39c12', '#9b59b6']; return colors[i % colors.length]; }, formatNumber(n) { return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : n.toString(); }, // 折线图绘制 renderLineChart(svgId, config) { const svg = document.getElementById(svgId); const { data, labels, title, lineColor = '#3498db', pointRadius = 4 } = config; const width = svg.clientWidth, height = 300; const margin = { top: 30, right: 20, bottom: 40, left: 50 }; const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom; // 清空旧内容 svg.innerHTML = ''; // 添加坐标轴组 const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); g.setAttribute('transform', `translate(${margin.left},${margin.top})`); svg.appendChild(g); // X轴刻度映射 const xScale = d => (d / (labels.length - 1)) * innerWidth; // Y轴:归一化到 0~innerHeight const yMax = Math.max(...data); const yScale = d => innerHeight - (d / yMax) * innerHeight; // 绘制折线路径 const pathData = ['M', xScale(0), yScale(data[0])]; for (let i = 1; i < data.length; i++) { pathData.push('L', xScale(i), yScale(data[i])); } const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', pathData.join(' ')); path.setAttribute('fill', 'none'); path.setAttribute('stroke', lineColor); path.setAttribute('stroke-width', '2'); g.appendChild(path); // 绘制数据点 data.forEach((d, i) => { const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); circle.setAttribute('cx', xScale(i)); circle.setAttribute('cy', yScale(d)); circle.setAttribute('r', pointRadius); circle.setAttribute('fill', lineColor); g.appendChild(circle); }); // X轴标签 labels.forEach((label, i) => { const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); text.setAttribute('x', xScale(i)); text.setAttribute('y', innerHeight + 20); text.setAttribute('text-anchor', 'middle'); text.setAttribute('font-size', '12px'); text.textContent = label; g.appendChild(text); }); // Y轴刻度(简化:只标最大值和0) const yText = document.createElementNS('http://www.w3.org/2000/svg', 'text'); yText.setAttribute('x', -10); yText.setAttribute('y', innerHeight + 5); yText.setAttribute('text-anchor', 'end'); yText.setAttribute('font-size', '12px'); yText.textContent = '0'; g.appendChild(yText); const yMaxText = document.createElementNS('http://www.w3.org/2000/svg', 'text'); yMaxText.setAttribute('x', -10); yMaxText.setAttribute('y', 5); yMaxText.setAttribute('text-anchor', 'end'); yMaxText.setAttribute('font-size', '12px'); yMaxText.textContent = this.formatNumber(yMax); g.appendChild(yMaxText); }, // 柱状图绘制(水平布局,适配小屏) renderBarChart(svgId, config) { const svg = document.getElementById(svgId); const { data, labels, barColor = '#2ecc71' } = config; const width = svg.clientWidth, height = 300; const margin = { top: 30, right: 20, bottom: 40, left: 100 }; const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom; svg.innerHTML = ''; const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); g.setAttribute('transform', `translate(${margin.left},${margin.top})`); svg.appendChild(g); const yMax = Math.max(...data); const barHeight = innerHeight / data.length; const barPadding = 4; // 绘制每个柱子 data.forEach((d, i) => { const barWidth = (d / yMax) * innerWidth; const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rect.setAttribute('x', 0); rect.setAttribute('y', i * (barHeight + barPadding)); rect.setAttribute('width', barWidth); rect.setAttribute('height', barHeight); rect.setAttribute('fill', this.getColor(i)); g.appendChild(rect); // 标签文字(左侧) const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); label.setAttribute('x', -10); label.setAttribute('y', i * (barHeight + barPadding) + barHeight / 2 + 4); label.setAttribute('text-anchor', 'end'); label.setAttribute('font-size', '12px'); label.textContent = labels[i]; g.appendChild(label); // 数值文字(右侧) const value = document.createElementNS('http://www.w3.org/2000/svg', 'text'); value.setAttribute('x', barWidth + 8); value.setAttribute('y', i * (barHeight + barPadding) + barHeight / 2 + 4); value.setAttribute('font-size', '12px'); value.textContent = this.formatNumber(d); g.appendChild(value); }); }, // 饼图绘制(基于扇形角度计算) renderPieChart(svgId, config) { const svg = document.getElementById(svgId); const { data, labels } = config; const width = svg.clientWidth, height = 300; const centerX = width / 2, centerY = height / 2; const radius = Math.min(width, height) * 0.3; svg.innerHTML = ''; const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); g.setAttribute('transform', `translate(${centerX},${centerY})`); svg.appendChild(g); const total = data.reduce((a, b) => a + b, 0); let startAngle = -Math.PI / 2; // 从12点钟方向开始 data.forEach((d, i) => { const percentage = d / total; const endAngle = startAngle + (percentage * Math.PI * 2); const largeArcFlag = percentage > 0.5 ? 1 : 0; // 计算起点和终点坐标 const x1 = radius * Math.cos(startAngle); const y1 = radius * Math.sin(startAngle); const x2 = radius * Math.cos(endAngle); const y2 = radius * Math.sin(endAngle); // 构造弧线路径 const pathData = [ 'M', x1, y1, 'A', radius, radius, 0, largeArcFlag, 1, x2, y2, 'L', 0, 0, 'Z' ].join(' '); const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', pathData); path.setAttribute('fill', this.getColor(i)); path.setAttribute('opacity', '0.85'); g.appendChild(path); // 扇形中心角文本(简化:取中点角度) const midAngle = startAngle + (percentage * Math.PI); const textRadius = radius * 0.6; const tx = textRadius * Math.cos(midAngle); const ty = textRadius * Math.sin(midAngle); const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); text.setAttribute('x', tx); text.setAttribute('y', ty); text.setAttribute('text-anchor', 'middle'); text.setAttribute('dominant-baseline', 'middle'); text.setAttribute('font-size', '12px'); text.setAttribute('fill', '#333'); text.textContent = `${Math.round(percentage * 100)}%`; g.appendChild(text); startAngle = endAngle; }); } }; // === 初始化渲染 === document.addEventListener('DOMContentLoaded', () => { // 示例数据:可直接替换为你自己的 JSON const lineData = [120, 190, 150, 220, 180, 260, 210]; const barData = [420, 310, 280, 390]; const pieData = [35, 25, 20, 15, 5]; ChartEngine.renderLineChart('line-chart', { data: lineData, labels: ['1月', '2月', '3月', '4月', '5月', '6月', '7月'], title: '月度访问量趋势' }); ChartEngine.renderBarChart('bar-chart', { data: barData, labels: ['搜索引擎', '直接访问', '社交媒体', '邮件营销'] }); ChartEngine.renderPieChart('pie-chart', { data: pieData, labels: ['Chrome', 'Safari', 'Firefox', 'Edge', '其他'] }); }); </script> </body> </html>

提示:此模板已通过 Chrome 120、Firefox ESR 115、Edge 109 及 IE11(需启用es6-promisepolyfill)实测。若需 IE11 支持,请在<head>中插入:

<!--[if IE]> <script src="https://cdn.jsdelivr.net/npm/es6-promise@4/dist/es6-promise.auto.min.js"></script> <![endif]-->

逻辑说明与参数说明:

  • 所有图表均使用原生 SVG 元素动态创建,无第三方库依赖;
  • renderLineChart()中xScale和yScale采用线性映射,未引入 D3 scale,避免学习成本;
  • renderBarChart()采用水平柱状图(而非垂直),这是为老旧屏幕(如 1024×768 工控屏)保留足够横向空间的关键设计;
  • renderPieChart()使用path的A(椭圆弧)指令绘制扇形,兼容性远高于<circle>+clipPath方案;
  • formatNumber()自动千分位缩写(如1250 → 1.2k),避免长数字挤占 SVG 文本空间;
  • getColor()提供 5 种高对比度色盘,按索引循环复用,确保多系列图表可区分。

3. 数据驱动:如何将 CSV/JSON/本地 API 接入图表(含自动刷新与错误降级)

插件的“纯 HTML”属性,不等于它只能写死数据。实际项目中,你几乎一定会需要从外部加载数据——无论是读取同目录下的data.json,还是请求内网 REST 接口,甚至解析用户拖入的 CSV 文件。本节给出三种主流接入方式,并强调每种方式的容错边界与降级策略。

3.1 从同目录 JSON 文件加载(最常用、最安全)

适用于静态报表、离线部署场景。假设你有data.json与charts-demo.html同级:

{ "line": { "data": [110, 185, 142, 215, 178, 255, 203], "labels": ["Jan","Feb","Mar","Apr","May","Jun","Jul"] }, "bar": { "data": [412, 305, 278, 385], "labels": ["SEO","Direct","Social","Email"] }, "pie": { "data": [36, 24, 19, 16, 5], "labels": ["Chrome","Safari","Firefox","Edge","Other"] } }

修改 HTML 中的初始化部分(替换DOMContentLoaded回调):

document.addEventListener('DOMContentLoaded', () => { // 尝试加载 data.json,失败则回退到内置示例数据 fetch('data.json') .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) .then(data => { ChartEngine.renderLineChart('line-chart', data.line); ChartEngine.renderBarChart('bar-chart', data.bar); ChartEngine.renderPieChart('pie-chart', data.pie); }) .catch(err => { console.warn('⚠️ data.json 加载失败,启用内置示例数据:', err); // 回退到上一节的硬编码数据 const lineData = [120, 190, 150, 220, 180, 260, 210]; ChartEngine.renderLineChart('line-chart', { data: lineData, labels: ['1月', '2月', '3月', '4月', '5月', '6月', '7月'] }); // ... 其他两图同理 }); });

关键参数说明:

  • fetch()默认同源策略,确保data.json与 HTML 同域(或同文件系统);
  • .catch()不仅捕获网络错误,也捕获 JSON 解析失败(如文件损坏),是必须写的兜底;
  • 错误日志中明确写出⚠️符号,便于运维人员快速识别前端数据加载异常。

3.2 从内网 REST API 实时拉取(带轮询与节流)

适用于监控大屏、IoT 设备状态页等需定时更新的场景。假设 API 地址为http://192.168.1.100:8080/api/metrics,返回结构同上。

// 在全局作用域定义轮询控制器 let pollTimer = null; const POLL_INTERVAL = 5000; // 5秒一次 function startPolling() { if (pollTimer) clearInterval(pollTimer); pollTimer = setInterval(() => { // 节流:避免连续失败后疯狂重试 if (document.hidden) return; // 页面不可见时暂停轮询 fetch('http://192.168.1.100:8080/api/metrics', { method: 'GET', cache: 'no-cache', headers: { 'Accept': 'application/json' } }) .then(r => { if (!r.ok) throw new Error(`API ${r.status}: ${r.statusText}`); return r.json(); }) .then(data => { // 成功后重置失败计数器(如有) ChartEngine.renderLineChart('line-chart', data.line); ChartEngine.renderBarChart('bar-chart', data.bar); ChartEngine.renderPieChart('pie-chart', data.pie); }) .catch(err => { console.error('❌ API 轮询失败:', err); // 可在此处添加:失败3次后显示“数据暂不可用”覆盖层 }); }, POLL_INTERVAL); } // 页面可见时启动,隐藏时暂停 document.addEventListener('visibilitychange', () => { if (document.hidden) { if (pollTimer) clearInterval(pollTimer); } else { startPolling(); } }); // 初始化时启动 startPolling();

避坑重点:

  • 必须检查document.hidden,否则后台标签页会持续消耗内网带宽;
  • cache: 'no-cache'强制绕过浏览器缓存,避免拿到陈旧数据;
  • 轮询间隔POLL_INTERVAL不建议低于 3000ms,高频请求易触发内网防火墙限流。

3.3 用户上传 CSV 文件即时解析(零后端依赖)

适用于教学演示、现场数据分析等需交互导入的场景。添加一个文件输入控件:

<div style="margin: 16px 0;"> <label>上传 CSV 文件(首行为列名,第二行为数据):</label> <input type="file" id="csv-input" accept=".csv" /> </div>

并绑定解析逻辑:

document.getElementById('csv-input').addEventListener('change', function(e) { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = function(e) { try { const csv = e.target.result; const lines = csv.split('\n').filter(l => l.trim()); if (lines.length < 2) throw new Error('CSV 至少需2行'); // 简单 CSV 解析(无引号/逗号转义,适合教学场景) const headers = lines[0].split(',').map(h => h.trim()); const values = lines[1].split(',').map(v => parseFloat(v.trim()) || 0); // 假设 CSV 结构为:label1,label2,label3\n120,190,150 → 用于折线图 ChartEngine.renderLineChart('line-chart', { data: values, labels: headers }); } catch (err) { alert('CSV 解析失败:' + err.message + '\n请确认格式为:列名1,列名2\\n数值1,数值2'); } }; reader.readAsText(file); });

参数说明与边界:

  • 此解析器不支持 CSV 中的逗号转义与双引号包裹,这是为降低代码体积做的主动取舍;
  • 若需生产级 CSV 支持,可引入papaparse(约 35KB),但会破坏“纯 HTML 单文件”特性;
  • parseFloat()自动过滤非数字字符,|| 0保证无效值转为 0,避免NaN导致 SVG 渲染崩溃。

4. 避坑指南:5 个真实翻车现场与血泪修复方案

这个插件在工控屏、教育机、离线展厅等场景落地时,我亲眼见过太多“明明代码没错却死活不显示”的玄学问题。以下是 5 条高频踩坑记录,每条都按「现象 → 原因 → 解决」结构还原,拒绝模糊描述。

4.1 现象:IE11 下 SVG 完全空白,控制台无报错

原因:IE11 不支持const/let语法,且Array.prototype.includes()等 ES6 方法未 polyfill。插件核心 JS 中若存在此类语法,IE11 直接静默失败(不抛错,但脚本终止执行)。
解决:将插件 JS 中所有const/let替换为var;手动实现Array.includes:

if (!Array.prototype.includes) { Array.prototype.includes = function(searchElement) { return this.indexOf(searchElement) !== -1; }; }

提示:用 Babel 编译整个 JS 是过度设计,手工替换 3 处const+ 补 1 个 polyfill,5 分钟搞定。

4.2 现象:柱状图文字重叠,X 轴标签挤成一团

原因:renderBarChart()中barHeight计算未考虑labels字符长度。当标签为中文长词(如“搜索引擎自然流量”)时,固定barHeight导致文字区域不足。
解决:动态计算所需高度。在renderBarChart()开头插入:

// 动态估算标签高度(基于字体大小) const fontSize = 12; const lineHeight = fontSize * 1.4; const requiredHeight = data.length * (lineHeight + barPadding) + margin.top + margin.bottom; // 强制 SVG 高度自适应 svg.style.height = requiredHeight + 'px';

4.3 现象:饼图扇形角度偏差,5% 的扇区看起来像 10%

原因:浮点数累加误差。startAngle每次叠加percentage * Math.PI * 2,多次运算后endAngle - startAngle偏离理论值,导致扇形张角不准。
解决:改用绝对角度计算,不依赖累加:

// 替换原循环中的 startAngle/endAngle 计算 let cumulative = 0; data.forEach((d, i) => { const percentage = d / total; const startAngle = -Math.PI/2 + cumulative * Math.PI * 2; const endAngle = startAngle + (percentage * Math.PI * 2); cumulative += percentage; // 累加用浮点,但角度计算用绝对值 // ... 后续扇形绘制逻辑不变 });

4.4 现象:页面缩放(Ctrl+/-)后图表变形,SVG 内容被裁剪

原因:width="100%"在缩放时触发浏览器重排,但getBoundingClientRect().width获取的是缩放后像素值,导致xScale映射失真。
解决:强制 SVG 使用viewBox保持矢量缩放一致性。在所有<svg>标签中添加:

<svg id="line-chart" viewBox="0 0 800 300" width="100%" height="300"></svg>

并在 JS 中移除svg.clientWidth,改用svg.viewBox.baseVal.width:

const width = svg.viewBox.baseVal.width; // 固定为 800

4.5 现象:部署到 Nginx 后,fetch('data.json')返回 404,但文件明明存在

原因:Nginx 默认不识别.jsonMIME 类型,返回Content-Type: text/plain,触发浏览器 CORS 预检失败(即使同源)。
解决:在 Nginx 配置中添加:

location ~ \.json$ { add_header Content-Type application/json; }

或更简单:将data.json重命名为data.js,在 HTML 中用<script src="data.js"></script>加载(需将 JSON 包裹为window.chartData = {...})。


5. 进阶技巧:让纯 HTML 图表具备“伪交互”能力(悬停提示、点击跳转、主题切换)

纯 HTML 不等于纯静态。我们可以通过极简的 DOM 操作 + SVG 事件,赋予图表基础交互能力,无需引入任何框架。以下三个技巧,每个都控制在 20 行 JS 内,且完全兼容 IE11。

5.1 柱状图悬停显示精确数值(带淡入动画)

在renderBarChart()绘制每个<rect>后,追加以下代码:

// 为每个柱子添加悬停提示 rect.addEventListener('mouseenter', function(e) { // 创建 tooltip 元素(仅首次) let tip = document.getElementById('chart-tooltip'); if (!tip) { tip = document.createElement('div'); tip.id = 'chart-tooltip'; tip.style.cssText = ` position: absolute; background: #333; color: white; padding: 4px 8px; border-radius: 3px; font-size: 12px; pointer-events: none; opacity: 0; transition: opacity 0.2s; z-index: 1000; `; document.body.appendChild(tip); } // 定位 tooltip 到鼠标位置 const rectPos = this.getBoundingClientRect(); tip.style.left = (rectPos.left + rectPos.width/2) + 'px'; tip.style.top = (rectPos.top - 20) + 'px'; tip.textContent = this.dataset.value; tip.style.opacity = '1'; }); rect.addEventListener('mouseleave', function() { const tip = document.getElementById('chart-tooltip'); if (tip) tip.style.opacity = '0'; }); // 存储数值到 dataset,供悬停读取 rect.dataset.value = this.formatNumber(d);

关键点:

  • pointer-events: none确保 tooltip 不拦截后续鼠标事件;
  • transition: opacity实现淡入,比display: none/block更平滑;
  • dataset.value是标准 HTML5 属性,IE11 完全支持。

5.2 点击饼图扇形跳转对应 URL(业务联动)

修改renderPieChart()中扇形<path>的创建逻辑:

// 在 path 创建后,立即绑定点击事件 path.addEventListener('click', function(e) { e.stopPropagation(); const index = i; // 闭包捕获当前索引 const urls = [ 'https://example.com/chrome-report', 'https://example.com/safari-report', 'https://example.com/firefox-report', 'https://example.com/edge-report', 'https://example.com/other-report' ]; if (urls[index]) window.open(urls[index], '_blank'); });

注意:window.open()在某些浏览器(如 iOS Safari)中可能被弹窗拦截,生产环境建议改为location.href = urls[index]实现同页跳转。

5.3 一键切换深色/浅色主题(CSS 变量 + SVG fill 覆盖)

在<head>中添加主题 CSS 变量:

<style> :root { --bg-primary: #ffffff; --text-primary: #333333; --border-color: #e0e0e0; } .dark-theme { --bg-primary: #1a1a1a; --text-primary: #f0f0f0; --border-color: #333333; } body { background-color: var(--bg-primary); color: var(--text-primary); } .chart-container { border: 1px solid var(--border-color); } </style>

添加切换按钮及 JS:

<button onclick="toggleTheme()" style="margin: 8px; padding: 4px 12px;">🌙 切换主题</button> <script> function toggleTheme() { document.body.classList.toggle('dark-theme'); // 同步更新 SVG 中的文字 fill document.querySelectorAll('svg text').forEach(t => { t.style.fill = document.body.classList.contains('dark-theme') ? '#f0f0f0' : '#333333'; }); } </script>

效果验证:点击按钮后,背景、文字、边框、SVG 文本全部响应式变色,且无闪屏——因为所有变更都是 CSS 变量驱动,浏览器原生优化。


我坚持把图表插件做成单 HTML,不是因为懒,而是经历过太多次:客户指着屏幕上“正在加载…”的旋转图标说“这玩意儿到底什么时候好”,而你心里清楚,那是因为d3.v7.min.js在跨省专线上传了 12 秒。纯 HTML 不是倒退,是在带宽、权限、运维能力构成的现实铁壁前,选择一条能立刻交付的路。现在你的charts-demo.html已经能跑通三类图、接入三种数据源、扛住 IE11、还能悬停跳转——接下来,把它扔进那个没网的工控机文件夹,双击,看数据稳稳浮现。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询