重构了自己 5 年前写的截图插件 - V2EX
请不要在回答技术问题时复制粘贴 AI 生成的内容
MagicCoder

重构了自己 5 年前写的截图插件

  •  
  •   MagicCoder 7h 41m ago 1096 views

    1

    前言

    时隔 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 

    这一层解决的是“截图流程怎么跑起来”的问题。

    比如截图源加载,旧版本会在入口文件里判断 enableWebRtcimgSrcscreenFlow 等参数。现在我把这块整理成了截图模式解析和执行流程。

    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 编辑器比较常见的实现方式:数据驱动画布重绘

    元素选中与命中检测

    支持二次编辑后,第一个要解决的问题就是:鼠标点下去时,怎么知道点中了哪个元素?

    元素二次编辑的交互链路

    由于不同元素的命中规则是不一样的,矩形可以判断鼠标是否在边框附近,圆形要判断是否在椭圆边缘,箭头要判断鼠标是否在箭头线段附近,文字和画笔更适合用包围盒处理。

    因此我把这块放到了 DrawingDataStoreCanvasElementSelection 中统一处理。

    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 暴露出去,让用户自己画”。

    自定义元素接入编辑体系

    这种方式虽然灵活,但它画出来的内容无法进入插件内部的编辑系统。

    这次重构后,我增加了 customElementAdapterscustomElementApi

    自定义元素需要满足一个基础结构:

    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 控制移动边界。

    有关此处的使用,详细文档请移步:工具栏模块化扩展

    优化截图源配置定义

    这次还顺手整理了截图源的配置传入字段,以前配置项比较分散,比如:

    • enableWebRtc
    • screenFlow
    • imgSrc
    • wrcWindowMode

    这些参数都是在描述截图来源和渲染方式,但分散在多个字段里,后面继续扩展会越来越难理解。

    因此现在增加了一个新的 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/textTextEditingController 中,单独处理文本输入、提交、点击编辑等流程。

    canvas 历史记录不能只保存 ImageData

    旧版本的撤销主要依赖 ImageData

    但是做二次编辑后,只保存 ImageData 不够了。

    因为画布元素还存在于 canvasElements 中,如果撤销时只恢复像素,不恢复元素数据,用户下一次选中、移动、删除时就会出现数据和画面不一致的问题。

    所以现在历史记录需要同时保存画布像素和元素快照。

    { data: imageData, canvasElements: [...] } 

    注意:这点非常重要。只要你的 canvas 是“可编辑画布”,就不能只把它当成图片处理。

    构建和开发体验优化

    除了业务代码,这次也顺手调整了构建体验。

    包管理器从原来的 yarn 切到了 pnpm,并在 package.json 中固定了版本。

    { "packageManager": "[email protected]" } 

    Rollup 构建也做了一些优化,比如显式配置 babelHelpers,减少无意义警告;开发构建时输出更清晰的进度信息;启动时打印项目名,让终端输出更容易识别。

    babel({ babelHelpers: "bundled" }) 

    这些不是核心功能,但对维护项目很重要。

    我做事情喜欢做到极致,在开发阶段我会消除所有的警告,让后续的调试、构建、发布都尽量顺手。

    项目地址

    这次重构最大的变化,是把截图插件从“过程式地操作 canvas”调整成了“用数据描述画布元素,再根据数据重绘 canvas”。

    简单来说,就是从:

    用户操作 -> 直接画到 canvas -> 保存像素历史 

    变成:

    用户操作 -> 更新元素数据 -> 清空画布 -> 根据元素数据重绘 -> 保存像素和元素快照 

    这个变化带来的收益非常明显:

    • 入口文件变轻
    • 运行时状态更清晰
    • 内置元素完美支持二次编辑
    • 自定义元素可以接入编辑体系
    • 截图源配置更统一
    • 后续功能扩展有了更明确的位置

    当然,代价也有。

    二次编辑会让 canvas 逻辑复杂很多,尤其是不同元素的命中检测、移动、缩放、历史记录同步,都需要单独处理。

    但从长期维护角度看,这是值得的。

    写在最后

    至此,文章就分享完毕了。

    我是神奇的程序员,一位前端开发工程师。

    如果你对我感兴趣,请移步我的个人网站,进一步了解。

    • 文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注
    • 本文首发于神奇的程序员公众号,未经许可禁止转载
    Supplement 1    4h 1m ago

    GitHub的项目地址放错了

    正确的是:https://github.com/likaia/js-screen-shot

    8 replies    2026-05-15 10:44:53 +08:00
    xmsl
        1
    xmsl  
       5h 4m ago
    太厉害了大佬,阅读完感受颇深
    onion83
        2
    onion83  
       4h 39m ago   1
    将 PPT 和 KPI 都写到了社区,现在 v2 班味太重了
    MagicCoder
        3
    MagicCoder  
    OP
       4h 36m ago
    @xmsl 感谢认可
    MagicCoder
        4
    MagicCoder  
    OP
       4h 35m ago
    @onion83 你哪里看出来我是这是 KPI 了?我分享技术也有错?我文章写的有问题你可以指出来,不要一上来看都没看,就在这理所应当的否定!
    gotOwt
        5
    gotOwt  
       4h 5m ago
    没开源吗
    gotOwt
        6
    gotOwt  
       4h 4m ago
    @gotOwt 额 看错了,有的 我瞅瞅
    MagicCoder
        7
    MagicCoder  
    OP
       4h 3m ago
    @gotOwt 开了,文章里有项目地址
    MagicCoder
        8
    MagicCoder  
    OP
       4h 1m ago
    @gotOwt 地址放错了,正确的是这个: https://github.com/likaia/js-screen-shot
    About     Help     Advertise     Blog     API     FAQ     Solana     5850 Online   Highest 6679       Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 201ms UTC 06:45 PVG 14:45 LAX 23:45 JFK 02:45
    Do have faith in what you're doing.
    ubao msn snddm index pchome yahoo rakuten mypaper meadowduck bidyahoo youbao zxmzxm asda bnvcg cvbfg dfscv mmhjk xxddc yybgb zznbn ccubao uaitu acv GXCV ET GDG YH FG BCVB FJFH CBRE CBC GDG ET54 WRWR RWER WREW WRWER RWER SDG EW SF DSFSF fbbs ubao fhd dfg ewr dg df ewwr ewwr et ruyut utut dfg fgd gdfgt etg dfgt dfgd ert4 gd fgg wr 235 wer3 we vsdf sdf gdf ert xcv sdf rwer hfd dfg cvb rwf afb dfh jgh bmn lgh rty gfds cxv xcv xcs vdas fdf fgd cv sdf tert sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf shasha9178 shasha9178 shasha9178 shasha9178 shasha9178 liflif2 liflif2 liflif2 liflif2 liflif2 liblib3 liblib3 liblib3 liblib3 liblib3 zhazha444 zhazha444 zhazha444 zhazha444 zhazha444 dende5 dende denden denden2 denden21 fenfen9 fenf619 fen619 fenfe9 fe619 sdf sdf sdf sdf sdf zhazh90 zhazh0 zhaa50 zha90 zh590 zho zhoz zhozh zhozho zhozho2 lislis lls95 lili95 lils5 liss9 sdf0ty987 sdft876 sdft9876 sdf09876 sd0t9876 sdf0ty98 sdf0976 sdf0ty986 sdf0ty96 sdf0t76 sdf0876 df0ty98 sf0t876 sd0ty76 sdy76 sdf76 sdf0t76 sdf0ty9 sdf0ty98 sdf0ty987 sdf0ty98 sdf6676 sdf876 sd876 sd876 sdf6 sdf6 sdf9876 sdf0t sdf06 sdf0ty9776 sdf0ty9776 sdf0ty76 sdf8876 sdf0t sd6 sdf06 s688876 sd688 sdf86