作者:週週(滬江資深Web前端開發工程師) 本文為原創文章,轉載請註明作者及出處
前言
近期在小D十週年活動之際,又看到了一個自家H5專題夢工廠生成的頁面。
回想起了一段往事,現在來看還蠻有趣的。主要是一個將業務逐步抽象成資料的過程,對於當時對資料設計等還不太敏感的自己有不小的促進作用。於是想通過本文分享下當初如何搭建視覺化編輯頁面系統中的一些開發設計思路,也希望對前端夥伴們在構建類似中大型應用時有一定幫助,可以更好地設計一些較複雜的資料結構。本文不會把具體的實現程式碼貼出來,更多的是背後為何要這樣設計的一些思考過程,如何把一些業務對映到資料。有不足之處,還請輕拍。
讓我們一起先來預覽下編輯器的後臺介面,編輯夥伴可以像在桌面應用中一樣進行操作,最後直接生成一個h5頁面。
背景
當時14年正值H5比較火熱的時期,業務中很多時候會碰到一些重複類似的h5活動頁面,開發幾乎要變成 ctrl+c 和 ctrl+v 了,略顯枯燥。後來湧現出了很多h5頁面編輯應用,某秀、某KA等。某一天可愛的老大又在背後看著我們,突然來了一句:
我們要不也搞一個?
當時反應是萬匹草泥馬在頭上奔過: 這個有點太複雜了,成本太高,還是不做了吧。不過冷靜下來後,心想做完還可以解放一批開發同學,挑戰也不小,所以心想
為什麼不呢?
於是就開始了一個視覺化編輯頁面系統 Web IDE 的打造之旅。從計劃開始做的那一刻起,其實是腦中一片空白,後來做了一些小DEMO後,才開始有了點思緒,也不是一蹴而就的。
先舉個小例子**
場景: 前後端協作,需求是在頁面上加一個非同步請求的列表模組。
1.傳送請求,處理資料。介面響應回來的資料可能是:
{
...,
data: {
list: [
{id: 1, content: '這是第一條資料'},
{id: 2, content: '這是第二條資料'},
{id: 3, content: '這是第三條資料'}
],
total: 4
}
}
複製程式碼
2.根據 list 中的資料,迴圈拼接出需要渲染的DOM結構
<li>1.這是第一條資料</li>
<li>2.這是第二條資料</li>
<li>3.這是第三條資料</li>
複製程式碼
3.找到需要追加或者替換的節點,然後加到頁面上。
在上面這個場景中,這類資料的結構可能是最常碰到的。整個過程可以理解為,先獲取資料,再把資料轉換到渲染檢視裡。看目前流行的框架,react,vue 等都幫我們省略了關注 DOM 的步驟,在這裡我們先拋開一些框架的便利,迴歸到原始的步驟,由一個 renderer 方法充當。
+--------+ +----------+ +-------+
| data | => | renderer | => | DOM |
+--------+ +----------+ +-------+
複製程式碼
夥伴要問了,這個流程比較熟悉,但如果做一個 Web IDE 不是就這麼幾步吧?
如何下筆
需求分析要詳細
要下筆時,發現課題變得複雜了。很多時候就會像下面這張圖中。
那先緩一緩,先嚐試著拆解成一些小部件來分析。多觀察,多聯想,多分析。
制定目標
如何把一個H5頁面轉換成一個可編輯的狀態。
現狀分析
觀察一個普通的H5活動頁面,試著先抽取幾個關鍵元素。試著滑動頁面,大致可以猜測這是一個滑動元件,佔滿全屏的,互動可以劃分為上下滑動,可能還有回撥等等,這時可以發現一個H5上可能有一個或多個子頁面(分頁),這裡面可能將會涉及到分頁的操作。再看每個分頁上,有圖片,連結,文字等等一些元素,展現形式差異很大,樣式都不一樣,看起來很難統一,不像上面的小例子中可以比較容易地拼湊出,先放一放。但是有兩個關鍵點浮現出來了,分頁+元素,一個h5頁面基本都是由很多頁面和元素組成的,目標將轉換成如何編輯分頁,如何編輯元素。
詳細分析
再單獨看一個頁面詳細地分析下:
可以發現頁面中有很多元素,大致有:
- 圖片
- 音訊
- 視訊
- 文字內容
- 連結
- 動畫
- 其他元素們
試著抽象一層,一個頁面可能會變成下面的結構:
頁面: [
元素1,
元素2,
元素3
]
複製程式碼
發現和“小例子”中的結構有點類似,一個塊包含多個子塊,照著類似的渲染方式,估計也能將元素們渲染到頁面上,但樣式卻各不一樣,元素之間的差異比較大,型別又不同,怎麼想拼接 list 列表一樣拼呢?試著比較下,上面“小例子”中的列表資料包含的是偏內容的資料,把 <li> 當做一個元素的話,分析下,這些元素的型別是一樣的,資料裡包含的是元素內容,而樣式等一些其他屬性或事件都是定義在了其他地方,並不在這個資料結構裡。再回到元素本身上觀察下,如何抽象,有哪些特徵?
-
型別
-
內容
-
位置
-
大小
-
顏色
-
背景圖
-
連結
-
其他特徵
元素: { 型別, 內容, 位置, 大小, ... }
元素上的屬性比較龐大,但還是可以放在一個元素的物件裡。設想如果把這些部分也放在該元素的資料結構上,不單單有內容資料,還有樣式上的資料,屬性上的資料等等,這樣是否就可以渲染了。那麼目標有新增,如何去編輯這“龐大”的屬性集合。
試錯
我們假設一段需要的帶屬性樣式的元素DOM結構:
<div class="element someClass" style="someKeyA: someValueA;someKeyB: someValueB;" data-custom="someCustomData">
<div class="content">
content's context
</div>
</div>
複製程式碼
相比“小例子”中的 <li> 多了很多屬性和結構,根據上面的 DOM 結構,用物件的形式抽象下,格式大致如下:
element: {
style: {
someKeyA: 'someValueA',
someKeyB: 'someValueB',
},
class: ['someClass'],
attribute: {
custom: 'someCustomData',
},
content: {
text: 'content\'s context'
}
}
複製程式碼
這樣的話,我們就可以通過一個拼接方法來生成我們想要的結構。這樣一個關於元素的資料結構設計就有了雛形。我們可以通過修改元素上一些屬性的值,改變元素的外在表現。整個過程可以簡化成資料的變化引起檢視的變化,和現在很多前端框架資料驅動思想有幾分相似。
整理
通過類似上面很多小 demo 的積累,最後可以整理拼裝下,回到單個頁面上,除了元素,可能還有一些其他設定,假想預留一些欄位。
那麼一個頁面抽象下,格式大致如下:
page: {
elements: [
{
style: ...,
class: ...,
attribute: ...,
content: ...,
},
{ element2 },
{ element3 },
{ element4 },
{ element5 },
],
setting: {
propertyA: {},
propertyB: 'valueB',
flagC: false,
}
}
複製程式碼
生成的 DOM 結構大致如下:
<div class="page" data-flagC="false" ...>
<div class="element element1" ...></div>
<div class="element element2" ...></div>
...
</div>
複製程式碼
再把一個個單頁拼起來,就變成了我們需要的H5頁面,格式大致如下:
h5: {
pages: [
{
elements: ...,
setting: ...,
},
{page2},
{page3},
{page4},
],
setting: {
propertyA: {},
propertyB: 'valueB',
flagC: false,
}
}
複製程式碼
從一個個小元素組成一個頁面,再由一個個頁面組成h5活動頁面。至此一個對於h5頁面的抽象出來的資料結構雛形基本完成了。
上面的結構沒有展開,展開後你會發現這個大物件可能上千上萬行,接下來關注下資料和操作介面中的對映關係了,如何去操作這些資料,資料怎麼展現,元素和頁面的關係等等。
業務對映到資料
為何要運算元據,而不是去操作DOM?
這也是在前期開發中踩過的坑,照著“所見即所得”的模式,像富文字編輯器一樣,輸入修改完就是最終輸出的內容,也未嘗不可,實現時習慣使用 jQuery 的夥伴很容易會聯想到直接操作 DOM,比如一個元素的定位,使用 jquery-ui 的 draggble 拖拖拽拽很方便的定位,最後產出的就是最後實際的HTML。但放在實際場景中後,會發現擴充性相容性不太友好。特別是在後期再去操作一段成品的 DOM 結構會變得比較麻煩,比如一個定位的資料,成品中的資料會看起來比較“死”,在適配不同螢幕時,計算對應的值會比較累。而如果是運算元據的話,可以在渲染之前對資料進行些處理,最後的產出就變得比較靈活,將資料層和檢視層抽離的比較獨立,擴充起來也比較容易。
對映關係
如何把這些介面的業務抽象成資料操作,首先還是簡單分析整理下。一個視覺化編輯應用的操作有很多,這裡只舉幾個型別的資料操作。使用者通過操作(比如輸入、拖拽、移動、點選等)來改變元素的屬性值。
用腦圖發散一下有哪些功能:
- 頁面的增刪改查
- 元素的增刪改查
- 歷史記錄的操作
- 使用者操作
- 其他
讓我們回到已經制定的目標上,如何編輯頁面,如何編輯元素。下面舉幾個例子
頁面編輯 一個H5由多個頁面組成,由幾個{元素}組成的[元素集合],此類關係通常可以用陣列來表示。
將頁面集合簡單成抽象成資料的操作:
+-------------+
| |
+-------------+
=> pages: [], index: -1
複製程式碼
新增頁面時,在 pages 陣列中 push 一個'page 1'的例項物件,再通過索引取到該例項資料, 然後通過渲染方法將對應的檢視渲染到介面中,這個關係鏈就基本完成了。
+-------------+
| page 1 |
+-------------+
=> pages: [ page1 ], index: 0
複製程式碼
交換頁面順序
+-------------+
| page 2 |
+-------------+
| page 1 |
+-------------+
=> pages: [ page2, page1 ], index: 1
複製程式碼
通過陣列的兩個值的順序交換即可以實現兩個頁面的順序交換,發現很多場景只需要通過一些陣列最基本的操作就可以實現一些看起來複雜的功能,而困難的更多是如何找到這一層對映的關係。
元素操作
元素有多種屬性組成,多個{屬性鍵值對}組成的{元素},此類關係通常可以用物件鍵值對來表示。
在元素物件上不斷擴充需要變化的屬性,比如元素的尺寸位置:
element: {
style: {
'top',
'left',
'width',
'height',
},
...
}
複製程式碼
可以設計如上圖四個輸入框,每個輸入框對應每一個屬性值,這樣一個簡單的元素屬性編輯控制元件就好了,依次類推,每加一個可編輯屬性就對應加一個編輯控制元件。基本上都是以key-value的形式來操作。整個過程簡化成使用者通過介面的輸入修改運算元據,資料更改後檢視對應重新渲染一遍。
根據不斷的嘗試和增加,最後結構變成了類似如下的格式:
element: {
id: 1,
role: {
type,
value
},
style: {
'top',
'left',
'width',
'height',
'transform',
...
},
inner: {
html: 'rich text',
style:{
'background-image',
'background-color',
'background-size',
'opacity',
'color',
'font-size',
'text-align',
'border-radius',
...
}
},
attribute:{
'animation-sequence',
}
}
+--------+--------+----------+---------+-------------+
| id | role | style | inner | attribute |
+--------+--------+----------+---------+-------------+
| 1 | link | ... | ... | ... |
+--------+--------+----------+---------+-------------+
| 2 | text | ... | ... | ... |
+--------+--------+----------+---------+-------------+
複製程式碼
完善後,一個元素的結構已經變得相對龐大了,包含了非常多的屬性,隨之而來的也是非常多對應的屬性編輯控制元件,也是相對比較複雜的地方。
歷史操作
怎麼抽象設計?這在平時業務場景裡並不多。先分析下歷史要什麼?主要就是撤銷和恢復,使用者可以 ctrl+z 回到上一個狀態。歷史這個大集合裡肯定有多個歷史狀態,由多個{歷史}組成[歷史集合],於是就想到了陣列。新狀態和老狀態的區別是什麼?可能就是有了新的操作,資料有了變化,那麼把這時的資料儲存起來,塞到歷史裡,相當於是一個 push 的操作,看起來可行。再假如需要回到上一個狀態,可以設定個索引 index, 將 index 指向到前一個,就拿到了前一個狀態。
| -- push
+------v------+
index --> | status 3 |
+-------------+
| status 2 |
+-------------+
| status 1 |
+------|------+
v -- shift
複製程式碼
抽取幾個關鍵點:
- 有多個狀態 -> 陣列
- 不同狀態之間指向 -> 陣列的索引值, 遊標
- 可以做個步數限制 -> 陣列的長度
複製程式碼
場景:有一個新的操作,即將新的資料插入到歷史中
history.push(statusNew);
複製程式碼
場景:如果滿了,將最先插入的資料拿出來
history.shift();
複製程式碼
場景:撤銷一步,將遊標指向到前一個,取到前一個狀態。重做一步同理。根據這時的資料重新渲染,那麼介面上也就回到了前一步的狀態。
cursor --;
callback(history[cursor]);
複製程式碼
那麼history的結構就可能長成如下:
[
{status1},
{status2},
{status3},
{statusNew},
]
複製程式碼
這樣一個簡單的歷史資料結構設計就完成了。
留個問題: 如果撤回到了上幾步,然後繼續操作,整個歷史狀態該怎麼處理?
最後
最後再通過組裝整合,一個視覺化編輯器主要的功能大致就滿足了。再重新看下操作介面上的資料,可以劃分為兩個部分,一個前臺頁面資料,一個後臺互動資料,大致如下:
回顧上面的過程,已經從一個簡單的資料列表渲染到具有前後臺複雜型資料互動的WebIDE,但從資料結構的設計形式上看,本質上變化其實並不是很大,只是<li>變成了<element>,<page>等,裡面包含的資料量也增加了許多。會不會發現這個資料雖然看起來十分龐大複雜,但也有幾分清晰簡單。而你的角色更像是一位建築設計師,把握整個結構框架,然後再管理一磚一瓦。
上述的過程在開發其他專案時同樣適用,在開始設計時,要一下子腦補出整個設計是比較困難的,特別是對某一個事物從一無所知到有點概念,從0到1的過程,客觀的說這並不容易。可以先試著抽離出幾個關鍵步驟,寫幾個小模組,把關鍵路徑走通,在初期十分有效,隨後再這些看似零散的小元件拼裝起來,往往這個雛形會比一開始想的清晰很多,如此反覆,整個設計也會變得更加清晰飽滿。資料的設計也是相對應的,由一個個小的資料組成,漸漸的便會形成一個比較龐大的資料,這時程式碼可能不是最關鍵的,而是如何合理有效清晰地管理這些資料,可能更像是後端資料庫管理一樣。往往需要經過不斷的試錯走些歪路的過程,最後會慢慢得心應手一點。好設計是不斷迭代出來的,勇敢試錯,不怕踩坑,有句話叫,坑踩的深才銘心刻骨。
iKcamp原創新書《移動Web前端高效開發實戰》已在亞馬遜、京東、噹噹開售。
iKcamp新課程推出啦~~~~~開始免費連載啦~每週2更共11堂iKcamp課|基於Koa2搭建Node.js實戰專案教學(含視訊)| 課程大綱介紹