☰
手写Canvas绘图组件:柱状图饼图实现与避坑指南
2026/10/14 4:32:33 网站建设 项目流程

简介:面向Web前端开发者的一套JS绘图组件资源包,用于在网页中快速构建柱状图、饼图和折线图等交互式数据可视化图表,适合需要在数据分析页面或管理后台中呈现统计结果的人员使用。包体共51个文件、约286KB,以27个HTML示例页面为主,附带12张效果截图与9个XML配置,并包含JScharts.pdf说明文档、readme.txt使用指南以及jscharts.js核心源码,可在浏览器中直接打开示例并对照代码修改样式。已有172人学习下载。资源内提供了柱状图、饼图、折线图的多套主题示例,涵盖基础蓝色、自定义红色、彩虹、黑白等风格,从数据绑定、自定义样式到点击悬停交互均有展示。通过readme与PDF文档可快速了解API调用方式,也可阅读sources下的源码进行二次开发,是一份兼顾演示与进阶学习的轻量级图表工具包。

1. 第一次手写 js 绘图组件:我为什么没直接引图表库

拿到这份 js 绘图组件源码包时,我第一反应是“又一个大而全的图表库封装”?拆开之后发现完全不是——它就是一个实现柱状图、饼图两类基本图表的原生 JavaScript 绘图组件,核心代码几百行,Canvas 渲染,零依赖,暴露出来几个简单方法就能在页面上画出一张可用的数据图表。如果你是那种只需要在后台管理页面、数据看板或个人项目里画几张柱状图、饼图,又不想为了一个图表把所有图表库都引进来的人,这类手写组件其实比引第三方库更划算。它没有花哨的动画,但数据渲染逻辑、坐标计算、缩放适配都齐了,适合想搞懂绘图原理的开发者,也适合直接塞进现有项目当绘图工具用。

2. 绘图底座:Canvas 选型与坐标系换算

2.1 为什么用 Canvas 而不是 SVG 或 DOM 拼图

绘图组件第一步要解决的是“画在哪、怎么画”的问题。这份资源内部用的是 Canvas 2D API,外层封装了一层绘图上下文。对比 SVG 和直接拼 Div,Canvas 在绘制大量数据点时性能更好,渲染流程是“即时绘制、即时消失”,不存在 DOM 节点堆积。对柱状图、饼图这种几十到几百个数据单元的场景,Canvas 的绘制开销几乎可以忽略,而且导出的图像更容易被捕获成图片。

常见做法是让组件接收一个容器 DOM 节点,组件内部创建 canvas 并挂进容器,然后通过一个 config 对象控制图表类型、数据、颜色和尺寸。对比直接拼 DOM 的方案,Canvas 的劣势是文本和事件需要自己做,但优势也明显:不需要操心子元素层级、hover 态覆盖这些 CSS 问题。

我一般会把绘图组件的对外 API 设计成三个核心方法:

  • render(config):首次渲染或全量更新
  • update(data):只更新数据,保留配置
  • destroy():移除图表实例和监听事件

这样组件内部状态是自包含的,调用方只管给数据,不需要知道绘图细节。

2.2 坐标系换算:数据到像素的映射函数

Canvas 的坐标原点在左上角,x 向右为正,y 向下为正。而柱状图的纵轴数值是自下而上递增的,两者方向相反,所以必须有一层映射函数把数据值转为像素坐标。组件里会定义一个绘图区域(plot area),即去除边距、标题和图例之后真正画图形的矩形区域。

假设绘图区域的高度是plotHeight,纵轴数据范围是minValue到maxValue,某一个数据值value对应的像素 y 坐标:

