导言
在实际开发中,往往会碰到一些场景,在一个鼠标滚轮滚动过一定位置的列表中,点击一个具体的列表项,跳到了这个列表项的详情页,当返回的时候,为了保持良好的用户体验,希望在回到列表的时候,还能回到之前列表滑动到过的位置。
scrollRestoration
在chrome 46之后,history引入了scrollRestoration属性,这个属性有提供两个值,第一个是auto,作为它的默认值,基于元素进行位置记录,浏览器会原生记录下window中某个元素的滚动位置,不管是浏览器强制刷新切换页面,还是pushState,replaceState改变页面状态,可能由于一些操作滚动条会变,但是这个属性始终让元素能恢复到之前的屏幕范围内,但是要注意,只能记录下在window中滚动的元素,如果是某个容器中的局部滚动,浏览器是无法识别出的,事实上元素在某个容器或者容器的容器中时,浏览器并不知道你想要保存哪个dom的滚动位置,所以这种情况会失效。在IE于Safari目前也不支持这个属性。 对于另一个属性manual,这等于把属性设置为手工进行,这将丢失上述原生的恢复能力,在是浏览器强制刷新切换页面,或者pushState,replaceState改变页面状态时,滚动条都会回到顶部。
容器元素滚动恢复
为了实现容器滚动恢复,具体的思路是,在路由切换,元素即将消失于屏幕前,记录下元素的滚动位置,元素重新渲染,或者出现于屏幕中时,再恢复这个元素的滚动位置。 得益于React-Router的设计思路,类似Router组件负责搜集location变化,并把状态向下传递,设计滚动管理组件ScrollManager,用于管理整个应用的滚动状态。同理类似于React-Router中的Route,作为具体执行者,进行路由匹配,设计对应的滚动恢复执行者ScrollElement,用以执行具体的恢复逻辑。
示例使用React 16.8版本,方便使用React Hook, React context的Api。
滚动管理者ScrollManager
滚动管理者做为整个应用的管理员,应该具有一个管理者对象,用来设置原始滚动位置,恢复,保存原始节点等,通过React context的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类似于类的属性。设置缓存:
cache
/*
注册缓存内存,类似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
manager对象使用到上述的缓存对象,并使用key作为缓存的索引,关于key会在scrollElement中进行说明。
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将对象进行传递
Provider
<ScrollManagerContext.Provider value={manager}>
{shouldChild && props.children}
</ScrollManagerContext.Provider>
这样一个基本的ScrollManager雏形就完成了。但manager还需要一个重要的能力:获知元素切换前的位置。只有实现了这个能力,manager才能进行setLoction。
获知元素切换前的位置
在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
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…
其他
window的恢复
虽然有scrollRestoration的帮助,但是由于此接口兼容性问题,在chrome 46以下也不支持,window的恢复也可以照此思路实现。
刷新问题
示例中的实现只是把位置信息保留在内存中,刷新就会丢失,如果遇到刷新也要保存的场景,可以把位置信息同步到sessionStorage,localStorage等,进行持久化存储。
tips
打个广告,成都美团招前端,感兴趣的小伙伴可邮件至klfzlyt@outlook.com