赞
踩
本文源自于面试题:如果让你设计前端Router,你会怎么去做?
接下去将从常见的前端路由模式,以及 vue 和 react 两个框架的router开始,实现简单的router。
Hash模式是 html5 以前常用的路由模式,具有以下特点:
在实际的URL路径前,使用哈希字符(#)进行分割。
hash路由向服务器发送请求,请求的是#以前的URL,因此不需要在服务器层面做任何处理。
使用的是监听hashChange
的方法来监听hash的变化。
浏览器兼容性好。
相比较Hash模式,History具有以下特点:
没有烦人的'#'号,SEO友好。
在SPA应用中,需要服务器端的配置(Nginx try_files),否则会发生404错误。
使用的是html5 新增的popstate
事件去监听pathname
的变化。
向下兼容到IE10。
react在此基础上创建了Browser Router
基于内存的路由,Vue应用场景主要是用于处理服务器端渲染,为SSR提供路由历史。可以当做测试用例去看待
还有native router、static 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]);
- },
- });
- }
先来看基本的使用,可以看出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
组件做了什么。
这里通过取出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;
- }
这里可以看到,它就是一个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>
- );
- }
首先,我们要创建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>
- )
- }
区别就是获取的是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;
- };
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。