從零開發一款圖片編輯器Mitu-Dooring
背景介紹
我們知道,為了提高企業研發效能和對客戶需求的快速響應,現在很多企業都在著手數字化轉型,不僅僅是大廠(阿里,字節,騰訊,百度)在做低代碼可視化這一塊,很多中小企業也在做,擁有可視化低代碼相關技術背景的程序員也越來受重視。
我最近一直在做數據可視化和lowcode/nocode相關的項目,針對我自己的工作經驗和對lowcode/nocode的探索,也寫了一系列低代碼可視化搭建系列文章,今天我們繼續來分享可視化相關的內容——可視化圖片編輯器。
在分享過程中,我會以最近我寫開源的一個項目Mitu為案例,仔細拆解它的實現過程。Mitu主要是輔助H5編輯器 H5-Dooring 做圖像處理用的,大家也可以輕松基于它進行二次開發和擴展,變成更強大的圖片編輯器。
在文章末尾我會附上 github 地址 和 demo 地址,方便大家學習和體驗。接下來我就來帶大家介紹和剖析一下這款開源圖片編輯器 Mitu。
項目介紹

以上是圖片編輯器的部分演示效果,我們可以通過拖拽重組的方式快速生成我們想要的圖片,也能將圖片保存為模版,以便后期復用。在項目開發之前我也設計了一個簡單的原型,保證自己的開發方向不會跑偏,大家可以參考一下:
按照我一向的寫作風格,我先列一下技術實現的大綱,以便大家有選擇且高效率的閱讀和學習:
- 可視化編輯器項目搭建和技術選型
- 圖形庫設計
- 屬性編輯器設計
- 自定義圖元控制器實現
- 預覽功能實現
- 保存圖片功能實現
- 模版保存實現
- 導入模版功能實現
- 可視化圖片編輯器后期規劃
好了,話不多說,接下來開始我們的技術實現。
技術實現
項目搭建和技術選型
編輯器的實現思路和技術棧無關,這里我采用了 React 來實現,當然大家如果更喜歡 Vue 或者 sveltejs,也是沒問題的,項目整體技術選型如下:
- umi 可擴展的企業級前端應用框架
- React + Typescript
- Antd 前端組件庫
- fabric 一個可以簡化 Canvas 程序編寫的庫
- localStorage 本地數據存儲
當然在項目的實現過程中還有很多細節和思想,接下來我會一一和大家介紹。如果大家對 fabric 這個庫不太熟悉也不用擔心,我會通過具體功能的實現來帶大家熟悉這個庫。
在介紹下面的內容之前我們先安裝一下 fabric ,然后初始化一個畫布。
- yarn add fabric
初始化一個畫布:
- import { fabric } from "fabric";
- import { nanoid } from 'nanoid';
- import { useEffect, useState, useRef } from 'react';
- export default function IndexPage() {
- const canvasRef = useRef<any>(null);
- useEffect(() => {
- canvasRef.current = new fabric.Canvas('canvas');
- // 創建一個文本元素
- const shape = new fabric.IText(nanoid(8), {
- text: 'H5-Dooring',
- width : 60,
- height : 60,
- fill : '#06c',
- left: 30,
- top: 30
- })
- // 將文本元素插入畫布
- canvasRef.current.add(shape);
- // 設置畫布的背景色
- canvasRef.current.backgroundColor = 'rgba(255,255,255,1)';
- })
- return <canvas id="canvas" width={600} height={400}></canvas>
- }
這樣我們就創建好了一個畫布,并在畫布中插入了一段可編輯可拖拽的文本,如下:
圖形庫設計
作為一款圖片編輯器,為了提高使用的靈活性我們還需要提供一些基礎圖形方便我們設計圖片,所以我在編輯器里添加了圖形庫:
主要有如文本,圖片,直線,矩形,圓形,三角形,箭頭,馬賽克,當然大家可以根據自己的需求添加更多的基本圖元。我們在圖片庫中點擊任意一個元素即可將其插入畫布,這塊是利用 fabric 的 add 方法,當然 fabric 也內制了很多基本圖形,我們可以在文檔中參考一下。為了讓圖形插入更有封裝性,我定義了圖形的基本 schema 結構:
- const baseShapeConfig = {
- IText: {
- text: 'H5-Dooring',
- width : 60,
- height : 60,
- fill : '#06c'
- },
- Triangle: {
- width: 100,
- height: 100,
- fill: '#06c'
- },
- Circle: {
- radius: 50,
- fill: '#06c'
- },
- Rect: {
- width : 60,
- height : 60,
- fill : '#06c'
- },
- Line: {
- width: 100,
- height: 1,
- fill: '#06c'
- },
- Arrow: {},
- Image: {},
- Mask: {}
- }
這樣我們插入圖形的方法就可以這樣寫:
- type ElementType = 'IText' | 'Triangle' | 'Circle' | 'Rect' | 'Line' | 'Image' | 'Arrow' | 'Mask'
- const insertShape = (type:ElementType) => {
- shape = new fabric[type]({
- ...baseShapeConfig[type],
- left: size[0] / 3,
- top: size[1] / 3
- })
- canvasRef.current.add(shape);
- }
后續我們添加圖形時只需要定義 schema 即可,但是需要注意的是 fabric 創建圖形的方式并不都都是統一的,我們需要對特定圖片的創建進行特殊判斷,比如直線路徑:
- if(type === 'Line') {
- shape = new fabric.Path('M 0 0 L 100 0', {
- stroke: '#ccc',
- strokeWidth: 2,
- objectCaching: false,
- left: size[0] / 3,
- top: size[1] / 3
- })
- }
當然我們也可以用 switch 來對不同情況進行不同處理,這樣我們就實現了一個基本圖片庫。
屬性編輯器設計
屬性編輯器主要是用來對圖形屬性進行配置的,比如填充顏色,描邊顏色,描邊寬度,目前我主要定義了這3個維度,大家也可以基于此繼續擴展更多的可編輯屬性,類似于 H5-Dooring 的組件屬性配置面板。
我們可以在編輯器右側的屬性編輯區控制圖形的屬性,因為屬性目前只有3個,我就直接硬編碼寫上去了,大家也可以用動態渲染的方式來實現。需要注意的是我們怎么知道我們選中的是那個組件呢? 好在 fabric 提供了一系列 api 幫助我們更好的控制元素對象,這里我們用 getActiveObject 方法拿到當前選中的元素,具體實現代碼如下:
- // ...
- // 定義基礎屬性
- const [attrs, setAttrs] = useState({
- fill: '#0066cc',
- stroke: '',
- strokeWidth: 0,
- })
- // 更新選中的元素
- const updateAttr = (type: 'fill' | 'stroke' | 'strokeWidth' | 'imgUrl', val:string | number) => {
- setAttrs({...attrs, [type]: val})
- // 獲取當前選中元素對象
- const obj = canvasRef.current.getActiveObject()
- // 設置元素屬性
- obj.set({...attrs})
- // 重新渲染
- canvasRef.current.renderAll();
- }
屬性編輯器的樣式實現這里我就不一一介紹了,都比較基礎,我們來看一下編輯項的基本結構:
- <span className={styles.label}>描邊寬度: </span>
- <InputNumber size="small" min={0} value={attrs.strokeWidth} onChange={(v) => updateAttr('strokeWidth', v)} />
自定義圖元控制器實現因為默認情況下 fabric 沒有提供刪除按鈕和邏輯,所以我們需要自己二次擴展,恰好 fabric 提供了自定義擴展的方法,接下來我們就一起自定義一個刪除按鈕并實現刪除邏輯。
具體實現代碼如下:
- // 刪除按鈕
- const deleteIcon = "data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3E%3Csvg version='1.1' id='Ebene_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' width='595.275px' height='595.275px' viewBox='200 215 230 470' xml:space='preserve'%3E%3Ccircle style='fill:%23F44336;' cx='299.76' cy='439.067' r='218.516'/%3E%3Cg%3E%3Crect x='267.162' y='307.978' transform='matrix(0.7071 -0.7071 0.7071 0.7071 -222.6202 340.6915)' style='fill:white;' width='65.545' height='262.18'/%3E%3Crect x='266.988' y='308.153' transform='matrix(0.7071 0.7071 -0.7071 0.7071 398.3889 -83.3116)' style='fill:white;' width='65.544' height='262.179'/%3E%3C/g%3E%3C/svg%3E";
- // 刪除方法
- function deleteObject(eventData, transform) {
- const target = transform.target;
- const canvas = target.canvas;
- canvas.remove(target);
- canvas.requestRenderAll();
- }
- // 渲染icon
- function renderIcon(ctx, left, top, styleOverride, fabricObject) {
- const size = this.cornerSize;
- ctx.save();
- ctx.translate(left, top);
- ctx.rotate(fabric.util.degreesToRadians(fabricObject.angle));
- ctx.drawImage(img, -size/2, -size/2, size, size);
- ctx.restore();
- }
- // 全局添加刪除按鈕
- fabric.Object.prototype.controls.deleteControl = new fabric.Control({
- x: 0.5,
- y: -0.5,
- offsetY: -32, // 自定義距元素的偏移距離, 也可以定義offsetX
- cursorStyle: 'pointer',
- mouseUpHandler: deleteObject,
- render: renderIcon,
- cornerSize: 24
- });
這樣我們就實現了自定義元素控制,我們也可以按照類似的方法實現自定義的控件。效果如下:
預覽功能實現
預覽功能我主要是利用原生 canvas 的 toDataURL 方法來生成base64的數據,然后賦值給 img 標簽。還有一個細節需要注意的是如果我們在預覽之前畫布仍然有選中狀態的元素,那么控制點也會被截取出來,如下:
這樣對用戶體驗非常不好,我們需要在預覽時看到一張純粹的圖片,我的方案是在預覽前取消畫布所有元素的選中狀態,可以用 fabric 實例的 discardActiveObject() 方法取消激活狀態,然后更新畫布即可,具體實現邏輯如下:
- // 1. 取消畫布所有元素的選中狀態
- canvasRef.current.discardActiveObject()
- canvasRef.current.renderAll();
- // 2. 將當前畫布轉化為圖片的base64地址
- const img = document.getElementById("canvas");
- const src = (img as HTMLCanvasElement).toDataURL("image/png");
- // 3. 設置元素url,顯示預覽彈窗
- setImgUrl(src)
- setIsShow(true)
預覽效果展示:
保存圖片功能實現
保存圖片其實和預覽功能很像,唯一不同的是我們需要把圖片下載到本地,那么我主要是用純前端的方式實現圖片下載,大家也可以用自己熟悉的前端下載方案,接下來貼一下我的方案實現:
- function download(url:string, filename:string, cb?:Function) {
- return fetch(url).then(res => res.blob().then(blob => {
- let a = document.createElement('a');
- let url = window.URL.createObjectURL(blob);
- a.href = url;
- a.download = filename;
- a.click();
- window.URL.revokeObjectURL(url);
- cb && cb()
- }))
- }
主要是用的window 的 URL 對象的 createObjectURL 和 revokeObjectURL 方法,兩年前我也在我的文章中分享過對應的實現,感興趣的可以參考一下。下載的效果如下:
模版保存實現
在設計圖片編輯器的過程中我們也要考慮保存用戶的資產,比如做的比較好的圖片可以保存為模版,以便下次復用,所以我在編輯器里還實現的簡單的模版保存和使用的功能。我們先看一下效果:
我們在演示中可以看到保存為模版之后會自動同步到左側的模版列表中,我們下次創作時可以直接導入模版進行二次創作。以下是實現的邏輯圖:
由上圖可以發現我們保存模版不僅僅是保存圖片,還需要保存圖片對應的 json schema 數據,之所以要保存 json schema 是為了當用戶切換到對應的模版之后可以保證模版的每個元素都可以還原,類似于我們最熟悉的 PSD 源文件。fabric 提供了序列化畫布的方法 toDatalessJSON(),我們在保存模版的時候只要把序列化后的 json 和圖片一起保存即可,這里方便處理我暫時存在 localStorage 中,大家也可以使用大容量本地化存儲方案 indexedDB,我之前也基于 indexedDB 封裝了開箱即用的緩存庫 xdb,大家可以直接拿來使用。
- xdb | 基于promise封裝且支持過期時間的開箱即用的indexedDB緩存庫
保存模版的具體實現如下:
- const handleSaveTpl = () => {
- const val = tplNameRef.current.state.value
- const json = canvasRef.current.toDatalessJSON()
- const id = nanoid(8)
- // 存json
- const tpls = JSON.parse(localStorage.getItem('tpls') || "{}")
- tpls[id] = {json, t: val};
- localStorage.setItem('tpls', JSON.stringify(tpls))
- // 存圖片
- canvasRef.current.discardActiveObject()
- canvasRef.current.renderAll()
- const imgUrl = getImgUrl()
- const tplImgs = JSON.parse(localStorage.getItem('tplImgs') || "{}")
- tplImgs[id] = imgUrl
- localStorage.setItem('tplImgs', JSON.stringify(tplImgs))
- // 更新模版列表
- setTpls((prev:any) => [...prev, {id, t: val}])
- setIsTplShow(false)
- }
導入模版功能實現
導入模版的本質是反序列化 Json Schema,在研究 fabric 的過程中發現了其可以直接加載 json 渲染圖形序列,所以我們可以直接將上文保存的 json 直接加載到畫布:
- // 1.加載前清空畫布
- canvasRef.current.clear();
- // 2.重置畫布背景色
- canvasRef.current.backgroundColor = 'rgba(255,255,255,1)';
- // 3. 渲染json
- canvasRef.current.loadFromJSON(tpls[id].json, canvasRef.current.renderAll.bind(canvasRef.current))
然后我們就可以根據保存的模版列表,動態切換模版了:
后期規劃這款圖片編輯器我已經在 github 開源了,大家可以基于次開發更強大的圖片編輯器。
本文轉載自微信公眾號「 趣談前端」