不到 100 行代碼,實現 React Router 核心邏輯
造輪子就是應用核心原理 + 周邊功能的堆砌,所以學習成熟庫的源碼往往會受到非核心代碼干擾,Router 這個 repo 用不到 100 行源碼實現了 React Router 核心機制,很適合用來學習。
精讀
Router 快速實現了 React Router 3 個核心 API:Router
、navigate
、Link
,下面列出基本用法,配合理解源碼實現會更方便:
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,即 Router
套 Router
後,不僅監聽方式要變化,還需要將命中的組件緩存下來,需要考慮的點會逐漸變多。
下面該實現 navigate
Link
了,他倆做的事情都是跳轉,有如下區別:
-
API 調用方式不同,
navigate
是調用式函數,而Link
是一個內置navigate
能力的a
標籤。 -
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
就很簡單了,有幾個考慮點:
-
返回一個正常的
<a>
標籤。 -
因爲正常
<a>
點擊後就發生網頁刷新而不是單頁跳轉,所以點擊時要阻止默認行爲,換成我們的navigate
(源碼裏沒做這個抽象,筆者稍微優化了下)。 -
但按住
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>
標籤默認行爲,又能在點擊時優化爲單頁跳轉,裏面對 preventDefault
與 metaKey
的判斷值得學習。
總結
從這個小輪子中可以學習到一下幾個經驗:
-
造輪子之前先想好使用 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
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/QZ49KF-k14NamqlF_hBT9A