150行hook实现React滚动恢复

4,765 阅读9分钟

导言

在实际开发中,往往会碰到一些场景,在一个鼠标滚轮滚动过一定位置的列表中,点击一个具体的列表项,跳到了这个列表项的详情页,当返回的时候,为了保持良好的用户体验,希望在回到列表的时候,还能回到之前列表滑动到过的位置。

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