不到 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 地址變化時(無論是用戶觸發還是自己的
navigateLink觸發),渲染新 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