這裡給大家分享我在網上總結出來的一些知識,希望對大家有所幫助 前言 在使用 Vue 3 組件庫 Naive UI 的數據表格組件 DataTable 時碰到的問題,NaiveUI 的數據表格組件 DataTable 在固定頭部和列的示例中,在鍵盤操作下表格橫向滾動會有問題,本文是記錄下解決問題的過程 ...
這裡給大家分享我在網上總結出來的一些知識,希望對大家有所幫助
前言
在使用 Vue 3 組件庫 Naive UI 的數據表格組件 DataTable 時碰到的問題,NaiveUI 的數據表格組件 DataTable 在固定頭部和列的示例中,在鍵盤操作下表格橫向滾動會有問題,本文是記錄下解決問題的過程,並最後向 Naive UI 提交 PR。
問題復現步驟:
- 滑鼠點擊表頭,此時按鍵盤左右鍵,表格橫向滾動沒問題;
- 再把滑鼠移入表體,按鍵盤左右鍵,會發現表頭滾動而表體沒動。
相關 issue:
Naive UI 中的實現
打開 Chrome 開發者工具,可以看到固定頭和列中,表頭和表體是由兩個 table 元素單獨實現,我們遇到的問題可能就是表頭表體同步滾動的實現有點問題,具體得看源碼中的實現驗證下。
在 DataTable 組件源碼中涉及到滾動的相關文件 src/data-table
:
use-scroll.ts
處理表格滾動事件DataTable.tsx
表格組件tableParts/Header.tsx
表頭組件tableParts/Body.tsx
表體組件
我們按照復現步驟的操作來看看滾動的時候做了什麼?
大致過程就是表頭或表體滾動時會觸發 scroll
事件,在監聽 scroll
事件的回調中獲取 scrollLeft
值,然後設置另一部分的 scrollLeft
來同步滾動。
1. 滑鼠點擊表頭,按鍵盤左右鍵,表頭表體橫向滾動正常
當滑鼠移入表頭時,表頭監聽了 mouseenter
事件,會設當前 scrollPartRef
為 head
;按鍵盤左右鍵使表頭滾動,觸發了表頭 scroll
事件,執行 handleTableHeaderScroll
方法,該方法是由 DataTable.tsx
組件提供(provider
),在 use-scroll.ts
中的 useScroll
方法導出的;handleTableHeaderScroll
作用主要是來同步表體的滾動及一些樣式設置;代碼如下:
// use-scroll.ts function handleTableHeaderScroll (): void { // 判斷當前滾動的部分是不是表頭,scrollPartRef 值為 head 或 body if (scrollPartRef.value === 'head') { // beforeNextFrameOnce 的作用是每一幀只調用一次傳入的回調 // syncScrollState 的作用是同步滾動表體和一些樣式設置 beforeNextFrameOnce(syncScrollState) } }
2. 再把滑鼠移入表體,按鍵盤左右鍵,表頭橫向滾動正常而表體沒動
當將滑鼠移入表體時,表體監聽了 mouseenter
事件,會設當前 scrollPartRef
為 body
,在按鍵盤左右鍵時表頭滾動,執行了表頭 scroll
事件回調 handleTableHeaderScroll
,但不滿足判斷條件 scrollPartRef.value === 'head'
,沒有執行 syncScrollState
方法。
問題原因:在移入表體後,此時滑鼠焦點依舊在表頭,所以按鍵盤左右鍵時,仍然是表頭滾動及觸發 scroll
事件,執行的是 handleTableHeaderScroll
方法,而此時 scrollPartRef
的值為 body
,導致沒有執行 syncScrollState
方法來同步表體的 scrollLeft
值,最終表現表體沒有跟隨表頭滾動。
其他組件庫中的實現
在解決問題前,觀察了一下各組件庫表格組件中固定表頭和列的示例,看看是否有類似問題,查看之後發現表頭和表體都是通過兩個 table 元素來單獨實現,這就遇到一個問題,因為是兩個 table 元素,那怎麼實現表頭表體同步滾動呢?以及怎麼解決在 Naive UI 中遇到的問題?
Element Plus
Element Plus 中,當滑鼠點擊表頭,按鍵盤左右鍵是無法橫向滾動的,只有滑鼠焦點在表體上才能橫向滾動;也就是滾動只能由表體滾動帶動表頭滾動。
源碼實現裡面它的表格滾動條不像 Naive UI 表頭和表體都設為 overflow: scroll
來產生滾動,而是在表體包了一層封裝的滾動條組件,表頭則沒有包直接設為 overflow: hidden
不讓滾動;在滾動表體時,獲取滾動條組件的 scrollLeft
來同步表頭的 scrollLeft
。Table 組件源碼點這裡
Ant Design Vue
Ant Design Vue 的表現同 Element Plus,表頭無法滾動,只能由表體滾動帶動表頭滾動。
源碼實現原理跟 Element Plus 差不多,它的表格表頭也是設為 oveflow: hidden
無法滾動,表體設為 overflow: auto scroll
來滾動,然後監聽表體的滾動事件 scroll
獲取 scrollLeft
來同步表頭 scrollLeft
。Table 組件源碼點這裡
問題解決過程
問題復現
根據 Naive UI DataTable 源碼中固定頭和列時同步滾動的實現方式,搞一個 demo 復現問題。
代碼實現思路:滾動分為表頭、表體兩個部分,監聽各自的滾動事件 scroll
,滾動某一個部分時,在 scroll
事件處理函數中通過設置另一部分 scrollLeft
來同步滾動,因為在設置 scrollLeft
時也會觸發 scroll
事件,這樣就會造成死迴圈,所以需要判斷當前滾動的是哪個部分,這裡用 scrollPartRef
變數來記錄,在滑鼠移入表頭時設 scrollPartRef
為 'head'
,在滑鼠移出表頭或移入表體時設 scrollPartRef
為 ‘body’
,然後在滾動事件處理回調 handleHeaderScroll
/ handleBodyScroll
方法中,判斷 scrollPartRef
是不是為對應的 'head'
/ 'body'
,是的話才會執行 syncScrollState
方法來同步另一部分的 scrollLeft
。
具體代碼如下:
Demo 線上地址:[Bug] NaiveUI-DataTable-scrolling-sync (demo) - codesandbox
/** * Naive UI DataTable 組件滾動同步 demo 實現 */ <template> <div class="wrap"> <p>scrollPart:{{ scrollPartRef }}</p> <div ref="headerRef" class="header" @mouseenter="handleHeaderMouseenter" @mouseleave="handleHeaderMouseleave" @scroll="handleHeaderScroll" > <div class="content" tabIndex="-1"> head-head-head-head-head-head-head-head-head-head-head-head-head-head-head-head-head-head-head-head-head-head-head </div> </div> <div ref="bodyRef" class="body" @mouseenter="handleBodyMouseenter" @scroll="handleBodyScroll" > <div class="content" tabIndex="-1"> body-body-body-body-body-body-body-body-body-body-body-body-body-body-body-body-body-body-body-body-body-body-body </div> </div> </div> </template> <script setup lang="ts"> import { defineComponent, ref } from "vue"; const scrollPartRef = ref(); // 當前滾動部分,值為 'head' 或 'body' const headerRef = ref(); const bodyRef = ref(); function handleHeaderScroll() { if (scrollPartRef.value === "head") { syncScrollState(); } } function handleBodyScroll() { if (scrollPartRef.value === "body") { syncScrollState(); } } // 同步滾動 let scrollLeft = 0; function syncScrollState() { if (scrollPartRef.value === "head") { scrollLeft = headerRef.value.scrollLeft; bodyRef.value.scrollLeft = scrollLeft; } else { scrollLeft = bodyRef.value.scrollLeft; headerRef.value.scrollLeft = scrollLeft; } } function handleHeaderMouseenter() { scrollPartRef.value = "head"; } function handleHeaderMouseleave() { scrollPartRef.value = "body"; } function handleBodyMouseenter() { scrollPartRef.value = "body"; } </script> <style> .wrap { width: 600px; } .content { width: 1000px; height: 80px; background-color: lightblue; } .header { padding: 10px; background-color: lightgray; overflow: auto; margin-bottom: 20px; } .body { padding: 10px; background-color: lightgray; overflow: auto; } .content { border: 1px solid orange; } </style>
問題分析
想下使瀏覽器原生滾動條滾動的交互操作有幾種:
- 觸控板手勢滾動
- 滑鼠按住滾動條拖動
- 鍵盤 shift 鍵 + 滑鼠滾輪滾動
- 滑鼠焦點在滾動內容上,接著用鍵盤左右鍵滾動
- 還有麽?
概括下來就是分為三種:觸控板、滑鼠、鍵盤,作者實現的時候可能沒考慮到鍵盤操作的場景。
問題原因上面分析過,在滑鼠點擊表頭後移入表體,scrollPartRef
會被設為 'body'
,而此時滑鼠焦點依舊在表頭上,當操作鍵盤方向鍵,滾動的是表頭,執行它的 handleHeaderScroll
方法,但不滿足判斷條件 scrollPartRef.value === 'head'
,沒有執行 syncScrollState
方法,導致表體沒有同步滾動。
現在能想到的解決方案有兩種:一種就是參考其他組件庫中的方案,不讓表頭能主動滾動,只能由表體滾動帶動表頭滾動;一種就是修複復現操作下的問題。
我認為現在的實現方式太複雜了,需要監聽表頭、表體的滑鼠事件 mouseenter
、mouseleave
來預設當前滾動部分 scrollPartRef
,如果按照這種思路,要修複鍵盤操作下的問題,是不是還要監聽當前焦點focus
事件然後做判斷,有沒有更簡單的方式?
解決思路
我的想法是只監聽 scroll
事件能不能做到同步滾動,現在有表頭、表體兩個滾動部分,那麼可分為主動滾動和被動滾動;在未滾動前,我們不預設主動滾動是哪部分(即不設置 scrollPartRef
),等到真正滾動的時候,如果我們能知道主動滾動的是哪部分,這樣就能獲取主動滾動部分的 scrollLeft
,去設置被動滾動部分的 scrollLeft
,以此實現同步滾動。如果大家有更好的解決思路,歡迎討論!
怎麼判斷主動滾動的是哪部分?
當時給 Naive UI 提 PR 的時候想到的是第一種思路,但是我覺得第二種思路更好一點,後續重新提交一個。
第一種思路:在每次滾動中取表頭或表體的 scrollLeft
和上一次滾動記錄下的 lastScrollLeft
(初始為 0)比較來判斷當前主動滾動部分是哪個,這裡取表頭部分的 scrollLeft
,如果差值不為 0,說明當前主動滾動部分為表頭(即 scrollPartRef = ‘head’
),否則為表體。
const scrollPartRef = ref(); // 當前主動滾動部分 const headerRef = ref(); const bodyRef = ref(); function handleHeaderScroll() { if (scrollPartRef.value !== "body") { syncScrollState(); } else { // 每次滾動結束,置空 scrollPartRef.value = undefined; } } function handleBodyScroll() { if (scrollPartRef.value !== "head") { syncScrollState(); } else { // 每次滾動結束,置空 scrollPartRef.value = undefined; } } let lastScrollLeft = 0; function syncScrollState() { if (!scrollPart.value) { // 取 header 的 scrollLeft 跟上一次滾動記錄的 scrollLeft 比較 const directionHead = lastScrollLeft - headerRef.value.scrollLeft; // 不為 0 說明 header 滾動了,主動滾動即為 head,否則為 body scrollPart.value = directionHead !== 0 ? "head" : "body"; } if (scrollPart.value === "head") { lastScrollLeft = headerRef.value.scrollLeft; bodyRef.value.scrollLeft = lastScrollLeft; } else { lastScrollLeft = bodyRef.value.scrollLeft; headerRef.value.scrollLeft = lastScrollLeft; } }第二種思路:主動滾動部分肯定會先觸發滾動事件,所以可以在表頭或表體的
scroll
事件處理函數中判斷 scrollPartRef
是否存在,不存在則將 scrollPartRef
設為對應的 'head'
\ 'body'
(即為主動滾動部分),然後調用syncScrollState
同步被動滾動部分的scrollLeft
。代碼如下:
function handleHeaderScroll() { if(!scrollPart.value) { scrollPartRef.value = 'head' } if (scrollPartRef.value === "head") { syncScrollState(); } else { // 每次滾動結束,置空 scrollPartRef.value = undefined } } function handleBodyScroll() { if(!scrollPart.value) { scrollPartRef.value = 'body' } if (scrollPartRef.value === "body") { syncScrollState(); } else { // 每次滾動結束,置空 scrollPartRef.value = undefined } } function syncScrollState() { if (scrollPart.value === "head") { lastScrollLeft = headerRef.value.scrollLeft; bodyRef.value.scrollLeft = lastScrollLeft; } else { lastScrollLeft = bodyRef.value.scrollLeft; headerRef.value.scrollLeft = lastScrollLeft; }
怎麼判斷滾動結束?
我們需要在每一次滾動結束後置空 scrollPartRef
,否則同步會出問題。被動滾動部分在被設置 scrollLeft
時也會觸發 scroll
事件,而被動滾動部分的 scroll
事件會晚於主動滾動的 scroll
事件觸發,所以可以認為被動滾動事件執行完滾動就結束了,在它的事件回調處理中的 else 分支置空 scrollPartRef
,如上代碼所示。
但是這樣判斷結束在 Safari 中還是有點有問題的,詳見下麵遺留問題。
完整代碼
Demo 線上地址:[Fix] NaiveUI-DataTable-scrolling-sync (demo) - codesandbox
<template> <div class="wrap"> <p>scrollPart:{{ scrollPart }}</p> <div ref="headerRef" class="header" @scroll="handleHeaderScroll"> <div class="content" tabIndex="-1"> head-head-head-head-head-head-head-head-head-head-head-head-head-head-head-head-head-head-head-head-head-head-head </div> </div> <div ref="bodyRef" class="body" @scroll="handleBodyScroll"> <div class="content" tabIndex="-1"> body-body-body-body-body-body-body-body-body-body-body-body-body-body-body-body-body-body-body-body-body-body-body </div> </div> </div> </template> <script setup lang="ts"> import { ref } from 'vue' const scrollPart = ref() // 當前主動滾動部分 const headerRef = ref() function handleHeaderScroll() { if (scrollPart.value !== 'body') { console.log('<<< head scroll start >>>') syncScrollState() } else { scrollPart.value = undefined console.log('<<< body scroll end >>>') console.log('\n') } } const bodyRef = ref() function handleBodyScroll() { if (scrollPart.value !== 'head') { console.log('<<< body scroll start >>>') syncScrollState() } else { scrollPart.value = undefined console.log('<<< head scroll end >>>') console.log('\n') } } let lastScrollLeft = 0 function syncScrollState() { // 取 header 的 scrollLeft 跟上一次滾動記錄的 scrollLeft 比較 const directionHead = lastScrollLeft - headerRef.value.scrollLeft // 不為 0 說明 header 滾動了,主動滾動即為 head,否則為 body scrollPart.value = directionHead !== 0 ? 'head' : 'body' if (scrollPart.value === 'head') { lastScrollLeft = headerRef.value.scrollLeft bodyRef.value.scrollLeft = lastScrollLeft } else { lastScrollLeft = bodyRef.value.scrollLeft headerRef.value.scrollLeft = lastScrollLeft } console.log('final scrollLeft', lastScrollLeft) } </script> <style> .wrap { width: 600px; } .content { width: 1000px; height: 80px; background-color: lightblue; } .header { padding: 10px; background-color: lightgray; overflow: auto; margin-bottom: 20px; } .body { padding: 10px; background-color: lightgray; overflow: auto; } .content { border: 1px solid orange; } </style>
PR
遺留問題
雖然解決了鍵盤操作的問題,但是後面發現在 Safari 瀏覽器中使用觸控板快速滑動會有點小問題,這是由於在 Safari 中,滾動會有彈性效果導致的,復現步驟:
- 滑鼠先點擊表頭,讓焦點在表頭上;
- 滑鼠移入表體,使用觸控板快速滑動,有彈性效果;
- 按鍵盤左鍵,第一下表頭滾動,表體沒動。
原因是因為快速滑動表體(主動滾動部分)時,表頭(被動滾動部分)到達邊界後就不會再觸發 scroll
事件了,而表體因為彈性效果依舊在觸發 scroll
事件,導致 scrollPartRef
一直為 'body'
,未被清空,後面再按鍵盤滾動表頭時,事件處理中條件 scrollPartRef.value === 'head'
不滿足,未執行 syncScrollState
方法,導致了表體未同步滾動。根本原因就是我們沒法正確判斷滾動什麼時候結束,如果能知道什麼時候滾動結束,那麼在滾動結束時重置 scrollPartRef
就不會有問題了。
聽說現在有了 scrollend
,但是相容性不行。