vue 改变domclass_基于 vue 开发甘特图组件的心路历程(兼设计分享)
這篇文章主要講述筆者開發(fā) v-gantt 甘特圖組件的經(jīng)過。
起源
公司項目有個甘特圖的需求。
筆者考察了世面上 常見的甘特圖組件 后,本著 我上我也行 的心態(tài),以及考慮之前拓展其他開源組件時的痛苦體驗,決定自研。一番設(shè)計后,就開始動工了。
設(shè)計
布局拆解
首先對目標界面進行拆解。可以看到甘特圖組件是由 左側(cè)可折疊的樹 & 右側(cè)條狀圖 兩部分組成的。
滾動方面, 頭部日期 header 在橫向滾動中應(yīng)當(dāng)跟隨條狀圖內(nèi)容;在縱向滾動中保持固定。 左側(cè)樹 組件則相反。考慮到拆解組件時已經(jīng)將左右側(cè)分離了。所以縱向滾動行為會通過 同步兩邊縱向滾動容器的 scrollTop 這一 js 技巧來實現(xiàn)。
繼續(xù)拆解右側(cè)條狀圖組件。可以觀察到其由三部分組成:
繼續(xù)拆解,我們會發(fā)現(xiàn)實質(zhì)上甘特圖有三種節(jié)點:
- 群組節(jié)點。包含自身進度狀態(tài)和若干子節(jié)點的節(jié)點
- 葉子節(jié)點。沒有子節(jié)點的節(jié)點
- 里程碑節(jié)點。特殊的葉子節(jié)點。持續(xù)時間僅為一天。只包含 完成 & 未完成 兩種狀態(tài)
其中群組節(jié)點內(nèi)又是一個布局容器。所以實際實現(xiàn)會有一個遞歸的處理。
組件設(shè)計
拆解完布局,也就得到了具體的 vue 組件設(shè)計~
基本就是 布局拆解 的代碼實現(xiàn)。
數(shù)據(jù)結(jié)構(gòu)
定義好組件后,我們需要設(shè)計組件所需要的數(shù)據(jù)結(jié)構(gòu)。這里我們結(jié)合 typescript 定義來設(shè)計數(shù)據(jù)分層。
組件接受的 data 屬性類型
// 首先是傳入的總 data,本質(zhì)是節(jié)點的數(shù)組 type GanttPropData = GanttPropNode[]// 節(jié)點共有三種類型 type GanttPropNode = GanttPropItem | GanttPropGroup | GanttPropMilestone// 葉子節(jié)點類型 interface GanttPropItem {id: string // 唯一標識name: string // 名稱startDate: string // 起始時間endDate: string // 結(jié)束時間progress: number // 0 - 100 }// 群組節(jié)點類型。基本與葉子節(jié)點相同,多一個 children 屬性又是一個節(jié)點的數(shù)組 // 區(qū)別是:起始時間和進度都是可選的。這是因為甘特圖內(nèi)會忽略這些數(shù)據(jù),并完全基于其子節(jié)點數(shù)據(jù)生成 interface GanttPropGroup {id: stringname: stringstartDate?: stringendDate?: stringprogress?: numberchildren: GanttPropData }// 甘特圖節(jié)點類型 interface GanttPropMilestone {id: stringname: stringdate: string // 單一日期done: boolean // 完成狀態(tài) }這就是甘特圖組件所接收的數(shù)據(jù)類型。但這里仍缺失群組節(jié)點的 持續(xù)時間 & 進度狀態(tài) 數(shù)據(jù)。所以內(nèi)部要進行一層轉(zhuǎn)換。
完整數(shù)據(jù)
// 基本數(shù)據(jù)結(jié)構(gòu)仍相同 type GanttData = GanttNode[] type GanttNode = GanttItem | GanttGroup | GanttMilestone// 群組節(jié)點所有可選項都是必填 interface GanttGroup {id: stringname: stringstartDate: stringendDate: stringprogress: numberchildren: GanttData }// 其他節(jié)點類型完全一致 type GanttItem = GanttPropItem type GanttMilestone = GanttPropMilestone可以看到關(guān)鍵轉(zhuǎn)換基本發(fā)生在群組節(jié)點的數(shù)據(jù)類型上。下面是核心的 transform & transformGroup 算法。
function transform(data: GanttPropData): GanttData {return data.map((d) => {if (isGroup(d)) {return transformGroup(d)} else if (isMilestone(d)) {return transformMilestone(d)} else {return transformItem(d)}}) }function transformGroup(g: GanttPropGroup): GanttGroup {return {id: g.id,name: g.name,children: transform(g.children),// 起始時間 = 子節(jié)點中最早的起始時間get startDate() {return this.children.reduce((result, c) => {const startDate = isMilestone(c) ? c.date : c.startDatereturn !result || dayjs(startDate).isBefore(result) ? startDate : result}, '')},// 結(jié)束時間 = 子節(jié)點中最晚的結(jié)束時間get endDate() {return this.children.reduce((result, c) => {const endDate = isMilestone(c) ? c.date : c.endDatereturn !result || dayjs(endDate).isAfter(result) ? endDate : result}, '')},// 進度 = 子節(jié)點進度的加權(quán)平均值(權(quán)重 = 持續(xù)時間)get progress() {let finished = 0let total = 0this.children.forEach((c) => {if (isMilestone(c)) returnconst duration = dayjs.$duration(c.startDate, c.endDate)finished += duration * c.progresstotal += duration})return finished / total},} }數(shù)據(jù)準備好了,但要給最終的組件使用還是需要進行數(shù)據(jù)轉(zhuǎn)換。這里需要產(chǎn)出至少兩份數(shù)據(jù):
感興趣的同學(xué)可以去看看源碼。這里簡要講講布局數(shù)據(jù)的產(chǎn)出
布局數(shù)據(jù)
布局數(shù)據(jù),具體來說指的就是一個條狀圖究的 長度 、 高度 以及 左上角在布局容器中的定位 。
我們?nèi)允墙Y(jié)合 typescript 定義來分析。
// 布局數(shù)據(jù)基礎(chǔ) interface BaseLayoutItem {x: number // 距離容器左邊距(列數(shù))y: number // 距離容器上邊距(行數(shù))w: number // 橫跨的列數(shù)h: number // 縱跨的行數(shù) }type GanttLayoutData = GanttLayoutNode[] type GanttLayoutNode =| GanttLayoutLeaf| GanttLayoutGroup| GanttLayoutMilestone interface GanttLayoutLeaf extends BaseLayoutItem {progress: number } interface GanttLayoutGroup extends GanttLayoutLeaf {children: GanttLayoutData } interface GanttLayoutMilestone extends BaseLayoutItem {done: boolean }整個布局數(shù)據(jù)的結(jié)構(gòu)也是很類似的。其中 progress、done 屬性會作為節(jié)點的狀態(tài)來展示
小結(jié)
至此,只要將節(jié)點的定位、狀態(tài)信息渲染出來,整個甘特圖的展示功能就算完成了。接下來就可以進行更復(fù)雜的交互功能設(shè)計。
功能設(shè)計
組件間數(shù)據(jù)共享 & 跨組件通信
這是所有功能實現(xiàn)的基礎(chǔ)。
有些數(shù)據(jù),我們是希望其在每個組件都能訪問。可行的方案如下:
- provide/inject,但在 typescript 環(huán)境下缺乏類型提示
- props & events,但是組件可能嵌套層級較深,比如為了傳遞一個拖拽事件、一個 rowH 屬性,prop 定義和 emit 代碼會遍布每一個組件。
為此,筆者結(jié)合這兩種做法,實現(xiàn)了下述方案:定義一個會傳遍全局的屬性(bus),bus 中會存放全局通用的屬性,同時包含一個簡單的事件發(fā)布監(jiān)聽器。
interface Bus {rowH: number // 行高和列寬是每一個布局節(jié)點都需要的colW: numberee: EventEmitter }interface EventEmitter {on(event: GanttEvent, handler: Function) {}emit(event: GanttEvent, ...args: any) {} }有些同學(xué)可能會建議直接傳遞某個 vue 實例到各個組件,作為全局環(huán)境。比如就是根實例。但筆者傾向于定義好清晰的全局數(shù)據(jù)接口,而不是將根組件完全暴露給所有子組件。這樣就不像“公交車”,而是“垃圾場”了。
有了方便的跨組件通信機制,就可以方便的實現(xiàn)各種功能了。
樹/群組節(jié)點折疊實現(xiàn)
節(jié)點折疊,也就是指通過 el-tree 提供的折疊樹節(jié)點功能,聯(lián)動實現(xiàn)群組節(jié)點折疊的效果。那么關(guān)鍵點就是:
- 將 el-tree 提供的節(jié)點折疊信息,通知到甘特圖節(jié)點這邊
- 當(dāng)外部數(shù)據(jù)發(fā)生變更時,已折疊的節(jié)點保持它的狀態(tài)
兩點結(jié)合起來,就是要求:
所以,數(shù)據(jù)定義如下:
/*** key 是節(jié)點 id;value 表示是否被折疊*/ interface CollapsedMap {[id: string]: boolean }// 存放在公交車 bus 上 interface Bus {collapsedMap: CollapsedMap }具體步驟是:
// src/index.vue { watch: { // 監(jiān)聽 GanttPropData 的變化 data: { handler(v) { this.collapsedMap = { // 就是提取所有節(jié)點的 key,一個類似 flatmap 的過程,初始 val 都是 false ...getCollapsedMap(v), // 優(yōu)先保留折疊狀態(tài) ...this.collapsedMap, } }, immediate: true, }, }, }
拖拽實現(xiàn)
這是一個比較重頭的功能。
首先是需求定義:
因為通知外界只需要用我們之前定義的 bus.ee 事件監(jiān)聽發(fā)布器即可實現(xiàn),所以我們要解決的是:
可選的拖拽實現(xiàn)如下:
在數(shù)據(jù)結(jié)構(gòu)定義的章節(jié)筆者提過,節(jié)點的布局信息是由 GanttLayoutData 決定的。實際并不準確:真實的定位樣式,是需要額外結(jié)合 拖拽信息(dragData & resizeData)實現(xiàn)。也就是:
- 節(jié)點的左邊距 x = layoutData.x + dragData.offsetX
- 節(jié)點的長度 w = layoutData.w + resizeData.offsetX
其中 dragData & resizeData 的實現(xiàn)是:
更具體的信息,可以參考 src/components/gantt-node.vue 源碼
快速定位功能
簡單來說,就是點擊 今天 按鈕,今天列跳轉(zhuǎn)到視窗內(nèi);點擊 樹節(jié)點 ,對應(yīng)的條狀圖節(jié)點出現(xiàn)在視窗內(nèi)。
第一反應(yīng)是利用瀏覽器內(nèi)置的 scrollToView api 實現(xiàn)。事實上跳轉(zhuǎn)到今天列功能就是這樣實現(xiàn)的。
但后一種聯(lián)動要復(fù)雜一些。條狀圖節(jié)點是通過 transform: translate 絕對定位的節(jié)點,再加上先前在 布局拆解 階段提及的兩種滾動行為實現(xiàn),導(dǎo)致 scrollToView 不能滿足我們的需求。
那么剩下的方案,就是手動控制 橫向滾動容器的 scrollLeft 和 縱向滾動容器的 scrollTop 。
具體操作是:
哇,三言兩語就說完了呢~ 實際開發(fā)的時候踩了不少坑 (特別是折磨在 drag & drop api 上)
具體實現(xiàn)
這一部分,感興趣的朋友就可以 參考源碼 啦~ 也可以直接通過社交平臺(github)的 issue & pr 功能與筆者交流。
結(jié)語
這次歷時三周的開發(fā),讓筆者體會到設(shè)計先行對代碼質(zhì)量的提升。核心體驗就是開發(fā)是遵照著目標前進的,可以專心于邏輯的同時保持組件設(shè)計分工的穩(wěn)定。
另外,架構(gòu)是且應(yīng)當(dāng)是不斷演進的。不同的功能復(fù)雜度適合不同的架構(gòu)復(fù)雜度。事實上,就跨組件通信機制而言組件內(nèi)部就重構(gòu)了一兩次。但筆者認為這些勞動都是值得的。只有抱著 架構(gòu)應(yīng)當(dāng)持續(xù)演進 的心態(tài),就能保持好隨時 微重構(gòu) 的心態(tài),保持對添加新功能的信息~ 。最后再借《反脆弱》一書的核心論點論證:一個貌似穩(wěn)定的系統(tǒng)其實是畏懼變化、僵死且具有脆弱性的系統(tǒng);一個貌似反復(fù)變更、時不時破壞重建的系統(tǒng),則是能夠不斷演進,持續(xù)變強。
感謝閱讀!
總結(jié)
以上是生活随笔為你收集整理的vue 改变domclass_基于 vue 开发甘特图组件的心路历程(兼设计分享)的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 向前欧拉公式 matlab_你可能不知道
- 下一篇: 电脑电池修复_笔记本电脑不充电是怎么回事