在實際開發中,每每會碰到一些場景,在一個鼠標滾輪滾動過必定位置的列表中,點擊一個具體的列表項,跳到了這個列表項的詳情頁,當返回的時候,爲了保持良好的用戶體驗,但願在回到列表的時候,還能回到以前列表滑動到過的位置。html
在chrome 46以後,history引入了scrollRestoration屬性,這個屬性有提供兩個值,第一個是auto,做爲它的默認值,基於元素進行位置記錄,瀏覽器會原生記錄下window中某個元素的滾動位置,不論是瀏覽器強制刷新切換頁面,仍是pushState,replaceState改變頁面狀態,可能因爲一些操做滾動條會變,可是這個屬性始終讓元素能恢復到以前的屏幕範圍內,可是要注意,只能記錄下在window中滾動的元素,若是是某個容器中的局部滾動,瀏覽器是沒法識別出的,事實上元素在某個容器或者容器的容器中時,瀏覽器並不知道你想要保存哪一個dom的滾動位置,因此這種狀況會失效。在IE於Safari目前也不支持這個屬性。 對於另外一個屬性manual,這等於把屬性設置爲手工進行,這將丟失上述原生的恢復能力,在是瀏覽器強制刷新切換頁面,或者pushState,replaceState改變頁面狀態時,滾動條都會回到頂部。前端
爲了實現容器滾動恢復,具體的思路是,在路由切換,元素即將消失於屏幕前,記錄下元素的滾動位置,元素從新渲染,或者出現於屏幕中時,再恢復這個元素的滾動位置。 得益於React-Router的設計思路,相似Router組件負責蒐集location變化,並把狀態向下傳遞,設計滾動管理組件ScrollManager,用於管理整個應用的滾動狀態。同理相似於React-Router中的Route,做爲具體執行者,進行路由匹配,設計對應的滾動恢復執行者ScrollElement,用以執行具體的恢復邏輯。node
示例使用React 16.8版本,方便使用React Hook, React context的Api。chrome
滾動管理者作爲整個應用的管理員,應該具備一個管理者對象,用來設置原始滾動位置,恢復,保存原始節點等,經過React context的Api,該對象分發給具體的滾動執行者。api
export interface IManager {
registerOrUpdateNode: (key: string, node: HTMLElement) => void;
setLocation: (key: string, node: HTMLElement | null) => void;
setMatch: (key: string, matched: boolean) => void;
restoreLocation: (key: string) => void;
unRegisterNode: (key: string) => void;
}
複製代碼
上述的Manage對象,有註冊HTMLElement元素,設置HTMLElement元素位置,恢復等方法,可是缺乏了緩存對象。對於緩存可使用React.userRef,這個api相似於類的屬性。設置緩存:瀏覽器
/* 註冊緩存內存,相似this.cache */
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 }>({});
複製代碼
經過React.useRef設置了各種緩存。接下來來實現Manager對象:緩存
manager對象使用到上述的緩存對象,並使用key做爲緩存的索引,關於key會在scrollElement中進行說明。session
const manager = {
registerOrUpdateNode: (key: string, node: HTMLElement) => {
nodeCache.current[key] = node;
},
setMatch: (key: string, matched: boolean) => {
matchCache.current[key] = matched;
},
unRegisterNode: (key: string) => {
nodeCache.current[key] = null;
},
setLocation: (key: string, node: HTMLElement | null) => {
if (!node) {
return;
}
locationCache.current[key] = { x: node.scrollLeft, y: node.scrollTop };
},
restoreLocation: (key: string) => {
if (!locationCache.current[key]) {
return;
}
const { x, y } = locationCache.current[key];
nodeCache.current[key].scrollLeft = x;
nodeCache.current[key].scrollTop = y;
}
};
複製代碼
其中registerOrUpdateNode用來保存當前的真實dom節點,unRegisterNode對應用於清空,setLocation用來保存頁面切換前的滾動位置,restoreLocation用於恢復。 在簡單實現了manager對象以後,即可以經過context將對象進行傳遞dom
<ScrollManagerContext.Provider value={manager}>
{shouldChild && props.children}
</ScrollManagerContext.Provider>
複製代碼
這樣一個基本的ScrollManager雛形就完成了。但manager還須要一個重要的能力:獲知元素切換前的位置。只有實現了這個能力,manager才能進行setLoction。ide
在React-Router中,使用了props.history.listen,一切路由狀態的切換都從props.history.listen中發起,因爲listen能夠監聽多個函數,即可利用props.history.listen,在React-Router路由狀態切換前,插入一段監聽函數,去得到相關的節點信息,在得到變化前的節點信息以後,才執行React-Router的路由切換。 路徑爲:
loactionChange---->getDomLocation----->React-Router路由update
複製代碼
示例中使用了一個狀態shoudChild,來確保監聽函數必定是先於React-Router的監聽函數觸發。 實現上使用useEffect模擬了didMount和unMount,在回調函數中,會對每一個nodeCache中的HTMLElement變量,判斷matchCache, matchCache爲true,代表從當前match(路由渲染的頁面)離開,因此離開以前,保存scroll位置。
useEffect(() => {
const unlisten = props.history.listen((_location, _action) => {
// 每次location變化時,保存結點信息
// 這個回調要在history的全部回調中第一個執行,緣由是這個時候還沒進行setState,而且即將要進行setState,在這個回調中拿到的狀態或者dom屬性是進行狀態更新前的
const cacheNodes = Object.entries(nodeCache.current);
cacheNodes.forEach(entry => {
const [key, node] = entry;
// matchCache爲true,代表從當前match(路由渲染的頁面)離開,因此離開以前,保存scroll
if (matchCache.current[key]) {
manager.setLocation(key, node);
}
});
});
// 保證先監聽完上面的回調函數後,才實例化Router! 保證了上面的回調函數最早入棧
setShouldChild(true);
return () => {
// reset全部緩存 防止內存泄露
locationCache.current = {};
matchCache.current = {};
nodeCache.current = {};
cancelRestoreFnCache.current = {};
Object.values(cancelRestoreFnCache.current).forEach(
cancel => cancel && cancel()
);
unlisten();
};
// 依賴爲空,didmount與unmount
}, []);
複製代碼
在組件銷燬時,要清空全部的緩存,防止內存泄漏。ScrollManager在使用時放在Router的外側,這樣能夠控制Router的實例化:
<ScrollManager history={history}>
<Router history={history}>
…………
…………
</Router>
</ScrollManager>
複製代碼
ScrollElement的主要職責是控制真實的HTMLElement元素,決定緩存的key,包括決定什麼時候觸發恢復,什麼時候保存原始HTMLElement的引用,設置是否須要保存位置的標誌等。ScrollElement接受以下Props:
interface IProps {
// 必須 緩存的key
scrollKey: string;
children?: React.ReactElement;
// 爲true觸發滾動恢復
when?: boolean;
// 外部傳入ref
getRef?: () => HTMLElement;
}
複製代碼
其中scrollKey必須傳入的的字段,用來標誌緩存的具體元素,緩存的位置信息,緩存的狀態等,須要全局惟一。使用when字段可控制是否須要進行滾動恢復,ScrollElement本質上是個代理,會拿到子元素的ref,接管其控制權,也能夠自行實現getRef傳入組件中,組件會對傳入的ref作操做。
// ScrollElement
export default function(props: IProps) {
const nodeRef = React.useRef<HTMLElement>();
const manager: IManager = useContext<IManager>(ScrollManagerContext);
const currentMatch = useContext(RouterContext).match;
useEffect(() => {
if (currentMatch) {
// 設置標誌,代表在location改變時,能夠保存路徑
manager.setMatch(props.scrollKey, true);
// 更新ref,代理的dom可能會remount,因此要每次更新
nodeRef.current &&
manager.registerOrUpdateNode(props.scrollKey, nodeRef.current);
// 恢復原先滑動過的位置,可經過外部props通知是否須要進行恢復,通常爲:when={xxx.length>0}
(props.when === undefined || props.when) &&
manager.restoreLocation(props.scrollKey);
} else {
// 沒命中設置標誌,不要保存路徑
manager.setMatch(props.scrollKey, false);
}
// 銷燬時註銷這個node
return () => manager.unRegisterNode(props.scrollKey);
});
if (props.getRef) {
// 獲得ref了 不用關心children了
nodeRef.current = props.getRef();
return props.children;
}
const onlyOneChild = React.Children.only(props.children);
// 代理第一個child,須要是真實的dom,div,h1,h2……不能是組件
if (typeof onlyOneChild.type === "string") {
// 必須是 原生tag 在合格的子元素上 加上新的ref
// 以便接管控制權
return React.cloneElement(onlyOneChild, { ref: nodeRef });
} else {
console.warn(
"-------------滾動恢復將失效,ScrollElement的children須爲原生的單個html標籤-------------"
);
return props.children;
}
}
複製代碼
使用useEffect,會執行didmount,didupdate,willunmount生命週期,在初次加載或者每次更新的時候,會根據當前的Route匹配與否作對應的處理,若是Route匹配成功,代表當前的ScrollElement組件應是渲染的,這時能夠在effect中執行更新ref的操做,之因此在effect中執行更新,是因爲代理的dom可能會remount,因此要每次更新。同時還須要 設置標誌,代表在location改變時,是能夠保存滾動位置的,至關於告訴Manager,我此刻渲染成功了,你能夠在離開頁面的時候把我如今的位置保留下來。如過match爲false,代表此刻組件並無跟路由匹配上,不該渲染,因此manager此刻也不該保存這個元素的位置信息。 在元素匹配成功,而且更新了dom,這個時候即可在effect中恢復元素到原先的位置:
(props.when === undefined || props.when) &&
manager.restoreLocation(props.scrollKey);
複製代碼
在以前的manager部分有過介紹,這個時候會根據key得到緩存的位置信息,並設dom屬性,以恢復元素位置:
restoreLocation: (key: string) => {
if (!locationCache.current[key]) {
return;
}
const { x, y } = locationCache.current[key];
nodeCache.current[key].scrollLeft = x;
nodeCache.current[key].scrollTop = y;
}
複製代碼
元素的恢復能夠經過when來判斷是否須要滾動恢復。 若是ScrollElement是第一次渲染,因爲沒有保存過滾動位置,執行restoreLocation不會觸發任何行爲。至此ScrollElement就實現完成。使用方法:
<ScrollElement
when={bigArray.length > 0}
scrollKey="xxxxx(全局惟一)"
>
<ul>
…………
…………
</ul>
</ScrollElement>
複製代碼
在上面的恢復過程當中,只執行了一次恢復的行爲:
nodeCache.current[key].scrollLeft = x;
nodeCache.current[key].scrollTop = y;
複製代碼
對於一些瀏覽器,有可能執行一次位置賦值瀏覽器獲得的結果並不如預期,可能會有誤差,可引入一個工具函數使得能夠屢次執行:
// 可取消,爲cancelable的
const tryMutilTimes = (
callback: (...args: any[]) => void,
tickInterval: number,
timeout: number
) => {
const timeId = setInterval(callback, tickInterval);
setTimeout(() => {
clearTimeout(timeId);
}, timeout);
return () => clearTimeout(timeId);
};
複製代碼
使用一個定時器屢次執行callback,同時設置一個執行時間上限,並返回一個取消的函數給到外部。 tryMutilTimes爲可取消的,這給restoreLocation很好的控制能力,更改後的restoreLocation爲:
restoreLocation: (key: string) => {
if (!locationCache.current[key]) {
return;
}
const { x, y } = locationCache.current[key];
let shoudNextTick = true;
cancelRestoreFnCache.current[key] = tryMutilTimes(
() => {
if (shoudNextTick && nodeCache.current[key]) {
nodeCache.current[key]!.scrollLeft = x;
nodeCache.current[key]!.scrollTop = y;
// 若是恢復成功 就取消,不用再恢復了
if (
nodeCache.current[key]!.scrollTop === y &&
nodeCache.current[key]!.scrollLeft === x
) {
shoudNextTick = false;
cancelRestoreFnCache.current[key]();
}
}
// 每隔50ms試一次恢復,試到500ms結束, 時間可配置
},
props.restoreInterval || 50,
props.tryRestoreTimeout || 500
);
}
複製代碼
設置一個時間間隔屢次嘗試滾動恢復的操做,若是最終恢復的位置與預期一致,即可取消tryMutilTimes屢次嘗試,滾動恢復結束,若是與預期不符,便再次嘗試,直到timeout時間內與預期的滾動位置一致。
最終的示例地址: codesandbox.io/s/kind-moon…
雖然有scrollRestoration的幫助,可是因爲此接口兼容性問題,在chrome 46如下也不支持,window的恢復也能夠照此思路實現。
示例中的實現只是把位置信息保留在內存中,刷新就會丟失,若是遇到刷新也要保存的場景,能夠把位置信息同步到sessionStorage,localStorage等,進行持久化存儲。
打個廣告,成都美團招前端,感興趣的小夥伴可郵件至klfzlyt@outlook.com