Vue3+Typescript 开发一个图片拼接 PWA

一个简单的长图拼接工具,主要应用了 Vue3 技术栈和 HTML5 的 Canvas 技术。最后包装成 PWA,可以从手机桌面直接访问。本文做一个项目回顾和总结,源码和地址附下:

线上地址: picStitch
源码: hooozen/picStitch

技术总结

主要涉及的技术:

  • 面向对象技术
  • Vue3 + Typescript 组合式 API
  • Vue-router@4 和 pinia 状态管理
  • Vite 构建和 PWA 包装
  • HTML5 Canvas API 和 Drag API
  • 移动端触摸事件适配
  • Vue3 深层组件封装

面向对象开发

无论是主动还是被迫,随着业务逻辑变得复杂,开发者必然要引入面向对象技术。对于这个项目,采用面向对象技术主要有两个原因:

从主观方面来讲,我想把这个项目做成 Web 和小程序多端应用,于是把一些图片裁剪、绘制的核心代码封装成公共基础类。这样可以方便地实现复用和维护,为后面多端的开发和维护节省大量精力。

客观方面,这个项目涉及了图片预览和图片编辑两块画布。两块画布在图片绘制方面有共同的逻辑,同时又分别承担保存和编辑等不同的职责。最重要的是,在两块画布之间要做状态的复制和同步:当从预览画布到编辑画布时,需要把预览画布的图片状态复制到编辑画布。当编辑画布编辑成功时,要把编辑画布的状态同步回预览画布。为了避免性能问题,两块画布要共用图片对象,也就是两块画布仅保存图片的裁剪信息,这样可以极大地节约内存。示意图如下:

两块画布与图片之间的关联

在对业务逻辑反复梳理后,以及开发过程中的反复实验,最终封装了以下几个基础的类(源码):

  • ImageClipView: 图像的裁剪视图,用来保存图片的裁剪状态,并对外提供裁剪方法
  • ImageCavnasView: 图像的绘制视图,用来保存图片在画布上的绘制状态
  • PicStitchCavnas: 项目的 Canvas 基类,封装了 HTML5 Canvas 的绘制、刷新和保存等方法,以及一些抽像接口.
  • FlexCanvas: 弹性画布类. 因为预览图片时画布的高度以及图片的缩放比例要根据所有图片动态计算,因此封装成类.
  • JointCanva: “连接”画布类. 用于编辑图片的连接处,主要封装了对画布上图片进行裁剪的方法.
  • ImageClipViewManager: 图像管理类. 单例模式,通过管理图片对象来生成视图,以及实现视图的复制和状态同步.

经过以上封装后,对于同一个 Image 对象,可以通过生成多个 ImageClipView 来保存对图片的不同裁剪状态。然后在画布和 ImageClipView 之间封装一层 ImageCanvasView 来提供对裁剪后图片的绘制缩放以及绘制坐标等方法和属性,便于图片的绘制。而 ImageClipViewManager 类使用单例模式,对所有的 ImageClipView 进行管理,可以复制指定的视图使其能将一个画布的图片转移到另一个画布上。这样 FlexCanvasJointCanvas 只需要管绘制和编辑的责任,而不需要关系画布间的通信。

以上类的封装主要遵循了单一职责原则,在我看来这是面向对象最重要的原则。单一原则使得类的扩展和使用变得十分简单清晰.

最后,写 JS 的面向对象一定要用 Typescript,这将节约大量宝贵时间。

Vue3 技术栈

我个人对各种技术栈的态度是很冷静的,我认为“Vue 和 React 以及其他个各种技术栈的区别不会大于警犬和猎狗之间的区别”。所以对于这些前端库我只是看作一种时兴的工具,用到的时候去翻阅文档或者源码即可。我觉得对于前端开发者来说原生 JavaScript 以及编程思想和经典模式才是最重要的。因此对此就不过多介绍了。

尝试下来,Vite + Vue3 + pinia 确实是一套特别省力和轻量的生产力工具,尤其是引入 TypeScript 和组合式 API 后,可以抛弃之前 Vue2 时代很多复杂的概念。例如 pinia 实现的状态管理几乎可以不使用 actiongetter 就可以实现绝大部分业务逻辑,对于小型项目来说要比 Vuex “人性化”得多。Vite 的构建速度和配置速度也要甩 webpack 好几道街。

总的来说,Vite + Vue3 是一套离“开箱即用”又近了一大步的前端技术栈,非常推荐。

利用 Vue 底层 API 封装组件

大家一定用过 ElementUI 的 Message 组件和 Notify 等组件。这些组件和常见的 Vue 组件有个不同点是它们可以通过方法调用,而不用将组件写在模板了。并且像 Message 组件是直接插入到 body 上关闭后又会从 body 终移除的。同时可以多次调用一次产生多个 Message 弹出。如下图所示:

element 的 Message 组件可以通过方法调用产生多个实例
Message 用后即毁,不会造成性能问题

