简介:一套基于Canvas画布技术的前端图片编辑器源码,适合需要实现绘图、标注、滤镜或简单设计功能的前端开发人员。项目围绕fabric.js封装了画布交互、图形模块、命令管理、工具函数等核心逻辑,并配有清晰的目录结构,便于学习与二次改造。资源包共有88个文件,以JS脚本和SCSS样式为主,辅以TypeScript类型声明、JSON配置、Markdown说明文档以及示例图片与演示页面,整体压缩后约720KB,轻量而完整。当前已有522人学习下载,可作为图片编辑器从零搭建或性能优化的参考蓝本。内容中既包含编辑器主流程、常量定义与命令模块,也提供了文档站点配置、构建脚本和演示事例,能帮助阅读者快速理解各模块职责并整体跑通项目。
1. 从 canvas 图片编辑器源码里能拆出什么
收到fabric-photo-master这个基于 canvas 的前端图片编辑器源码包时,我正好在给一个旧项目补图片标注功能,canvas 绘制逻辑全堆在组件里,新增一个画笔就要动一片代码。这份源码把 fabric.js 封装成了编辑器框架:命令模式管理撤销重做、独立的 shape 模块、键盘快捷键、图像滤镜,目录按src/modules/commands、src/modules/shape分开,结构比预期干净。它不只是一个能跑的 demo,更是一份编辑器源码软件级别的设计样本。前端开发者能从中理解 canvas 绘图引擎的初始化、状态快照和扩展机制;后端转前端看它,也比单纯翻 canvas 教程更直接。下面按构建链路、命令系统、图片加载排错和组件化改造四部分拆解。
2. 从构建配置看编辑器源码的结构边界
这个压缩包里的fabric-photo-master不是在单个 HTML 里写完的 demo,它还带了website/、_config.yml、.umirc.ts,说明作者把「库源码」「文档站点」「可运行示例」放在同一个仓库里。解压后先别急着打开src/index.js,先看构建配置,能少走很多弯路。
2.1 目录结构里藏着的职责边界
顶层出现rollup.config.js和webpack.config.js,很多前端初学者会疑惑为什么一套代码要两套构建。这里的常见分工是:webpack 负责开发期调试 demo,rollup 负责把src/打成 npm 包;website/目录配合.umirc.ts走 UmiJS 文档站,_config.yml是 GitHub Pages 之类的站点描述文件,和编辑器本体逻辑无关。先抓主次,忽略文档站,核心代码集中在src/、demo/和public/三个位置。
主目录与功能对照如下:
| 路径 | 作用 | 关注点 |
|---|---|---|
src/index.js | 编辑器主入口,对外暴露初始化方法 | 从这里读整体装配顺序 |
src/consts.js | 画布尺寸、颜色、快捷键等常量 | 改默认配置优先看这里 |
src/command.js | 命令基类 | 撤销/重做的扩展基础 |
src/modules/commands | 文本、图片、历史等具体命令 | 每个能力对应一个命令类 |
src/modules/shape | 内置图形定义 | 矩形、圆形等创建逻辑 |
demo/main.js | 可运行示例的初始化代码 | 复现问题时的入口 |
website/ | 文档站源码 | 引入到具体项目可移除 |
源码根目录还有.prettierrc、.eslintrc.js、tsconfig.json,说明仓库同时接受 TS 与 JS 混合开发。实际调试中src/内部以.js文件为主,说明核心逻辑没有完全 TS 化,改造时保持 JS 风格反而改动面最小。
2.2 两条构建链路:webpack 与 rollup 的分工
package.json里的脚本常见做法如下,这里不贴具体版本,重点看命令职责:
{ "scripts": { "dev": "webpack serve --config webpack.config.js", "build": "rollup -c rollup.config.js", "build:demo": "webpack --mode production", "docs:dev": "umi dev" } }这段配置说明:dev启本地开发服务器方便调试;build用 rollup 产出最终库文件,供其他项目引入;build:demo生成纯静态 demo 页;docs:dev只负责文档站。先用npm run dev把demo/main.js跑起来,确认能画出画布,再回去改src/下的源码。
rollup 配置里通常会声明external: ['fabric'],意思是打包时不要把 fabric.js 塞进产物,而让引入方自行安装。这样做的好处是库体积小,不会与宿主项目的 fabric 版本冲突。打开rollup.config.js时,重点看output段,确认产物格式是esm还是umd,这决定后面是import Editor from 'fabric-photo-editor'还是用<script>标签直接引入。
2.3 从入口到 demo 的调用链
demo/main.js是最快理解功能的钥匙。常见的初始化方式如下:
import Editor from '../src/index.js'; const container = document.getElementById('editor-container'); const editor = new Editor(container, { width: 960, height: 600, backgroundColor: '#f5f5f5' }); editor.loadImage('/public/demo.jpeg');这里new Editor(container, options)中,第一个参数是 DOM 容器,第二个参数是覆盖默认配置的对象;loadImage的路径在 demo 环境里由 webpack 的 devServer 把public/目录作为静态资源根目录。如果发现 demo 图片加载不出来,第一件事就是检查 devServer 的静态目录配置,而不是怀疑 canvas 代码。
从这里能看出源码的结构设计:src/index.js负责对外 API,src/modules/负责内部能力,consts.js提供默认值。后续改造时,尽量不直接改index.js里已暴露的方法签名,而是通过新增模块或命令完成扩展,这样能保持源码本身的升级兼容性。
2.4 容易被忽略的配置文件
.nvmrc是 Node 版本约束文件,内容类似于一个具体版本号,配合 nvm 使用;.fatherrc.ts是 father 构建工具的配置,这套源码里主要用于文档站点库的构建。.npmrc通常配置了镜像源,在公司内网环境下,这个文件会导致外部开发者npm install依赖超时。如果 clone 后装依赖失败,先打开.npmrc看一眼,不用一味删掉,可以执行npm install --registry=https://registry.npmmirror.com临时覆盖。.eslintignore和.prettierignore标记了不需要检查的文件,比如website/dist和public,改样式时如果编辑器不生效,看看是不是被 ignore 了。
3. canvas 绘图引擎与命令系统的核心实现
fabric-photo-master的价值不在于画出图片,而在于把 canvas 绘图引擎里的状态管理、选择模型、历史记录组织成一套可维护的代码。这里抽三个关键点展开:画布初始化参数、常量收口、命令模式。
3.1 初始化 fabric Canvas 时的参数取舍
src/index.js中大概率会有类似下面的初始化代码:
import { fabric } from 'fabric'; const canvas = new fabric.Canvas(canvasElement, { preserveObjectStacking: true, selection: true, defaultCursor: 'default', backgroundColor: '#ffffff' });fabric.Canvas接收 canvas 元素和选项对象。preserveObjectStacking很重要,默认 false 时,每次选中对象都会把它移到顶层,图片编辑器里一旦打开,图形层级会被频繁打乱;设成 true 可以保持原有层级,贴近用户对 Photoshop 的预期。selection默认就是 true,显式写出来能让后续维护者知道这里支持框选多个对象。defaultCursor决定鼠标悬停画布空白处的样式,改成'crosshair'可以做出截图工具的效果。
初始化完成后,还需要设置画布尺寸。常见代码是:
canvas.setDimensions({ width: options.width || 960, height: options.height || 600 });setDimensions既会修改 DOM 属性,也会同步内部视口尺寸。这里有个坑:如果容器是响应式布局,手动指定宽高会让画布在不同屏幕下看起来很小。源码把它设计成可配置值,而不是直接取容器宽度,说明它面向的是固定画布大小的工具类场景。如果你的项目需要自适应,可以在 window resize 时重新调用setDimensions并保持画布内对象坐标按比例缩放。
3.2 consts.js:默认值与快捷键统一收口
src/consts.js是源码里最容易被忽视的部分。建议先把里面定义的常量打印出来,再决定要不要覆盖。典型的常量包括画布默认尺寸、背景色、历史记录上限和快捷键映射:
| 常量名 | 示例值 | 说明 |
|---|---|---|
DEFAULT_WIDTH | 960 | 初始化画布宽度 |
DEFAULT_HEIGHT | 600 | 初始化画布高度 |
HISTORY_LIMIT | 50 | 历史栈最大深度,过大会占内存 |
KEY_DELETE | 'Delete' | 删除选中对象 |
KEY_UNDO | 'Meta+Z' | 撤销快捷键,Mac/Windows 有差异 |
这些常量收敛到单一文件的好处是,后续做换肤、切换文案、适配不同画布比例时,不需要在命令模块里翻找魔法数字。例如HISTORY_LIMIT如果设成 100,那么每次快照都会JSON.stringify整个画布,频繁操作时内存会明显上涨,数据量大的项目建议调回 20~30。
3.3 command.js 的命令基类与撤销重做
撤销重做是图片编辑器最容易问到的功能,不少前端面试题里也会出现。源码里src/command.js定义命令基类,常见实现是:
class Command { constructor(options) { this.canvas = options.canvas; this.name = options.name || 'command'; this.before = JSON.stringify(this.canvas.toJSON()); } execute() { throw new Error('execute() must be implemented'); } undo() { this.canvas.loadFromJSON(this.before, () => { this.canvas.requestRenderAll(); }); } }这个基类的核心是before快照,保存执行命令前的画布 JSON。canvas.toJSON()会导出对象属性、位置、变换矩阵,不导出像素数据,所以快照体积可控。undo()通过loadFromJSON恢复画布;loadFromJSON是异步的,回调里必须调用canvas.requestRenderAll()刷新。如果漏掉这一步,画布不会即刻更新,界面会表现为「撤销没反应」。
具体命令类去继承Command,比如插入图片命令:
class ImageCommand extends Command { execute() { return new Promise((resolve) => { fabric.Image.fromURL(this.url, (img) => { this.canvas.add(img); this.canvas.setActiveObject(img); this.canvas.requestRenderAll(); resolve(); }, { crossOrigin: 'anonymous' }); }); } }execute里用 Promise 包一层是为了配合历史栈的记录时机:执行完命令后,再把this推入 undo 栈。源码里modules/commands下每个文件对应一个命令,从命名能看出来,比如insert-text、history等。每一个execute后调用requestRenderAll是这套命令系统最容易忽略的约定,如果自定义命令忘记这一步,画布操作显示总是滞后一步,看起来像命令没生效。
3.4 命令模块与 shape 模块的联动
src/modules/shape负责内置图形,矩形、圆形、线条。它们同样走命令模式,避免直接操作 canvas 对象。大致流程是:
- 点击工具栏的矩形按钮,创建
ShapeCommand。 - 执行
canvas.add(shape),同时保存before快照。 - 执行下一个操作时,把上一个命令推入历史栈。
如果要把这个源码改成支持「圆角矩形」,不必在 shape 里画 path,直接给fabric.Rect设置rx、ry属性:
const rect = new fabric.Rect({ left: 100, top: 100, width: 200, height: 120, rx: 8, ry: 8, fill: 'rgba(255, 0, 0, 0.2)' });rx、ry分别控制 x、y 方向的圆角半径,不传则渲染直角。这属于 fabric 内置能力,不需要侵入源码,也符合「用命令加新功能」的设计思路。
4. 图片编辑器添加图片不显示的定位与处理
在社区里,「js+html+编辑器添加图片不显示」是高频搜索词,我也踩过同样的坑。这个源码包里demo/public/demo.jpeg就是现成的调试素材,下面按加载链路逐步排查。
4.1 从 fabric.Image.fromURL 出发的加载时序
fabric 通过fabric.Image.fromURL加载图片,源码中可能这样使用:
fabric.Image.fromURL(url, (img) => { canvas.add(img); canvas.setActiveObject(img); canvas.requestRenderAll(); }, { crossOrigin: 'anonymous' });fromURL的第二个参数是加载完成回调,第三个参数是传递给Image的crossOrigin属性。如果图片加载失败,回调不会执行,也没有抛错,症状就是「图片不显示」。最稳妥的排查是先在回调里加日志:
fabric.Image.fromURL(url, (img) => { console.log('img loaded:', img.width, img.height); canvas.add(img); }, { crossOrigin: 'anonymous' });如果控制台有日志,说明图片字节没问题,问题出在canvas.add之后的渲染层级或坐标;如果没有任何日志,说明请求就失败了。注意fromURL的回调是异步的,不要在fromURL调用后立即执行依赖img的代码。
4.2 跨域与 CORS:污染画布的第一现场
使用本机相对路径./demo.jpeg时通常没有跨域问题,但编辑器一般会接收用户上传或对象存储的 URL,此时跨域请求占大多数。canvas 要读取或导出图片,必须在图片加载前设置crossOrigin: 'anonymous',并且响应头里必须带Access-Control-Allow-Origin。用 curl 验证最直接:
curl -I https://your-cdn.example.com/demo.jpeg返回头中需要看到:
access-control-allow-origin: *如果看不到,fabric 的图片即使显示出来了,后续执行canvas.toDataURL('image/jpeg')也会抛SecurityError,因为 canvas 已被污染。这类问题不能靠前端补丁解决,必须在 CDN 或后端网关添加 CORS 头。
4.3 画布尺寸、层级与遮挡的排查
排除跨域后,图片仍不显示,可以按下面顺序检查。先看坐标和尺寸:
fabric.Image.fromURL(url, (img) => { img.set({ left: 0, top: 0, scaleX: 1, scaleY: 1, selectable: true }); canvas.add(img); canvas.sendObjectToBack(img); canvas.requestRenderAll(); }, { crossOrigin: 'anonymous' });sendObjectToBack保证新图不被已有对象盖住;如果图片有透明边缘,还要检查backgroundColor是否与图片透明区域颜色一致,容易误以为没显示。另一个常见场景是canvas.setDimensions的宽高比原图小,图片或图形落到可视区域外,此时在回调里打印img.left、img.top,对比画布宽高就能判断。下面是针对这类问题的排查对照表:
| 现象 | 可能原因 | 验证手段 |
|---|---|---|
| 回调不执行 | 图片请求失败或跨域拒绝 | curl 看响应头 |
| 图片落在主画布区域外 | 初始坐标大于画布宽高 | 打印img.left/img.top |
| 被其他对象遮挡 | 层级在底层对象之下 | 执行sendObjectToBack |
| canvas 导出报错 | 跨域图片未加 CORS 头 | 执行toDataURL测试 |
4.4 加载前检查用户给的是不是图片
图片编辑器经常遇到用户传了 PDF 或 SVG 路径,导致图片不显示。fabric.Image.fromURL只能处理位图,SVG 可以交给fabric.loadSVGFromURL,PDF 需要先用 pdf.js 渲染成 canvas,再交给 editor。这个源码没有内置 pdf 能力,但改造时可以在命令层加一个预处理:
function loadFileAsCanvas(file) { if (file.type === 'application/pdf') { return pdfToCanvas(file); // 用 pdf.js 把第一页渲染到 canvas } return createImageFromFile(file); // URL.createObjectURL }URL.createObjectURL生成的blob:地址属于同源,但在调用URL.revokeObjectURL之后图片会失效,所以要把 revoke 动作放在浏览器真正解码图片之后,一般是在img.onload回调里执行。还有一个容易踩的问题:有些接口返回的是带data:image/png;base64前缀的 Data URL,fabric.Image.fromURL可以接受,但这类 URL 往往体积较大,大图 base64 超过 2MB 后解析会明显变慢,最好先用 canvas 降采样再交给编辑器。
5. 把编辑器源码改造成前端组件库的进阶做法
源码只能跑 demo,生产环境要接 React/Vue,还要做两件事:打包封装成前端组件库,并把命令系统的扩展点暴露出来。
5.1 打包配置的边界调整
在rollup.config.js中保留external: ['fabric'],同时把src/index.js作为输入,分别输出dist/fabric-photo.esm.js和dist/fabric-photo.umd.js。这样组件库里可以import Editor from 'fabric-photo-editor',浏览器也可以直接用<script>标签使用 UMD 产物。注意要在globals里声明fabric: 'fabric',否则 UMD 产物在浏览器找不到依赖。输出段建议开启sourcemap: true,方便使用方排查问题;如果入口文件包含 scss,还需要在 rollup 里配置 postcss 插件。
5.2 添加一个自定义命令并接入历史栈
不修改源码结构,新增src/modules/commands/filter-blur.js,继承Command:
import Command from '../../command.js'; class FilterBlurCommand extends Command { constructor({ canvas, object }) { super({ canvas, name: 'filter-blur' }); this.object = object; this.blur = new fabric.Image.filters.Blur({ blur: 0.6 }); } execute() { this.object.filters.push(this.blur); this.object.applyFilters(); this.canvas.requestRenderAll(); } }execute里先执行super()保存before快照,再调用object.applyFilters()应用滤镜。将该命令实例推入历史栈,undo就能通过基类的loadFromJSON回滚。对外的初始化入口可以增强为配置注入:
const editor = new Editor(container, { commands: [FilterBlurCommand] });commands数组的设计让使用方不需要改index.js,扩展能力从源码继承变成了配置注入。
5.3 事件与销毁的最后一公里
封装组件库时,把 canvas 的object:modified、selection:created等事件转发给使用方,例如editor.on('history:change', callback)。事件名建议统一放在consts.js里,避免魔法字符串。组件库里最容易被忽视的是destroy,React 严格模式下组件会执行两次 mount,不清理 canvas 会导致事件重复绑定。完善后的销毁逻辑如下:
destroy() { this.canvas.dispose(); this.off('object:modified'); this.eventBus.clear(); }canvas.dispose()会解绑 canvas 内部全部事件,this.eventBus.clear()清掉编辑器级事件。最后,当editor.destroy()被调用时,记得先canvas.dispose()再移除事件监听,否则同一个 canvas DOM 在 Vue 或 React 热更新后会被重复初始化,这是源码改造时最容易留的手尾。
本文还有配套的精品资源,点击获取