十一篇文章,十一次进阶。
我们从最基础的 SVGElement 基因开始,一步步走过了矩形、圆形、椭圆、路径的绘制;掌握了分组与复用的组织艺术;理解了矩阵变换的数学本质;学会了列表数据的操作技巧;最后还解锁了 SMIL 动画的动效魔法。
但学以致用,才是学习的终点。
今天,我们将把前 11 篇文章的所有知识点串联起来,从零构建一个简易版的 Figma 画板——一个功能完整的 SVG 图形编辑器。
这个项目将涵盖:
图形创建:矩形、圆形、椭圆、路径,一键添加到画布。
选择与高亮:点击选中图形,边框高亮反馈。
拖拽移动:选中后拖拽改变位置。
变换控制:旋转手柄和缩放手柄,可视化操控。
属性面板:实时编辑选中图形的颜色、尺寸、位置。
图层管理:上移、下移、删除图层。
导出功能:导出为 SVG 文件和 PNG 图片。
这不是一个“玩具项目”——它包含了真实图形编辑器的核心架构。完成之后,你将拥有一个可以继续扩展的、工业级的 SVG 编辑框架。
一、项目架构设计
在写代码之前,我们先想清楚架构。
1.1 核心设计原则
数据驱动
我们不直接操作 DOM 来管理“选中状态”或“图层顺序”,而是维护一个图形数据数组,每次数据变化后重新渲染画布。
graphics: [
{ id: 'rect_1', type: 'rect', x: 50, y: 50, width: 100, height: 80, fill: '#4A90D9' },
{ id: 'circle_1', type: 'circle', cx: 250, cy: 150, r: 50, fill: '#E67E22' },
...
]单一职责
每个功能模块独立封装——renderer 负责渲染,selector 负责选择,transformer 负责变换,propertyPanel 负责属性编辑。
事件驱动
用户操作 → 触发事件 → 更新数据 → 重新渲染 → 用户看到新状态。这是一个闭环。
1.2 技术栈
本项目零依赖,全部使用原生 JavaScript + DOM API + SVG API。你只需要一个浏览器。
1.3 目录结构
/editor
├── index.html # 主页面
├── style.css # 样式
└── script.js # 所有逻辑(为便于阅读,集中在一个文件)二、HTML 结构与 CSS 样式
2.1 HTML 结构
<!-- 顶部工具栏 -->
<header class="toolbar">
<div class="tool-group">
<button data-tool="select" class="active">🖱 选择</button>
<button data-tool="rect">▭ 矩形</button>
<button data-tool="circle">◯ 圆形</button>
<button data-tool="ellipse">⬮ 椭圆</button>
<button data-tool="path">╲ 路径</button>
<button data-tool="freedraw">✎ 自由绘制</button>
</div>
<div class="tool-group">
<button id="deleteBtn">🗑 删除</button>
<button id="moveUpBtn">⬆ 上移</button>
<button id="moveDownBtn">⬇ 下移</button>
</div>
<div class="tool-group">
<button id="exportSvgBtn">📄 导出 SVG</button>
<button id="exportPngBtn">🖼 导出 PNG</button>
</div>
</header><!-- 主区域 -->
<main class="main">
<!-- 画布 -->
<div class="canvas-wrapper">
<svg id="canvas" viewBox="0 0 800 500" width="800" height="500">
<!-- 网格背景 -->
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#e8ecf1" stroke-width="0.5" />
</pattern>
<rect width="800" height="500" fill="url(#grid)" />
<!-- 图形容器 -->
<g id="shapes"></g>
<!-- 选择框(高亮) -->
<rect id="selectionBox" fill="none" stroke="#4A90D9" stroke-width="1.5"
stroke-dasharray="4,4" display="none" />
<!-- 变换手柄 -->
<g id="handles" display="none">
<!-- 旋转手柄 -->
<circle id="rotateHandle" cx="0" cy="-40" r="8" fill="white" stroke="#4A90D9" stroke-width="2"
style="cursor: grab" />
<line id="rotateLine" x1="0" y1="0" x2="0" y2="-40" stroke="#4A90D9" stroke-width="1.5" />
<!-- 缩放手柄(右下角) -->
<circle id="scaleHandle" cx="0" cy="0" r="8" fill="white" stroke="#4A90D9" stroke-width="2"
style="cursor: nwse-resize" />
</g>
</svg>
</div>
<!-- 属性面板 -->
<aside class="panel">
<h3>属性面板</h3>
<div id="propertyPanel">
<p style="color: #999; font-size: 14px;">点击画布上的图形以编辑</p>
</div>
</aside>
</main>2.2 CSS 样式
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f0f2f5;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.app {
background: white;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0,0,0,0.1);
overflow: hidden;
width: 1100px;
max-width: 98vw;
}
/* ... 剩下部分省略 */三、JavaScript 核心逻辑
3.1 数据层
// ===== 数据层 =====
const state = {
graphics: [], // 所有图形数据
selectedId: null, // 当前选中的图形 ID
tool: 'select', // 当前工具: 'select' | 'rect' | 'circle' | 'ellipse' | 'path' | 'freedraw'
nextId: 1,
};
// 图形数据工厂
function createGraphic(type, props) {
const base = { id: `g_${state.nextId++}`, type };
let data;
switch (type) {
case 'rect':
data = { ...base, x: 100, y: 100, width: 120, height: 80, fill: '#4A90D9', stroke: '#2C3E50', strokeWidth: 2 };
break;
case 'circle':
data = { ...base, cx: 200, cy: 150, r: 50, fill: '#E67E22', stroke: '#2C3E50', strokeWidth: 2 };
break;
case 'ellipse':
data = { ...base, cx: 200, cy: 150, rx: 70, ry: 45, fill: '#27AE60', stroke: '#2C3E50', strokeWidth: 2 };
break;
case 'path':
data = { ...base, d: 'M 50 50 L 150 50 L 100 130 Z', fill: '#9B59B6', stroke: '#2C3E50', strokeWidth: 2 };
break;
default:
data = base;
}
// 合并传入的属性
return { ...data, ...props };
}3.2 渲染层
渲染层的核心职责是:根据 state.graphics 数据,在 SVG 中创建或更新对应的 DOM 元素。
// ===== 渲染层 =====
const canvas = document.getElementById('canvas');
const shapesContainer = document.getElementById('shapes');
const selectionBox = document.getElementById('selectionBox');
const handlesGroup = document.getElementById('handles');
const rotateHandle = document.getElementById('rotateHandle');
const scaleHandle = document.getElementById('scaleHandle');
const rotateLine = document.getElementById('rotateLine');
const propertyPanel = document.getElementById('propertyPanel');
const SVG_NS = 'http://www.w3.org/2000/svg';
// 存储图形 DOM 元素的映射 { id: SVGElement }
const elementMap = new Map();
function render() {
// 1. 清除所有现有图形
shapesContainer.innerHTML = '';
elementMap.clear();
// 2. 遍历数据,创建 DOM 元素
state.graphics.forEach(data => {
const el = createElement(data);
shapesContainer.appendChild(el);
elementMap.set(data.id, el);
});
// 3. 更新选择状态
updateSelection();
}
// 剩下部分省略3.3 选择逻辑
// ===== 选择逻辑 =====
function selectGraphic(id) {
state.selectedId = id;
updateSelection();
}
function deselectAll() {
state.selectedId = null;
updateSelection();
}3.4 图形创建(绘制工具)
// ---- 绘制图形 ----
function startDrawing(point) {
isDrawing = true;
drawStartX = point.x;
drawStartY = point.y;
const data = createGraphic(state.tool, {});
// 初始尺寸为 0
if (data.type === 'rect') { data.x = drawStartX; data.y = drawStartY; data.width = 1; data.height = 1; }
if (data.type === 'circle') { data.cx = drawStartX; data.cy = drawStartY; data.r = 1; }
if (data.type === 'ellipse') { data.cx = drawStartX; data.cy = drawStartY; data.rx = 1; data.ry = 1; }
if (data.type === 'path') { data.d = `M ${drawStartX} ${drawStartY} L ${drawStartX} ${drawStartY} Z`; }
state.graphics.push(data);
tempData = data;
render();
selectGraphic(data.id);
}
// 剩下部分省略3.5 拖拽移动
选中图形后,拖拽它来改变位置。
// ---- 绘制图形 ----
function startDrawing(point) {
isDrawing = true;
drawStartX = point.x;
drawStartY = point.y;
const data = createGraphic(state.tool, {});
// 初始尺寸为 0
if (data.type === 'rect') { data.x = drawStartX; data.y = drawStartY; data.width = 1; data.height = 1; }
if (data.type === 'circle') { data.cx = drawStartX; data.cy = drawStartY; data.r = 1; }
if (data.type === 'ellipse') { data.cx = drawStartX; data.cy = drawStartY; data.rx = 1; data.ry = 1; }
if (data.type === 'path') { data.d = `M ${drawStartX} ${drawStartY} L ${drawStartX} ${drawStartY} Z`; }
state.graphics.push(data);
tempData = data;
render();
selectGraphic(data.id);
}
// 剩下部分省略3.6 变换控制(旋转与缩放)
// 部分代码
// 变换中(旋转/缩放)
if (isTransforming && transformData) {
const point = getSVGPoint(e);
if (transformType === 'rotate') {
const currentAngle = Math.atan2(point.y - transformCenter.y, point.x - transformCenter.x);
const deltaAngle = (currentAngle - transformStartAngle) * 180 / Math.PI;
// 从初始矩阵开始叠加旋转,避免累积误差
const rotateMatrix = new DOMMatrix()
.translate(transformCenter.x, transformCenter.y)
.rotate(deltaAngle)
.translate(-transformCenter.x, -transformCenter.y);
const combined = transformStartMatrix.multiply(rotateMatrix);
applyMatrixToGraphic(transformData, combined);
} else if (transformType === 'scale') {
const currentDist = Math.hypot(point.x - transformCenter.x, point.y - transformCenter.y);
const scale = Math.max(0.1, currentDist / transformStartDist);
// 从初始矩阵开始叠加缩放,避免累积误差
const scaleMatrix = new DOMMatrix()
.translate(transformCenter.x, transformCenter.y)
.scale(scale)
.translate(-transformCenter.x, -transformCenter.y);
const combined = transformStartMatrix.multiply(scaleMatrix);
applyMatrixToGraphic(transformData, combined);
}
updateGraphicDOM(transformData);
updateSelection();
return;
}3.7 属性面板
属性面板实时显示和编辑选中图形的属性。
// ===== 属性面板 =====
function getPropertyFields(data) {
const common = [
{ key: 'fill', label: '填充色', type: 'color' },
{ key: 'stroke', label: '描边色', type: 'color' },
{ key: 'strokeWidth', label: '描边宽', type: 'number' },
];
switch (data.type) {
case 'rect':
return [
{ key: 'x', label: 'X 坐标', type: 'number' },
{ key: 'y', label: 'Y 坐标', type: 'number' },
{ key: 'width', label: '宽度', type: 'number' },
{ key: 'height', label: '高度', type: 'number' },
...common,
];
case 'circle':
return [
{ key: 'cx', label: '圆心 X', type: 'number' },
{ key: 'cy', label: '圆心 Y', type: 'number' },
{ key: 'r', label: '半径', type: 'number' },
...common,
];
case 'ellipse':
return [
{ key: 'cx', label: '圆心 X', type: 'number' },
{ key: 'cy', label: '圆心 Y', type: 'number' },
{ key: 'rx', label: 'X 半径', type: 'number' },
{ key: 'ry', label: 'Y 半径', type: 'number' },
...common,
];
case 'path':
return [
{ key: 'd', label: '路径数据', type: 'text' },
...common,
];
default:
return common;
}
}
// 部分代码省略3.8 图层管理
// ===== 图层管理 =====
function moveUp() {
if (!state.selectedId) return;
const index = state.graphics.findIndex(g => g.id === state.selectedId);
if (index < 0 || index >= state.graphics.length - 1) return;
[state.graphics[index], state.graphics[index + 1]] = [state.graphics[index + 1], state.graphics[index]];
render();
}
function moveDown() {
if (!state.selectedId) return;
const index = state.graphics.findIndex(g => g.id === state.selectedId);
if (index <= 0) return;
[state.graphics[index], state.graphics[index - 1]] = [state.graphics[index - 1], state.graphics[index]];
render();
}
function deleteSelected() {
if (!state.selectedId) return;
state.graphics = state.graphics.filter(g => g.id !== state.selectedId);
state.selectedId = null;
render();
}3.9 导出功能
导出 SVG:序列化当前画布内容为 SVG 字符串,并下载为 .svg 文件。
导出 PNG:将 SVG 渲染到 Canvas,再导出为 PNG 图片。
// ===== 导出功能 =====
function exportSVG() {
// 克隆 SVG 并移除交互元素
const clone = canvas.cloneNode(true);
const selBox = clone.querySelector('#selectionBox');
const handles = clone.querySelector('#handles');
if (selBox) selBox.remove();
if (handles) handles.remove();
const svgString = new XMLSerializer().serializeToString(clone);
const blob = new Blob([svgString], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'drawing.svg';
a.click();
URL.revokeObjectURL(url);
}
// 部分代码省略3.10 工具切换与初始化
function setTool(tool) {
// 切换工具时,如果正在绘制 path,先取消
if (isDrawingPath && tool !== 'path') {
cancelPathDrawing();
}
// 切换工具时,如果正在自由绘制,先取消
if (isFreeDrawing && tool !== 'freedraw') {
cancelFreeDrawing();
}
state.tool = tool;
document.querySelectorAll('[data-tool]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tool === tool);
});
// 切换到绘制工具时取消选择
if (tool !== 'select') {
deselectAll();
}
// 更新光标
canvas.style.cursor = tool === 'select' ? 'default' : 'crosshair';
}
// 部分代码省略。。。3.11 path 多点折线绘制
// ---- path 多点折线绘制 ----
// 交互:点击添加顶点 → 移动鼠标显示预览线 → 双击/回车结束 → ESC 取消
// path 双击检测状态
let lastPathClickTime = 0;
let lastPathClickPoint = null;
const DBLCLICK_INTERVAL = 300; // 双击最大间隔(ms)
const DBLCLICK_DISTANCE = 6; // 双击最大距离(px)
// 部分代码省略3.12 自由绘制
// ---- 自由绘制 ----
// 交互:按住鼠标拖动 → 实时记录轨迹点 → 松开鼠标完成
// 开始自由绘制
function startFreeDrawing(point) {
isFreeDrawing = true;
freeDrawPoints = [{ x: point.x, y: point.y }];
// 创建 path 数据,初始只有一个点
freeDrawData = createGraphic('path', {});
freeDrawData.fill = 'none'; // 自由绘制默认不填充
freeDrawData.d = `M ${point.x} ${point.y}`;
state.graphics.push(freeDrawData);
render();
selectGraphic(freeDrawData.id);
}
// 部分代码省略。。。四、完整代码整合
由于篇幅限制,部分代码已在上述各节中分段展示。需要完整代码进行进阶的小伙伴,请评论区留言。下图是作者手搓图图例:


