產品:能實現長列表的滾動恢復嘛?我:... 得加錢
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傳遞
- 真正使用時,管理者元件要放在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
效果
總結
一個滾動恢復功能,如果想要健壯,完善地實現。其實需要掌握Router,Route相關的原理、history監聽路由變化原理、React Effect的相關執行時機以及一個好的設計思路。而這些都需要我們平時不斷的研究,不斷的追求完美。雖然這並不能“加錢”,但這種能力以及追求是我們成為技術大牛的路途中,最寶貴的財富。當然,能夠加錢最好了😍。
創作不易,歡迎點贊!