前言
上個星期看了 react-router-dom
的原始碼,寫了一篇原始碼解析的文章。大致上理解了 BrowserRouter、Switch、Route、Link 這幾個基本的元件後,這周想實現一個 react-router-dom
簡單版本。
如果還沒看過,可以先看一下,看過以後會更容易理解怎麼實作 😃。
之前都已經實作了 react-redux
、redux-saga
、promise
這些函式庫了,這次不跟上怎麼行,所以就讓我來細說怎麼實作吧! 😆
- 首先,
<Router>
元件用history.location
初始化location
狀態。 <Route>
元件會從 Context 中拿到location
,然後渲染符合location
的元件。- 當使用者點擊 UI 上的
<Link>
時,會呼叫history.push()
把定義在<Route>
上的to
放進 history 中,這時瀏覽器上的 URL 就會跟著一起更動,但是不會跳轉頁面。 - 接著,位置的改動會觸發
history.listen()
,進而改變原本儲存在<Router>
中的location
狀態,然後重複以上第 2 步驟。
這邊
<Router>
你可以當作就是 BrowserRouter。
react-router-dom 元件
現在你知道 react-router-dom
的簡易運行原理了,所以接下來我們重新釐清一下要寫哪些元件:
- BrowserRouter: 起手式元件,讓包在其中的 Child Component 擁有路由的能力,並使用 HTML5 History API 管理 Location。
- Route:是定義元件相對應的 path,當 path 符合目前的 URL 時將會被渲染。
- Link:像是 HTML 的
<a>
,能夠在點擊後轉變目前的 Location。 - Switch:讓第一個符合 URL 的元件會被渲染,反之,如果沒有 Switch 則所有符合 URL 的元件都會被渲染。
- hooks API — useParams:可以取得在 URL 上的參數,例如我們定義
path
為/user/:id
,當 URL 為/user/123
時,能夠取得 URL 上的 id 為 123。
react-router-dom 輔助程式
我們會另外撰寫兩個輔助的程式:
- Context:使用 React Context API 管理資料,讓在 BrowserRouter 中的
- matchPath:使用 Regular Expression 判斷目前的 URL 是否符合在 Route 上定義的
path
。
第三方函式庫
以下兩個第三方函式庫皆是在 react-router
的實作中有用到的函式庫,我們使用函式庫減輕程式碼實作的量,讓我們可以專注實作 react-router
的邏輯。
🔧 history
History API 我們直接拿 react-router
官方的 🔗 history 函式庫來用。
🔧 path-to-regexp
這個套件個功能就如它的名字一樣,可以幫助我們將定義在 Route 上的 path
轉換成 Regular Expression,方便我們可以利用產生的 regexp 萃取出 URL 上的資訊。
使用方法很簡單, pathToRegexp
可以傳入兩個參數,第一個參數為要轉換成 regexp 的 path
,第二個為輸出的參數, path
中的 parms 資料會儲存在 keys
中,例如 :bar
。
Clean Code 的作者 uncle Bob 表示他不喜歡把輸入的參數當作輸出的參數。
從零開始實作 react-router-dom
本篇文章的實作方式都是使用 functional component 以及 hooks API,因為看起來比較簡潔乾淨 😃。
起手式,先來處理兩個輔助函式
Context
首先是基本建立 Context 的方法,不熟悉 Context API 請洽官方文件 🔗Context 與 🔗Hooks API。
matchPath
這是用來匹配 URL 與定義在 Route 元件上定義的 path
,會用到前面提到的 path-to-regexp
這個函式庫,將 path
轉換成 regexp,然後萃取出 URL 上的訊息。
請注意這個函式的兩個參數,很容易搞混:
- pathname:由
history.location.pathname
取得的 URL 訊息。 - path:定義在 Route 元件上的
path
。
regexp.exc(pathname)
回傳的是一個陣列,舉例來說, path
定義為 /user/:id
,匹配 pathname
後會回傳 ["/user/123", "123"]
,陣列中第一個值為符合的字串,而後面皆是字串中符合的 params。
然後透過 match.slice(1)
把 params 儲存到 values
中,但是這時候的 values
只是一個陣列, useParams
需要的是 key 與 value 的對應,所以最後用 keys
跟 values
產生一個物件表示 URL 中符合的 params。
接下來就是主程式了
BrowserRouter
BrowserRouter 是 react-router
的根節點,它讓包裹在裡面的元件擁有路由的能力。以下說明這個元件的做的事情:
- 使用
createBrowserHistory
初始化 history。 - 定義
location
狀態儲存history.location
,也就是目前網頁在的位置。 - 定義
history.listen
,讓儲存目前網頁位置的history.location
改變時,可以把改變的值儲存到location
中。 computeRoomMatch
是react-router
的函式,這邊沒有做更動。實際上這次的實作沒有包含 Route 上 exact 的參數,所以這個函式僅僅只用在 Switch 元件取得 default 的 location 。- 最後用 Context API 傳遞
history
、location
等在子元件用得到的狀態。
Route
Route 的目的很簡單,匹配目前的 URL 與在 Route 元件上定義的 path
,然後渲染符合的元件。
所以首先透過 Context 取的目前的 location
,然後看看 props.computedMatch
有沒有值,如果有值代表使用了 Switch 元件匹配符合的 path
。反之,如果沒有值,則代表沒有 Switch 元件,因此,使用 matchPath
匹配目前的 URL,也就是 location.pathname
與 path
是否一樣。
最終,如果 match
有值,則代表定義在 Route 上的 path
是符合目前的 URL ,因此渲染在 Route 中的子元件。
Link
Link 的目標為提供使用者 <a href="...">
,讓使用者點擊後可以在各個元件中路由,但是不會重新載入頁面。
我們一樣整理一下這個元件要做的兩件事:
- 定義
handleOnClick
,讓使用者在點擊時,使用event.preventDefault()
攔截跳轉頁面的行為,然後把目的地to
儲存到 history 中。 - 回傳
<a href="...">
並在標籤上註冊點擊事件的 callback function,最後渲染子元件。
Switch
Switch 的目標是讓第一個符合 URL 的元件會被渲染,反之,如果沒有 Switch 則所有符合 URL 的元件都會被渲染。
所以,很簡單的思考模式即是迭代所有的子元件,然後判斷目前的 URL 是否匹配子元件上定義的 path
,當符合時則把該元件作為唯一會被渲染的元件。
這裡會用到 React 的高階 API — React.Children.forEach
迭代子元件,你可能會想問為什麼不用 props.children.forEach
就好呢?我上網查了一下資料,唯一能夠解釋的是 React.Children.forEach
可以處理 null
與 undefined
的值。
最後,渲染匹配的元件使用的一樣是 React 高階API — React.cloneElement
。我們直接看官方文件如何解釋這個 API。
React.cloneElement
:複製並回傳一個基於element
的新 React element。這個回傳的 element 的 prop 將會是原本 element 的 prop 與新的 prop 進行 shallow 合併之後的結果。新的 children 將會取代原先的 children。 原先 element 的key
與ref
將會被保留。
結論
如果想看實作程式碼,可以到 👉 Codesandbox 上看看。
這篇文章我們用 Functional Component 與 Hooks API 實作了一個簡易的 react-router-dom
,包括 BrowserRouter、Route、Link、Switch 以上四個元件,其程式邏輯皆是來自於 react-router
官方在 GitHub 上的程式碼。
這次的實作比之前 react-redux
、redux-saga
、promise
簡單許多,目前實作的難度比較大概是 promise == redux-saga > react-redux > react-router-dom
,不過由於都是最低限度的實作,為的是了解套件的原理,難免有些不周全,所以難度比較僅限參考。
分享就到這邊,如果喜歡我的文章可以幫我拍個幾下手,在閱讀文章時如果有遇到什麼問題,或是有什麼建議,都歡迎留言告訴我,謝謝。 😃