五、功能演示与使用说明
5.1 基本操作
操作 | 说明 |
|---|---|
选择图形 | 点击“选择”工具后,点击任意图形 |
拖拽移动 | 选中图形后,按住并拖动 |
绘制图形 | 切换到“矩形/圆形/椭圆/路径”工具,在画布上点击并拖拽 |
旋转图形 | 选中图形后,拖拽上方的圆形手柄 |
缩放图形 | 选中图形后,拖拽右下角的方形手柄 |
多点折线绘制 | 点击添加顶点 → 移动鼠标显示预览线 → 双击/回车结束 → ESC 取消 |
自由绘制 | 按住鼠标拖动 → 实时记录轨迹点 → 松开鼠标完成 |
修改属性 | 选中图形后,在右侧属性面板中修改 |
删除图形 | 选中后点击“删除”按钮 |
调整图层 | 选中后点击“上移”或“下移” |
导出 SVG | 点击“导出 SVG”,下载 .svg 文件 |
导出 PNG | 点击“导出 PNG”,下载 .png 图片 |
5.2 技术亮点回顾
功能 | 使用的 API/技术 | 对应系列文章 |
|---|---|---|
图形创建 | createElementNS、setAttribute | 第 3、4、5 篇 |
边界计算 | SVGGraphicsElement.getBBox() | 第 2 篇 |
坐标转换 | DOMMatrix、getScreenCTM() | 第 8 篇 |
变换控制 | DOMMatrix 矩阵运算 | 第 8 篇 |
属性编辑 | setAttribute、baseVal.value | 第 3 篇 |
导出 SVG | XMLSerializer | — |
导出 PNG | Canvas drawImage、toDataURL | — |
六、扩展方向
这个画板虽然“简易”,但已经具备了图形编辑器的核心骨架。你可以在此基础上继续扩展:
(1)更丰富的图形类型
多边形(<polygon>):使用 SVGPointList 操作顶点【第 8 篇】。
文本(<text>):使用 SVGTextElement。
图片(<image>):使用 SVGImageElement。
(2)更强大的选择能力
框选(拖拽矩形批量选择):使用 getIntersectionList()【第 2 篇】。
多选 + 批量变换。
图层树显示。
(3)更精细的变换控制
8 个方向的手柄(四角 + 四边中点)。
变换时显示实时数值。
对齐线与智能吸附。
(4)撤销/重做
使用命令模式(Command Pattern),每次操作保存一个命令对象
维护历史栈,支持撤销和重做。
(5)更完善的导出
导出为 PDF。
导出为不同分辨率。
导出选区。
七、本章总结
模块 | 核心 API | 关键认知 |
|---|---|---|
数据层 | — | 数据驱动渲染,DOM 只是数据的“投影” |
渲染层 | getBBox()、setAttribute() | getBBox() 返回的是不考虑 transform 的边界框 |
选择逻辑 | 事件冒泡、closest() | 点击检测从顶层向下遍历 |
图形创建 | createElementNS | SVG 元素必须使用 http://www.w3.org/2000/svg 命名空间 |
拖拽移动 | getScreenCTM()、DOMMatrix | 屏幕坐标 → SVG 坐标的转换是关键 |
变换控制 | DOMMatrix 矩阵运算 | 旋转/缩放 = 平移→变换→平移回来 |
属性面板 | 双向数据绑定 | 数据变化 → 渲染 → 面板更新 → 用户修改 → 数据变化 |
导出 SVG | XMLSerializer | 直接序列化 DOM 为 XML 字符串 |
导出 PNG | Canvas drawImage() | SVG → Image → Canvas → PNG |
三个核心认知:
数据是唯一的真相来源——所有 UI 状态(选中、位置、样式)都存储在 state 中,DOM 只是数据的可视化表现。这让你可以轻松实现撤销/重做、保存/加载等功能。
矩阵是变换的通用语言——无论是旋转、缩放还是平移,都可以用 DOMMatrix 统一表达。理解矩阵运算,你就理解了所有变换的本质。
getBBox() 是布局计算的基石——它让你能够精确计算任意图形的位置和大小,是实现选择框、手柄定位、碰撞检测等功能的基础。
写在最后
十二篇文章,从零到一。
我们从一个最简单的 <circle> 开始,一路走到今天这个功能完整的图形编辑器。这中间,我们学会了:
SVG 元素的继承体系(SVGElement → SVGGraphicsElement → SVGGeometryElement)。
各种图形接口(SVGRectElement、SVGCircleElement、SVGPathElement)。
分组与复用(<g>、<defs>、<use>、<symbol>)。
SVG数据:SVGLength 与 SVGNumber。
矩阵变换(DOMMatrix、getCTM()、getScreenCTM())。
列表操作(SVGStringList、SVGNumberList、SVGPointList)。
SMIL 动画(<animate>、<animateTransform>、<animateMotion>)
这些知识,不仅仅是“SVG 的 API”——它们是 Web 图形编程的底层通用能力。无论你以后用 Canvas、WebGL 还是 Three.js,这些关于坐标系统、变换矩阵、事件处理的底层认知,都会让你走得更远。
现在,轮到你动手了。
打开编辑器,画一个属于你自己的图形吧。然后,尝试给它加一个你一直想要的功能——也许是一个自由画笔,也许是一个对齐网格,也许是一个导出动画的功能。
互动话题:你最想为这个画板添加什么功能?或者你用它画了什么有趣的东西?欢迎在评论区分享你的创意和作品!