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