React-router V6 攔截 路由跳轉

語言: CN / TW / HK

目標實現效果:攔截路由變化做自定義處理,比如在一個表單尚未填寫完成,用户就要離開當前頁面此時需要給用户做一個提醒,如下圖所示

先説一下背景知識:React-router 是由三個庫一起組成的 history、react-router、react-router-dom 我們平時需要用到的是 react-router-dom

v5 版本實現路由攔截

  • 以前在使用 v5 版本時,是這樣實現路由攔截的

    // 文檔:http://v5.reactrouter.com/core/api/Prompt
      <Prompt
        when={boolean} // 組件何時激活
        message={(location, action) => {
          // 做一些攔截操作 location 要前往的路由,此時可以先保存下來後續使用
          // return false 取消跳轉 比如此時彈起一個自定義彈窗,
          // return true 允許跳轉
        }}
      />

v6 版本實現

  • v6 版本沒有了 Prompt 組件,Google 搜索之後找到了這個stackoverflow v6 beta 時提供了兩個 hooks useBlocker / usePrompt 可以用來實現路由攔截,但是到正式版的時候這兩個 hook 就被移除了,這個issue 裏面有討論,這裏有人找出瞭解決方案就是把刪除的這兩個 hooks 再加回去 :joy:
  • 其實路由攔截功能主要是用到了 history 庫裏面的 block 方法,這裏是相關代碼
  • histoy block 文檔

    history.block will call your callback for all in-page navigation attempts, but for navigation that reloads the page (e.g. the refresh button or a link that doesn't use history.push) it registers a beforeunload handler to prevent the navigation. In modern browsers you are not able to customize this dialog. Instead, you'll see something like this (Chrome):

  • 簡單的翻譯下就是 histoy.block 會阻止頁面中的所有導航並調用callback,但是直接關閉 tab 頁或是刷新會註冊 beforeunload 事件繼而觸發瀏覽器的默認詢問彈窗,不支持去除默認彈框,我下面採用了一種 hack 的辦法來去除 默認詢問彈框
  • 完整代碼

    import { History, Transition } from 'history'
    import { useContext, useEffect } from 'react'
    import { UNSAFE_NavigationContext as NavigationContext } from 'react-router-dom'
    
    type ExtendNavigator = Navigator & Pick<History, 'block'>
    
    export function useBlocker(blocker: (tx: Transition) => void, when = true) {
      const { navigator } = useContext(NavigationContext)
    
      useEffect(() => {
        if (!when) return
        // 如不需要刷新頁面或關閉tab時取消瀏覽器詢問彈窗,下面的綁定事件則不需要
        window.addEventListener('beforeunload', removeBeforeUnload)
        const unblock = (navigator as any as ExtendNavigator).block(tx => {
          const autoUnblockingTx = {
            ...tx,
            retry() {
              unblock()
              tx.retry()
            },
          }
          blocker(autoUnblockingTx)
        })
        // 由於無法直接 remove history 庫中綁定的 beforeunload 事件,只能自己在綁定一個 beforeunload 事件(在原事件之前),觸發時調用 unblock
        // 
        function removeBeforeUnload() {
          unblock()
        }
        return () => {
          unblock()
          window.removeEventListener('beforeunload', removeBeforeUnload)
        }
      }, [when])
    }
  • 使用 useBlocker

    export default function UnsavedPrompt({ when }: Iprops): JSX.Element {
        const [open, setOpen] = useState(false)
        const blockRef = useRef<any>(null)
        useBlocker(tx => {
          setOpen(true)
          blockRef.current = tx
        }, when)
        return (
          <Modal
            open={open}
            toggle={() => setOpen(false)}
            onCancel={() => blockRef.current?.retry()}
            onOk={() => setOpen(false)}
          >
            <p className='text-center text-light-700 text-sm'>
              You have unsaved change, exit without saving?
            </p>
          </Modal>
        )
      }

注意

  • 書寫本文的時間是 2022-08-11 , react-router/react-router-dom 的最新版本為 6.3.0 ,後續可能隨着 react-router-dom 的升級可能還會加回來該功能,上述代碼僅供參考

分割線

上面關於 React-router v6 路由攔截的寫法就已經分享完了,下面再順道記錄一下如何比較表單變化即觸發路由攔截的條件,主要實現了一個 useCompare 的 hook 來做的

  • 分析:對比表單前後兩次數據是否發生變化,無外乎就是把表單的初始數據存一分,然後與正在操作的表單進行深對比,但是由於表單會存在 input 這種組件,他的變化頻率比較快所以要做防抖處理,下面是 useCompare 的具體代碼
  • 先來看一下 useCompare 如何使用

    import { useCompare } from 'hooks/useDebounce'
    
    const compareFunc = useCompare()
    useEffect(() => {
      compareFunc(formData, formDataInit, (flag: boolean) => setFormIsDirty(flag))
    }, [formData, formDataInit])
    // 這裏的 formDataInit 一般要在初始狀態時從 formData 深拷貝一份出來
  • useCompare 實現

    type Tcb = (args: boolean) => void
    
    // debounce/compare hooks
    export function useCompare() {
      const compareFunc = useDebounce(compare)
      return compareFunc
    }
    
    function compare(a: any, b: any, fn: Tcb) {
      fn(!isEqual(a, b))
    }
  • 因為要做防抖處理,首先要實現一個 useDebounce,這裏選擇自己寫了,沒有用現成的

    import { useRef } from 'react'
    
    type TdebounceFnType = (...args: any[]) => void
    
    export default function useDebounce(fn: TdebounceFnType, wait = 1000) {
      const debounceFnRef = useRef(debounce(fn, wait))
      return debounceFnRef.current
    }
    
    // debounce 原始函數
    export function debounce(this: any, fn: TdebounceFnType, wait = 1000) {
        let timer: NodeJS.Timeout | null = null
        const ctx = this
        return function (...args: any[]) {
          timer && clearTimeout(timer)
          timer = setTimeout(() => {
            fn.apply(ctx, args)
          }, wait)
        }
      }