function valueToPixelY(value, minValue, maxValue, plotTop, plotHeight) { // 将数据值归一化到 [0,1],再用 (1 - ratio) 反转 y 方向 const ratio = (value - minValue) / (maxValue - minValue); return plotTop + (1 - ratio) * plotHeight; }

这段代码的逻辑在于先做归一化,把任意数据范围的值映射到 0 到 1 之间,然后用1 - ratio把“数值越大越高”转换为“y 坐标越小越靠上”。plotTop是绘图区域的上边界坐标,plotHeight是绘图区域的高度。真实场景里 maxValue 和 minValue 不应该直接取数据的最大最小值,我一般会向上取整到“友好的刻度值”,比如数据最大值是 87,那么纵轴顶取 90 或 100 而不是 87,否则柱子顶到画布边缘很难看。

横轴的映射相对简单,等分即可:

function categoryToPixelX(index, categoryCount, plotLeft, plotWidth) { // categoryCount 是柱子分类的数量,index 是当前分类的序号 const slotWidth = plotWidth / categoryCount; return plotLeft + index * slotWidth + slotWidth / 2; }

2.3 刻度计算:避免手写魔法数字

刻度是绘图组件里最容易翻车的地方。很多人直接Math.max(...data)就当作纵轴范围,结果遇到小数、负数或数据偶尔变化的情况就需要手动调参。组件里提供了一个computeTicks(min, max, targetCount)辅助方法,它会算出 5 条到 6 条刻度线,刻度值为整数,且覆盖数据的实际范围。

function computeTicks(min, max, targetCount = 5) { // 先算出粗略的刻度间距,再修正为“好看的数”(1/2/5 的整数倍) const rawStep = (max - min) / targetCount; const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep))); const normalized = rawStep / magnitude; let step; if (normalized >= 5) { step = 10 * magnitude; } else if (normalized >= 2) { step = 5 * magnitude; } else { step = 2 * magnitude; } // 以 step 的整倍数重新划定上下限 const niceMin = Math.floor(min / step) * step; const niceMax = Math.ceil(max / step) * step; const ticks = []; for (let value = niceMin; value <= niceMax; value += step) { ticks.push(value); } return { ticks, min: niceMin, max: niceMax }; }

这段代码先通过Math.log10得到量级,再把刻度间距修正为 1、2、5 的整数倍,最后以步长对齐上下限。这样不管数据是 0 到 7 还是 0 到 700,得到的刻度都是人类友好的数值。实际使用中,目标刻度数不一定是 5,组件允许调用方传入tickCount配置,内部再按这个逻辑生成。

3. 柱状图实战:从数据数组到一根根柱子

3.1 柱状图的布局参数与柱子几何计算

这份资源里的柱状图支持两种数据形态:单组数据和多组数据(分组柱状图)。单组数据就是每个分类一根柱子,多组数据是同一分类下并排画多根柱子。组件初始化时通过config.type = 'bar'进入柱状图分支,然后根据config.series数组长度判断是否需要分组。

在计算柱子几何之前,需要先确定几个关键参数:柱子总宽度占分类槽位的比例(barRatio,一般取 0.6 到 0.8),分组柱子之间的间距(barGap)。例如一个分类槽位宽度是 80 像素,单组数据时柱子宽度就是 80 乘以 0.7,约 56 像素。多组数据时,先把槽位按分组数等分,每组分到的宽度里再扣去间距。

关键实现如下:

function layoutBars(plotArea, categoryCount, seriesCount, barRatio, barGap) { const slotWidth = plotArea.width / categoryCount; // 预留间距,计算每组实际可用的绘图宽度 const groupWidth = slotWidth * barRatio; const barWidth = seriesCount > 1 ? (groupWidth - barGap * (seriesCount - 1)) / seriesCount : groupWidth; return { slotWidth, groupWidth, barWidth }; }

这里的layoutBars只返回布局参数,真正画柱子时拿返回值和数据一一对应。barGap是柱子之间的间隔,如果 seriesCount 大于 1 且不预留这个间隔,分组柱子会全部粘在一起,标签读取困难。我一般会把 barRatio 默认设为 0.7,数据密集时调到 0.5 以下,数据少时调到 0.8 以上。

3.2 柱状图完整渲染流程

柱状图分支的渲染流程分为四个步骤:准备绘图区域、计算刻度、绘制网格和坐标轴、绘制柱子与标签。下面是一段简化但结构完整的实现:

function renderBarChart(ctx, canvas, config, data) { // 1. 清理画布 ctx.clearRect(0, 0, canvas.width, canvas.height); // 2. 计算绘图区域:预留顶部图例、底部横轴标签、左侧纵轴刻度空间 const plotArea = { left: 50, top: 40, width: canvas.width - 80, height: canvas.height - 100 }; // 3. 收集所有 series 的数据,求统一的纵轴范围 const allValues = data.series.flatMap(s => s.values); const range = computeTicks(Math.min(...allValues), Math.max(...allValues), config.tickCount || 5); // 4. 绘制横轴网格线 ctx.strokeStyle = '#e5e5e5'; ctx.lineWidth = 1; for (const tick of range.ticks) { const y = valueToPixelY(tick, range.min, range.max, plotArea.top, plotArea.height); ctx.beginPath(); ctx.moveTo(plotArea.left, y); ctx.lineTo(plotArea.left + plotArea.width, y); ctx.stroke(); } // 5. 布局柱子 const { barWidth } = layoutBars(plotArea, data.categories.length, data.series.length, config.barRatio || 0.7, 4); // 6. 逐个 series、逐个分类绘制柱子 data.series.forEach((series, seriesIndex) => { const color = config.colors[seriesIndex % config.colors.length]; ctx.fillStyle = color; series.values.forEach((value, catIndex) => { const x = plotArea.left + catIndex * (plotArea.width / data.categories.length) + seriesIndex * (barWidth + 4); const y = valueToPixelY(value, range.min, range.max, plotArea.top, plotArea.height); const height = plotArea.top + plotArea.height - y; ctx.fillRect(x, y, barWidth, height); }); }); // 7. 绘制分类标签 ctx.fillStyle = '#333'; ctx.font = '12px sans-serif'; ctx.textAlign = 'center'; data.categories.forEach((cat, catIndex) => { const x = plotArea.left + catIndex * (plotArea.width / data.categories.length) + (plotArea.width / data.categories.length) / 2; ctx.fillText(cat, x, canvas.height - 70); }); }

这段代码第 3 步是重点——纵轴范围必须从所有 series 的值里取,而不是只看第一组数据。否则第二组数据一旦有更大的值,柱子的顶部就会被裁剪掉。第 5 步的barWidth计算依赖seriesCount,多组数据时每组柱宽自动变窄。第 7 步绘制分类标签时,textAlign设为center,文本会以 x 坐标为中心对齐,不再需要手动估算文本宽度。

3.3 配置项与默认值的取舍

柱状图分支支持的配置项不多,但每个都有实际用途:

配置项默认值作用
barRatio0.7柱子总宽度占分类槽位的比例,数据密集时调小
tickCount5纵轴目标刻度数量,实际数量可能因修约而略有出入
colors默认色板不同 series 对应的柱子颜色,超过数量时循环取色
showValuefalse是否在柱子顶部显示具体数值
showGridtrue是否绘制横向网格线

showValue开启时,绘制柱子的循环里需要追加一句ctx.fillText(value, x + barWidth / 2, y - 6),在柱子顶部上方约 6 像素的位置写数值。注意如果数据值是负数,柱子方向会从 y 轴向下方延伸,文本位置要相应调整到柱子末端之下。这个组件里默认只处理正负值对称刻度,但文本绘制逻辑还是按“数值为正”写的,这一点是我拆包时发现的一个边界情况。

4. 饼图实战:角度累加与扇形绘制

4.1 角度换算:从占比到弧度

饼图的数学基础是扇形面积,一个数据项占比多少,就占据圆周多少角度。整个圆周是 2π 弧度,某项数据占比为value / total,那么它对应的弧度就是2π * ratio。绘制扇形的核心是依次累加起始角度,每画完一个扇形,起始角度就加上当前扇形的弧度。

Canvas 的ctx.arc(x, y, radius, startAngle, endAngle)方法接受的是起始角和结束角,注意角度是从正 x 轴方向开始、顺时针计算。常见的错误是以为 Canvas 的角度从正上方开始,实际上是三点钟方向。如果需要从十二点钟方向起笔,可以把起始角减去 π/2。组件里默认从正上方开始绘制,适配饼图的视觉习惯。

function drawPieSlice(ctx, cx, cy, radius, startAngle, endAngle, color) { ctx.beginPath(); ctx.moveTo(cx, cy); // 先移到圆心 ctx.arc(cx, cy, radius, startAngle, endAngle); // 画弧 ctx.closePath(); // 闭合回圆心 ctx.fillStyle = color; ctx.fill(); }

moveTo和closePath的作用是把弧线的两个端点分别与圆心连接,形成一个闭合的扇形区域。arc方法会自动把当前位置连接到圆弧起点,所以先moveTo(圆心)再arc是标准画法。这里还有一个细节:半径为radius - 0.5之类的小偏移可以避免相邻扇形之间存在缝隙,因为 Canvas 的笔画宽度可能让两个相邻扇形边缘留出一像素白线。

4.2 占比标签的放置策略

饼图除了画扇形,还要处理标签。占比较小的扇形弧度很窄,标签文字直接放在扇形内部会全部挤在一起。组件采用的是“扇内放占比文本,扇外放名称标签”的策略。

function drawPieLabels(ctx, cx, cy, radius, slices, labelRadius) { let startAngle = -Math.PI / 2; // 从正上方开始 slices.forEach(slice => { const endAngle = startAngle + slice.angle; const middleAngle = startAngle + slice.angle / 2; // 标签放在半径 labelRadius 处,通常在扇形区域的中线上 const labelX = cx + Math.cos(middleAngle) * labelRadius; const labelY = cy + Math.sin(middleAngle) * labelRadius; ctx.fillStyle = '#333'; ctx.font = '12px sans-serif'; ctx.textAlign = middleAngle > Math.PI / 2 && middleAngle < Math.PI * 3 / 2 ? 'right' : 'left'; ctx.fillText(slice.label, labelX, labelY); startAngle = endAngle; }); }

这里的labelRadius一般大于扇形的绘制半径,让标签落在饼图外侧。middleAngle > Math.PI / 2 && middleAngle < Math.PI * 3 / 2判断的是标签位于左侧还是右侧,左侧的标签右对齐,右侧的左对齐,这样一个自然段的名称不会越过圆心。这个判断解决了饼图标签最烦人的对齐问题,值得直接抄进自己的组件里。

4.3 环形图与 3D 效果的扩展思路

资源里饼图组件还预留了一个innerRadius配置项,设为大于 0 的值时,组件自动从扇形图切换为环形图。实现方式是在画扇形时增加一个内圆裁剪,逻辑上等价于“先画扇形,再用内圆半径的圆清空中心区域”。更高效的做法是直接用路径构建环形扇区。

function drawRingSlice(ctx, cx, cy, innerRadius, outerRadius, startAngle, endAngle, color) { ctx.beginPath(); ctx.arc(cx, cy, outerRadius, startAngle, endAngle); ctx.arc(cx, cy, innerRadius, endAngle, startAngle, true); // 逆时针回到内圆起始点 ctx.closePath(); ctx.fillStyle = color; ctx.fill(); }

两个arc调用拼接出一个环形扇区,第二个arc的最后一个参数传true表示逆时针绘制。这种方式比“画完再盖一个圆”干净得多,不会产生多余的重绘。如果你想要 3D 饼图效果,可以通过偏移一两像素的深色扇形复制实现,但组件源码里没做,这点复杂度留给使用者自己加。

5. 避坑与常见问题:画布模糊、浮点误差、负值裁剪

5.1 画布文字和线条发虚

现象:柱状图绘制完成后,坐标轴文字、网格线边缘模模糊糊,像隔了一层磨砂玻璃。把 canvas 宽度设成 1000 像素仍然解决不了问题。

原因:canvas 元素有两个尺寸,一个是 CSS 展示尺寸,一个是绘图缓冲区尺寸。只设置 CSS 尺寸时,canvas 内部的绘图缓冲区默认是 300×150,浏览器会把缓冲区内容拉伸到展示尺寸,于是所有线条和文字都被放大了,边缘必然发虚。高分屏(devicePixelRatio > 1)环境下这个现象更严重。

解决:在组件初始化时读取目标容器的实际尺寸,然后乘以window.devicePixelRatio重新设置 canvas 的width和height属性,绘图上下文的坐标也统一乘以这个比例因子。

function setupCanvas(canvas, container) { const dpr = window.devicePixelRatio || 1; const rect = container.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; canvas.style.width = rect.width + 'px'; canvas.style.height = rect.height + 'px'; // 缩放绘图上下文,让后续代码继续以 CSS 像素为逻辑单位 const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); return ctx; }

这段代码把 canvas 的绘图缓冲区尺寸设置为实际物理像素,然后用ctx.scale(dpr, dpr)把坐标系缩放回 CSS 像素逻辑。后续的绘制代码不需要任何改动,所有坐标都已按缩放后的坐标系计算,文字和线条会重新以物理像素渲染。

5.2 饼图最后一个扇形出现缺口或重叠

现象:饼图渲染完成,所有占比相加恰好是 100%,但图上一个扇形的边界和另一个扇形重叠了 1 像素,或者最后一个扇形没有闭合,留下一条很细的缝隙。

原因:浮点数累加误差。四个扇形的占比分别是 30.2%、25.7%、22.1%、22%,在计算 endAngle 时依次累加,最后一个扇形的结束角是2π * (累加值),累加过程中每一步都有舍入误差,最终结果可能接近 2π 但略小,于是弧线没有画满一整圈。

解决:最后一个扇形的结束角不要用startAngle + angle计算,而是直接强制等于2 * Math.PI - Math.PI / 2(如果起始角是负 π/2),或者等于Math.PI * 1.5。这能确保图形始终闭合。

slices.forEach((slice, index) => { const isLast = index === slices.length - 1; // 最后一个扇形的结束角度强制为整圆终点,避免浮点误差 const endAngle = isLast ? startAngle + (Math.PI * 2 - startAngle) : startAngle + slice.angle; drawPieSlice(ctx, cx, cy, radius, startAngle, endAngle, slice.color); startAngle = endAngle; });

更多时候我会在数据预处理阶段就把所有角度计算一遍,取累计结果的toFixed(6)再转回数字,从源头削减误差累积。

5.3 数据中出现负数后柱子被裁剪

现象:某个数据从正数变成负数后,柱状图的柱子全都不见了,或者坐标轴区域内的柱子绘制异常,纵向的柱子跑到了画布外。

原因:纵轴范围累加计算时取了数据的 min 和 max,但如果 min 是负的、max 是正的,valueToPixelY的映射逻辑仍然成立,问题往往出在绘图区域的边界处理上——很多组件只给正数预留了空间,负数方向的柱子高度计算为负值,画到画布之外。

解决:纵轴范围使用对称刻度。比如数据范围是-120到80,那么computeTicks应该生成-150到150的刻度,让 0 值位于绘图区间的中下部而不是底部。组件里的修正方式:

function computeSymmetricRange(values) { const min = Math.min(...values); const max = Math.max(...values); const absMax = Math.max(Math.abs(min), Math.abs(max)); // 对称到正负两个方向,保证 0 在绘图区域内始终可见 return { min: -absMax, max: absMax }; }

加了对称范围之后,还需要在绘制循环里判断柱子的方向:value >= 0时从基线(0 值对应的 y 坐标)向上画,value < 0时从基线向下画,高度用绝对值。这个细节在组件源码里已有体现,但如果你要照着造轮子,最容易漏的就是这一处。

5.4 窗口缩放后图表变形

现象:浏览器窗口拉宽后,图表里的柱子被整体拉宽,饼图变成了椭圆,文字比例也不对。刷新页面后恢复正常。

原因:canvas 的绘制是即时的一次性操作,尺寸变化后 canvas 的 CSS 尺寸变了,但绘图缓冲区没有重新计算,组件也没有触发重绘。

解决:监听容器尺寸变化,防抖后重新执行render(config)。现代浏览器使用ResizeObserver最合适,不需要手动监听window.resize,也不用担心其他 UI 变化导致尺寸变化时漏掉。

function observeContainer(container, callback) { if (typeof ResizeObserver === 'undefined') { // 兜底方案:监听 window 的 resize 事件 window.addEventListener('resize', callback); return; } const observer = new ResizeObserver(() => callback()); observer.observe(container); // 返回一个销毁函数,组件 destroy() 时调用 return () => observer.disconnect(); }

常见做法是只在resize时重新设置 canvas 尺寸并重绘,但这个兜底方案里observeContainer返回的是一个自定义销毁函数,调用destroy()时应该同时断开 ResizeObserver 和移除 listener,防止内存泄漏。

5.5 数据更新后动画/重绘竞态

现象:快速连续调用update(data)多次后,图表最终显示的数据和最后一次传入的数据不一致,柱子位置在几个值之间跳来跳去好一会儿才停下。

原因:组件用了requestAnimationFrame做动画插值,但每次更新数据都启动一个新的动画循环,没有取消上一次的循环。多个动画循环同时运行时会互相覆盖绘制结果。

解决:在动画启动前先调用cancelAnimationFrame取消上一个循环,并且给每次动画一个唯一的 token,循环结束时比对 token,不一致就停止绘制。

let animToken = 0; let animFrameId = null; function animateTo(data) { // 取消上一次动画循环 if (animFrameId) { cancelAnimationFrame(animFrameId); } const token = ++animToken; // 插值动画逻辑... const frame = () => { if (token !== animToken) return; // 本次动画已失效,丢弃 // 绘制一帧... animFrameId = requestAnimationFrame(frame); }; animFrameId = requestAnimationFrame(frame); }

这段代码的核心是token失效机制——即使cancelAnimationFrame由于时序问题没有及时取消旧循环,旧循环内部的token比对也会让它自动退出。我一般在实现动画类组件时都会带上这个模式,它是动画竞态问题的最简单解法。

6. 让组件更顺手:响应式适配、销毁与像素级自校验

6.1 把尺寸变化做成自动的

组件在实际项目里几乎一定会被放进可伸缩的布局中。侧边栏收起、窗口放大缩小,都要求图表跟着容器走。比较省事的做法是把“尺寸测量 + 重绘”封装成一个内部方法,构造函数里自动挂载ResizeObserver。有一个经验是:不要在每次 resize 回调里直接执行完整重绘,先比较一下容器的宽高是否真的发生了变化,因为有些浏览器在滚动条出现或消失时也会触发 observer 回调,但实际尺寸没变。

另一个常见做法是把绘图区域的边距、字体大小、柱子宽度做成相对值,而不是写死的像素。比如左侧纵轴刻度区的宽度根据tick字符串的最大位数动态计算,数值超过一万时显示“1.2万”之类的缩写。这样可以减少 resize 后布局挤压的概率。

6.2 destroy 方法为什么值得认真写

我在评审同事代码时经常发现,很多人写的图表组件压根没有 destroy 方法。表面上不影响功能,但在单页应用里,页面切换后组件实例还在,定时器还在转,ResizeObserver 还挂在容器上,内存一点点涨上去。

合理的 destroy 实现至少要做三件事:取消所有动画帧、断开 ResizeObserver、把 canvas 从容器中移除。如果组件内部注册了全局事件(比如点击空白处关闭 tooltip),还需要显式移除这些事件函数,否则事件回调里的闭包会一直持有组件实例。

destroy() { // 1. 取消动画循环 if (this._animFrameId) { cancelAnimationFrame(this._animFrameId); this._animFrameId = null; } // 2. 断开尺寸监听 if (this._resizeObserver) { this._resizeObserver.disconnect(); this._resizeObserver = null; } // 3. 移除 canvas 并释放引用 if (this._canvas && this._canvas.parentNode) { this._canvas.parentNode.removeChild(this._canvas); } this._ctx = null; this._config = null; }

顺手写一个不带任何动画的render()也比只支持动画的重绘更好调试——数据异常时可以手动关闭动画,单独确认绘制结果是否正确,缩小问题范围。

6.3 像素级验证绘图的土办法

这个组件源码包里没有附带单元测试,但绘图组件其实是有办法自动校验的:创建一个离屏 canvas,调用组件的绘制方法,然后用getImageData检查特定像素点的颜色是否命中预期值。柱状图的话,每个柱子中心的颜色就是该 series 的颜色;饼图的话,某个扇形中心的像素颜色应该等于对应的 color 值。

function verifyPixel(canvas, x, y, expectedColor, tolerance = 10) { const ctx = canvas.getContext('2d'); const { data } = ctx.getImageData(x, y, 1, 1); const [r, g, b] = expectedColor; return Math.abs(data[0] - r) <= tolerance && Math.abs(data[1] - g) <= tolerance && Math.abs(data[2] - b) <= tolerance; }

该方法的核心思路是:颜色容差设为 10 而不是 0,因为 Canvas 可能因为抗锯齿产生中间颜色,但也别把容差设得太大,否则空白区域的白色也会被误判为期望颜色。验证结果异常时,优先检查数据到像素坐标的映射函数,我调试那个负值柱状图裁剪问题时,就是先靠这个土办法确认了 y 坐标计算跑偏,才定位到纵轴范围没做对称处理的。

从那以后,每次把组件接入新项目,我都强制走一遍这个流程:写死数据 → 渲染 → 像素级校验三个关键坐标点 → resize 一次 → 再校验一次。整个过程不到五分钟,但能过滤掉大部分恼人的绘图玄学问题。希望帮到你。

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

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

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

立即咨询