精讀《react-snippets - Router 原始碼》

黃子毅發表於2022-05-23

造輪子就是應用核心原理 + 周邊功能的堆砌,所以學習成熟庫的原始碼往往會受到非核心程式碼干擾,Router 這個 repo 用不到 100 行原始碼實現了 React Router 核心機制,很適合用來學習。

精讀

Router 快速實現了 React Router 3 個核心 API:RouternavigateLink,下面列出基本用法,配合理解原始碼實現會更方便:

const App = () => (
  <Router
    routes={[
      { path: '/home', component: <Home /> },
      { path: '/articles', component: <Articles /> }
    ]}
  />
)

const Home = () => (
  <div>
    home, <Link href="/articles">go articles</Link>,
    <span onClick={() => navigate('/details')}>or jump to details</span>
  </div>
)

首先看 Router 的實現,在看程式碼之前,思考下 Router 要做哪些事情?

  • 接收 routes 引數,根據當前 url 地址判斷渲染哪個元件。
  • 當 url 地址變化時(無論是使用者觸發還是自己的 navigate Link 觸發),渲染新 url 對應的元件。

所以 Router 是一個路由渲染分配器與 url 監聽器:

export default function Router ({ routes }) {
  // 儲存當前 url path,方便其變化時引發自身重渲染,以返回新的 url 對應的元件
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  useEffect(() => {
    const onLocationChange = () => {
      // 將 url path 更新到當前資料流中,觸發自身重渲染
      setCurrentPath(window.location.pathname);
    }

    // 監聽 popstate 事件,該事件由使用者點選瀏覽器前進/後退時觸發
    window.addEventListener('popstate', onLocationChange);

    return () => window.removeEventListener('popstate', onLocationChange)
  }, [])

  // 找到匹配當前 url 路徑的元件並渲染
  return routes.find(({ path, component }) => path === currentPath)?.component
}

最後一段程式碼看似每次都執行 find 有一定效能損耗,但其實根據 Router 一般在最根節點的特性,該函式很少因父元件重渲染而觸發渲染,所以效能不用太擔心。

但如果考慮做一個完整的 React Router 元件庫,考慮了更復雜的巢狀 API,即 RouterRouter 後,不僅監聽方式要變化,還需要將命中的元件快取下來,需要考慮的點會逐漸變多。

下面該實現 navigate Link 了,他倆做的事情都是跳轉,有如下區別:

  1. API 呼叫方式不同,navigate 是呼叫式函式,而 Link 是一個內建 navigate 能力的 a 標籤。
  2. Link 其實還有一種按住 ctrl 後開啟新 tab 的跳轉模式,該模式由瀏覽器對 a 標籤預設行為完成。

所以 Link 更復雜一些,我們先實現 navigate,再實現 Link 時就可以複用它了。

既然 Router 已經監聽 popstate 事件,我們顯然想到的是觸發 url 變化後,讓 popstate 捕獲,自動觸發後續跳轉邏輯。但可惜的是,我們要做的 React Router 需要實現單頁跳轉邏輯,而單頁跳轉的 API history.pushState 並不會觸發 popstate,為了讓實現更優雅,我們可以在 pushState 後手動觸發 popstate 事件,如原始碼所示:

export function navigate (href) {
  // 用 pushState 直接重新整理 url,而不觸發真正的瀏覽器跳轉
  window.history.pushState({}, "", href);

  // 手動觸發一次 popstate,讓 Route 元件監聽並觸發 onLocationChange
  const navEvent = new PopStateEvent('popstate');
  window.dispatchEvent(navEvent);
}

接下來實現 Link 就很簡單了,有幾個考慮點:

  1. 返回一個正常的 <a> 標籤。
  2. 因為正常 <a> 點選後就發生網頁重新整理而不是單頁跳轉,所以點選時要阻止預設行為,換成我們的 navigate(原始碼裡沒做這個抽象,筆者稍微優化了下)。
  3. 但按住 ctrl 時又要開啟新 tab,此時用預設 <a> 標籤行為就行,所以此時不要阻止預設行為,也不要繼續執行 navigate,因為這個 url 變化不會作用於當前 tab。
export function Link ({ className, href, children }) {
  const onClick = (event) => {
    // mac 的 meta or windows 的 ctrl 都會開啟新 tab
    // 所以此時不做定製處理,直接 return 用原生行為即可
    if (event.metaKey || event.ctrlKey) {
      return;
    }

    // 否則禁用原生跳轉
    event.preventDefault();

    // 做一次單頁跳轉
    navigate(href)
  };

  return (
    <a className={className} href={href} onClick={onClick}>
      {children}
    </a>
  );
};

這樣的設計,既能兼顧 <a> 標籤預設行為,又能在點選時優化為單頁跳轉,裡面對 preventDefaultmetaKey 的判斷值得學習。

總結

從這個小輪子中可以學習到一下幾個經驗:

  • 造輪子之前先想好使用 API,根據使用 API 反推實現,會讓你的設計更有全域性觀。
  • 實現 API 時,先思考 API 之間的關係,能複用的就提前設計好複用關係,這樣巧妙的關聯設計能為以後維護減少很多麻煩。
  • 即便程式碼無法複用的地方,也要儘量做到邏輯複用。比如 pushState 無法觸發 popstate 那段,直接把 popstate 程式碼複用過來,或者自己造一個狀態溝通就太 low 了,用瀏覽器 API 模擬事件觸發,既輕量,又符合邏輯,因為你要做的就是觸發 popstate 行為,而非只是更新渲染元件這個動作,萬一以後再有監聽 popstate 的地方,你的觸發邏輯就能很自然的應用到那兒。
  • 儘量在原生能力上擴充,而不是用自定義方法補齊原生能力。比如 Link 的實現是基於 <a> 標籤擴充的,如果採用自定義 <span> 標籤,不僅要補齊樣式上的差異,還要自己實現 ctrl 後開啟新 tab 的行為,甚至 <a> 預設訪問記錄行為你也得花高成本補上,所以錯誤的設計方向會導致事半功倍,甚至無法實現。
討論地址是:精讀《react-snippets - Router 原始碼》· Issue #418 · dt-fe/weekly

如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公眾號

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證

相關文章