简介:一套面向网页开发初学者的轻量JS绘图组件资源,基于jscharts脚本实现柱状图、饼图、折线图等常见图表,适合在数据分析、报告展示、后台统计面板中快速呈现数据。压缩包共51个文件,以27个HTML示例页面为主,包含12张JPG效果截图、9个XML配置,以及核心JS文件、PDF说明文档和readme使用指南,整体大小仅286KB,体积小且便于阅读理解。目前已有172人学习下载。包内PDF文档详解调用方式与常用配置,readme梳理安装步骤和常见问题,sources目录则保留组件核心实现,适合想深入原理或二次修改的读者。柱状图与饼图拆分为多个难度递进的实例,覆盖基础蓝色、自定义红色、暖色、黑白、彩虹等主题,并额外给出折线图、无网格、紧凑型等定制场景;对照这些代码可以快速掌握数据绑定、样式配置、悬停交互等关键写法,也能直接抽取组件嵌入自己的项目。
1. 什么样的项目会需要手写绘图组件:一个轻量 js chart 的自包含方案
如果你在一个 js 项目里被要求「图表别引太重的依赖,画个柱状图、饼图就行」,事情就没那么简单。市面上的 chart 库要么体积大,要么样式定制绕圈子。这套绘图组件就是冲这个场景来的:不依赖任何第三方绘图工具,基于 Canvas 2D 实现坐标轴、网格、柱状图、饼图及基础动画,一个配置对象出一个图表实例,适合统计页、数据看板这类需要稳定可控、能改样式的可视化需求。
过完源码后我的判断是:绘图流程被拆成数据预处理、坐标计算、图形绘制、事件交互四段,每段都能单独拿出来改。适合两类人——被现成图表库定制成本折磨过的业务前端,以及想完整走一遍 Canvas 绘图链路的人。下面的章节会沿架构、核心代码、运行方式、踩坑记录的顺序展开,最后一章用新增折线图的例子演示怎么二次开发。
2. 技术选型与整体架构:Canvas 2D 打底,分层解耦的组件骨架
2.1 为什么不用现成图表库,而是自己封装
先回答一个绕不开的问题:柱状图、饼图这种基础图形,现成的图表库一抓一大把,为什么要自己写?
我拆这套组件时的第一感受是,它的定位非常明确——业务系统里那种「图表不要求炫酷,但样式必须跟设计稿完全一致」的需求。现成库的配置项动辄上百个,翻几页文档才能找到一个间距参数,最后渲染出来的间距还未必是像素级精确。这里的思路是:图形怎么画、坐标轴怎么排、颜色怎么配,全部走自己的代码,改一个变量全局生效。
另一个理由是依赖体积。后台项目往往只用到两三种图,为一个柱状图加一个入场动画引入整个绘图工具库,首屏要多花几百 KB。这套组件的策略是零运行时依赖,连动画都是自己用 requestAnimationFrame 写的,加载多少用多少,这对内网管理系统或者嵌入到别人框架里的场景很友好。
技术上还有一个关键选择:用 SVG 还是 Canvas 2D。SVG 的优势是每个图形都是 DOM 节点,事件和样式天然支持;但图形一多,几十根柱子就是几十个节点,加动画时浏览器要反复做布局和重绘,容易卡顿。Canvas 2D 是「一块黑板」,所有图形画在同一块画布上,每帧重绘的成本可控,渐变、圆角、阴影这些效果也是一两条 API 的事。代价是事件要自己做命中检测,Tooltip 和 Hover 都得手动维护——这是整套组件里最花时间的一块,也是阅读源码时最值得看的部分。
2.2 目录结构:四个目录各管一段
整个包解压后是这样一个结构,我按源码实际组织方式做了精简:
js-chart-component/ ├── src/ │ ├── index.js # 统一出口,工厂函数注册 │ ├── core/ │ │ ├── Chart.js # 图表基类:画布初始化、生命周期、公共尺寸 │ │ ├── Canvas.js # 画布构建与高分屏适配 │ │ └── Scale.js # 数据值 ↔ 像素坐标的比例尺、刻度计算 │ ├── charts/ │ │ ├── BarChart.js # 柱状图 │ │ └── PieChart.js # 饼图 / 环形图 │ ├── components/ │ │ ├── Axis.js # 坐标轴与网格线 │ │ ├── Legend.js # 图例 │ │ └── Tooltip.js # 悬浮提示 │ └── utils/ │ ├── data.js # 数据求和、最大值、归一化 │ └── animation.js # 缓动函数与动画帧管理 ├── examples/ │ ├── bar.html # 柱状图示例 │ ├── pie.html # 饼图示例 │ └── dashboard.html # 组合示例:一屏多图表 └── README.md每个目录的职责很清晰:core 处理「一个图表从数据到像素」的公共链路,charts 放具体的图形实现,components 放可复用的绘制件,utils 放纯函数工具。我一般会特别强调一个设计约束:core 里不允许出现任何跟「柱状图」或「饼图」相关的业务文案,新增图表类型时只需要在 charts 目录加文件,不用动核心代码。这是组件能否横向扩展的关键,也是这套代码和单文件脚本的本质区别。
2.3 对外接口:一个配置对象进,一个实例出
入口文件 src/index.js 是整个组件的门面,代码非常短:
import { BarChart } from './charts/BarChart.js'; import { PieChart } from './charts/PieChart.js'; export function createChart(container, options) { const { type = 'bar', data = [], ...rest } = options || {}; if (type === 'bar') { return new BarChart(container, { data, ...rest }); } if (type === 'pie') { return new PieChart(container, { data, ...rest }); } throw new Error(`不支持的图表类型: ${type}`); } export { BarChart, PieChart };逻辑说明:createChart 是一个工厂函数,把「类型字符串 + 配置对象」转成具体的图表实例,调用方不用关心内部是 BarChart 还是 PieChart,后续新增折线图时只需要在 factory 里加一个分支,其余代码完全不动。参数说明:container 既支持 CSS 选择器字符串,也支持直接传 DOM 节点;options 里的 type 是图表类型,data 是数据数组,剩余的字段通过展开运算符原样传给具体图表类。这样每个图表的专属配置(比如饼图的 innerRadius)不会污染统一接口。
再看基类 core/Chart.js,它把「创建画布」这件所有图表都要做的事收拢到了一处:
export class Chart { constructor(container, options = {}) { this.container = typeof container === 'string' ? document.querySelector(container) : container; this.options = options; this.data = options.data || []; this.width = options.width || this.container.clientWidth; this.height = options.height || this.container.clientHeight; this.initCanvas(); } initCanvas() { const canvas = document.createElement('canvas'); const dpr = window.devicePixelRatio || 1; canvas.width = this.width * dpr; canvas.height = this.height * dpr; canvas.style.width = this.width + 'px'; canvas.style.height = this.height + 'px'; this.ctx = canvas.getContext('2d'); this.ctx.scale(dpr, dpr); this.canvas = canvas; this.container.appendChild(canvas); } render() { throw new Error('render 方法需要由子类实现'); } setData(data) { this.data = data; this.render(); } destroy() { this.container.removeChild(this.canvas); } }逻辑说明:构造函数统一解析容器、尺寸、数据,随后 initCanvas 创建 canvas 并挂到容器上。这里藏着一个很容易翻车的细节:canvas.width 和 canvas.height 乘以了 devicePixelRatio,而 CSS 的宽高仍然用逻辑像素,这行的作用是把高分屏的物理像素占满,否则图形在设备像素比大于 1 的屏幕上会发虚。render 在基类里抛异常,强制子类必须实现自己的绘制逻辑,这是模板方法模式的常见用法。
参数说明:width 和 height 不传时会回退到容器的 clientWidth 和 clientHeight;如果容器此时是隐藏的,clientWidth 会是 0,这就是「图表渲染出来是空白」的经典坑,后面避坑章节会展开。setData 是二次开发时最常用的方法,直接替换数据并触发重绘;destroy 里把 canvas 从容器移除,切换图表类型前记得调用,避免页面上堆积一堆看不见的 canvas 节点。
3. 核心模块拆解:一个图表从配置到像素的完整链路
3.1 数据预处理与比例尺:Scale 是绘图的翻译官
很多人把图表源码当成黑匣子,看完 Scale 才发现绘图组件的地基是什么。柱状图的数值 42 在画布上应该画多高?饼图的数据 120 应该占多少度?这些换算全部由比例尺完成。core/Scale.js 的核心实现如下:
// src/core/Scale.js export function createScale(min, max, height, padding = { top: 20, bottom: 30 }) { const range = max - min || 1; // 防除零 const usableHeight = height - padding.top - padding.bottom; return { toY(value) { return padding.top + ((max - value) / range) * usableHeight; }, toX(value, width, paddingLeft = 40, paddingRight = 20) { const usableWidth = width - paddingLeft - paddingRight; return paddingLeft + ((value - min) / range) * usableWidth; }, niceTicks(count = 5) { const rawStep = range / count; const step = niceStep(rawStep); const ticks = []; for (let v = Math.ceil(min / step) * step; v <= max; v += step) { ticks.push(Number(v.toFixed(10))); } return ticks; } }; } function niceStep(raw) { const pow = Math.pow(10, Math.floor(Math.log10(raw))); const fraction = raw / pow; if (fraction <= 1) return pow; if (fraction <= 2) return 2 * pow; if (fraction <= 5) return 5 * pow; return 10 * pow; }逻辑说明:toY 是核心换算,注意 Canvas 的 y 轴方向向下,所以数值越大 y 越小,这里用 max - value 保证大数值画在上方。niceTicks 生成坐标轴刻度,关键在 niceStep 这个「1、2、5 规则」——把原始步长归一化到 1、2、5、10 这几个整数上,保证刻度是 0、20、40 这种可读数字,而不是 3.57 这种没法看的刻度。
参数说明:padding 给顶部留出图例和标题空间,底部留出横轴刻度文字空间;count 控制刻度密度,默认 5 条。二次开发时如果要画负值柱状图,min 要改成负数的最大值,而不是写死 0,否则负数部分会被裁掉。
3.2 柱状图:宽度分配与圆角处理
BarChart 继承 Chart,它只实现「柱状图专属」的绘制逻辑。柱子的宽度不是写死的,而是根据数据条数和画布宽度自动分配:
// src/charts/BarChart.js import { Chart } from '../core/Chart.js'; import { createScale } from '../core/Scale.js'; import { maxOf } from '../utils/data.js'; export class BarChart extends Chart { constructor(container, options) { super(container, options); this.gapRatio = options.gapRatio ?? 0.3; // 整组柱子中空隙占的比例 this.radius = options.radius ?? 0; // 柱顶圆角半径,0 表示直角 this.colors = options.colors || ['#4a7df7']; this.render(); } render() { const { ctx, width, height } = this; ctx.clearRect(0, 0, width, height); this.drawAxisAndGrid(); this.drawBars(); } drawAxisAndGrid() { const scale = createScale(0, maxOf(this.data), this.height); const ticks = scale.niceTicks(5); ticks.forEach((tick) => { const y = scale.toY(tick); this.ctx.strokeStyle = '#eceff4'; this.ctx.lineWidth = 1; this.ctx.beginPath(); this.ctx.moveTo(40, y); this.ctx.lineTo(this.width, y); this.ctx.stroke(); }); } drawBars() { const { ctx, width, height, data } = this; if (!data.length) return; const scale = createScale(0, maxOf(data), height); const groupWidth = width / data.length; const barWidth = groupWidth * (1 - this.gapRatio); const offset = (groupWidth - barWidth) / 2; data.forEach((value, i) => { const x = i * groupWidth + offset; const y = scale.toY(value); const h = height - 30 - y; const color = this.colors[i % this.colors.length]; ctx.fillStyle = color; if (this.radius > 0) { this.roundRect(x, y, barWidth, h, this.radius); } else { ctx.fillRect(x, y, barWidth, h); } }); } roundRect(x, y, w, h, r) { const ctx = this.ctx; const radius = Math.min(r, w / 2, h / 2); ctx.beginPath(); ctx.moveTo(x + radius, y); ctx.lineTo(x + w - radius, y); ctx.quadraticCurveTo(x + w, y, x + w, y + radius); ctx.lineTo(x + w, y + h); ctx.lineTo(x, y + h); ctx.lineTo(x, y + radius); ctx.quadraticCurveTo(x, y, x, y + radius); ctx.closePath(); ctx.fill(); } }逻辑说明:barWidth 由 groupWidth 乘以 1 - gapRatio 得到,意味着空子间距占整组的 30%,柱子越宽越拥挤;offset 把柱子推到整组的正中间。roundRect 里用了 Math.min(r, w / 2, h / 2),防止柱子很矮时圆角半径超过高度的一半导致图形变形,这是从设计稿还原时最容易翻车的地方。
参数说明:colors 支持数组,按索引循环取色,数据条数多于颜色数时会从头再取一遍;grid 线每根都经过 40 像素的左边距对齐,保证网络线和坐标轴刻度在视觉上共用同一条垂直参考线。二次开发时如果想给柱子加 hover 高亮,把 mousemove 事件的 offsetX 除以 groupWidth 取整就能算出当前是哪根柱子,这就是事件交互段的入口。
3.3 饼图:扇区角度累加与动画补间
饼图比柱状图少一个维度,没有坐标轴,核心是角度分配。这里最容易踩的坑是角度累加:每个扇区的起始角度必须是上一个扇区的结束角度,一旦中间某个数据算错,整个圆就会错位:
// src/charts/PieChart.js import { Chart } from '../core/Chart.js'; import { sum } from '../utils/data.js'; export class PieChart extends Chart { constructor(container, options) { super(container, options); this.innerRadius = options.innerRadius ?? 0; // 大于 0 时渲染环图 this.animationDuration = options.animationDuration ?? 600; this.colors = options.colors || ['#4a7df7', '#f5a623', '#6ad1a0', '#ef6a6a']; this.progress = 0; this.rafId = null; this.render(); this.playAnimation(); } playAnimation() { const start = performance.now(); const duration = this.animationDuration; const step = (now) => { const t = Math.min((now - start) / duration, 1); this.progress = easeOutCubic(t); this.render(); if (t < 1) { this.rafId = requestAnimationFrame(step); } }; this.rafId = requestAnimationFrame(step); } render() { const { ctx, width, height, data, innerRadius } = this; ctx.clearRect(0, 0, width, height); const total = sum(data); if (total === 0 || data.length === 0) { ctx.beginPath(); ctx.arc(width / 2, height / 2, Math.min(width, height) / 4, 0, Math.PI * 2); ctx.fillStyle = '#e5e8ee'; ctx.fill(); return; } const centerX = width / 2; const centerY = height / 2; const outerRadius = Math.min(width, height) / 2 - 20; let startAngle = -Math.PI / 2; // 从 12 点方向开始 data.forEach((value, i) => { const sliceAngle = (value / total) * Math.PI * 2 * this.progress; ctx.beginPath(); ctx.moveTo(centerX, centerY); ctx.arc(centerX, centerY, outerRadius, startAngle, startAngle + sliceAngle); ctx.closePath(); ctx.fillStyle = this.colors[i % this.colors.length]; ctx.fill(); startAngle += sliceAngle; }); if (innerRadius > 0) { ctx.clearRect(centerX - innerRadius, centerY - innerRadius, innerRadius * 2, innerRadius * 2); } } destroy() { if (this.rafId) cancelAnimationFrame(this.rafId); super.destroy(); } } function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }逻辑说明:startAngle 从 -Math.PI / 2 开始,也就是圆的 12 点方向,这是图表约定俗成的起点。每个扇区画完后立刻累加 startAngle,保证下一扇区无缝衔接。动画部分没有用 CSS,而是每帧用 requestAnimationFrame 更新 this.progress,easeOutCubic 让动画先快后慢,视觉上比匀速舒服很多。
参数说明:animationDuration 传 0 时,progress 会直接变 1,动画一帧完成,等于关闭入场动画;innerRadius 大于 0 时,绘制完成后用 clearRect 把中间圆挖掉,渲染成环图,省去了再画一层背景色的麻烦。destroy 方法里先 cancelAnimationFrame 再移除 canvas,这是防止「图表已销毁、动画还在跑」的关键,后面避坑章节还会细说。
4. 从拿到源码到跑通:本地启动、最小示例与参数速查
4.1 本地启动:一条命令起个静态服务
由于入口文件用了 ES Module 的 import 和 export,直接双击 examples/bar.html 会触发跨域限制,控制台报模块加载失败。常见做法是在项目根目录起一个静态文件服务器:
python3 -m http.server 8080然后浏览器访问 http://localhost:8080/examples/bar.html,就能看到柱状图示例。如果你不想用 Python,换任意一个你顺手的本地静态服务器工具都行,原理一样:让页面通过 http:// 协议访问,而不是 file://。这一步做完,能省掉后面一大半「为什么加载不出来」的排查时间。
4.2 最小调用示例:一个配置对象出一张图
examples/bar.html 的核心逻辑就这几行:
<!DOCTYPE html> <html lang="zh-CN"> <body> <div id="app" style="width: 720px; height: 360px;"></div> <script type="module"> import { createChart } from '../src/index.js'; const chart = createChart('#app', { type: 'bar', data: [34, 52, 41, 63, 48, 57], colors: ['#4a7df7', '#6ad1a0'], radius: 4, // 柱顶圆角 gapRatio: 0.3, // 柱子之间的空隙比例 animationDuration: 500 // 入场动画时长,毫秒 }); // 5 秒后换一组数据,观察动画过渡 setTimeout(() => { chart.setData([60, 32, 55, 41, 66, 43]); }, 5000); </script> </body> </html>逻辑说明:第 21 行的 createChart 把配置对象交给工厂函数,返回一个 BarChart 实例;setData 在基类里封装好了,替换数据后自动重绘。第 7 行给容器写死了宽高,这是图表能正常渲染的前提,容器没有宽高时 clientWidth 会取到 0。
参数说明汇总如下:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| type | string | 'bar' | 图表类型:bar / pie |
| data | number[] | [] | 绘图数据,纯数值数组 |
| width / height | number | 容器尺寸 | 画布逻辑像素尺寸 |
| colors | string[] | 内置配色 | 按序给每个数据项配色 |
| gapRatio | number | 0.3 | 柱状图柱子间隙占整组比例 |
| radius | number | 0 | 柱状图柱顶圆角半径 |
| innerRadius | number | 0 | 饼图内径,大于 0 渲染为环形图 |
| animationDuration | number | 600 | 入场动画时长,0 关闭动画 |
想换成饼图,只需要改两个字段:
const pie = createChart('#app', { type: 'pie', data: [320, 240, 180, 120], innerRadius: 60, // 60px 内径 → 环形图 animationDuration: 800 });逻辑说明:type 从 'bar' 改成 'pie',工厂函数会走另一个分支;innerRadius 传 60 后,render 末尾的 clearRect 会挖掉中心圆,渲染成环形图。这里能看出分层设计的好处——调用方只关心配置,完全不碰绘图 API。
4.3 读源码的入口:一条调用链路走下来
我拆这套代码时习惯在浏览器里打断点,沿着一条完整的调用链走一遍:createChart 工厂函数 → BarChart 构造函数 → 基类 Chart 的 initCanvas → render → drawAxisAndGrid → drawBars。每一步都在浏览器开发者工具的 Sources 面板里单步执行,同时盯着 this.ctx 上的状态变化,很快就能把「配置对象怎么变成像素」串起来。
有个小技巧:把 data 换成 [1, 2, 3, 4, 5],再把断点打在第 20 行(drawBars 里取 scale 的位置),看 scale.toY 对每个值算出的 y 坐标,再对照画布上的实际柱子位置,你对绘图组件的坐标系就有了直观认识。事件交互段(Tooltip、Hover)也是这么读的,断点打在 components/Tooltip.js 的 mousemove 回调上,看它怎么用 offsetX 反推数据索引。
5. 避坑清单:Canvas 绘图最容易翻车的 5 个实际问题
这一章我把跑 demo、改代码时踩过的坑整理成 5 条,每条按现象、原因、解决三段写,基本是拿真机换来的血泪经验,可以直接对着排查。
5.1 高分屏下图表边缘发虚
现象:图表在设备像素比大于 1 的屏幕上看起来毛刺明显,柱子边缘和文字像被羽化过一样。
原因:Canvas 内部绘图缓冲的尺寸默认等于 CSS 像素尺寸,而高分屏上每个 CSS 像素对应多个物理像素。画布按 1:1 采样后放大,边缘自然发虚。
解决:初始化时把 canvas 的实际宽高乘以 devicePixelRatio,再用 ctx.scale(dpr, dpr) 把所有绘图坐标统一到逻辑像素坐标系。这就是基类 initCanvas 里那段代码的作用,二次开发时如果自己 new 了一个 canvas,务必复制这段逻辑:
const dpr = window.devicePixelRatio || 1; canvas.width = width * dpr; canvas.height = height * dpr; canvas.style.width = width + 'px'; canvas.style.height = height + 'px'; ctx.scale(dpr, dpr);5.2 容器隐藏时渲染成空白
现象:页面初始加载时图表区域是 display:none,切到该标签页后图表是空的;或者窗口缩放后图表「缺」了一块。
原因:构造函数里 width 回退到 container.clientWidth,容器隐藏时这个值是 0,canvas 被创建成 0 宽 0 高。resize 场景则是尺寸读取先于布局完成,拿到的是上一帧的旧值。
解决:确保容器可见后再创建图表;需要响应式时用 ResizeObserver 在回调里重新取尺寸并调用 render:
const ro = new ResizeObserver(() => { chart.width = container.clientWidth; chart.height = container.clientHeight; chart.render(); }); ro.observe(container);注意 ResizeObserver 的回调在下一帧才触发,如果想在回调里立刻读取布局,要先用 requestAnimationFrame 包一层,否则读到的还是旧尺寸。
5.3 动画重复触发导致鬼影闪烁
现象:快速切换数据源时,图表上同时出现新旧两组图形,像鬼影一样交替闪烁。
原因:每次 setData 都启动一个新的 requestAnimationFrame 循环,旧的循环没有被取消,两个循环各自调用 render,互相覆盖到一半就被下一次 clearRect 打断。
解决:在基类里维护一个 rafId,启动新动画前先 cancelAnimationFrame 旧的:
setData(data) { this.data = data; if (this.rafId) cancelAnimationFrame(this.rafId); this.render(); }同理,destroy 方法里也必须 cancelAnimationFrame,否则组件销毁后动画循环还在后台空转,浏览器控制台会一直有绘制任务在跑。
5.4 数据全 0 时饼图直接画挂
现象:接口返回的全是 0 或空数组时,饼图出现 NaN 角度,控制台报错,严重时整个图表区域被异常绘制搞成黑色。
原因:total = 0,value / total 得到 Infinity,arc 的角度变成 NaN。Canvas 对 NaN 的处理是直接放弃绘制或把路径留在画布上,表现就是「画了一半的圆」。
解决:render 开头对 total === 0 和 data.length === 0 做短路,画一个灰色占位圆直接 return。这是最省事的兜底,也是我在 PieChart 源码里看到的标准做法:
if (total === 0 || data.length === 0) { ctx.beginPath(); ctx.arc(centerX, centerY, Math.min(width, height) / 4, 0, Math.PI * 2); ctx.fillStyle = '#e5e8ee'; ctx.fill(); return; }柱状图同理,drawBars 开头那句 if (!data.length) return 也是同一目的。空数据不是异常,是正常业务状态,组件必须在任何数据情况下都能画出「至少一个看得懂的东西」。
5.5 绘图状态残留导致颜色错乱
现象:柱状图先渲染的柱子带圆角,后面的柱子变成直角;或者 fillStyle 扩散到了网格线上,网格从浅灰变成深蓝。
原因:Canvas 是状态机,fillStyle、lineWidth、shadow 这些属性会一直保留到下次被修改。子类绘制时改了全局状态却没有还原,后面的绘制全部继承,状态像墨汁一样从一根柱子洇到整张图。
解决:绘制分阶段组件时,每一段开始前 ctx.save(),结束后 ctx.restore();用了 translate 或 scale 的必须成对出现。我一般还会在 render 最开头先 ctx.setTransform(1, 0, 0, 1, 0, 0) 重置变换,再重新 scale(dpr, dpr),避免上一次的位移污染后续所有坐标计算。这两件事都做了,颜色错乱的问题基本绝迹。
6. 二次开发实战:给组件新增折线图的完整过程
6.1 新增折线图:继承基类,只写自己特有的部分
前面章节反复强调「加新图表不动核心代码」,现在实操一遍。在 charts 目录新建 LineChart.js,继承 Chart 基类。折线图和柱状图最大的差别是:没有柱子宽度分配,不需要逐根处理圆角,核心是算坐标、连线、加平滑。
// src/charts/LineChart.js import { Chart } from '../core/Chart.js'; import { createScale } from '../core/Scale.js'; import { maxOf } from '../utils/data.js'; export class LineChart extends Chart { constructor(container, options) { super(container, options); this.smooth = options.smooth ?? true; // 是否平滑曲线 this.lineColor = options.lineColor || '#4a7df7'; this.lineWidth = options.lineWidth ?? 2; this.render(); } render() { const { ctx, width, height, data } = this; ctx.clearRect(0, 0, width, height); if (data.length < 2) return; // 少于两个点没法连线 const scale = createScale(0, maxOf(data), height); const points = data.map((value, i) => ({ x: (i / (data.length - 1)) * width, y: scale.toY(value) })); ctx.save(); ctx.strokeStyle = this.lineColor; ctx.lineWidth = this.lineWidth; ctx.lineJoin = 'round'; ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for (let i = 1; i < points.length; i++) { const mx = (points[i - 1].x + points[i].x) / 2; const my = (points[i - 1].y + points[i].y) / 2; ctx.quadraticCurveTo(points[i - 1].x, points[i - 1].y, mx, my); } ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y); ctx.stroke(); ctx.restore(); } }逻辑说明:x 坐标按索引均分,最后一个点落在画布右边缘,所以用 data.length - 1 做分母。平滑曲线的思路是取相邻两点的中点,用 quadraticCurveTo 画贝塞尔曲线经过中点,视觉上就是一条过所有数据点的 S 型过渡线。这样折线不会像 SVG 默认的折线那样出现尖锐折角,也不会像高阶插值那样剧烈抖动。
然后回到 index.js 的工厂函数里注册新类型:
import { LineChart } from './charts/LineChart.js'; if (type === 'line') { return new LineChart(container, { data, ...rest }); }至此,调用方写 type: 'line' 就能生成折线图,整个改动只需要新增一个文件和处理一个分支,core 目录一行没动,这正是组件分层带来的扩展性红利。
6.2 改造后的验证方法
新增图表不是「画出来就算完」,我给自己定了一个三连验证,按顺序跑一遍能拦下九成的问题:
第一,正常数据验证。用柱状图示例的数据切到 line,确认折线穿过所有数据点、没有超出画布、最后一个点完整可见。第二,边界数据验证。data 传 [] 和 [0, 0, 0],确认页面无报错、控制台无 NaN 警告,至少画出一个占位提示。第三,高分屏与缩放验证。把窗口从窄拉到宽再缩回来,确认折线跟随容器重新计算坐标,边缘不发虚、不残留旧图形。这三步分别对应数据正确性、健壮性和渲染一致性,图表组件的回归测试没有比这三样更省事的组合了。
从那以后,我每次改完图表组件,都会强制自己把这三件事走一遍:先喂一组边界数据,再放大到高分屏看边缘,最后拉着窗口来回缩放。图表相关的 bug 大多藏在细节里,跑完这三步再交给测试,基本不会再被打回来。希望帮到你。
本文还有配套的精品资源,点击获取