前言
时隔5年,断断续续花了亿些时间完成了js-screen-shot项目的重构,这个插件最早的目标很简单:在 Web 端实现一个类似 QQ / 微信截图的功能。用户可以框选区域,然后在画布里画矩形、圆形、箭头、画笔、马赛克、文字,最后保存截图内容。
随着功能越来越多,再加上那会儿我的技术水平还不够好,架构设计的比较差,代码也不可避免地变复杂了。尤其是后面加入了 WebRTC 截屏、自定义工具栏、图片模式、Electron 适配等能力后,入口文件越堆越大。
插件从发布到现在,NPM的周下载量保持在1000+,同时有很多人反馈说画布里的内容无法二次编辑,于是就有了本次重构计划:让画布内的元素真正变成可管理、可选中、可移动、可重绘的对象。
本文就跟大家分享下我这次重构截图插件的整体思路、用到的技术点,以及过程中遇到的一些坑,欢迎各位感兴趣的开发者阅读本文。
为什么要重构
我们先来看下重构前的结构。
早期版本的核心目录大致如下:
src
├── main.ts
└── lib
├── main-entrance
│ ├── CreateDom.ts
│ ├── InitData.ts
│ └── PlugInParameters.ts
├── split-methods
├── common-methods
└── type
└── ComponentType.ts
这个结构在功能少的时候是可以接受的,main.ts 负责串联整个截图流程,split-methods 存放绘制逻辑,common-methods 存放一些公共方法,InitData 管理插件运行时数据。
但是当功能继续增加后,它逐渐暴露出了几个问题。
main.ts 变得太重
未重构前,在 pre_release 分支中,main.ts 已经有 1500 多行代码。
里面同时处理了:
- 插件初始化
- DOM 创建与获取
- 截图源加载
- 鼠标按下、移动、抬起
- 裁剪框绘制与拖拽
- 工具栏绘制
- 文字输入
- 撤销
- 保存与确认
- WebRTC 截屏
- 自定义工具栏
这会导致一个很直接的问题:任何功能都能改到入口文件。
比如:我只是想优化一下鼠标移动时的命中判断,都需要在 main.ts 里翻很久,因为它既包含画布状态,也包含工具栏状态,还包含 DOM 结构。
状态管理过于集中
旧版本里大量状态集中在 InitData.ts 中,通过模块级变量保存。
let dragging = false;
let toolClickStatus = false;
let selectedColor = "#F53340";
let toolName = "";
let penSize = 2;
let history: Array<Record<string, any>> = [];
let cutOutBoxPosition = {
startX: 0,
startY: 0,
width: 0,
height: 0
};
这种方式写起来很快,但后期维护会比较痛苦。
因为这些状态虽然都跟截图有关,但它们的职责并不一样,零零散散的包含了:
- 裁剪框状态
- 工具栏状态
- 画布绘制状态
- DOM 引用
- 用户传入的配置
当它们全部放在一起时,代码很难看出一个状态到底属于哪个模块,也很难判断修改它会影响哪些地方。
画布内容不可二次编辑
旧版本的绘制逻辑是:“直接画到 canvas 上”。比如用户画了一个矩形,代码会立刻在 canvas 上画线,然后通过 ImageData 保存历史记录,这种方式做撤销很容易,但是要做“二次编辑”就是个大工程了。
小科普:因为 canvas 本身是位图,它并不知道上面哪个区域是矩形、哪个区域是箭头、哪个区域是文字。你一旦画上去,它就变成了像素。所以要支持选中、移动、缩放、删除,就必须额外维护一份“画布元素数据”。
重构后的目录结构
这次重构后,核心目录变成了下面这样:
src
├── main.ts
├── store
│ ├── CropBoxStore.ts
│ ├── DrawingDataStore.ts
│ ├── ScreenShotCanvasStore.ts
│ ├── TextInputStore.ts
│ ├── ToolBarStore.ts
│ ├── UserParamStore.ts
│ └── dom
├── lib
│ ├── application
│ ├── constants
│ ├── features
│ ├── shared
│ ├── type
│ └── utils
└── tests
入口文件 main.ts 从原来的 1500 多行降到了 200 多行,它现在更像是一个调度器,只负责把各个模块串起来。
export default class ScreenShot {
constructor(options: ScreenShotOptions) {
const normalizedOptions = normalizeScreenShotOptions(options);
setPlugInParameters(normalizedOptions);
new CreateDom(normalizedOptions);
screenDomStore.initWebRtcDom();
setOptionalParameter(normalizedOptions);
screenDomStore.hydrateDomRefs();
toolPanelDomStore.hydrateDomRefs();
this.load(normalizedOptions);
}
}
这样调整后,入口文件不再关心具体怎么画矩形、怎么判断箭头命中、怎么移动文字,它只负责组织流程。
我的重构思路
这次重构我主要按下面几个方向推进。
按业务流程拆 application 层
application 目录负责插件运行流程。
src/lib/application
├── core
│ ├── ScreenFlowLoader.ts
│ ├── ScreenFrameDrawer.ts
│ ├── ScreenInitializer.ts
│ ├── ScreenShotModeExecutor.ts
│ ├── ScreenShotModeResolver.ts
│ ├── ScreenSourceManager.ts
│ └── UiCoordinator.ts
├── mouse
│ ├── CanvasMouseClickHandlers.ts
│ ├── CanvasMouseDownHandlers.ts
│ ├── CanvasMouseMoveHandlers.ts
│ ├── ToolbarDrawingHandler.ts
│ └── CustomToolEventBridge.ts
└── CreateDom.ts
这一层解决的是“截图流程怎么跑起来”的问题。
比如截图源加载,旧版本会在入口文件里判断 enableWebRtc、imgSrc、screenFlow 等参数。现在我把这块整理成了截图模式解析和执行流程。
const plan = resolveScreenShotPlan();
executeLoadPlan(
plan,
mouseEvents,
context,
triggerCallback,
cancelCallback,
() => this.screenShotImageController,
canvas => {
this.screenShotImageController = canvas;
}
);
这样后续如果要继续增加新的截图来源,不需要继续往 main.ts 里塞条件判断,而是扩展模式解析和执行器。
按功能拆 features 层
features 目录负责具体能力,比如绘制、配置处理、事件处理、历史记录。
src/lib/features/canvas
├── calculations
├── config
├── drawing
├── events
├── state
└── utils
这里面比较特殊的是 drawing 目录,它只处理 canvas 绘制。
drawing
├── DrawArrow.ts
├── DrawCircle.ts
├── DrawCutOutBox.ts
├── DrawImgToCanvas.ts
├── DrawLineArrow.ts
├── DrawMasking.ts
├── DrawMosaic.ts
├── DrawPencil.ts
├── DrawRectangle.ts
└── DrawText.ts
原来这些文件放在 split-methods 下面,名字虽然是拆开了,但从目录上看不出它们属于哪个业务模块。现在放到 features/canvas/drawing 后,职责会更明确:这些文件就是 canvas 绘制能力。
把可复用能力放到 shared 层
shared 目录放的是跨流程复用的能力。
比如:
src/lib/shared
├── canvas
│ ├── CanvasElementHitTest.ts
│ ├── CanvasElementSelection.ts
│ ├── CanvasElementTransform.ts
│ ├── CanvasElementToolbarSync.ts
│ ├── CustomCanvasElementUtils.ts
│ └── TextEditingController.ts
├── dom
├── platform
├── text
└── ui
这里最核心的是 shared/canvas。
因为这次大版本更新的重点是“画布内元素可二次编辑”,选中、命中检测、拖拽、缩放、重绘这些逻辑并不属于某一个具体工具,它们是所有画布元素都要复用的能力。
使用 Store 拆分运行时状态
为了尽可能的轻量化,这次我选择引入 mobx 来做全局的状态管理。
以前 InitData 里面放了所有状态,现在拆成了多个 store:
src/store
├── CropBoxStore.ts
├── DrawingDataStore.ts
├── ScreenShotCanvasStore.ts
├── TextInputStore.ts
├── ToolBarStore.ts
├── UserParamStore.ts
└── dom
├── ScreenDomStore.ts
└── ToolPanelDomStore.ts
这样拆完后,每个 store 的职责就比较清楚了。
CropBoxStore负责裁剪框位置、拖拽、缩放等状态ToolBarStore负责当前工具、画笔大小、颜色、工具栏位置DrawingDataStore负责画布元素、历史记录、当前选中元素UserParamStore负责用户传入的配置ScreenDomStore负责截图相关 DOM 引用ToolPanelDomStore负责工具面板相关 DOM 引用
其中 DrawingDataStore 是这次改动的核心。
canvasElements: [],
activeElementId: null,
rectOperateIndex: null,
editingTextElementId: null,
pendingEditingTextElement: null
这些状态让画布上的内容从“像素”变成了“元素对象”。
画布元素二次编辑是怎么实现的
canvas 的难点在于:它不会帮你保存图形对象。
当你在 canvas 上画了一个矩形,它只知道某些像素变成了红色,并不知道这里原来是一个矩形。
所以,这次我为每个绘制内容都维护了一份快照。
export interface BaseCanvasElement {
id: string;
x: number;
y: number;
drawNode?: boolean;
dotRadius?: number;
}
export interface SquareElement extends BaseCanvasElement {
width: number;
height: number;
borderWidth: number;
color: string;
}
export interface TextElement extends BaseCanvasElement {
width: number;
height: number;
color: string;
fontSize: number;
text: string;
borderWidth: number;
}
画布中的元素会统一存到 canvasElements 中。
export type CanvasElement =
| SquareElement
| RoundElement
| LineArrowElement
| ArrowElement
| PencilElement
| MosaicElement
| TextElement
| CustomCanvasElement;
当用户绘制时,流程变成了这样:
- 鼠标按下时创建当前元素 ID
- 鼠标移动时绘制临时图形
- 同步更新当前元素快照
- 鼠标抬起时保存历史记录
- 后续重绘时根据
canvasElements重新画一遍
这样做以后,移动和缩放就不是去“移动像素”,而是修改元素数据,然后清空画布重新绘制。
clearCanvasSurface();
drawingDataStore.redrawCanvasElements();
这也是 canvas 编辑器比较常见的实现方式:数据驱动画布重绘。
元素选中与命中检测
支持二次编辑后,第一个要解决的问题就是:鼠标点下去时,怎么知道点中了哪个元素?
由于不同元素的命中规则是不一样的,矩形可以判断鼠标是否在边框附近,圆形要判断是否在椭圆边缘,箭头要判断鼠标是否在箭头线段附近,文字和画笔更适合用包围盒处理。
因此我把这块放到了 DrawingDataStore 和 CanvasElementSelection 中统一处理。
drawingDataStore.checkMouseInElement(x, y, elementId => {
if (elementId) {
selectCanvasElementBorder(elementId, dotRadius);
}
});
选中元素后,会记录当前选中的元素 ID。
drawingDataStore.updateActiveElementId(canvasElement.id);
并且给当前元素打上 drawNode 标识,重绘时根据这个标识画出选中边框和操作节点。
这块实现后,矩形、圆形、箭头、画笔、文字、自定义元素都可以进入同一套选中逻辑。
元素移动与缩放
移动元素的核心逻辑放在 CanvasElementTransform.ts。
它并不直接操作 DOM,也不直接关心鼠标事件,只接收当前鼠标位置、拖拽偏移量和目标元素 ID。
export const moveCanvasElementOnCanvas = (
mouseX: number,
mouseY: number,
dragOffset: { x: number; y: number },
elementId: string | null
) => {
const targetElement = resolveCanvasElement(elementId);
if (targetElement == null) return;
drawingDataStore.updateDrawStatus(true);
clearCanvasSurface();
// 根据元素类型更新位置
// ...
drawingDataStore.redrawCanvasElements();
};
矩形和文字这类元素比较简单,只需要更新 x / y。
箭头就要麻烦一些,因为它除了包围盒,还有起点、终点、箭头顶点等信息。
画笔和马赛克也不能只更新包围盒,还要把内部的点位一起平移。
points: originalPoints.map(point => ({
x: point.x + deltaX,
y: point.y + deltaY
}))
这也是这次重构里比较容易踩坑的地方:不同元素看起来都叫移动,但内部数据结构并不一样。
如果强行用一套 x / y / width / height 处理所有元素,箭头、画笔、马赛克很快就会出问题。
自定义工具如何接入编辑逻辑
旧版本已经支持用户自定义工具栏,但那时的自定义工具是“把 canvas 暴露出去,让用户自己画”。
这种方式虽然灵活,但它画出来的内容无法进入插件内部的编辑系统。
这次重构后,我增加了 customElementAdapters 和 customElementApi。
自定义元素需要满足一个基础结构:
export interface CustomCanvasElement extends BaseCanvasElement {
customType: "custom";
width: number;
height: number;
toolId?: number;
toolName?: string;
payload?: unknown;
}
插件内部会给自定义工具回调传入一组 API:
export type CustomCanvasElementApi = {
addElement: (input: CustomCanvasElementInput) => CanvasElementSnapshot | null;
updateElement: (element: CanvasElement) => void;
removeElement: (id: string) => void;
selectElement: (id: string) => boolean;
getElement: (id: string) => CanvasElementSnapshot | undefined;
getActiveElement: () => CanvasElementSnapshot | undefined;
redraw: () => void;
};
用户自定义工具在绘制完成后,不再只是把内容画到 canvas 上,而是可以通过 addElement 把元素注册进插件内部。
同时,用户可以通过 adapter 告诉插件这个元素如何绘制、如何命中、如何移动、如何缩放。
export type CustomCanvasElementAdapter = {
draw: (
element: CustomCanvasElement,
context: CanvasRenderingContext2D
) => void;
hitTest?: (
element: CustomCanvasElement,
point: { x: number; y: number }
) => boolean;
move?: (
element: CustomCanvasElement,
delta: { x: number; y: number },
bounds: CropBoxBounds
) => CustomCanvasElement | void;
resize?: (
element: CustomCanvasElement,
handleIndex: number,
point: { x: number; y: number },
bounds: CropBoxBounds
) => CustomCanvasElement | void;
};
这样做之后,自定义元素就不再是插件体系外的“自由绘制内容”,而是可以进入统一的选中、移动、重绘、删除逻辑。
比如五角星这种自定义图形,就可以通过 draw 负责画星星,通过 hitTest 判断鼠标是否点中,通过 move 控制移动边界。
有关此处的使用,详细文档请移步:工具栏模块化扩展
优化截图源配置定义
这次还顺手整理了截图源的配置传入字段,以前配置项比较分散,比如:
enableWebRtcscreenFlowimgSrcwrcWindowMode
这些参数都是在描述截图来源和渲染方式,但分散在多个字段里,后面继续扩展会越来越难理解。
因此现在增加了一个新的 capture 配置。
export type ScreenShotCaptureOptions = {
source?: "display-media" | "injected-stream" | "dom" | "image";
render?: "browser-frame" | "window-frame";
stream?: MediaStream;
imageSrc?: string;
};
处于兼容性考虑,插件内部会先把新旧参数统一归一化。
const normalizedOptions = normalizeScreenShotOptions(options);
如果用户继续使用旧参数,插件仍然兼容,只是内部会统一转成新的截图模式。
这块的好处是后面如果继续扩展截图来源,比如增加新的图片输入方式,或者增加某种自定义渲染模式,不需要再让入口文件继续膨胀。
这次遇到的几个坑
整个重构过程中自然遇到了一些问题,这里简单跟大家分享下。
选中态必须在正确时机清理
做元素编辑时,一开始很容易出现一个 bug:用户选中了一个旧元素,然后开始画新元素,旧元素的选中边框还在。
这个问题本质上是状态没有归属清楚。
现在的处理方式是:开始绘制新元素前,需要先清理当前选中元素的状态。
drawingDataStore.updateActiveElementId(null);
drawingDataStore.updateRectOperateIndex(null);
drawingDataStore.resetCanvasElementNodeState();
否则用户看到的就是“我明明在画新矩形,但旧元素还处于选中状态”。
这类问题不是 canvas 绘制问题,而是交互状态问题。
拖拽已有元素后,要切换当前选中元素
另一个问题是:如果当前已经选中了 A 元素,然后用户直接拖拽 B 元素,拖拽结束后应该选中 B。
这个逻辑看起来很自然,但实现时要注意鼠标按下、移动、抬起之间的状态传递。
现在我用一个 pointerSession 保存本次指针操作的信息。
const pointerSession = {
prevElementId: null,
dragOffset: { x: 0, y: 0 },
transformingExisting: false
};
这样在 mousedown 时确认命中的元素,在 mousemove 时移动这个元素,在 mouseup 时完成本次编辑状态同步。
文字编辑不能只当普通矩形处理
设计文字元素结构对象的时候,它看起来也有 x / y / width / height,于是我就想把它纳入普通矩形去,实际做的时候,发现它还涉及输入框、文本内容、字号、二次编辑。就出现了两个问题:
- 空文本元素被当成宽高为 0 的无效元素删掉
- 二次编辑时文本输入框和画布文本状态不同步
最后只好将文字相关逻辑拆到 shared/text 和 TextEditingController 中,单独处理文本输入、提交、点击编辑等流程。
canvas 历史记录不能只保存 ImageData
旧版本的撤销主要依赖 ImageData。
但是做二次编辑后,只保存 ImageData 不够了。
因为画布元素还存在于 canvasElements 中,如果撤销时只恢复像素,不恢复元素数据,用户下一次选中、移动、删除时就会出现数据和画面不一致的问题。
所以现在历史记录需要同时保存画布像素和元素快照。
{
data: imageData,
canvasElements: [...]
}
注意:这点非常重要。只要你的 canvas 是“可编辑画布”,就不能只把它当成图片处理。
构建和开发体验优化
除了业务代码,这次也顺手调整了构建体验。
包管理器从原来的 yarn 切到了 pnpm,并在 package.json 中固定了版本。
{
"packageManager": "pnpm@10.32.1"
}
Rollup 构建也做了一些优化,比如显式配置 babelHelpers,减少无意义警告;开发构建时输出更清晰的进度信息;启动时打印项目名,让终端输出更容易识别。
babel({
babelHelpers: "bundled"
})
这些不是核心功能,但对维护项目很重要。
我做事情喜欢做到极致,在开发阶段我会消除所有的警告,让后续的调试、构建、发布都尽量顺手。
项目地址
这次重构最大的变化,是把截图插件从“过程式地操作 canvas”调整成了“用数据描述画布元素,再根据数据重绘 canvas”。
简单来说,就是从:
用户操作 -> 直接画到 canvas -> 保存像素历史
变成:
用户操作 -> 更新元素数据 -> 清空画布 -> 根据元素数据重绘 -> 保存像素和元素快照
这个变化带来的收益非常明显:
- 入口文件变轻
- 运行时状态更清晰
- 内置元素完美支持二次编辑
- 自定义元素可以接入编辑体系
- 截图源配置更统一
- 后续功能扩展有了更明确的位置
当然,代价也有。
二次编辑会让 canvas 逻辑复杂很多,尤其是不同元素的命中检测、移动、缩放、历史记录同步,都需要单独处理。
但从长期维护角度看,这是值得的。
写在最后
至此,文章就分享完毕了。
我是神奇的程序员,一位前端开发工程师。
如果你对我感兴趣,请移步我的个人网站,进一步了解。
- 文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注😊
- 本文首发于神奇的程序员公众号,未经许可禁止转载💌