淺析history 和 react-router 的實(shí)現(xiàn)原理
前言
在前一篇文章中,我們?cè)敿?xì)的說(shuō)了 react-router@3.x 升級(jí)到 @6.x 需要注意的問(wèn)題以及變更的使用方式。
react-router 版本更新非常快,但是它的底層實(shí)現(xiàn)原理確是萬(wàn)變不離其中,在本文中會(huì)從前端路由出發(fā)到 react-router 原理總結(jié)與分享。
前端路由
在 Web 前端單頁(yè)面應(yīng)用 SPA(Single Page Application)中,路由是描述 URL 和 UI 之間的映射關(guān)系,這種映射是單向的,即 URL 的改變會(huì)引起 UI 更新,無(wú)需刷新頁(yè)面
如何實(shí)現(xiàn)前端路由
實(shí)現(xiàn)前端路由,需要解決兩個(gè)核心問(wèn)題
- 如何改變 URL 卻不引起頁(yè)面刷新?
- 如何監(jiān)測(cè) URL 變化?
在前端路由的實(shí)現(xiàn)模式有兩種模式,hash 和 history 模式,分別回答上述兩個(gè)問(wèn)題
hash 模式
- hash 是 url 中 hash(#) 及后面的部分,常用錨點(diǎn)在頁(yè)面內(nèi)做導(dǎo)航,改變 url 中的 hash 部分不會(huì)引起頁(yè)面的刷新
- 通過(guò) hashchange 事件監(jiān)聽(tīng) URL 的改變。改變 URL 的方式只有以下幾種:通過(guò)瀏覽器導(dǎo)航欄的前進(jìn)后退、通過(guò)
<a>標(biāo)簽、通過(guò)window.location,這幾種方式都會(huì)觸發(fā)hashchange事件
history 模式
- history 提供了
pushState和replaceState兩個(gè)方法,這兩個(gè)方法改變 URL 的 path 部分不會(huì)引起頁(yè)面刷新 - 通過(guò) popchange 事件監(jiān)聽(tīng) URL 的改變。需要注意只在通過(guò)瀏覽器導(dǎo)航欄的前進(jìn)后退改變 URL 時(shí)會(huì)觸發(fā)
popstate事件,通過(guò)<a>標(biāo)簽和pushState/replaceState不會(huì)觸發(fā)popstate方法。但我們可以攔截<a>標(biāo)簽的點(diǎn)擊事件和pushState/replaceState的調(diào)用來(lái)檢測(cè) URL 變化,也是可以達(dá)到監(jiān)聽(tīng) URL 的變化,相對(duì)hashchange顯得略微復(fù)雜
JS 實(shí)現(xiàn)前端路由
基于 hash 實(shí)現(xiàn)
由于三種改變 hash 的方式都會(huì)觸發(fā)hashchange方法,所以只需要監(jiān)聽(tīng)hashchange方法。需要在DOMContentLoaded后,處理一下默認(rèn)的 hash 值
// 頁(yè)面加載完不會(huì)觸發(fā) hashchange,這里主動(dòng)觸發(fā)一次 hashchange 事件,處理默認(rèn)hash
window.addEventListener('DOMContentLoaded', onLoad);
// 監(jiān)聽(tīng)路由變化
window.addEventListener('hashchange', onHashChange);
// 路由變化時(shí),根據(jù)路由渲染對(duì)應(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 實(shí)現(xiàn)
因?yàn)?history 模式下,<a>標(biāo)簽和pushState/replaceState不會(huì)觸發(fā)popstate方法,我們需要對(duì)<a>的跳轉(zhuǎn)和pushState/replaceState做特殊處理。
- 對(duì)
<a>作點(diǎn)擊事件,禁用默認(rèn)行為,調(diào)用pushState方法并手動(dòng)觸發(fā)popstate的監(jiān)聽(tīng)事件 - 對(duì)
pushState/replaceState可以重寫(xiě) history 的方法并通過(guò)派發(fā)事件能夠監(jiān)聽(tīng)對(duì)應(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;
};
};
// 重寫(xiě)pushstate事件
history.pushState = _wr('pushstate');
function onLoad() {
routerView = document.querySelector('#routeView');
onPopState();
// 攔截 <a> 標(biāo)簽點(diǎn)擊事件默認(rèn)行為
// 點(diǎn)擊時(shí)使用 pushState 修改 URL并更新手動(dòng) UI,從而實(shí)現(xiàn)點(diǎ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)聽(tīng)pushstate方法
window.addEventListener('pushstate', onPopState());
// 頁(yè)面加載完不會(huì)觸發(fā) hashchange,這里主動(dòng)觸發(fā)一次 popstate 事件,處理默認(rèn)pathname
window.addEventListener('DOMContentLoaded', onLoad);
// 監(jiān)聽(tīng)路由變化
window.addEventListener('popstate', onPopState);
// 路由變化時(shí),根據(jù)路由渲染對(duì)應(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 庫(kù)給 browser、hash 兩種 history 提供了統(tǒng)一的 API,給到 react-router-dom 使用
- react-router 實(shí)現(xiàn)了路由的最核心能力。提供了
<Router>、<Route>等組件,以及配套 hook - react-router-dom 是對(duì) react-router 更上一層封裝。把 history 傳入
<Router>并初始化成<BrowserRouter>、<HashRouter>,補(bǔ)充了<Link>這樣給瀏覽器直接用的組件。同時(shí)把 react-router 直接導(dǎo)出,減少依賴(lài)
History 實(shí)現(xiàn)
history
在上文中說(shuō)到,BrowserRouter使用 history 庫(kù)提供的createBrowserHistory創(chuàng)建的history對(duì)象改變路由狀態(tài)和監(jiān)聽(tīng)路由變化。
? 那么 history 對(duì)象需要提供哪些功能訥?
- 監(jiān)聽(tīng)路由變化的
listen方法以及對(duì)應(yīng)的清理監(jiān)聽(tīng)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: '/',
};
// 路由變化時(shí)的回調(diào)
const handlePop = function () {
const currentLocation = {
pathname: window.location.pathname,
};
EventBus.emit(currentLocation); // 路由變化時(shí)執(zhí)行回調(diào)
};
// 定義history.push方法
const push = (path) => {
const history = window.history;
// 為了保持state棧的一致性
history.pushState(null, '', path);
// 由于push并不觸發(fā)popstate,我們需要手動(dòng)調(diào)用回調(diào)函數(shù)
location = { pathname: path };
EventBus.emit(location);
};
const listen = (listener) => EventBus.subscribe(listener);
// 處理瀏覽器的前進(jìn)后退
window.addEventListener('popstate', handlePop);
// 返回history
const history = {
location,
listen,
push,
};
return history;
};對(duì)于 BrowserHistory 來(lái)說(shuō),我們的處理需要增加一項(xiàng),當(dāng)我們觸發(fā) push 的時(shí)候,需要手動(dòng)通知所有的監(jiān)聽(tīng)者,因?yàn)?pushState 無(wú)法觸發(fā) popState 事件,因此需要手動(dòng)觸發(fā)
HashHistory
const createHashHistory = () => {
const EventBus = EventEmitter();
let location = {
pathname: '/',
};
// 路由變化時(shí)的回調(diào)
const handlePop = function () {
const currentLocation = {
pathname: window.location.hash.slice(1),
};
EventBus.emit(currentLocation); // 路由變化時(shí)執(zhí)行回調(diào)
};
// 不用手動(dòng)執(zhí)行回調(diào),因?yàn)閔ash改變會(huì)觸發(fā)hashchange事件
const push = (path) => (window.location.hash = path);
const listen = (listener: Function) => EventBus.subscribe(listener);
// 監(jiān)聽(tīng)hashchange事件
window.addEventListener('hashchange', handlePop);
// 返回的history上有個(gè)listen方法
const history = {
location,
listen,
push,
};
return history;
};在實(shí)現(xiàn) hashHistory 的時(shí)候,我們只是對(duì)hashchange進(jìn)行了監(jiān)聽(tīng),當(dāng)該事件發(fā)生時(shí),我們獲取到最新的 location 對(duì)象,在通知所有的監(jiān)聽(tīng)者 listener 執(zhí)行回調(diào)函數(shù)
React-Router@6 丐版實(shí)現(xiàn)
- 綠色為 history 中的方法
- 紫色為 react-router-dom 中的方法
- 橙色為 react-router 中的方法
Router
??? 基于 Context 的全局狀態(tài)下發(fā)。Router 是一個(gè) “Provider-Consumer” 模型
Router 做的事情很簡(jiǎn)單,接收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ā)生改變的時(shí)候,監(jiān)聽(tīng) history,能夠在 location 發(fā)生改變的時(shí)候,執(zhí)行回調(diào)改變 location。
在下面的代碼中,能夠發(fā)現(xiàn)監(jiān)聽(tīng)者為 setState 函數(shù),在上述 hashHistory 中,如果我們的 location 發(fā)生了改變,會(huì)通知到所有的監(jiān)聽(tīng)者執(zhí)行回調(diào),也就是我們這里的 setState 函數(shù),即我們能夠拿到最新的 location 信息通過(guò) LocationContext 傳遞給子組件,再去做對(duì)應(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 組件只是一個(gè)工具人,并沒(méi)有做任何事情。
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>.`,
);
}實(shí)際上處理一切邏輯的組件是 Routes,它內(nèi)部實(shí)現(xiàn)了根據(jù)路由的變化,匹配出一個(gè)正確的組件。
const Routes = ({ children }) => {
return useRoutes(createRoutesFromChildren(children));
};useRoutes 為整個(gè) v6 版本的核心,分為路由上下文解析、路由匹配、路由渲染三個(gè)步驟
<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 代碼中,通過(guò) createRoutesFromChildren 函數(shù)將 Route 組件結(jié)構(gòu)化??梢园?nbsp;<Route> 類(lèi)型的 react element 對(duì)象,變成了普通的 route 對(duì)象結(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 中通過(guò) pathname 和路由的 path 進(jìn)行匹配
因?yàn)槲覀冊(cè)?Route 中定義的 path 都是相對(duì)路徑,所以我們?cè)?matchRoutes 方法中,需要對(duì) routes 對(duì)象遍歷,對(duì)于 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)我們?cè)L問(wèn)/#/home/1/2的時(shí)候,獲得的 matches 如下

我們得到的 match 順序是從 Home → Home1 → Home2
_renderMatches
_renderMatches 才會(huì)渲染所有的 matches 對(duì)象
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 采用從右往左的遍歷順序,將上一項(xiàng)的返回值作為后一項(xiàng)的 outlet,那么子路由就作為 outlet 傳遞給了父路由
Outlet
實(shí)際上就是內(nèi)部渲染 RouteContext 的 outlet 屬性
function Outlet(props) {
return useOutlet(props.context);
}
function useOutlet(context?: unknown) {
let outlet = useContext(RouteContext).outlet; // 獲取上一級(jí) RouteContext 上面的 outlet
if (outlet) {
return (
<OutletContext.Provider value={context}>{outlet}</OutletContext.Provider>
);
}
return outlet;
}Link
在 Link 中,我們使用<a>標(biāo)簽來(lái)做跳轉(zhuǎn),但是 a 標(biāo)簽會(huì)使頁(yè)面重新刷新,所以需要阻止 a 標(biāo)簽的默認(rèn)行為,調(diào)用 useNavigate 方法進(jìn)行跳轉(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;
}本文所有的代碼鏈接可點(diǎn)擊查看
參考鏈接
- react router v6 使用詳解以及部分源碼解析(新老版本對(duì)比) - 掘金
- 「React 進(jìn)階」react-router v6 通關(guān)指南 - 掘金
- 一文讀懂 react-router 原理
到此這篇關(guān)于一文了解 history 和 react-router 的實(shí)現(xiàn)原理的文章就介紹到這了,更多相關(guān)history 和 react-router實(shí)現(xiàn)原理內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
React?Server?Component混合式渲染問(wèn)題詳解
React?官方對(duì)?Server?Comopnent?是這樣介紹的:?zero-bundle-size?React?Server?Components,這篇文章主要介紹了React?Server?Component:?混合式渲染,需要的朋友可以參考下2022-12-12
詳解如何用webpack4從零開(kāi)始構(gòu)建react開(kāi)發(fā)環(huán)境
這篇文章主要介紹了詳解如何用webpack4從零開(kāi)始構(gòu)建react開(kāi)發(fā)環(huán)境,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2019-01-01
react實(shí)現(xiàn)組件狀態(tài)緩存的示例代碼
本文主要介紹了react實(shí)現(xiàn)組件狀態(tài)緩存的示例代碼,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2023-02-02
使用react-virtualized實(shí)現(xiàn)圖片動(dòng)態(tài)高度長(zhǎng)列表的問(wèn)題
一般我們?cè)趯?xiě)react項(xiàng)目中,同時(shí)渲染很多dom節(jié)點(diǎn),會(huì)造成頁(yè)面卡頓, 空白的情況。為了解決這個(gè)問(wèn)題,今天小編給大家分享一篇教程關(guān)于react-virtualized實(shí)現(xiàn)圖片動(dòng)態(tài)高度長(zhǎng)列表的問(wèn)題,感興趣的朋友跟隨小編一起看看吧2021-05-05
詳解react native頁(yè)面間傳遞數(shù)據(jù)的幾種方式
這篇文章主要介紹了詳解react native頁(yè)面間傳遞數(shù)據(jù)的幾種方式,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2018-11-11
react進(jìn)階教程之異常處理機(jī)制error?Boundaries
在react中一旦出錯(cuò),如果每個(gè)組件去處理出錯(cuò)情況則比較麻煩,下面這篇文章主要給大家介紹了關(guān)于react進(jìn)階教程之異常處理機(jī)制error?Boundaries的相關(guān)資料,文中通過(guò)實(shí)例代碼介紹的非常詳細(xì),需要的朋友可以參考下2022-08-08

