日韩av黄I国产麻豆传媒I国产91av视频在线观看I日韩一区二区三区在线看I美女国产在线I麻豆视频国产在线观看I成人黄色短片

歡迎訪問 生活随笔!

生活随笔

當(dāng)前位置: 首頁 >

vue 改变domclass_基于 vue 开发甘特图组件的心路历程(兼设计分享)

發(fā)布時(shí)間:2025/4/5 47 豆豆
生活随笔 收集整理的這篇文章主要介紹了 vue 改变domclass_基于 vue 开发甘特图组件的心路历程(兼设计分享) 小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,幫大家做個(gè)參考.

語雀原文 有更好的排版體驗(yàn)~

這篇文章主要講述筆者開發(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è)條狀圖組件。可以觀察到其由三部分組成:

  • 日期列表。展示整個(gè)甘特圖數(shù)據(jù)的跨越日期范圍。此外還負(fù)責(zé)展示工作日、節(jié)假日信息,以及其他的一些高亮信息
  • 工具欄。放置一些改變視圖的操作按鈕
  • 條狀圖容器。布局條狀圖節(jié)點(diǎn)
  • 繼續(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ù):

  • 日期列數(shù)據(jù),就是橫跨的日期范圍,并據(jù)此去拿工作日數(shù)據(jù)
  • 布局?jǐ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é)合起來,就是要求:

  • 需要存放折疊好的節(jié)點(diǎn)信息,且全局共享
  • 該信息需要獨(dú)立存放而不是依附著 GanttData ,否則數(shù)據(jù)一刷新(每次 GanttPropData 更新都會(huì)生成全新的 GanttData )折疊信息就丟失了
  • 所以,數(shù)據(jù)定義如下:

    /*** key 是節(jié)點(diǎn) id;value 表示是否被折疊*/ interface CollapsedMap {[id: string]: boolean }// 存放在公交車 bus 上 interface Bus {collapsedMap: CollapsedMap }

    具體步驟是:

  • 監(jiān)聽 el-tree 的折疊事件(node-click),記錄該節(jié)點(diǎn)是否被折疊
  • 在 <gantt-group> 組件監(jiān)聽自身的折疊情況 this.bus.collapsedMap[this.data.id] ,決定自身高度以及是否顯示子節(jié)點(diǎn)們
  • 每當(dāng) GanttData 更新時(shí),對(duì) collapsedMap實(shí)現(xiàn)的是增量更新
  • // 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è)比較重頭的功能。

    首先是需求定義:

  • 甘特圖節(jié)點(diǎn)可以整體被鼠標(biāo)拖拽;葉子節(jié)點(diǎn)還可以拖拽其右側(cè)實(shí)現(xiàn)長(zhǎng)度更改功能
  • 拖拽時(shí),應(yīng)限定方向在水平方向
  • 拖拽時(shí),條狀圖應(yīng)當(dāng)有相應(yīng)移位和形變(感覺跟手)
  • 結(jié)束拖拽時(shí),甘特圖保持狀態(tài),并通知外界
  • 因?yàn)橥ㄖ饨缰恍枰梦覀冎岸x的 bus.ee 事件監(jiān)聽發(fā)布器即可實(shí)現(xiàn),所以我們要解決的是:

  • 監(jiān)聽拖拽事件發(fā)生
  • 監(jiān)聽時(shí),改變節(jié)點(diǎn)的布局信息
  • 可選的拖拽實(shí)現(xiàn)如下:

  • drag & drop event。但不支持限定方向;不支持自定義 cursor 樣式
  • mouse event 系列。支持細(xì)粒度地模擬拖拽,沒有 drag & drop 的限制
  • 第三方拖拽庫。只能說一方面坑的很;另一方面,當(dāng)你真正理解所需要的功能時(shí),代碼實(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)是:

  • 在節(jié)點(diǎn)的 mousedown 事件初始化(offsetX = 0)
  • 在節(jié)點(diǎn)的 mousemove 事件變更(offsetX += event.movementX)
  • 在節(jié)點(diǎn)的 mousedown 事件,如果發(fā)生了實(shí)質(zhì)變更,則通過 bus.ee.emit 發(fā)送變更
  • 更具體的信息,可以參考 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 。

    具體操作是:

  • 監(jiān)聽樹節(jié)點(diǎn)的 click 事件,通過 bus.ee.emit 發(fā)送 ScrollToNodeEvent ,攜帶節(jié)點(diǎn) id
  • 所有 <gantt-node> 組件都會(huì)監(jiān)聽該事件。當(dāng)發(fā)現(xiàn)事件 id 與自身 id 匹配時(shí),通過 bus.ee.emit 發(fā)送 ScrollToEvent ,攜帶自身在 頂級(jí) <gantt-layout> 容器中的位置。注意這里需要遞歸往上累積計(jì)算
  • <gantt-chart> 組件(掌控著兩個(gè)滾動(dòng)容器的位置)監(jiān)聽 ScrollToEvent ,調(diào)整兩個(gè)滾動(dòng)容器的 scrollTop & scrollLeft 值
  • 哇,三言兩語就說完了呢~ 實(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)容,希望文章能夠幫你解決所遇到的問題。

    如果覺得生活随笔網(wǎng)站內(nèi)容還不錯(cuò),歡迎將生活随笔推薦給好友。