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

查看 11|回复 0
作者:MagicCoder   

前言
时隔 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> = [];
    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 控制移动边界。

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

    优化截图源配置定义
    这次还顺手整理了截图源的配置传入字段,以前配置项比较分散,比如:
  • 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/text 和 TextEditingController 中,单独处理文本输入、提交、点击编辑等流程。
    canvas 历史记录不能只保存 ImageData
    旧版本的撤销主要依赖 ImageData。
    但是做二次编辑后,只保存 ImageData 不够了。
    因为画布元素还存在于 canvasElements 中,如果撤销时只恢复像素,不恢复元素数据,用户下一次选中、移动、删除时就会出现数据和画面不一致的问题。
    所以现在历史记录需要同时保存画布像素和元素快照。
    {
      data: imageData,
      canvasElements: [...]
    }

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

    构建和开发体验优化
    除了业务代码,这次也顺手调整了构建体验。
    包管理器从原来的 yarn 切到了 pnpm,并在 package.json 中固定了版本。
    {
      "packageManager": "[email protected]"
    }
    Rollup 构建也做了一些优化,比如显式配置 babelHelpers,减少无意义警告;开发构建时输出更清晰的进度信息;启动时打印项目名,让终端输出更容易识别。
    babel({
      babelHelpers: "bundled"
    })
    这些不是核心功能,但对维护项目很重要。

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

    项目地址
  • js-screen-shot 官网
  • GitHub 仓库地址

    这次重构最大的变化,是把截图插件从“过程式地操作 canvas”调整成了“用数据描述画布元素,再根据数据重绘 canvas”。
    简单来说,就是从:
    用户操作 -> 直接画到 canvas -> 保存像素历史
    变成:
    用户操作 -> 更新元素数据 -> 清空画布 -> 根据元素数据重绘 -> 保存像素和元素快照
    这个变化带来的收益非常明显:
  • 入口文件变轻
  • 运行时状态更清晰
  • 内置元素完美支持二次编辑
  • 自定义元素可以接入编辑体系
  • 截图源配置更统一
  • 后续功能扩展有了更明确的位置

    当然,代价也有。
    二次编辑会让 canvas 逻辑复杂很多,尤其是不同元素的命中检测、移动、缩放、历史记录同步,都需要单独处理。
    但从长期维护角度看,这是值得的。
    写在最后
    至此,文章就分享完毕了。
    我是神奇的程序员,一位前端开发工程师。
    如果你对我感兴趣,请移步我的个人网站,进一步了解。
  • 文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注😊
  • 本文首发于神奇的程序员公众号,未经许可禁止转载💌
  • 您需要登录后才可以回帖 登录 | 立即注册

    返回顶部