造輪子就是應用核心原理 + 周邊功能的堆砌,所以學習成熟庫的原始碼往往會受到非核心程式碼干擾,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
如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。
關注 前端精讀微信公眾號
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證)