產品:能實現長列表的滾動恢復嘛?我:... 得加錢

語言: CN / TW / HK

theme: Chinese-red

前言

某一天,產品經理找到我,他希望我們能夠給使用者更好的體驗,提供長列表的滾動記憶功能。就是說當滑鼠滾輪滾動到長列表的某個位置時,單擊一個具體的列表項,就切換路由到了這個列表項的詳情頁;當導航返回到長列表時,還能回到之前滾動到的位置去。

思路

我低頭思考了一陣兒,想到了history引入的scrollRestoration屬性,也許可以一試。於是我回答,可以實現,一天工作量吧😂。產品經理聽到後,滿意地走了,但是我後知後覺,我為數不多的經驗告訴我,這事兒可能隱隱有風險😨。但是沒辦法,no zuo no die。

scrollRestoration

Chrome46之後,history引入了scrollRestoration屬性。該屬性提供兩個值,auto(預設值),以及manual。當設定為auto時,瀏覽器會原生地記錄下window中某個元素的滾動位置。此後不管是重新整理頁面,還是使用pushState等方法改變頁面路由,始終可以讓元素恢復到之前的螢幕範圍中。但是很遺憾,他只能記錄下在window中滾動的元素,而我的需求是某個容器中滾動。
完犢子😡,實現不了。
其實想想也是,瀏覽器怎麼可能知道開發者想要儲存哪個DOM節點的滾動位置呢?這事只有開發者自己知道,換句話說,得自己實現。於是乎,想到了一個大致思路是:

發生滾動時將元素容器當時的位置儲存起來,等到長列表再次渲染時,再對其重新賦值scrollTop和scrollLeft

真正的開發思路

其實不難想到,滾動恢復應該屬於長列表場景中的通用能力,既然如此,那...,誇下的海口是一天,所以沒招,只能根據上述的簡單思路實現了一個,很low,位置資訊儲存在localStorage中,算是交了差。但作為一個有追求的程式設計師,這事必須完美解決,既然通用那麼公共元件提上日程😎。在肝了幾天之後,出爐的完美解決方案:

在路由進行切換、元素即將消失於螢幕前,記錄下元素的滾動位置,當元素重新渲染或出現於螢幕時,再進行恢復。得益於React-Router的設計思路,類似於Router元件,設計滾動管理元件ScrollManager,用於管理整個應用的滾動狀態。同理,類似於Route,設計對應的滾動恢復執行者ScrollElement,用以執行具體的恢復邏輯。

滾動管理者-ScrollManager

