本文源自于面试题:如果让你设计前端Router,你会怎么去做?
接下去将从常见的前端路由模式,以及 vue 和 react 两个框架的router开始,实现简单的router。
路由模式
Hash模式
Hash模式是 html5 以前常用的路由模式,具有以下特点:
-
在实际的URL路径前,使用哈希字符(#)进行分割。
-
hash路由向服务器发送请求,请求的是#以前的URL,因此不需要在服务器层面做任何处理。
-
使用的是监听
hashChange
的方法来监听hash的变化。 -
浏览器兼容性好。
History模式
相比较Hash模式,History具有以下特点:
-
没有烦人的'#'号,SEO友好。
-
在SPA应用中,需要服务器端的配置(Nginx try_files),否则会发生404错误。
-
使用的是html5 新增的
popstate
事件去监听pathname
的变化。 -
向下兼容到IE10。
react在此基础上创建了Browser Router
Memory模式
基于内存的路由,Vue应用场景主要是用于处理服务器端渲染,为SSR提供路由历史。可以当做测试用例去看待
其他
还有native router、static router。
Vue Router
vue-router 原理
插件的注入
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
createApp(App).use(router).mount('#app');
可以看到 vue-router 是通过app.use()
来使用的,这是 vue 用来安装一个插件的方法,那么什么是插件?
插件是一个拥有install()
方法的对象,也可以是一个安装函数本身。
所以 vue-router 的重点就是 install 方法做了什么。
首先使用app.component
方法注册两个组件,RouterLink
和RouterView
。它们的使用在这里就不多赘述。
app.component('RouterLink', RouterLink);
app.component('RouterView', RouterView);
然后在全局注册了$router
实例,并且this
指向我们当前的router
,所以我们可以通过this.$router
访问到这个实例。
const router = this;
app.config.globalProperties.$router = router;
接着对全局注册的$route
进行了监听,通过get
获取到我们当前的route
Object.defineProperty(app.config.globalProperties, '$route', {
enumerable: true,
get: () => vue.unref(currentRoute),
});
再往下是针对 vue3 多实例的一个处理,判断浏览器环境,并且初始化导航的情况下去执行后面的内容,目的是避免多实例对router
创建造成的影响。
if (isBrowser &&
!started &&
currentRoute.value === START_LOCATION_NORMALIZED) {
started = true;
push(routerHistory.location).catch(err => {
warn('Unexpected error when starting the router:', err);
});
}
接下去是一些添加响应式的操作,其实就是对于路由上下文进行的监听。
const reactiveRoute = {};
for (const key in START_LOCATION_NORMALIZED) {
Object.defineProperty(reactiveRoute, key, {
get: () => currentRoute.value[key],
enumerable: true,
});
}
其中,START_LOCATION_NORMALIZED
定义如下:
const START_LOCATION_NORMALIZED = {
path: '/',
name: undefined,
params: {},
query: {},
hash: '',
fullPath: '/',
matched: [],
meta: {},
redirectedFrom: undefined,
};
再往后,使用了app.provide
方法,将上面监听、创建的变量挂载到app实例上,以便我们后面去访问。
这里其实也是针对 vue3 的兼容处理,因为 composition API 中无法获取到this
。
app.provide(routerKey, router);
app.provide(routeLocationKey, vue.shallowReactive(reactiveRoute));
app.provide(routerViewLocationKey, currentRoute);
最后就是app.unmount
触发时的处理,这里也做了多实例的兼容。
这里用了installedApps
,它是一个Set
数据结构,当对应的app
实例触发卸载生命周期时删除集合中的app
当没有app
时,将我们的状态变为初始值。
const unmountApp = app.unmount;
installedApps.add(app);
app.unmount = function () {
installedApps.delete(app);
if (installedApps.size < 1) {
pendingLocation = START_LOCATION_NORMALIZED;
removeHistoryListener && removeHistoryListener();
removeHistoryListener = null;
currentRoute.value = START_LOCATION_NORMALIZED;
started = false;
ready = false;
}
unmountApp();
};
路由的实现
主要就是createWebHashHistory
、createMemoryHistory
、createWebHistory
三个API。
在路由模式里已经说了实现思路,即如何监听URL的变化,进行对应的更新。
-
Hash
主要就是在路径前拼接'#'
function createWebHashHistory(base) {
base = location.host ? base || location.pathname + location.search : '';
if (!base.includes('#'))
base += '#';
if (!base.endsWith('#/') && !base.endsWith('#')) {
warn(`A hash base must end with a "#":\n"${base}" should be "${base.replace(/#.*$/, '#')}".`);
}
return createWebHistory(base);
}
-
History
function createWebHistory(base) {
base = normalizeBase(base);
const historyNavigation = useHistoryStateNavigation(base);
const historyListeners = useHistoryListeners(base, historyNavigation.state, historyNavigation.location, historyNavigation.replace);
function go(delta, triggerListeners = true) {
if (!triggerListeners)
historyListeners.pauseListeners();
history.go(delta);
}
const routerHistory = assign({
location: '',
base,
go,
createHref: createHref.bind(null, base),
}, historyNavigation, historyListeners);
Object.defineProperty(routerHistory, 'location', {
enumerable: true,
get: () => historyNavigation.location.value,
});
Object.defineProperty(routerHistory, 'state', {
enumerable: true,
get: () => historyNavigation.state.value,
});
return routerHistory;
}
手写实现
根据使用的方法,我们需要创建一个类,其中包含mode
和routes
属性。
class VueRouter {
constructor(options) {
this.mode = options.mode || 'hash'; // 默认hash模式
this.routes = options.routes || [];
this.init()
}
}
在init
方法中,我们要进行路由初始化,对于Hash
模式,我们监听到load事件后,就要获取/之后的路径
init() {
if (this.mode === 'hash') {
// 针对没有hash时,设置为/#/
location?.hash ? '' : (location.hash = '/')
window.addEventListener('load', () => {
location.hash.slice(1)
});
}
}
接着我们要创建一个类来维护我们的路由上下文,并在Router
类中创建history
实例,将获取到的路径加入到上下文中。
class HistoryRoute {
constructor() {
this.current = null;
}
}
所以针对hash路由的初始化如下,包括对于hashChange
的监听:
if (this.mode === 'hash') {
// 针对没有hash时,设置为/#/
location?.hash ? '' : (location.hash = '/')
window.addEventListener('load', () => {
this.history.current = location.hash.slice(1)
});
window.addEventListener('hashChange', () => {
this.history.current = location.hash.slice(1)
});
}
照上面的思路,针对history路由的初始化:
else {
location.pathname ? '' : (location.pathname = '/')
window.addEventListener('load', () => {
this.history.current = location.pathname
});
window.addEventListener('popState', () => {
this.history.current = location.pathname
});
}
我们还要将path
和component
进行关联,以键值对的方式存储,在constructor
中,创建Map
this.routesMap = this.createMap(this.routes);
createMap(routes) {
return routes.reduce((pre, cur) => {
pre[cur.path] = cur.component;
return pre;
}, {})
// 返回的形式如下
// {
// ['/home']: Home,
// ['/about']: About
// }
}
接下去我们要提供install
方法,并且按照源码里的思路去挂载我们的实例,这里用mixin
来实现。
我们在beforeCreate
声明周期判断是否挂载到根实例上。
install(v) {
Vue = v;
Vue.mixins({
beforeCreate() {
if (this.$options && this.$options.router) {
// 已经挂载到根实例
this._root = this;
this._router = this.$options.router
} else {
// 子组件
this._root = this.$parent && this.parent._root
}
}
})
}
然后对$router
和$route
进行创建和监听
Object.defineProperty(this, '$router', {
get() {
return this._root._router;
}
})
Object.defineProperty(this, '$route', {
get() {
return this._root._router.history.current;
}
})
最后,要创建两个RouterView
和RouterLink
两个组件,,通过render(h)
的方式创建,其中,RouterLink
最终要渲染成a
标签,将to
属性转变为href
属性。
Vue.component('router-link', {
props: {
to: String,
},
render(h) {
let mode = this._self._root._router.mode;
let to = mode === 'hash' ? '#' + this.to : this.to;
return h('a', { attrs: { href: to } }, this.$slots.default);
},
});
Vue.component('router-view', {
render(h) {
let current = this._self._root._router.history.current;
let routeMap = this._self._root._router.routesMap;
return h(routeMap[current]);
},
});
完整代码如下:
let Vue = null; // 通过install注入vue实例
class HistoryRoute {
constructor() {
this.current = null; // 上下文状态维护
}
}
class VueRouter {
constructor(options) {
this.mode = options.mode || 'hash'
this.routes = options.routes || []
this.routesMap = this.createMap(this.routes); // 维护状态
this.history = new HistoryRoute();
this.init();
}
init() {
if (this.mode === 'hash') {
// 先判断用户打开时有没有hash值,没有的话跳转到#/
location.hash ? '' : (location.hash = '/');
window.addEventListener('load', () => {
this.history.current = location.hash.slice(1);
})
window.addEventListener('hashchange', () => {
this.history.current = location.hash.slice(1);
})
} else { // browserRouter
loaction.pathname ? '' : (location.pathname = '/');
window.addEventListener('load', () => {
this.history.current = location.pathname;
})
window.addEventListener('popstate', () => {
this.history.current = location.pathname;
})
}
}
createMap(routes) { // 将component和path匹配
return routes.reduce((pre, cur) => {
pre[cur.path] = cur.component; // 用一个对象,以键值对的方式将component和path存储起来
return pre;
}, {})
// 返回的形式如下
// {
// ['/home']: Home,
// ['/about']: About
// }
}
}
// 作为插件安装,就是vue.use(router)这步的操作
VueRouter.install = (v) => {
Vue = v;
Vue.mixin({
beforeCreate() {
if (this.$options && this.$options.router) {
// 如果是根组件
this._root = this; //把当前实例挂载到_root上
this._router = this.$options.router;
Vue.util.defineReactive(this, 'xxx', this._router.history);
} else {
//如果是子组件
this._root = this.$parent && this.$parent._root;
}
Object.defineProperty(this, '$router', {
get() {
return this._root._router;
},
});
Object.defineProperty(this, '$route', {
get() {
return this._root._router.history.current;
},
});
},
});
Vue.component('router-link', {
props: {
to: String,
},
render(h) {
let mode = this._self._root._router.mode;
let to = mode === 'hash' ? '#' + this.to : this.to;
return h('a', { attrs: { href: to } }, this.$slots.default);
},
});
Vue.component('router-view', {
render(h) {
let current = this._self._root._router.history.current;
let routeMap = this._self._root._router.routesMap;
return h(routeMap[current]);
},
});
}
React Router
react-router原理
先来看基本的使用,可以看出router
是类似于Provider
和Context
的挂载。
// App.tsx
import { Route, Routes, Link } from 'react-router-dom'
import Home from './views/Home'
import About from './views/About'
import './App.css'
function App() {
return (
<>
<h1>React Router Demo</h1>
<Link to="/">Home</Link>
<Link to="about">About</Link >
<br />
<Routes>
<Route path="/" element={<Home />} />
<Route path="about" element={<About />} />
</Routes>
</>
)
}
export default App;
// main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
)
我们去看<Routes>
组件的声明,重点是去看createRoutesFromChildren
。
export function Routes({
children,
location,
}: RoutesProps): React.ReactElement | null {
return useRoutes(createRoutesFromChildren(children), location);
}
可以看到对其中的每个子节点,也就是<Route>
组件进行了递归的处理,生成route
,其中children
属性包含了嵌套的子组件,最终返回一个routes
数组。
export function createRoutesFromChildren(
children: React.ReactNode,
parentPath: number[] = []
): RouteObject[] {
let routes: RouteObject[] = [];
React.Children.forEach(children, (element, index) => {
let treePath = [...parentPath, index];
if (element.type === React.Fragment) {
routes.push.apply(
routes,
createRoutesFromChildren(element.props.children, treePath)
);
return;
}
let route: RouteObject = {...};
if (element.props.children) {
route.children = createRoutesFromChildren(
element.props.children,
treePath
);
}
routes.push(route);
});
return routes;
}
这样我们就知道了Routes
和Route
组件是怎么实现嵌套路由的。
接着我们以BrowserRouter
为例,去看在App
外嵌套BrowserRouter
做了什么。
export function BrowserRouter({
basename,
children,
future,
window,
}: BrowserRouterProps) {
let historyRef = React.useRef<BrowserHistory>();
if (historyRef.current == null) {
historyRef.current = createBrowserHistory({ window, v5Compat: true });
}
let history = historyRef.current;
let [state, setStateImpl] = React.useState({
action: history.action,
location: history.location,
});
let { v7_startTransition } = future || {};
let setState = React.useCallback(
(newState: { action: NavigationType; location: Location }) => {
v7_startTransition && startTransitionImpl
? startTransitionImpl(() => setStateImpl(newState))
: setStateImpl(newState);
},
[setStateImpl, v7_startTransition]
);
React.useLayoutEffect(() => history.listen(setState), [history, setState]);
return (
<Router
basename={basename}
children={children}
location={state.location}
navigationType={state.action}
navigator={history}
future={future}
/>
);
}
可以看到重点有两块,一个是创建了history
对象维护路由的上下文,第二个是用useLayoutEffect
监听history
的变化进行更新,最后返回Router
组件,所以我们要去看createBrowserHistory
和Router
组件做了什么。
createBrowserHistory
这里通过取出location
中的pathname
,search
,hash
,调用getUrlBasedHistory
,生成history
对象。
export function createBrowserHistory(
options: BrowserHistoryOptions = {}
): BrowserHistory {
function createBrowserLocation(
window: Window,
globalHistory: Window["history"]
) {
let { pathname, search, hash } = window.location;
return createLocation(
"",
{ pathname, search, hash },
(globalHistory.state && globalHistory.state.usr) || null,
(globalHistory.state && globalHistory.state.key) || "default"
);
}
function createBrowserHref(window: Window, to: To) {
return typeof to === "string" ? to : createPath(to);
}
return getUrlBasedHistory(
createBrowserLocation,
createBrowserHref,
null,
options
);
}
history对象包含了一些对URL的操作,包括push,pop,replace等。
function getUrlBasedHistory(...): UrlHistory {
let globalHistory = window.history;
let action = Action.Pop;
let listener: Listener | null = null;
function getIndex(): number {
let state = globalHistory.state || { idx: null };
return state.idx;
}
function handlePop() {
action = Action.Pop;
let nextIndex = getIndex();
let delta = nextIndex == null ? null : nextIndex - index;
index = nextIndex;
if (listener) {
listener({ action, location: history.location, delta });
}
}
function push(to: To, state?: any) {
action = Action.Push;
let location = createLocation(history.location, to, state);
if (validateLocation) validateLocation(location, to);
index = getIndex() + 1;
let historyState = getHistoryState(location, index);
let url = history.createHref(location);
globalHistory.pushState(historyState, "", url);
}
function replace(to: To, state?: any) {
action = Action.Replace;
let location = createLocation(history.location, to, state);
if (validateLocation) validateLocation(location, to);
index = getIndex();
let historyState = getHistoryState(location, index);
let url = history.createHref(location);
globalHistory.replaceState(historyState, "", url);
}
function createURL(to: To): URL {
let base =
window.location.origin !== "null"
? window.location.origin
: window.location.href;
let href = typeof to === "string" ? to : createPath(to);
return new URL(href, base);
}
let history: History = {
get action() {
return action;
},
get location() {
return getLocation(window, globalHistory);
},
listen(fn: Listener) {
window.addEventListener(PopStateEventType, handlePop);
listener = fn;
return () => {
window.removeEventListener(PopStateEventType, handlePop);
listener = null;
};
},
createHref(to) {
return createHref(window, to);
},
createURL,
encodeLocation(to) {
let url = createURL(to);
return {
pathname: url.pathname,
search: url.search,
hash: url.hash,
};
},
push,
replace,
go(n) {
return globalHistory.go(n);
},
};
return history;
}
Router
这里可以看到,它就是一个Provider
包裹App
,使我们应用内部能够获取到Router
。
export function Router(...): React.ReactElement | null {
let basename = basenameProp.replace(/^\/*/, "/");
let navigationContext = React.useMemo(
() => ({
basename,
navigator,
static: staticProp,
future: {
v7_relativeSplatPath: false,
...future,
},
}),
[basename, future, navigator, staticProp]
);
if (typeof locationProp === "string") {
locationProp = parsePath(locationProp);
}
let {
pathname = "/",
search = "",
hash = "",
state = null,
key = "default",
} = locationProp;
let locationContext = React.useMemo(() => {
let trailingPathname = stripBasename(pathname, basename);
return {
location: {
pathname: trailingPathname,
search,
hash,
state,
key,
},
navigationType,
};
}, [basename, pathname, search, hash, state, key, navigationType]);
return (
<NavigationContext.Provider value={navigationContext}>
<LocationContext.Provider children={children} value={locationContext} />
</NavigationContext.Provider>
);
}
手写实现
BrowserRouter
首先,我们要创建Router的上下文,再获取location
中的pathname
,用状态去存储。
function BrowserRouter(props) {
const RouterContext = createContext();
const [path, setPath] = useState(() => {
const { pathname } = window.location;
return pathname || '/';
})
}
接下去要监听用户的前进后退行为,即触发popstate
事件,将path
更新为最新的pathname
useEffect(() => {
window.addEventListener('popstate', handlePopstate);
return window.removeEventListener('popstate', handlePopstate);
}, []);
const handlePopstate = () => {
const { pathname } = window.location;
setPath(pathname);
}
最后返回我们的Provider
。
return (
<RouterContext.Provider value={path}>
{props.children}
</RouterContext.Provider>
)
此时我们还缺少一些跳转的功能:
const push = (path) => {
setPath(path);
window.history.pushState({ path }, null, path)
};
const goBack = () => {
window.history.go(-1);
}
所以同样要创建HistoryContext
并且包裹children
。
const HistoryContext = createContext();
return (
<RouterContext.Provider value={path}>
<HistoryContext.Provider value={{ push, goBack }}>
{props.children}
</HistoryContext.Provider>
</RouterContext.Provider>
)
然后要考虑怎么去消费我们的Router
,就是通过Routes
中包裹的Route
,判断当前path
是否等于componentPath
,如果相等就渲染当前组件。
export function Route(props) {
const { element: Component, path: componentPath } = props;
return (
<RouterContext.Consumer>
{(path) => {
componentPath === path ? <Component /> : null;
}}
</RouterContext.Consumer>
)
}
最终代码如下:
// browser-router
function BrowserRouter(props) {
const RouterContext = createContext();
const HistoryContext = createContext();
const [path, setPath] = useState(() => {
const { pathname } = window.location;
return pathname || '/';
});
useEffect(() => {
window.addEventListener('popstate', handlePopstate);
return window.removeEventListener('popstate', handlePopstate);
}, []);
const handlePopstate = () => {
const { pathname } = window.location;
setPath(pathname);
}
const push = (path) => {
setPath(path);
window.history.pushState({ path }, null, path)
};
const goBack = () => {
window.history.go(-1);
}
return (
<RouterContext.Provider value={path}>
<HistoryContext.Provider value={{ push, goBack }}>
{props.children}
</HistoryContext.Provider>
</RouterContext.Provider>
)
}
export default BrowserRouter
export function Route(props) {
const { element: Component, path: componentPath } = props;
return (
<RouterContext.Consumer>
{(path) => {
componentPath === path ? <Component /> : null;
}}
</RouterContext.Consumer>
)
}
HashRouter
区别就是获取的是location.hash
,监听的是hashChange
事件,改变的是location.hash
。
const [path, setPath] = useState(() => {
const { hash } = window.location;
if (hash) {
return hash.slice(1)
}
return '/#/'
});
useEffect(() => {
window.addEventListener('hashChange', handleHashChange);
return window.removeEventListener('hashChange', handleHashChange);
}, []);
const handleHashChange = () => {
const { hash } = window.location;
setPath(hash.slice(1));
}
const push = (path) => {
setPath(path);
window.location.hash = path;
};