零依赖实现「形切形」:我用 6200 行原生 Canvas 写了个网页设计工具
仓库:smarkfire/huahua | 在线体验:huahua-alpha.vercel.app 14 个文件,6225 行代码,0 依赖,0 构建。觉得思路有用的话,求个 Star。
先说结论
我做了一个纯前端的设计工具「形色画布」:几何拼贴、形切形挖空、六种艺术字效果、AI 图案填充、透明背景导出、中英双语。整个项目没有 package.json,没有 node_modules,没有任何第三方库 ,双击 index.html 就能跑。
这篇文章复盘它的技术选型和四个核心实现:evenodd 挖空、单渲染路径、226 词条的 i18n 方案、断网可用的 AI 演示模式。所有代码都来自仓库源码,可以直接对照阅读。
为什么不用 Fabric.js / Konva / React
做画布类工具,社区的标准答案是 Fabric.js 或 Konva 起步。我没用,理由有三层:
能力边界不匹配。 Fabric 和 Konva 强在「对象 + 变换 + 序列化」,但我的核心功能是形切形------用 A 形状镂空 B 形状,这在它们的对象模型里没有直接对应物,最终还是要回到 Canvas 的 Path2D 和填充规则层面自己实现。既然核心难点绕不开原生 API,引入库的意义就打了折扣。
分发形态是硬约束。 我想要「双击 HTML 就能用」的单文件分发体验。任何依赖都意味着 npm + 构建管线 + 产物体积,与这个目标冲突。
教学价值。 零依赖意味着每一行行为都可见、可改、可学。对一个开源项目来说,这是比开发效率更值钱的属性------读者读到的就是全部真相。
至于 React/Vue:画布工具的 DOM 很浅(一个 canvas + 几个面板),状态更新集中在高频指针事件上,框架的声明式更新在这里收益极小,虚拟 DOM 反而是额外开销。渲染层的性能靠 rAF 脏标记自己控制。
架构:全局命名空间 + 加载顺序即依赖顺序
text
artcanvas/
├── index.html # 唯一页面:布局 + data-i18n 标注 + 各弹窗
├── css/app.css # 全部样式
└── js/
├── i18n.js # 国际化:zh/en 词典、t()、DOM 批量应用、语言持久化
├── util.js # 工具:DOM / 数值 / toast / 模态框 / 透明色约定
├── assets.js # 内置纹理:canvas 程序化生成
├── shapes.js # 形状几何核心:路径构建 / 命中测试 / 坐标变换(纯逻辑)
├── state.js # 文档模型 / 选择集 / 撤销重做 / 自动保存
├── render.js # 渲染引擎:视口 / 棋盘格预览 / 艺术字 / rAF 脏标记
├── interact.js # 指针交互:创建 / 移动 / 缩放旋转 / 框选 / 双指缩放
├── actions.js # 命令层:增删 / 阵列 / 对齐 / 形切形 / 透明背景切换
├── exporter.js # 导出 PNG/JPEG/WebP / 工程 JSON
├── ai.js # AI 生成:真实 API + 演示模式
├── panels.js # 面板双向同步
└── main.js # 启动 / 快捷键 / 语言切换 / 关于弹窗
每个模块是一个 IIFE,挂到全局 AC 命名空间上,index.html 里的 script 加载顺序就是依赖顺序。没有打包器、没有模块系统,但职责划分是严格的:
- shapes.js 是纯逻辑:输入输出都是数据,不碰 DOM 和 canvas,几何算法可以脱离浏览器单测
- render.js 是纯绘制 :只消费
state,不修改状态 - interact.js 只翻译事件 :指针手势 → 状态变更,全部通过
actions.js命令层落地,顺手解决撤销重做
这个分层让「屏幕上看到什么」和「导出什么」可以共享同一条渲染路径------这是后面透明背景实现的关键前提。
核心实现一:形切形,本质是 evenodd 的数据结构设计
先看效果:选中五角星作为「模具」,点击「形切形」,所有与它重叠的形状被压出五角星轮廓的洞,且挖空区域是真实镂空------底下叠的形状能透出来。
实现分两步。第一步在 actions.js,把模具轮廓变成目标形状的数据:
javascript
function cutWithSelection() {
var st = AC.state;
var cut = st.getSelected();
if (!cut) { U.toast(U.t('toast.pickCutter')); return; }
/* 模具轮廓:形状本地坐标 → 世界坐标 */
var cutterPts = SH.outlinePts(cut.type, 0, 0, cut.w, cut.h, cut.pts)
.map(function (pt) {
return SH.shapeLocalToWorld(cut, pt[0], pt[1]);
});
/* 遍历所有重叠目标,把轮廓点换算进目标形状的本地坐标系,存为 cutouts */
for (var i = st.doc.shapes.length - 1; i >= 0; i--) {
var t = st.doc.shapes[i];
// ...AABB 粗判重叠 + 中心点精判...
var localPts = cutterPts.map(function (wp) {
// worldToLocal:目标形状的逆变换(含旋转/翻转)
return SH.worldToLocal(t, wp[0], wp[1]);
});
t.cutouts.push({ type: 'poly', pts: localPts });
}
}
关键决策:挖空不生成新形状,而是作为 cutouts 属性挂在原形状上。这样模具移走后洞留在原地,目标形状的填充、旋转、缩放全部照常工作,撤销重做也只需要一条历史记录。
第二步在 render.js,cutouts 的多边形作为子路径加进 Path2D,用 evenodd 规则填充:
javascript
var path = buildPath(s); // 外轮廓
if (s.cutouts) {
for (var i = 0; i < s.cutouts.length; i++) {
var co = s.cutouts[i];
// 挖空多边形作为子路径加入同一个 Path2D
// fill('evenodd') 时与外轮廓重叠的区域即为洞
}
}
ctx2.fill(path, 'evenodd');
evenodd 的规则是「穿越奇数次边界的区域才填充」,外轮廓 + 内多边形重叠处穿越两次,自然成为透明洞。图片填充时配合 ctx2.clip(path, 'evenodd') 再 drawImage,图案会精确落在镂空之外的区域内。
一个 API、两条数据约定,撑起了这个工具最有辨识度的功能。
核心实现二:一条渲染路径,两个渲染场景
「屏幕所见」和「导出所得」不一致,是画布工具的经典坑:屏幕上有选框、网格、棋盘格提示,导出时这些都不能出现;透明背景在屏幕上要「可见地提示」,导出时又必须是真透明。
我的做法是把场景绘制收敛成一个函数:
javascript
/* 导出 / 主渲染共用的场景绘制
opts.transparent 导出跳过整个背景层
opts.checkerboard 透明底色时绘制棋盘格(仅屏幕预览) */
function renderScene(ctx2, opts) {
var doc = AC.state.doc;
drawDocBg(ctx2, doc, opts); // 依据 opts 决定画背景/画棋盘格/什么都不画
for (var i = 0; i < doc.shapes.length; i++) drawShape(ctx2, doc.shapes[i]);
}
屏幕渲染调用 renderScene(ctx, { checkerboard: true }),导出 PNG 调用 renderScene(ctx, { transparent: true })。透明背景由此自然获得三种正确行为:
| 场景 | 背景层行为 | 结果 |
|---|---|---|
| 屏幕 + 透明底色 | 绘制棋盘格 | 用户知道「这里是透明的」 |
| 导出 PNG / WebP | 跳过背景层 | 保留 alpha 通道,真透明 |
| 导出 JPEG | 先铺白底 | 避免 alpha 被 0 填充成黑块 |
JPEG 那一行值得多说一句:JPEG 没有 alpha 通道,浏览器把透明像素编码成黑色,用户拿到的就是一张黑底图。铺白是对用户预期正确的兜底,类似的兜底还有 <input type="color"> 不接受非 hex 值------所以 UI 上透明模式激活时要禁用取色器并显示记忆色,而不是把非法值塞进去。
背景色的数据约定同样简单:文档 bg.color 只有两类取值------'transparent' 哨兵字符串或 #rrggbb。判断透明就是一次全等比较,不用碰 alpha 数值运算。
核心实现三:226 词条的 i18n,静态走属性、动态走函数
没有 i18next,双语是这样做的:zh/en 两套扁平词典(共 226 个 key),两种消费方式。
静态文本在 HTML 里用属性标注:
html
<button data-i18n="tool.rect" data-i18n-title="tool.rectTip">矩形</button>
<input data-i18n-ph="panel.colorPh" placeholder="颜色">
切换语言时 AC.i18n.applyDOM() 扫一遍属性批量替换,textContent、title、placeholder、aria-label 各有对应的 data-i18n-* 属性。
动态文本(JS 里生成的 toast、错误信息、图层名)统一走:
javascript
U.toast(U.t('toast.cutDone', { n: n })); // "已切割 {n} 个形状"
两个工程细节比方案本身更重要:
en 回退 zh。 词典是两套独立的对象,en 里缺的 key 自动落回 zh,新增功能忘了翻译不会出现裸 key 撕开界面。
语言持久化 + 首屏防闪。 语言记忆存 localStorage,但 main.js 加载前页面已经渲染过一次中文了------所以 index.html 里埋了一段内联脚本,在 DOM 渲染前就按存储值/浏览器语言应用静态文本,避免「先中文后英文」的闪变。
写完 226 个词条之后,我用脚本扫了一遍 HTML 的 data-i18n 属性和 JS 里的 U.t() 调用,核对 key 覆盖率------这一步抓出了 4 个漏翻的 key,人肉核对肯定漏。
核心实现四:断网可用的「AI 演示模式」
AI 图案填充做了双模式。真实模式就是标准的 OpenAI images 兼容请求(POST {model, prompt, response_format: "b64_json"}),Key 存 localStorage 不上传。有意思的是演示模式:
javascript
function demoGenerate(prompt) {
var seed = U.hashStr(prompt || 'art'); // 文本 hash → 整数种子
var rng = U.mulberry32(seed); // 确定性 PRNG
var pal = PALETTES[seed % PALETTES.length];
// ...用 rng 依次决定渐变角度、图层密度、笔触形状...
}
同一个 prompt 永远得到同一张图案------mulberry32 是确定性伪随机,种子来自 prompt 的 hash。这带来三个好处:零配置零网络就能体验 AI 填充的交互流程;结果可复现,方便演示和对比不同 prompt 的差异;以及实现成本极低,一个函数加六套配色方案。
产品上的意义更大:用户不配 Key 也能走完「输入描述 → 生成图案 → 填充形状」的完整动线,之后再决定要不要接真模型。演示模式是功能的完整体验版,而不是阉割版。
踩坑记录
JPEG 透明变黑底。 上面说过,导出格式为 JPEG 时强制铺白。教训是:格式差异要在导出层显式处理,不能指望浏览器兜底。
<input type="color"> 拒绝非法值。 把 'transparent' 塞给它会被静默忽略,UI 状态和实际值就会分叉。解法是透明模式时禁用取色器、显示进入透明前的记忆色,退出时恢复。
双语文案长度差异撑破布局。 「形切形」翻成 "Cookie-cutter" 之后按钮宽度变化。CSS 上给这类控件留了弹性空间,同时英语文案尽量控制在两词以内。i18n 不只是翻译,也是布局问题。
数据总结
| 指标 | 数值 |
|---|---|
| 源码行数 | 6225 行(含 HTML/CSS) |
| 文件数 | 14 个 |
| 第三方依赖 | 0 |
| i18n 词条 | 226 个 key × zh/en 两套 |
| 支持格式 | PNG / JPEG / WebP × 1--3 倍率 |
| 部署 | 任意静态服务器 / 双击打开 |
部署用的 Vercel 关联 GitHub 仓库自动部署,push main 分支 20 秒内上线,全流程免费。
开源了,求个 Star
仓库在这里:github.com/smarkfire/h...
在线体验(打开即用):huahua-alpha.vercel.app
写这篇文章的目的就是把这些实现细节摊开给做画布类工具的同学参考:evenodd 挖空的数据结构、单渲染路径的场景复用、无框架的 i18n 方案,都是可以直接抄走的思路。如果对你有帮助,去仓库点个 Star,也欢迎提 issue 讨论实现。
版权说明:项目仅供个人学习与研究使用,商业用途请联系作者授权(微信号:aichuandao)。