滾動管理者作為整個應用的管理員,應該具有一個管理者物件,用來設定原始滾動位置,恢復和儲存原始的節點等。然後通過Context,將該物件分發給具體的滾動恢復執行者。其設計如下: typescript export interface ScrollManager { /** * 儲存當前的真實DOM節點 * @param key 快取的索引 * @param node * @returns */ registerOrUpdateNode: (key: string, node: HTMLElement) => void; /** * 設定當前的真實DOM節點的元素位置 * @param key 快取的索引 * @param node * @returns */ setLocation: (key: string, node: HTMLElement | null) => void; /** * 設定標誌,表明location改變時,是可以儲存滾動位置的 * @param key 快取的索引 * @param matched * @returns */ setMatch: (key: string, matched: boolean) => void; /** * 恢復位置 * @param key 快取的索引 * @returns */ restoreLocation: (key: string) => void; /** * 清空節點的快取 * @param key * @returns */ unRegisterNode: (key: string) => void; } - 上述Manager雖然提供了各項能力,但是缺少了快取物件,也就是儲存這些位置資訊的地方。使用React.useRef,其設計如下: typescript //快取位置的具體內容 const locationCache = React.useRef<{ [key: string]: { x: number; y: number }; }>({}); //原生節點的快取 const nodeCache = React.useRef<{ [key: string]: HTMLElement | null; }>({}); //標誌位的快取 const matchCache = React.useRef<{ [key: string]: boolean; }>({}); //清空節點方法的快取 const cancelRestoreFnCache = React.useRef<{ [key: string]: () => void; }>({}); - 有了快取物件,我們就可以實現manager,使用key作為快取的索引,關於key會在ScrollElement中進行說明。 typescript const manager: ScrollManager = { registerOrUpdateNode(key, node) { nodeCache.current[key] = node; }, unRegisterNode(key) { nodeCache.current[key] = null; //及時清除 cancelRestoreFnCache.current[key] && cancelRestoreFnCache.current[key](); }, setMatch(key, matched) { matchCache.current[key] = matched; if (!matched) { //及時清除 cancelRestoreFnCache.current[key] && cancelRestoreFnCache.current[key](); } }, setLocation(key, node) { if (!node) return; locationCache.current[key] = { x: node?.scrollLeft, y: node?.scrollTop }; }, restoreLocation(key) { if (!locationCache.current[key]) return; const { x, y } = locationCache.current[key]; nodeCache.current[key]!.scrollLeft = x; nodeCache.current[key]!.scrollTop = y; }, }; - 之後,便可以通過Context將manager物件向下傳遞 typescript <ScrollManagerContext.Provider value={manager}> {props.children} </ScrollManagerContext.Provider> - 除了上述功能外,manager還有一個重要功能:獲知元素在導航切換前的位置。在React-Router中一切路由狀態的切換都由history.listen來發起,由於history.listen可以監聽多個函式。所以可以在路由狀態切換前,插入一段監聽函式,來獲得節點相關資訊。 typescript location改變 ---> 獲得節點位置資訊 ---> 路由update - 在實現中,使用了一個狀態shouldChild,來確保監聽函式一定在觸發順序上先於Router中的監聽函式。實現如下: ```typescript const [shouldChild, setShouldChild] = React.useState(false);

//利用useLayoutEffect的同步,模擬componentDidMount,為了確保shouldChild在Router渲染前設定 React.useLayoutEffect(() => { //利用history提供的listen監聽能力 const unlisten = props.history.listen(() => { const cacheNodes = Object.entries(nodeCache.current); cacheNodes.forEach((entry) => { const [key, node] = entry; //如果matchCache為true,表明從當前路由渲染的頁面離開,所以離開之前,儲存scroll if (matchCache.current[key]) { manager.setLocation(key, node); } }); });

    //確保該監聽先入棧,也就是監聽完上述回撥函式後才例項化Router
    setShouldChild(true);
    //銷燬時清空快取資訊
    return () => {
        locationCache.current = {};
        nodeCache.current = {};
        matchCache.current = {};
        cancelRestoreFnCache.current = {};
        Object.values(cancelRestoreFnCache.current).forEach((cancel) => cancel());
        unlisten();
    };

}, []);

//改造context傳遞 {shouldChild && props.children} - 真正使用時,管理者元件要放在Router元件外側,來控制Router例項化:typescript ... ```

滾動恢復執行者-ScrollElement

