ImageSplit 切图工具 —— 完整流程与技术原理文档
面向想要理解这套切图工具如何运作的读者(含新开发者、产品、非技术协作方)。 本文既讲清本项目(ImageSplit)的真实实现,也提炼出一套通用的切图流程模型,可迁移到其他切图项目。
目录
1. 项目定位
ImageSplit 是一个 100% 纯前端的图片切分工具。 核心承诺:图片不离开浏览器。
- 无服务器、无数据库、无上传 → 天然隐私
- 整个应用打包成一个 HTML 文件(
vite-plugin-singlefile),双击即可运行,零安装 - 技术栈:React 19 + TypeScript + Zustand 状态管理 + Tailwind CSS 4 + Canvas API + JSZip/FileSaver
它解决什么问题:把一张大图切成若干小图——用于社交媒体九宫格、拼图素材、长图分段、多区块素材等。
2. 一张图看懂整体流程
┌─────────────────────────────────────────────────────────────┐
│ 浏览器(纯前端) │
│ │
│ ①接图 ②配置 ③算切位 ④预览 ⑤真切 ⑥下载 │
│ ┌──────┐ ┌──────┐ ┌────────┐ ┌──────┐ ┌────────┐ ┌────┐│
│ │ 拖入 │ │ 选模式 │ │ split │ │ 画布 │ │ export │ │文件││
│ │ 粘贴 │──▶│ 行列数 │──▶│ Image │──▶│ 画廊 │──▶│ Slices │──▶│/ZIP││
│ │ 点击 │ │ 画线 │ │ .ts │ │ 预览 │ │ .ts │ │ ││
│ └──────┘ └───┬───┘ └────────┘ └──┬───┘ └────────┘ └────┘│
│ │ │ │
│ ▼ 唯一真相来源 ▼ 读同一套矩形 │
│ ┌───────────────────────────────────────────┐ │
│ │ Zustand Store(useImageStore) │ │
│ └───────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
关键设计:⑤ 真切的矩形 = ④ 预览的矩形(同一套 sliceImage.ts 纯函数),做到"所见即所得"。
3. 六个阶段详解
阶段一:接图(输入)
入口:src/components/ImageUploader.tsx
用户从三种通道提供图片:
| 通道 | 触发 |
|---|---|
| 拖放 | onDrop,取 dataTransfer.files[0] |
| 点击选择 | 隐藏 <input type="file"> ;
| 粘贴 | 全局 document.addEventListener('paste'),任意处 Ctrl+V |
数据读取链路:
[File] 文件对象
└─> FileReader.readAsDataURL(file) // 文件 → Base64 数据串
└─> new Image(); img.src = dataURL // 载入图片
└─> img.onload // 解码出真实宽高
└─> ImageInfo {
file, element,
width: naturalWidth, height: naturalHeight,
name: 去掉扩展名的文件名
}
└─> store.setImage(info)
通用要点:图片需要真实宽高才能算切割,所以必须先让浏览器解码(
img.onload后取naturalWidth/Height),而不能只用文件大小。
阶段二:配置(用户决定怎么切)
入口:src/components/ControlPanel.tsx → store 的 set* action
三种切割模式 + 各自的配置,全部写入共享大脑:
| 模式 | 配置 | 写入函数 |
|---|---|---|
| grid 等分网格 | rows × cols 行列数 |
setGridConfig |
| fixed 固定尺寸 | width × height 像素 |
setFixedConfig |
| custom 自定义线 | 一组 CustomLine[](横/竖线位置) |
add/remove/updateCustomLine |
⚠️ 关键法则(失效规则):任何会改变切分结果的配置变更,都会把已算好的
slices清空,强制系统重算 —— 详见第 3.4 节。
阶段三:算切位(纯数学)
入口:src/utils/splitImage.ts
computeGridSlices / computeFixedSlices / computeCustomSlices
(图片宽度, 图片高度, 配置) ──▶ SliceRect[]
产出核心数据结构:
interface SliceRect {
x: number // 左上角 X
y: number // 左上角 Y
width: number // 宽度
height: number // 高度
row: number // 所在行(命名用)
col: number // 所在列(命名用)
}
通用要点:这一步纯粹算坐标、不切图。把它做成纯函数(同输入 → 同输出、无副作用)是保证"预览=导出"的关键,因为预览、画廊、导出三处都能复用同一个函数。
阶段四:预览(所见)
两个观察者都读同一套矩形:
- 画布
CanvasPreview.tsx:把切割线(虚线)画在原图上,支持缩放/平移、自定义线编辑(双击加线/拖拽移线/右键删线)。 - 画廊
SlicePreview.tsx:底部横向缩略图条,逐块URL.createObjectURL(blob)生成预览图,点击可全屏查看。
🧠 内存:画廊为每个切片生成 blob URL,必须在组件卸载/变更时
URL.revokeObjectURL释放,否则泄漏内存。
阶段五:真切(裁剪成文件)
入口:src/utils/exportSlices.ts
对所有矩形 rect:
sliceToBlob(img, rect, format, quality):
1. 新建空白 canvas,尺寸 = rect 的宽高
2. ctx.drawImage(原图, rect.x, rect.y, rect.width, rect.height,
0, 0, rect.width, rect.height)
// 从原图"抠"出该区域,贴到画布左上角
3. canvas.toBlob(...) // 编码成图片数据 Blob
// PNG 无损;JPEG/WebP 传 quality/100 压缩
exportAllSlices(...):
1 张 → saveAs(单文件) → 直接下载
多张 → JSZip:逐块 zip.file() → 打包 → saveAs(压缩包)
通用要点:裁剪的本质 = 离屏 canvas +
drawImage源区域拷贝。图片自始至终在浏览器内存,导出只是"抠出来再让浏览器下载"。
阶段六:下载(输出)
- 单个切片:
{文件名}_{row}_{col}.{格式}直接下载 - 多切片:打包成
{文件名}_slices.zip - 支持格式:PNG(无损)、JPEG / WebP(有损,可调质量)
文件名中的
row/col正是矩形里携带的,一箭双雕:既定义裁剪位置,又决定下载文件名。
4. 三种切法:数学原理
4.1 等分网格(Grid)—— 末列/末行吸收余量
sliceW = Math.floor(imgWidth / cols) // 每列"理想"宽,向下取整
sliceH = Math.floor(imgHeight / rows) // 每行"理想"高
遍历 r, c:
width = isLastCol ? imgWidth - c*sliceW : sliceW // 最后一列,拉伸到右缘
height = isLastRow ? imgHeight - r*sliceH : sliceH // 最后一行,拉伸到底缘
例子:1000px 宽 / 3 列 → floor(1000/3)=333,得 333+333+334(末列吸收 1px 余量)。不留缝、不溢出。
4.2 固定尺寸(Fixed)—— 整数列 + 边缘钳制
cols = Math.ceil(imgWidth / width) // 向上取整求列数
rows = Math.ceil(imgHeight / height) // 保证覆盖整图
遍历:x = c*width; y = r*height
width = Math.min(width, imgWidth - x) // 到右缘就钳制
height = Math.min(height, imgHeight - y) // 到底缘就钳制
例子:350px 宽 / 200px 块 → ceil(350/200)=2 列;第 2 列宽度 = min(200, 350-200)=150(被钳制,绝不溢出)。
4.3 自定义线(Custom)—— 线的位置 → 网格 + 去重护栏
xPositions = [0, ...竖线位置(四舍五入, 排序), imgWidth]
uniqueX = [...new Set(xPositions)] // 去重:防两条线在同像素 → 零宽片
遍历相邻两 x(以及相邻两 y):
w = xNext - x; h = yNext - y
if (w > 0 && h > 0) 加入矩形 // 护栏:拒绝零/负尺寸
🛡️ 两条线拖到几乎同一点,四舍五入后可能是同像素 → 用
Set去重 +w>0 && h>0护栏,永不产生 0×0 不可下载切片。
5. 通用切图流程模型(可复用抽象)
把 ImageSplit 抽象成一整套任何切图工具都适用的流程。这六个阶段是切图类软件的普遍骨架:
| 阶段 | 通用职责 | 本项目的实现 | 关键原则 |
|---|---|---|---|
| ① 输入 | 拿到底图 + 真实尺寸 | ImageUploader / FileReader | 必须先解码得到宽高 |
| ② 配置 | 收集"怎么切"的参数 | ControlPanel → store | 配置是后续切割的唯一输入 |
| ③ 规划 | 把尺寸+配置 → 切割矩形 | splitImage.ts(纯函数) | 做成纯函数,可被多处复用 |
| ④ 预览 | 用同一矩形可视化 | CanvasPreview / SlicePreview | 预览与最终结果共源 |
| ⑤ 裁剪 | 按矩形真正切出文件 | exportSlices.ts(离屏 canvas) | 裁剪 = drawImage 源区拷贝 |
| ⑥ 输出 | 下载单文件 / 打包 | FileSaver + JSZip | 包名体现布局 行列 |
可迁移的三条设计经验(换其他语言/框架也成立):
- 切割数学与渲染解耦:切割算法是纯函数,不依赖 UI —— 前端/后端都能用。
- 预览与导出共源:同一个矩形集合,杜绝"预览是一套、导出是另一套"的 bug。
- 共享状态放"唯一来源":用一层集中状态(Zustand/Redux/vuex……)统一读写,避免组件各自为政不同步。
6. 边界情况与常见坑
| 场景 | 现象 | 原因 / 对策 |
|---|---|---|
| 图片尺寸除不尽 | 会留一截空白 | Grid 末列/末行吸收余量;Fixed 用 ceil + 钳制 |
| 两条线几乎重合 | 0×0 切片 | 自定义线用 Set 去重 + w>0&&h>0 护栏 |
| 改了行列但画廊没变 | 显示旧切片 | store 忘了 slices: [] 失效,或依赖数组漏了 gridConfig |
| 画廊切多张后卡/内存涨 | blob 泄漏 | 忘了 URL.revokeObjectURL;或预览数量上限(本项目 100) |
| 放大后线条很粗 | 线宽没随缩放调整 | 绘制时除以 scale(2/scale),保持屏幕上的固定粗细 |
下载文件名 _0_0.png 只有一张 |
实际只切了 1 块 | 检查 rects 数量 / 配置是否被正确传入导出函数 |
| 中英文案切换不彻底 | 部分文案没变 | 组件硬编码了语言;或 i18n 两套对象没对齐 |
pnpm build 报类型错但 dev 正常 |
类型只被 build 检查 | tsc -b 做严格全量类型检查,dev 是转译 |
7. 改代码 / 扩展的入手点
| 你要做什么 | 改哪里 | 备注 |
|---|---|---|
| 改某一种切法 | src/utils/splitImage.ts |
纯函数,最安全,不碰 UI |
| 新增一种切割模式 | 类型 + splitImage + store + 三个 switch(mode) + i18n |
见上文"四周动手点" |
| 改切分相关状态行为 | src/store/useImageStore.ts |
必须保留 slices: [] 失效 |
| 改画布交互/缩放平移 | src/components/CanvasPreview.tsx |
新指针交互必须走 toImageCoords |
| 改导出格式/打包 | src/utils/exportSlices.ts |
注意浏览器格式兼容 |
| 改界面/文案/主题 | src/components/* + src/i18n/index.ts + src/index.css |
i18n 两套要同时改 |
| 更新双击可用的成品 | 重新 pnpm build,复制 dist/index.html → 根目录 ImageSplit.html |
不要手改成品 |
构建命令:
pnpm install # 安装(仅 pnpm)
pnpm dev # 开发
pnpm build # = tsc -b(类型检查)+ vite build → dist/index.html(单文件)
pnpm lint # ESLint
附:一次"所见即所得"的完整闭环示例
用户把一张 600×400 的图切成 2×2 网格,然后下载全部 4 张。
- 接图:
ImageUploader解码 →ImageInfo{width:600, height:400}→store.setImage - 配置:用户选 grid 模式,填 rows=2, cols=2 →
store.setMode('grid')+setGridConfig({rows:2,cols:2}),slices:[]失效 - 算:
computeGridSlices(600,400,{2,2})→ 4 个矩形,每个300×200(整除,无余量需吸收) - 预览:画布画 1 竖 + 1 横切割线;画廊生成 4 张缩略图
- 切:对 4 个矩形
sliceToBlob→ 4 个 PNG blob - 下:4 张 → 打包 → 下载
照片_slices.zip,内含照片_0_0.png…照片_1_1.png
全程图片只存在于浏览器内存与你的磁盘,从不上传。