基於Canvas實現的簡歷編輯器
大概一個月前,我發現社區老是給我推薦Canvas
相關的內容,比如很多 小遊戲、流程圖編輯器、圖片編輯器 等等各種各樣的項目,不知道是不是因爲我某一天點擊了相關內容觸發了推薦機制,還是因爲現在Canvas
比較火大家都在卷,本着我可以用不上但是不能不會的原則,我也花了將近一個月的時間通過Canvas
實現了簡歷編輯器。
關於Canvas
簡歷編輯器項目的相關文章:
- 社區老給我推Canvas,我也學習Canvas做了個簡歷編輯器
- Canvas圖形編輯器-數據結構與History(undo/redo)
- Canvas圖形編輯器-我的剪貼板裏究竟有什麼數據
- Canvas簡歷編輯器-圖形繪製與狀態管理(輕量級DOM)
- Canvas簡歷編輯器-Monorepo+Rspack工程實踐
爲什麼要自行實現一個簡歷編輯器:
- 固定模版不好用,各種模版用起來細節上並不是很滿意,要麼是模塊的位置固定,要麼是頁面邊距不滿意,而通過
Canvas
實現的簡歷編輯器都是圖形,完全依靠畫布繪製圖形,在給定的基礎圖形上可以任意繪製,不會有排版問題。 - 數據安全不能保證,因爲簡歷上通常會存在很多個人信息,例如電話、郵箱等等,這些簡歷網站通常都需要登錄才能用,數據都存在服務端,雖然泄漏的可能性不大,但是保護隱私還是很重要的,此編輯器是純前端項目,數據全部存儲在本地,沒有任何服務器上傳行爲,可以完全保證數據安全。
- 維持一頁簡歷不易,之前使用某簡歷模版網站時,某一項寫的字較多時導出就會出現多頁的情況,而我們大家大概都聽說過簡歷最好是一頁,所以在實現此編輯器時是直接通過排版的方式生成
PDF
,所以在設置頁面大小後,導出的PDF
總會是保持一頁,看起來會更美觀。
背景
我是有個基於DOM
實現的簡歷編輯器項目的,因爲暫時找不到可以用Canvas
實現的比較有意思的場景,所以才選擇了繼續做簡歷編輯器,最開始做簡歷編輯器就是因爲很多簡歷網站都是要開會員的,要不就是簡歷的自定義程度比較差,達不到我想要的效果,在學校的某一個晚上突發奇想於是自己做了一個出來。
因爲是本着學習的態度以及對技術的好奇心來做的,所以除了一些工具類的包例如 ArcoDesign
、ResizeObserve
、Jest
等包之外,關於 數據結構packages/delta
、插件化packages/plugin
、核心模塊packages/core
等都是手動實現的。實際上這也是本着 自己學習的項目能自己寫就自己寫,公司/商業化項目能有已有包就用已有包 的原則來的,在這裏的目標是學習而不是做產品,自己學習肯定是希望能夠更多地接觸相對底層一些的能力,自己可以多踩一些坑會對相關能力有更深的理解,如果是公司的項目那肯定是成熟的產品優先,成熟的產品對於邊界case
的處理以及積攢的issue
也不是輕易能夠比擬的。
開源地址: https://github.com/WindrunnerMax/CanvasEditor 。
在線DEMO
: https://windrunnermax.github.io/CanvasEditor/ 。
筆記
因爲我的主要目標是學習基本的Canvas
知識和能力,所以很多功能模塊都是採用簡單的方式實現的,主打一個能用就行。而實際上做好圖形編程是一件非常困難的事,如果要做一些複雜的能力我會更傾向於用konva
等工具包來實現,而即使是簡單地實現功能,在寫代碼的時候我也遇到了很多問題,也記錄一些思考來解決問題。
數據結構
數據結構的設計,類似於DeltaSet
,最終呈現的數據結構形式是扁平化的,但是在Core
中需要設計State
來管理樹形結構,因爲要設計Undo/Redo
的功能,在不全量存儲快照的情況下就意味着必須設計原子化的Op
,因爲想實現的功能有組合這個能力,所以最終實現的形式實際上是樹形的結構,而我希望的結構是扁平化的,因爲樹形結構查找起來比較費勁,需要實現的Op
類型也會變多,我希望能儘量減少Op
的類型並且能夠做到History
,所以最終定下的數據結構是DeltaSet
作爲存儲,通過State
來管理整個編輯器狀態。
History
原子化的Op
已經設計好了,所以在設計History
模塊時就不需要全量保存快照了,但是如果每個操作都需要併入History Stack
的話可能並不是很好,通常都是有N
個Op
的一併Undo/Redo
,所以這個模塊應該有一個定時器,如果在N
毫秒秒內沒有新的Op
加入的話就將Op
併入History Stack
,但是當時我在思考一個問題,如果這N
毫秒內用戶進行了Undo
操作應該怎麼辦,後來想想實際上很簡單,此時只需要清除定時器,將暫存的Op[]
立即放置於Redo Stack
即可。
繪製
任何元素都是矩形,數據結構也是據此設計抽象出來的,在繪製的時候分爲兩層Canvas
重疊的方式,內層的Canvas
是用來繪製具體圖形的,這裏預計需要實現增量更新,而外層的Canvas
是用來繪製中間狀態的,例如選中圖形、多選、調整圖形位置/大小等,在這裏是會全量刷新的,並且後邊可能會在這裏繪製標尺。在實現交互的過程中我遇到了一個比較棘手的問題,因爲不存在DOM
,所有的操作都是需要根據位置信息來計算的,比如選中圖形後調整大小的點就需要在選中狀態下並且點擊的位置恰好是那幾個點外加一定的偏移量,然後再根據MouseMove
事件來調整圖形大小,而實際上在這裏的交互會非常多,包括多選、拖拽框選、Hover
效果,都是根據MouseDown
、MouseMove
、MouseUp
三個事件完成的,所以如何管理狀態以及繪製UI
交互就是個比較麻煩的問題,在這裏我只能想到根據不同的狀態來攜帶不同的Payload
,進而繪製交互。
繪製狀態
在實現繪製的時候,我一直在考慮應該如何實現這個能力,因爲上邊也說了這裏是沒有DOM
的,所以最開始的時候我通過MouseDown
、MouseMove
、MouseUp
實現了一個非常混亂的狀態管理,完全是基於事件的觸發然後執行相關副作用從而調用Mask
的方法進行重新繪製。再後來我覺得這樣的代碼根本沒有辦法維護,所以改動了一下,將我所需要的狀態全部都存儲到一個Store
中,通過我自定義的事件管理來通知狀態的改變,最終通過狀態改變的類型來嚴格控制將要繪製的內容,也算是將相關的邏輯抽象了一層,只不過在這裏相當於是我維護了大量的狀態,而且這些狀態是相互關聯的,所以會有很多的if/else
去處理不同類型的狀態改變,而且因爲很多方法會比較複雜,傳遞了多層,導致狀態管理雖然比之前好了一些可以明確知道狀態是因爲哪裏導致變化的,但是實際上依舊不容易維護。最終我又思考了一下,決定在繪圖這裏實現類似於DOM
的能力,因爲我想實現的能力似乎本質上就是DOM
與事件的關聯,而DOM
結構是一種非常成熟的設計了,這其中有一些很棒的點子,例如DOM
的事件流,我不需要扁平化地調整每個Node
的事件,而是隻需要保證事件是從ROOT
節點起始,最終又在ROOT
上結束即可,並且整個樹形結構以及狀態是靠用戶利用DOM
的API
來實現的,我們管理之需要處理ROOT
就好了,這樣就會很方便,下個階段的狀態管理是準備用這種方式來實現的。
渲染與事件
在前邊我們提到了我們想通過模擬DOM
來完成Canvas
的繪製與交互,那麼在這裏就很明顯涉及到DOM
的兩個重要內容,即DOM
渲染與事件處理。那麼就先聊下渲染方面的內容,使用Canvas
實際上就很像將所有DOM
的position
設置爲absolute
,所有的渲染都是相對於Canvas
這個DOM
元素的位置繪製,那麼我們就需要考慮重疊的情況,那麼想一個例子,A
的zIndex
是10
,A
的子元素B
的zIndex
是100
,C
與A
是平級的且zIndex
爲20
,那麼當這三個元素重疊的時候,在最頂部的元素是C
,也就是說zIndex
實際上只看平級元素,再假如A
的zIndex
是10
,A
的子元素B
的zIndex
是1
,那麼在這兩個元素重疊的時候,在最頂部的元素是B
,也就是說子元素通常都是渲染在父元素之上的。那麼我們在這裏也需要模擬這個行爲,但是因爲我們沒有瀏覽器的渲染合成層,我們能夠操作的只有一層,所以在這裏我們需要根據一定的策略進行渲染,在渲染時我們與DOM
的渲染策略相同,即先渲染父元素再渲染子元素,類似於深度優先遞歸遍歷的渲染順序,不同的是我們需要在每個節點遍歷之前,將子節點根據zIndex
排序來保證同層級的節點渲染重疊關係。
在渲染的基礎上,我們還需要考慮事件的實現,例如我們的選中狀態,八向調整元素大小的點一定是在選區節點的上層的,那麼假如現在我們需要實現onMouseEnter
事件的模擬,那麼因爲Resize
這八個點位與選區節點是有一定重疊的,所以如果此時鼠標移動到重疊的點因爲Resize
的實際渲染位置更高,所以只應該觸發這個點的事件而不應該觸發後邊的選區節點事件,而實際上由於沒有DOM
結構的存在我們就只能使用座標計算,那麼在這裏我們最簡單的方法就是保證整個遍歷的順序,也就是說高節點的遍歷一定是要先於低節點的,當我們找到這個節點就結束遍歷然後觸發事件,事件的捕獲與冒泡機制我們也需要模擬,實際上這個順序跟渲染是反過來的,我們想要的是優點頂部的元素,優先更像樹的右子樹優先後序遍歷,也就是把前序遍歷的輸出、左子樹、右子樹三個位置調換一下即可,但是問題來了,在onMouseMove
這種高頻事件觸發的時候,我們每次都去計算節點的位置並且採用深度優先遍歷,是非常耗費性能的,所以在這裏實現一個典型的空間換時間,將當前節點的子節點按順序全部存儲起來,如果有節點的變動,就直接通知該節點的所有每一層父節點重新計算,這裏做成按需計算即可,這樣當另一顆子樹不變的時候還可以節省下次計算的時間,並且存儲的是節點的引用,不會有太大的消耗,這樣就變遞歸爲迭代了,另外因爲找到了當前的節點,在模擬捕獲與冒泡的時候就不需要再遞歸觸發了,通過兩個棧即可模擬。
焦點
平時我做富文本相關的功能比較多,所以在實現畫板的時候總想按照富文本的設計思路來實現,因爲之前也說過要實現History
以及在編輯面板富文本的能力,所以焦點就很重要,如果焦點不在畫板上的時候如果按下Undo/Redo
鍵畫板是不應該響應的,所以現在就需要有一個狀態來控制當前焦點是否在Canvas
上,經過調研發現了兩個方案,方案一是使用document.activeElement
,但是Canvas
是不會有焦點的,所以需要將tabIndex="-1"
屬性賦予Canvas
元素,這樣就可以通過activeElement
拿到焦點狀態了,方案二是在Canvas
上方再覆蓋一層div
,通過pointerEvents: none
來防止事件的鼠標指針事件,但是此時通過window.getSelection
是可以拿到焦點元素的,此時只需要再判斷焦點元素是不是設置的這個元素就可以了。
無限畫布
之前因爲沒有打算實現平移拖拽也就是無限畫布的能力,但是後來真的開始通過這個主框架來實現想做的業務功能的時候發現這樣是不行的,所以在後期想把這個能力加上,雖然本身這個能力並不複雜,但是因爲最開始沒有設計這個能力,導致後邊做的時候有點難受,比如Mask
批量刷新頻率不對齊、ctx
的translate
應該是偏移值取反、之前多處超出畫布不繪製的計算有誤等等,就感覺在沒有設計的情況下突然增加功能確實是有點難受的,不過好處是不需要大規模重構,只是個別點位的修正。
此外多扯點別的,這個項目除了一些輔助性的工具例如resize-observer
以及組件庫例如arco-design
都是自己寫的,相當於實現了Canvas
的引擎,特別是在現在的core-delta-plugin-utils
結構設計下,是完全可以抽離處理作爲工具包使用的,當然易用性與性能方面肯定比不上那些有名的開源框架。只不過今天我恰好看到了一個評論說的挺好的:如果是個人能力提升,那麼最好是首先理解開源庫,然後仿照實現開源庫的功能,主要的目標是學習;而如果是商業化的使用,那就變成了知名的開源庫優先,這樣可以很大程度上降低成本。
性能優化
在實現的過程中,繪製的性能優化主要有:
- 可視區域繪製,完全超出畫布的元素不繪製。
- 按需繪製,只繪製當前操作影響範圍內的元素。
- 分層繪製,高頻操作繪製在上層畫布,基礎元素繪製在下層畫布。
- 節流批量繪製,高頻操作節流繪製,上層畫布收集依賴批量繪製。
超鏈接
衆所周知Canvas
繪製出來就是純粹的圖片,而實際使用導出PDF
的超鏈接是可以點擊的,而我們當前就單純只是圖片無法做到這一點,所以需要解決這個問題,我想到的一個解決方案是在導出的時候,通過DOM
生成透明的a
標籤,覆蓋在原本的超鏈接位置,這樣就可以實現點擊跳轉效果了。PDF
本身也是文件格式,所以是可以藉助PDFKit/PDFjs
等PDF
排版生成工具來導出的,通過這種方式也可以直接在導出的時候直接將其寫入固定位置,並且可以不受瀏覽器打印的分頁限制。
TODO
因爲前邊提到了我現在還是比較簡單的實現方式,所以很多功能都不完善,還有很多想做的能力:
- 層級調整,這個之前我想到了並且在
core
中設計了這個能力,現在只是缺乏調整的按鈕用來調用,這個UI
我還沒考慮好應該怎麼做。 - 頁面配置,我發現很多同學的簡歷都是不是標準的
A4
紙大小,所以這裏還需要一個調整頁面畫布大小的問題。 - 導入導出
JSON
,這個就不用多說了,就是把底層數據結構導入導出的能力。 - 排版
PDF
導出,這個應該需要跟頁面配置一起做,現在的PDF
導出是依賴瀏覽器的打印,會有一些分頁的限制,如果自己排版的話就可以突破這個問題,多長的畫布都是一頁的簡歷大小。 - 複製粘貼模塊,在編輯的時候這個操作是很有用的,需要增加這個模塊。
最後
這次對於Canvas
的體驗讓我感覺還是不錯的,後邊我也會寫一些在實現的時候碰到的問題以及如何解決問題的文章,不過我目前的主業還是還是寫富文本編輯器,富文本編輯器也是天坑中的一員,後邊也可能會先寫編輯器相關的文章。