由于项目中我也要用到类似的组件,于是我参照 Element-plush Message 的源码自己封装了两个类似的组件 (源码)。主要的有以下细节和技巧需要注意:

  • 通过 index.ts 导出的方法调用后生成一个 Tip 实例,在生产实例时创建 VNode 并渲染。同时返回实例的关闭方法。

  • 创建实例利用 Vue 的 createVNoderender 方法来生成和渲染

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    import { createVNode, render } from 'vue'
    import TipConstructor from 'Tip.vue'

    const container = document.createElement("div");
    ...
    const createTip = ({ ...options }, context?: AppContext | null) => {
    ...
    const vnode = createVNode(TipConstructor, props);
    vnode.appContext = context || tip._context;
    render(vnode, container);
    document.body.appendChild(container.firstElementChild!);
    ...
    }
  • createVNode(TipConstructor, props) 方法中的 props 参数包含了属性和事件,其中方法是以 onMethodName 方式命名的。例如,如果 props 中包含了 onClose(){} 方法,意味着在 Tip 组件中可以触发 emit('close') 事件。这一点官方文档中没有介绍,但对组件的销毁非常 重要。

  • 组件的销毁通过 render(null, ...) 来实现:

    1
    render(null, container); // container 与渲染 vnode 时传入的相同
  • 对 Message 实例的管理,来实现弹出的位置有正确的偏移,以及限制 Message 实例的最大数量

以上仅作为一个简单笔记和提示,想要完整说明 Message 的实现思路恐怕需要一篇文章的篇幅或者阅读 element 的源码。

PWA

虽然经过这么多年的发展,PWA 仍旧不温不火,尤其在国内已经被各种“小程序”掩没,但不妨碍我仍觉得 PWA 是一个非常优秀的构想和伟大的尝试。在 Vite 下只需要一个插件,就可以将 Web 应用包装成 PWA:Vite PWA

图片拖动

在 Web 端实现图片的拖动以及自适应布局并不如想象中简单

图片的拖动排序看似是一个简单的功能,其实里面有很多细节和难点。尤其是 Web 端需要对不同窗口大小做自适应,就显得更加复杂了。简单介绍下我踩的坑以及最终的解决方案:

首先是 HTML5 的 Drag 事件❌。Drag 事件最大的问题就在于拖动时,元素本身是不移动的,而只是一个半透明的元素投影跟随鼠标移动。其实这有没有什么大问题,但是如果想实现流畅和优美的动画效果,就需要各种 track。因此反而不如使用鼠标事件更方便。(但本 App 还是利用了 Drag 事件实现了文件的拖拽上传)

其次是使用 Flex 定位来布局元素的初始定位,当元素排序变化时使用 Translate 来移动元素的位置❌。这样做的最大问题是,当移除某个元素时,元素在文档流中的初始位置会发生变化。这就意味着还要实时跟踪元素的在文档流中的位置变化,然后使用不同的参数去计算各个元素的 Translate,非常复杂。但这个方法也有好处,那就是窗口 resize 时元素会自动确定合适的位置,但前提是得解决好前面提到的问题。

直接改变 DOM 节点的真实位置❓。这个方案我根本没有尝试。因为在拖动过程中,划过元素时许多元素的位置都要更新,虽然有 Vue 的虚拟节点算法,但我仍然觉得这是对性能的浪费。更重要的是直接改变 DOM 节点的位置意味着将无法使用过渡动画!不能接受!

我最后采用了【绝对定位+鼠标事件+Translate】位移的方案,大体的思路如下:

首先通过外层元素的大小来划分格子。然后让所有的图片都 absolute 定位在外层元素的左上角,然后根据它们的顺序计算应该偏移的位置。这样做的好处是,所有的图片计算偏移量的算法都一样,也不会随着图片数量的变化而变化。同时能够兼顾使用各种过渡动画。

当然如何计算拖动过程中的元素位置,以及所处的格子和各个元素的顺序变化也需要仔细的考虑,但归根结底是一个数学问题,就不介绍了。

虽然最终比较好的解决了这个问题,但我觉得拖动元素作为一个很常用的需求,仍需要开发者花费这么大的精力和代码逻辑进行开发非常不合理,浏览器应该封装更加易用的 API。不知道大家在解决此类需求时采用了什么方案,我非常好奇。

移动端触摸事件

移动端的触摸事件并不兼容鼠标事件!并且有很多反常的行为,例如 touchmove 事件并不像 mousemove 事件可以获得所处的元素,因为其 target 永远指向初始点击的元素。并且像鼠标事件的 offsetX 等属性在触摸事件上也是没有的。另外移动端的触摸事件还容易触发浏览器的返回和刷新等手势,非常难受。

总之,如果你之前没有进行过移动端触摸事件的适配,那么一定为它预留好足够的时间来查阅文档和 StackOverflow。

Canvas 和 SVG

项目中用到的图片包括我自己设计的 LOGO 全都使用了 SVG 技术,这是一个很优雅的图片解决方案。并且 SVG 还可以通过 base64 编码嵌入到 CSS 作为 background-url

Canvas 用来处理图片非常方便,要注意保存图片的一些方法。例如 canvas.toBlobcanvas.toDataURL 方法的对比。

其他

一定要事先进行 UI 和交互设计,否则可能写着写着发现一些功能在当前的 UI 下很难实现,或者操作逻辑非常奇怪。而避免出现这种问题的最好的方法就是参考类似功能的 APP,很明显我这款 APP 的 UI 很大部分借鉴了微信,而交互逻辑借鉴了App Store 上的“PicTailor” (apple.com),如果这款软件有安卓版我也不会开发这个小工具了。