ScrollElement的主要職責其實是控制真實的HTMLElement元素,決定快取的key,包括決定何時觸發恢復,何時儲存原始HTMLElement的引用,設定是否需要儲存的位置等等。ScrollElement的props設計如下: typescript export interface ScrollRestoreElementProps { /** * 必須快取的key,用來標誌快取的具體元素,位置資訊以及狀態等,全域性唯一 */ scrollKey: string; /** * 為true時觸發滾動恢復 */ when?: boolean; /** * 外部傳入ref * @returns */ getRef?: () => HTMLElement; children?: React.ReactElement; } - ScrollElement本質上可以看作為一個代理,會拿到子元素的Ref,接管其控制權。也可以自行實現getRef傳入元件中。首先要實現的就是滾動發生時,記錄位置能力: ```typescript useEffect(() => { const handler = function (event: Event) {‘ //nodeRef就是子元素的Ref if (nodeRef.current === event.target) { //獲取scroll事件觸發target,並更新位置 manager.setLocation(props.scrollKey, nodeRef.current); } };

//使用addEventListener的第三個引數,實現在window上監聽scroll事件
window.addEventListener('scroll', handler, true);
return () => window.removeEventListener('scroll', handler, true);

}, [props.scrollKey]); - 接下來處理路由匹配以及DOM變更時處理的能力。注意,這塊使用了對`useLayoutEffect`和`useEffect`執行時機的理解處理:typescript //使用useLayoutEffect主要目的是為了同步處理DOM,防止發生閃動 useLayoutEffect(() => { if (props.getRef) { //處理getRef獲取ref //useLayoutEffect會比useEffect先執行,所以nodeRef一定繫結的是最新的DOM nodeRef.current = props.getRef(); }

if (currentMatch) {
    //設定標誌,表明當location改變時,可以儲存滾動位置
    manager.setMatch(props.scrollKey, true);
    //更新ref,代理的DOM可能會發生變化(比如key發生了變化,remount元素)
    nodeRef.current && manager.registerOrUpdateNode(props.scrollKey, nodeRef.current);
    //恢復原先滑動過的位置,可通過外部props通知是否需要進行恢復
    (props.when === undefined || props.when) && manager.restoreLocation(props.scrollKey);
} else {
    //未命中標誌設定,不要儲存滾動位置
    manager.setMatch(props.scrollKey, false);
}

//每次update登出,並重新註冊最新的nodeRef,解決key發生變化的情況
return () => manager.unRegisterNode(props.scrollKey);

}); - 上述程式碼,表示在初次載入或者每次更新時,會根據當前的Route匹配結果與否來處理。如果匹配,則表示ScrollElement元件應是渲染的,此時在`effect`中執行更新Ref的操作,為了解決key發生變化時DOM發生變化的情況,所以需要每次更新都處理。 - 同時設定標識位,相當於告訴`manager`,node節點此刻已經渲染成功了,可以在離開頁面時儲存位置資訊;如果路由不匹配,那麼則不應該渲染,`manager`此刻也不用儲存這個元素的位置資訊。主要是為了解決存在路由快取的場景。 - 也可以通過`when`來控制恢復,主要是用來解決非同步請求資料的場景。 - 最後判斷ScrollElement的子元素是否是合格的typescript //如果有getRef,直接返回children if (props.getRef) { return props.children as JSX.Element; }

const onlyOneChild = React.Children.only(props.children); //代理第一個child,判斷必須是原生的tag if (onlyOneChild && onlyOneChild.type && typeof onlyOneChild.type === 'string') { //利用cloneElement,繫結nodeRef return React.cloneElement(onlyOneChild, { ref: nodeRef }); } else { console.warn('-----滾動恢復失敗,ScrollElement的children必須為單個html標籤'); }

return props.children as JSX.Element; ```

多次嘗試機制

在某些低版本的瀏覽器中,可能存在一次恢復並不如預期的情況。所以實現多次嘗試能力,其原理就是用一個定時器多次執行callback,同時設定時間上限,並返回一個取消函式給外部,如果最終結果理想則取消嘗試,否則再次嘗試直到時間上限內達到理想位置。更改恢復函式: typescript restoreLocation(key) { if (!locationCache.current[key]) return; const { x, y } = locationCache.current[key]; //多次嘗試機制 let shouldNextTick = true; cancelRestoreFnCache.current[key] = tryMutilTimes( () => { if (shouldNextTick && nodeCache.current[key]) { nodeCache.current[key]!.scrollLeft = x; nodeCache.current[key]!.scrollTop = y; //如果恢復成功,就取消 if (nodeCache.current[key]!.scrollLeft === x && nodeCache.current[key]!.scrollTop === y) { shouldNextTick = false; cancelRestoreFnCache.current[key](); } } }, props.restoreInterval || 50, props.tryRestoreTimeout || 500 ); }, 至此,滾動恢復的元件全部完成。具體原始碼可以到github檢視,歡迎star。 http://github.com/confuciusthinker/my-scroll-restore

效果

scroll-restore.gif

總結

一個滾動恢復功能,如果想要健壯,完善地實現。其實需要掌握Router,Route相關的原理、history監聽路由變化原理、React Effect的相關執行時機以及一個好的設計思路。而這些都需要我們平時不斷的研究,不斷的追求完美。雖然這並不能“加錢”,但這種能力以及追求是我們成為技術大牛的路途中,最寶貴的財富。當然,能夠加錢最好了😍。

創作不易,歡迎點贊!