前言
大家好,我是林三心,用最通俗易懂的話講最難的知識點是我的座右銘,基礎是進階的前提是我的初心~
大概一個月前,我發現金塊老是給我推薦
Canvas
相關的內容,比如很多 小遊戲、流程圖編輯器、圖片編輯器 等等各種各樣的計畫,不知道是不是因為我某一天點選了相關內容觸發了推薦機制,還是因為現在
Canvas
比較火大家都在卷,本著我可以用不上但是不能不會的原則,我也花了將近一個月的時間透過
Canvas
實作了簡歷編輯器。
關於
Canvas
編輯器的歷史文章:
金塊老給我推Canvas,於是我也學習Canvas做了個簡歷編輯器
Canvas圖形編輯器-數據結構與History(undo/redo)
Canvas圖形編輯器-我的剪貼簿裏究竟有什麽數據
背景
我是有個基於
DOM
實作的簡歷編輯器計畫的,因為暫時找不到可以用
Canvas
實作的比較有意思的場景,所以才選擇了繼續做簡歷編輯器,最開始做簡歷編輯器就是因為很多簡歷網站都是要開會員的,要不就是簡歷的自訂程度比較差,達不到我想要的效果,在學校的某一個晚上突發奇想於是自己做了一個出來。
因為是本著學習的態度以及對技術的好奇心來做的,所以除了一些工具類的包例如
ArcoDesign
、
ResizeObserve
、
Jest
等包之外,關於 數據結構
packages/delta
、外掛程式化
packages/plugin
、核心模組
packages/core
等都是手動實作的。實際上這也是本著 自己學習的計畫能自己寫就自己寫,公司/商業化計畫能有已有包就用已有包 的原則來的,在這裏的目標是學習而不是做產品,自己學習肯定是希望能夠更多地接觸相對底層一些的能力,自己可以多踩一些坑會對相關能力有更深的理解,如果是公司的計畫那肯定是成熟的產品優先,成熟的產品對於邊界
case
的處理以及積攢的
issue
也不是輕易能夠比擬的。
開源地址: https://github.com/WindrunnerMax/ResumeEditor
線上
DEMO
: https://windrunnermax.github.io/ResumeEditor/
筆記
因為我的主要目標是學習基本的
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
的體驗讓我感覺還是不錯的,後邊我也會寫一些在實作的時候碰到的問題以及如何解決問題的文章,不過我目前的主業還是還是寫富文字編輯器,富文字編輯器也是天坑中的一員,後邊也可能會先寫編輯器相關的文章。
開源地 址: https://github.com/WindrunnerMax/ResumeEditor
作者: WindrunnerMax.
結語
我是林三心
一個待過 小型toG型外包公司、大型外包公司、小公司、潛力型創業公司、大公司 的作死型前端選手;
一個偏前端的全幹工程師;
一個不正經的金塊作者;
逗比的B站up主;
不帥的小紅書博主;
喜歡打鐵的籃球菜鳥;
喜歡歷史的乏味少年;
喜歡rap的五音不全弱雞如果你想一起學習前端,一起摸魚,一起研究簡歷最佳化,一起研究面試進步,一起交流歷史音樂籃球rap,可以來俺的摸魚學習群哈哈,點這個,有7000多名前端小夥伴在等著一起學習哦 -->
廣州的兄弟可以約飯哦,或者約球~我負責打鐵,你負責進球,謝謝~