150行hook實現React滾動恢復

導言

在實際開發中,每每會碰到一些場景,在一個鼠標滾輪滾動過必定位置的列表中,點擊一個具體的列表項,跳到了這個列表項的詳情頁,當返回的時候,爲了保持良好的用戶體驗,但願在回到列表的時候,還能回到以前列表滑動到過的位置。html

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,用以執行具體的恢復邏輯。node

示例使用React 16.8版本,方便使用React Hook, React context的Api。chrome

滾動管理者ScrollManager

滾動管理者作爲整個應用的管理員,應該具備一個管理者對象,用來設置原始滾動位置,恢復,保存原始節點等,經過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相似於類的屬性。設置緩存:瀏覽器

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中進行說明。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

Provider
<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

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

相關文章
相關標籤/搜索