• 请不要在回答技术问题时复制粘贴 AI 生成的内容
MagicCoder
V2EX  ›  程序员

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

  •  
  •   MagicCoder · 12h 23m ago · 1340 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  ·  8h 43m ago

    GitHub的项目地址放错了

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

    8 replies    2026-05-15 10:44:53 +08:00
    xmsl
        1
    xmsl  
       9h 46m ago
    太厉害了大佬,阅读完感受颇深
    onion83
        2
    onion83  
       9h 20m ago   ❤️ 1
    将 PPT 和 KPI 都写到了社区,现在 v2 班味太重了
    MagicCoder
        3
    MagicCoder  
    OP
       9h 18m ago
    @xmsl 感谢认可😊
    MagicCoder
        4
    MagicCoder  
    OP
       9h 16m ago
    @onion83 你哪里看出来我是这是 KPI 了?我分享技术也有错?我文章写的有问题你可以指出来,不要一上来看都没看,就在这理所应当的否定!
    gotOwt
        5
    gotOwt  
       8h 46m ago
    没开源吗
    gotOwt
        6
    gotOwt  
       8h 46m ago
    @gotOwt 额 看错了,有的 我瞅瞅
    MagicCoder
        7
    MagicCoder  
    OP
       8h 44m ago
    @gotOwt 开了,文章里有项目地址
    MagicCoder
        8
    MagicCoder  
    OP
       8h 42m ago
    @gotOwt 🤣地址放错了,正确的是这个: https://github.com/likaia/js-screen-shot
    About   ·   Help   ·   Advertise   ·   Blog   ·   API   ·   FAQ   ·   Solana   ·   2926 Online   Highest 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 63ms · UTC 11:27 · PVG 19:27 · LAX 04:27 · JFK 07:27
    ♥ Do have faith in what you're doing.