淺析history 和 react-router 的實現(xiàn)原理
前言
在前一篇文章中,我們詳細的說了 react-router@3.x 升級到 @6.x 需要注意的問題以及變更的使用方式。
react-router 版本更新非???,但是它的底層實現(xiàn)原理確是萬變不離其中,在本文中會從前端路由出發(fā)到 react-router 原理總結(jié)與分享。
前端路由
在 Web 前端單頁面應(yīng)用 SPA(Single Page Application)中,路由是描述 URL 和 UI 之間的映射關(guān)系,這種映射是單向的,即 URL 的改變會引起 UI 更新,無需刷新頁面
如何實現(xiàn)前端路由
實現(xiàn)前端路由,需要解決兩個核心問題
- 如何改變 URL 卻不引起頁面刷新?
- 如何監(jiān)測 URL 變化?
在前端路由的實現(xiàn)模式有兩種模式,hash 和 history 模式,分別回答上述兩個問題
hash 模式
- hash 是 url 中 hash(#) 及后面的部分,常用錨點在頁面內(nèi)做導(dǎo)航,改變 url 中的 hash 部分不會引起頁面的刷新
- 通過 hashchange 事件監(jiān)聽 URL 的改變。改變 URL 的方式只有以下幾種:通過瀏覽器導(dǎo)航欄的前進后退、通過
<a>
標簽、通過window.location
,這幾種方式都會觸發(fā)hashchange
事件
history 模式
- history 提供了
pushState
和replaceState
兩個方法,這兩個方法改變 URL 的 path 部分不會引起頁面刷新 - 通過 popchange 事件監(jiān)聽 URL 的改變。需要注意只在通過瀏覽器導(dǎo)航欄的前進后退改變 URL 時會觸發(fā)
popstate
事件,通過<a>
標簽和pushState
/replaceState
不會觸發(fā)popstate
方法。但我們可以攔截<a>
標簽的點擊事件和pushState
/replaceState
的調(diào)用來檢測 URL 變化,也是可以達到監(jiān)聽 URL 的變化,相對hashchange
顯得略微復(fù)雜
JS 實現(xiàn)前端路由
基于 hash 實現(xiàn)
由于三種改變 hash 的方式都會觸發(fā)hashchange
方法,所以只需要監(jiān)聽hashchange
方法。需要在DOMContentLoaded
后,處理一下默認的 hash 值
// 頁面加載完不會觸發(fā) hashchange,這里主動觸發(fā)一次 hashchange 事件,處理默認hash window.addEventListener('DOMContentLoaded', onLoad); // 監(jiān)聽路由變化 window.addEventListener('hashchange', onHashChange); // 路由變化時,根據(jù)路由渲染對應(yīng) UI function onHashChange() { switch (location.hash) { case '#/home': routerView.innerHTML = 'This is Home'; return; case '#/about': routerView.innerHTML = 'This is About'; return; case '#/list': routerView.innerHTML = 'This is List'; return; default: routerView.innerHTML = 'Not Found'; return; } }
基于 history 實現(xiàn)
因為 history 模式下,<a>
標簽和pushState
/replaceState
不會觸發(fā)popstate
方法,我們需要對<a>
的跳轉(zhuǎn)和pushState
/replaceState
做特殊處理。
- 對
<a>
作點擊事件,禁用默認行為,調(diào)用pushState
方法并手動觸發(fā)popstate
的監(jiān)聽事件 - 對
pushState
/replaceState
可以重寫 history 的方法并通過派發(fā)事件能夠監(jiān)聽對應(yīng)事件
var _wr = function (type) { var orig = history[type]; return function () { var e = new Event(type); e.arguments = arguments; var rv = orig.apply(this, arguments); window.dispatchEvent(e); return rv; }; }; // 重寫pushstate事件 history.pushState = _wr('pushstate'); function onLoad() { routerView = document.querySelector('#routeView'); onPopState(); // 攔截 <a> 標簽點擊事件默認行為 // 點擊時使用 pushState 修改 URL并更新手動 UI,從而實現(xiàn)點擊鏈接更新 URL 和 UI 的效果。 var linkList = document.querySelectorAll('a[href]'); linkList.forEach((el) => el.addEventListener('click', function (e) { e.preventDefault(); history.pushState(null, '', el.getAttribute('href')); onPopState(); }), ); } // 監(jiān)聽pushstate方法 window.addEventListener('pushstate', onPopState()); // 頁面加載完不會觸發(fā) hashchange,這里主動觸發(fā)一次 popstate 事件,處理默認pathname window.addEventListener('DOMContentLoaded', onLoad); // 監(jiān)聽路由變化 window.addEventListener('popstate', onPopState); // 路由變化時,根據(jù)路由渲染對應(yīng) UI function onPopState() { switch (location.pathname) { case '/home': routerView.innerHTML = 'This is Home'; return; case '/about': routerView.innerHTML = 'This is About'; return; case '/list': routerView.innerHTML = 'This is List'; return; default: routerView.innerHTML = 'Not Found'; return; } }
React-Router 的架構(gòu)
- history 庫給 browser、hash 兩種 history 提供了統(tǒng)一的 API,給到 react-router-dom 使用
- react-router 實現(xiàn)了路由的最核心能力。提供了
<Router>
、<Route>
等組件,以及配套 hook - react-router-dom 是對 react-router 更上一層封裝。把 history 傳入
<Router>
并初始化成<BrowserRouter>
、<HashRouter>
,補充了<Link>
這樣給瀏覽器直接用的組件。同時把 react-router 直接導(dǎo)出,減少依賴
History 實現(xiàn)
history
在上文中說到,BrowserRouter
使用 history 庫提供的createBrowserHistory
創(chuàng)建的history
對象改變路由狀態(tài)和監(jiān)聽路由變化。
? 那么 history 對象需要提供哪些功能訥?
- 監(jiān)聽路由變化的
listen
方法以及對應(yīng)的清理監(jiān)聽unlisten
方法 - 改變路由的
push
方法
// 創(chuàng)建和管理listeners的方法 export const EventEmitter = () => { const events = []; return { subscribe(fn) { events.push(fn); return function () { events = events.filter((handler) => handler !== fn); }; }, emit(arg) { events.forEach((fn) => fn && fn(arg)); }, }; };
BrowserHistory
const createBrowserHistory = () => { const EventBus = EventEmitter(); // 初始化location let location = { pathname: '/', }; // 路由變化時的回調(diào) const handlePop = function () { const currentLocation = { pathname: window.location.pathname, }; EventBus.emit(currentLocation); // 路由變化時執(zhí)行回調(diào) }; // 定義history.push方法 const push = (path) => { const history = window.history; // 為了保持state棧的一致性 history.pushState(null, '', path); // 由于push并不觸發(fā)popstate,我們需要手動調(diào)用回調(diào)函數(shù) location = { pathname: path }; EventBus.emit(location); }; const listen = (listener) => EventBus.subscribe(listener); // 處理瀏覽器的前進后退 window.addEventListener('popstate', handlePop); // 返回history const history = { location, listen, push, }; return history; };
對于 BrowserHistory 來說,我們的處理需要增加一項,當(dāng)我們觸發(fā) push 的時候,需要手動通知所有的監(jiān)聽者,因為 pushState 無法觸發(fā) popState 事件,因此需要手動觸發(fā)
HashHistory
const createHashHistory = () => { const EventBus = EventEmitter(); let location = { pathname: '/', }; // 路由變化時的回調(diào) const handlePop = function () { const currentLocation = { pathname: window.location.hash.slice(1), }; EventBus.emit(currentLocation); // 路由變化時執(zhí)行回調(diào) }; // 不用手動執(zhí)行回調(diào),因為hash改變會觸發(fā)hashchange事件 const push = (path) => (window.location.hash = path); const listen = (listener: Function) => EventBus.subscribe(listener); // 監(jiān)聽hashchange事件 window.addEventListener('hashchange', handlePop); // 返回的history上有個listen方法 const history = { location, listen, push, }; return history; };
在實現(xiàn) hashHistory 的時候,我們只是對hashchange進行了監(jiān)聽,當(dāng)該事件發(fā)生時,我們獲取到最新的 location 對象,在通知所有的監(jiān)聽者 listener 執(zhí)行回調(diào)函數(shù)
React-Router@6 丐版實現(xiàn)
- 綠色為 history 中的方法
- 紫色為 react-router-dom 中的方法
- 橙色為 react-router 中的方法
Router
??? 基于 Context 的全局狀態(tài)下發(fā)。Router 是一個 “Provider-Consumer” 模型
Router 做的事情很簡單,接收navigator
和location
,使用 context 將數(shù)據(jù)傳遞下去,能夠讓子組件獲取到相關(guān)的數(shù)據(jù)
function Router(props: IProps) { const { navigator, children, location } = props; const navigationContext = React.useMemo(() => ({ navigator }), [navigator]); const { pathname } = location; const locationContext = React.useMemo( () => ({ location: { pathname } }), [pathname], ); return ( <NavigationContext.Provider value={navigationContext}> <LocationContext.Provider value={locationContext} children={children} /> </NavigationContext.Provider> ); }
HashRouter
基于不同的 history 調(diào)用 Router 組件。并且在 history 發(fā)生改變的時候,監(jiān)聽 history,能夠在 location 發(fā)生改變的時候,執(zhí)行回調(diào)改變 location。
在下面的代碼中,能夠發(fā)現(xiàn)監(jiān)聽者為 setState
函數(shù),在上述 hashHistory 中,如果我們的 location 發(fā)生了改變,會通知到所有的監(jiān)聽者執(zhí)行回調(diào),也就是我們這里的 setState
函數(shù),即我們能夠拿到最新的 location 信息通過 LocationContext 傳遞給子組件,再去做對應(yīng)的路由匹配
function HashRouter({ children }) { let historyRef = React.useRef(); if (historyRef.current == null) { historyRef.current = createHashHistory(); } let history = historyRef.current; let [state, setState] = React.useState({ location: history.location, }); React.useEffect(() => { const unListen = history.listen(setState); return unListen; }, [history]); return ( <Router children={children} location={state.location} navigator={history} /> ); }
Routes/Route
我們能夠發(fā)現(xiàn)在 v6.0 的版本 Route 組件只是一個工具人,并沒有做任何事情。
function Route(_props: RouteProps): React.ReactElement | null { invariant( false, `A <Route> is only ever to be used as the child of <Routes> element, ` + `never rendered directly. Please wrap your <Route> in a <Routes>.`, ); }
實際上處理一切邏輯的組件是 Routes,它內(nèi)部實現(xiàn)了根據(jù)路由的變化,匹配出一個正確的組件。
const Routes = ({ children }) => { return useRoutes(createRoutesFromChildren(children)); };
useRoutes 為整個 v6 版本的核心,分為路由上下文解析、路由匹配、路由渲染三個步驟
<Routes> <Route path="/home" element={<Home />}> <Route path="1" element={<Home1 />}> <Route path="2" element={<Home2 />}></Route> </Route> </Route> <Route path="/about" element={<About />}></Route> <Route path="/list" element={<List />}></Route> <Route path="/notFound" element={<NotFound />} /> <Route path="/navigate" element={<Navigate to="/notFound" />} /> </Routes>
上述 Routes 代碼中,通過 createRoutesFromChildren 函數(shù)將 Route 組件結(jié)構(gòu)化??梢园?nbsp;<Route>
類型的 react element 對象,變成了普通的 route 對象結(jié)構(gòu),如下圖
useRoutes
useRoutes 才是真正處理渲染關(guān)系的,其代碼如下:
// 第一步:獲取相關(guān)的 pathname let location = useLocation(); let { matches: parentMatches } = React.useContext(RouteContext); // 第二步:找到匹配的路由分支,將 pathname 和 Route 的 path 做匹配 const matches = matchRoutes(routes, location); // 第三步:渲染真正的路由組件 const renderedMatches = _renderMatches(matches, parentMatches); return renderedMatches;
matchRoutes
matchRoutes 中通過 pathname 和路由的 path 進行匹配
因為我們在 Route 中定義的 path 都是相對路徑,所以我們在 matchRoutes 方法中,需要對 routes 對象遍歷,對于 children 里面的 path 需要變成完整的路徑,并且需要將 routes 扁平化,不在使用嵌套結(jié)構(gòu)
const flattenRoutes = ( routes, branches = [], parentsMeta = [], parentPath = '', ) => { const flattenRoute = (route) => { const meta = { relativePath: route.path || '', route, }; const path = joinPaths([parentPath, meta.relativePath]); const routesMeta = parentsMeta.concat(meta); if (route.children?.length > 0) { flattenRoutes(route.children, branches, routesMeta, path); } if (route.path == null) { return; } branches.push({ path, routesMeta }); }; routes.forEach((route) => { flattenRoute(route); }); return branches; };
當(dāng)我們訪問/#/home/1/2
的時候,獲得的 matches 如下
我們得到的 match 順序是從 Home → Home1 → Home2
_renderMatches
_renderMatches 才會渲染所有的 matches 對象
const _renderMatches = (matches, parentMatches = []) => { let renderedMatches = matches; return renderedMatches.reduceRight((outlet, match, index) => { let matches = parentMatches.concat(renderedMatches.slice(0, index + 1)); const getChildren = () => { let children; if (match.route.Component) { children = <match.route.Component />; } else if (match.route.element) { children = match.route.element; } else { children = outlet; } return ( <RouteContext.Provider value={{ outlet, matches, }} > {children} </RouteContext.Provider> ); }; return getChildren(); }, null); };
_renderMatches 這段代碼我們能夠明白 outlet 作為子路由是如何傳遞給父路由渲染的。matches 采用從右往左的遍歷順序,將上一項的返回值作為后一項的 outlet,那么子路由就作為 outlet 傳遞給了父路由
Outlet
實際上就是內(nèi)部渲染 RouteContext 的 outlet 屬性
function Outlet(props) { return useOutlet(props.context); } function useOutlet(context?: unknown) { let outlet = useContext(RouteContext).outlet; // 獲取上一級 RouteContext 上面的 outlet if (outlet) { return ( <OutletContext.Provider value={context}>{outlet}</OutletContext.Provider> ); } return outlet; }
Link
在 Link 中,我們使用<a>
標簽來做跳轉(zhuǎn),但是 a 標簽會使頁面重新刷新,所以需要阻止 a 標簽的默認行為,調(diào)用 useNavigate 方法進行跳轉(zhuǎn)
function Link({ to, children, onClick }) { const navigate = useNavigate(); const handleClick = onClick ? onClick : (event) => { event.preventDefault(); navigate(to); }; return ( <a href={to} onClick={handleClick}> {children} </a> ); }
Hooks
function useLocation() { return useContext(LocationContext).location; } function useNavigate() { const { navigator } = useContext(NavigationContext); const navigate = useCallback( (to: string) => { navigator.push(to); }, [navigator], ); return navigate; }
本文所有的代碼鏈接可點擊查看
參考鏈接
- react router v6 使用詳解以及部分源碼解析(新老版本對比) - 掘金
- 「React 進階」react-router v6 通關(guān)指南 - 掘金
- 一文讀懂 react-router 原理
到此這篇關(guān)于一文了解 history 和 react-router 的實現(xiàn)原理的文章就介紹到這了,更多相關(guān)history 和 react-router實現(xiàn)原理內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
React?Server?Component混合式渲染問題詳解
React?官方對?Server?Comopnent?是這樣介紹的:?zero-bundle-size?React?Server?Components,這篇文章主要介紹了React?Server?Component:?混合式渲染,需要的朋友可以參考下2022-12-12詳解如何用webpack4從零開始構(gòu)建react開發(fā)環(huán)境
這篇文章主要介紹了詳解如何用webpack4從零開始構(gòu)建react開發(fā)環(huán)境,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2019-01-01使用react-virtualized實現(xiàn)圖片動態(tài)高度長列表的問題
一般我們在寫react項目中,同時渲染很多dom節(jié)點,會造成頁面卡頓, 空白的情況。為了解決這個問題,今天小編給大家分享一篇教程關(guān)于react-virtualized實現(xiàn)圖片動態(tài)高度長列表的問題,感興趣的朋友跟隨小編一起看看吧2021-05-05詳解react native頁面間傳遞數(shù)據(jù)的幾種方式
這篇文章主要介紹了詳解react native頁面間傳遞數(shù)據(jù)的幾種方式,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-11-11react進階教程之異常處理機制error?Boundaries
在react中一旦出錯,如果每個組件去處理出錯情況則比較麻煩,下面這篇文章主要給大家介紹了關(guān)于react進階教程之異常處理機制error?Boundaries的相關(guān)資料,文中通過實例代碼介紹的非常詳細,需要的朋友可以參考下2022-08-08