Vue3探秘系列— 路由:vue-router的实现原理(十六-上)

前言

Hello~大家好。我是秋天的一阵风

🎉 欢迎来到 Vue3探秘系列专栏!在这里,我们将深入探索 Vue3 的各种奥秘,从源码到实践,一步步揭开它的神秘面纱。📚

🚀 以下是本系列文章的导航目录,方便你快速找到感兴趣的内容:

  1. 虚拟结点 vnode 的页面挂载之旅(一)
    不止响应式:Vue3探秘系列--- 虚拟结点vnode的页面挂载之旅(一)
    🌟 探索虚拟 DOM 如何变成页面上的真实内容,开启 Vue3 的渲染之旅!
  2. 组件更新会发生什么(二)
    不止响应式:Vue3探秘系列--- 组件更新会发生什么(二)
    🔃 深入组件更新的内部机制,看看 Vue3 是如何高效更新界面的。
  3. diff 算法的完整过程(三)
    不止响应式:Vue3探秘系列--- diff算法的完整过程(三)
    🧩 揭秘 Vue3 的 diff 算法,理解它是如何高效比较和更新 DOM 的。
  4. 组件的初始化过程(四)
    不止响应式:Vue3探秘系列--- 组件的初始化过程(四)
    🌱 从零开始,了解 Vue3 组件是如何初始化的,掌握组件生命周期的关键步骤。
  5. 响应式设计(五)
    终于轮到你了:Vue3探秘系列--- 响应式设计(五)
    🔗 深入 Vue3 的响应式系统,探索 Proxy 如何实现高效的数据响应机制。

这只是系列的一部分,更多精彩内容还在持续更新中!🔍

开始本篇的内容之前,我们先引出一个问题, 什么是SPA?

SPA 全称是Single Page Application ,也就是单页面应用 。单页面应用是一种现代Web应用的设计模式,它的主要特点是可以通过动态更新局部页面内容的方式,实现了无需重新加载整个页面即可完成页面间的跳转和内容更新,极大地提升了应用性能和用户体验。

为了更好地支持 SPA 中的导航和路由功能,Vue.js 提供了一个官方的路由管理库 ------ Vue RouterVue Router 是一个功能强大的路由管理器,它不仅能够帮助我们轻松地管理应用中的多个视图状态,还提供了丰富的API和机制,以应对复杂的应用场景。

我们先来用vue-cli搭建一个简单的案例环境代码:

1. public/index.html

javascript 复制代码
<div id="app">
  <h1>Hello App!</h1>
  <p>
    <router-link to="/">Go to Home</router-link>
    <router-link to="/about">Go to About</router-link>
  </p>
  <router-view></router-view>
</div>

其中,RouterLink RouterView Vue Router 内置的组件。

  • RouterLink 表示路由的导航组件,我们可以配置 to 属性来指定它跳转的链接,它最终会在页面上渲染生成 a 标签。

  • RouterView 表示路由的视图组件,它会渲染路径对应的 Vue 组件,也支持嵌套

2. main.js

javascript 复制代码
import { createApp } from "vue";
import { createRouter, createWebHashHistory } from "vue-router";
// 1. 定义路由组件
const Home = { template: "<div>Home</div>" };
const About = { template: "<div>About</div>" };
// 2. 定义路由配置,每个路径映射一个路由视图组件
const routes = [
  { path: "/", component: Home },
  { path: "/about", component: About },
];
// 3. 创建路由实例,可以指定路由模式,传入路由配置对象
const router = createRouter({
  history: createWebHistory(),
  routes,
});
// 4. 创建 app 实例
const app = createApp({});
// 5. 在挂载页面 之前先安装路由
app.use(router);
// 6. 挂载页面
app.mount("#app");

非常简单哈,首先是定义了两个组件HomeAbout,定义一个路由配置数组routes,设置路径与组件的映射关系。

接着就是通过createRouter方法指定路由模式为history以及传入路由配置数组。

最后使用 app.use方法以插件的方式来安装路由,use方法执行时,会去找router对象的install方法,所以每一个插件都必须实现一个install方法

Tips:

插件可以是一个带 install() 方法的对象,亦或直接是一个将被用作 install() 方法的函数。插件选项 (app.use() 的第二个参数) 将会传递给插件的 install() 方法。

app.use() 对同一个插件多次调用,该插件只会被安装一次。

一、 路由对象的创建 :createRouter

接下来,我们从创建路由对象开始探究:Vue Router 提供了一个 createRouter API,你可以通过它来创建一个路由对象,我们来看它的实现:

javascript 复制代码
function createRouter(options) {
  // 定义一些辅助方法和变量

// ...
// 创建 router 对象
const router = {
	// 当前路径
	currentRoute,
	addRoute,
	removeRoute,
	hasRoute,
	getRoutes,
	resolve,
	options,
	push,
	replace,
	go,
	back: () = >go(-1),
	forward: () = >go(1),
	beforeEach: beforeGuards.add,
	beforeResolve: beforeResolveGuards.add,
	afterEach: afterGuards.add,
	onError: errorHandlers.add,
	isReady,
	install(app) {
		// 安装路由函数
	}
}
return router
}

createRouter会定义非常多的辅助方法和变量,我们现在不需要关注。只要知道它会返回一个router对象,里面有currentRoute等数据,这代表当前路径信息。除此之外,还有其他辅助参数和辅助方法。

其中install方法是安装路由的核心。

创建完路由对象后,我们现在来安装它。

二、安装路由:install

我们在开头提过,以插件的方式进行安装的时候,会执行插件对象的install方法,并且会将app作为参数传入。

所以我们接着关注install方法:

javascript 复制代码
const router = {
  install(app) {
    const router = this;
    // 注册路由组件
    app.component("RouterLink", RouterLink);
    app.component("RouterView", RouterView);
    // 全局配置定义 $router 和 $route
    app.config.globalProperties.$router = router;
    Object.defineProperty(app.config.globalProperties, "$route", {
      get: () => unref(currentRoute),
    });
    // 在浏览器端初始化导航
    if (
      isBrowser &&
      !started &&
      currentRoute.value === START_LOCATION_NORMALIZED
    ) {
      // see above
      started = true;
      push(routerHistory.location).catch((err) => {
        warn("Unexpected error when starting the router:", err);
      });
    }
    // 路径变成响应式
    const reactiveRoute = {};
    for (let key in START_LOCATION_NORMALIZED) {
      reactiveRoute[key] = computed(() => currentRoute.value[key]);
    }
    // 全局注入 router 和 reactiveRoute
    app.provide(routerKey, router);
    app.provide(routeLocationKey, reactive(reactiveRoute));
    let unmountApp = app.unmount;
    installedApps.add(app);
    // 应用卸载的时候,需要做一些路由清理工作
    app.unmount = function () {
      installedApps.delete(app);
      if (installedApps.size < 1) {
        removeHistoryListener();
        currentRoute.value = START_LOCATION_NORMALIZED;
        started = false;
        ready = false;
      }
      unmountApp.call(this, arguments);
    };
  },
};

路由的安装的过程我们需要记住以下两件事情。

  1. 首先需要全局注册RouterView RouterLink 组件,这也是为什么你安装后无需自己再手动注册就能使用这两个组件的原因。

  2. 我们之前探究过provide的实现原理,使用provide注册的对象可以在其子孙组件中使用。

  3. 这里通过 provide 方式全局注入 router 对象和reactiveRoute对象,其中 router 表示用户通过 createRouter 创建的路由对象,我们可以通过它去动态操作路由,reactiveRoute 表示响应式的路径对象,它维护着路径的相关信息。

三、 路由切换

所谓路由切换,本质上可以视为依据不同的路径选择,将用户导向相应的页面。

1.初始值对象:START_LOCATION_NORMALIZED

首先我们先介绍一个当前路径对象currentRoute的初始值: START_LOCATION_NORMALIZED

javascript 复制代码
const START_LOCATION_NORMALIZED = {
  path: "/",
  name: undefined,
  params: {},
  query: {},
  hash: "",
  fullPath: "/",
  matched: [],
  meta: {},
  redirectedFrom: undefined,
};

可以看到对象上有pathname等等属性。

vue-router给我们提供了非常多的切换路径Api,比如router.push,router.replace等等,它们的底层其实都是通过pushWithRedirect方法来实现的。

javascript 复制代码
function push(to: RouteLocationRaw | RouteLocation) {
    return pushWithRedirect(to)
  }

function replace(to: RouteLocationRaw | RouteLocationNormalized) {
    return push(assign(locationAsObject(to), { replace: true }))
  }

2. 切换路径:pushWithRedirect

javascript 复制代码
function pushWithRedirect(to, redirectedFrom) {
  const targetLocation = (pendingLocation = resolve(to));
  const from = currentRoute.value;
  const data = to.state;
  const force = to.force;
  const replace = to.replace === true;
  const toLocation = targetLocation;
  toLocation.redirectedFrom = redirectedFrom;
  let failure;
  if (!force && isSameRouteLocation(stringifyQuery$1, from, targetLocation)) {
    failure = createRouterError(16 /* NAVIGATION_DUPLICATED */, {
      to: toLocation,
      from,
    });
    handleScroll(from, from, true, false);
  }
  return (failure ? Promise.resolve(failure) : navigate(toLocation, from))
    .catch((error) => {
      if (
        isNavigationFailure(
          error,
          4 /* NAVIGATION_ABORTED */ |
            8 /* NAVIGATION_CANCELLED */ |
            2 /* NAVIGATION_GUARD_REDIRECT */
        )
      ) {
        return error;
      }
      return triggerError(error);
    })
    .then((failure) => {
      if (failure) {
        // 处理错误
      } else {
        failure = finalizeNavigation(toLocation, from, true, replace, data);
      }
      triggerAfterEach(toLocation, from, failure);
      return failure;
    });
}
  1. 参数to有多种形式,可以是字符串路径,可以是带有路径的对象或者查询参数的对象等等。比如下面的例子:
javascript 复制代码
// 字符串路径
router.push('/users/eduardo')

// 带有路径的对象
router.push({ path: '/users/eduardo' })

// 命名的路由,并加上参数,让路由建立 url
router.push({ name: 'user', params: { username: 'eduardo' } })

// 带查询参数,结果是 /register?plan=private
router.push({ path: '/register', query: { plan: 'private' } })

// 带 hash,结果是 /about#team
router.push({ path: '/about', hash: '#team' })

所以pushWithRedirect第一步就是先通过reslove方法返回一个新的路径对象,这个新的路径对象会多一个matchedRoute属性,现在暂时不用管。

  1. 拿到新的路径对象后,就会执行navigate方法,这是一个处理导航守卫的函数,现在可以先忽略。

3. navigate执行后,就会执行 finalizeNavigation 完成导航,这个finalizeNavigation 函数才是真正切换路径的核心

3. 核心:finalizeNavigation

javascript 复制代码
function finalizeNavigation(toLocation, from, isPush, replace, data) {
  const error = checkCanceledNavigation(toLocation, from);
  if (error) return error;
  const isFirstNavigation = from === START_LOCATION_NORMALIZED;
  const state = !isBrowser ? {} : history.state;
  if (isPush) {
    if (replace || isFirstNavigation)
      routerHistory.replace(
        toLocation.fullPath,
        assign(
          {
            scroll: isFirstNavigation && state && state.scroll,
          },
          data
        )
      );
    else routerHistory.push(toLocation.fullPath, data);
  }
  currentRoute.value = toLocation;
  handleScroll(toLocation, from, isPush, isFirstNavigation);
  markAsReady();
}
  1. finalizeNavigation主要做两件事,第一个就是更新当前的路径currentRoute的值

  2. 第二个就是执行 routerHistory.push 或者是 routerHistory.replace 方法更新浏览器的URL的记录。

同学们在这里可能会疑惑,这个routerHistory对象是什么东西,从哪里来的?

我们回顾下路由创建时代码:

javascript 复制代码
// main.js
const router = createRouter({
  history: createWebHistory(),
  routes,
});
const app = createApp({});
app.use(router);

createWebHistory方法会创建一个routerHistory对象并且赋值给history属性。

history属性会和routes路径映射数组一起当作参数options传入createRouter方法里面。

createRouter里面会将它赋值给routerHistory变量

javascript 复制代码
let routerHistory = options.history

4. history对象 :createWebHistory

在我们创建 router 对象的时候,会创建一个 history 对象,前面提到 Vue Router 支持三种模式,这里我们重点分析 HTML5history 的模式:

javascript 复制代码
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(
    {
      // it's overridden right after
      location: "",
      base,
      go,
      createHref: createHref.bind(null, base),
    },
    historyNavigation,
    historyListeners
  );
  Object.defineProperty(routerHistory, "location", {
    get: () => historyNavigation.location.value,
  });
  Object.defineProperty(routerHistory, "state", {
    get: () => historyNavigation.state.value,
  });
  return routerHistory;
}

history对象有两个功能,第一个是通过historyNavigation来实现切换路径,第二个是通过historyListeners实现路由变化的监听。 一个切换,一个监听。这两个功能就是能实现页面路径变化无需刷新的关键。

(1)historyNavigation

historyNavigationuseHistoryStateNavigation 函数的返回值,我们来看它的实现:

javascript 复制代码
function useHistoryStateNavigation(base) {
  const { history, location } = window;
  let currentLocation = {
    value: createCurrentLocation(base, location),
  };
  let historyState = { value: history.state };
  if (!historyState.value) {
    changeLocation(
      currentLocation.value,
      {
        back: null,
        current: currentLocation.value,
        forward: null,
        position: history.length - 1,
        replaced: true,
        scroll: null,
      },
      true
    );
  }
  function changeLocation(to, state, replace) {
    const url =
      createBaseLocation() +
      // preserve any existing query when base has a hash
      (base.indexOf("#") > -1 && location.search
        ? location.pathname + location.search + "#"
        : base) +
      to;
    try {
      history[replace ? "replaceState" : "pushState"](state, "", url);
      historyState.value = state;
    } catch (err) {
      warn("Error with push/replace State", err);
      location[replace ? "replace" : "assign"](url);
    }
  }
  function replace(to, data) {
    const state = assign(
      {},
      history.state,
      buildState(
        historyState.value.back,
        // keep back and forward entries but override current position
        to,
        historyState.value.forward,
        true
      ),
      data,
      { position: historyState.value.position }
    );
    changeLocation(to, state, true);
    currentLocation.value = to;
  }
  function push(to, data) {
    const currentState = assign({}, historyState.value, history.state, {
      forward: to,
      scroll: computeScrollPosition(),
    });
    if (!history.state) {
      warn(
        `history.state seems to have been manually replaced without preserving the necessary values. Make sure to preserve existing history state if you are manually calling history.replaceState:\n\n` +
          `history.replaceState(history.state, '', url)\n\n` +
          `You can find more information at https://next.router.vuejs.org/guide/migration/#usage-of-history-state.`
      );
    }
    changeLocation(currentState.current, currentState, true);
    const state = assign(
      {},
      buildState(currentLocation.value, to, null),
      { position: currentState.position + 1 },
      data
    );
    changeLocation(to, state, false);
    currentLocation.value = to;
  }
  return {
    location: currentLocation,
    state: historyState,
    push,
    replace,
  };
}

我们直接看返回的对象里面含有pushreplace函数,这两个函数会添加给routerHistory对象上,因此当我们调用 routerHistory.push 或者是 routerHistory.replace 方法的时候实际上就是在执行这两个函数。

push replace 方法内部都是执行了changeLocation方法,该函数内部执行了浏览器底层的 history.pushState 或者history.replaceState方法,会向当前浏览器会话的历史堆栈中添加一个状态,这样就在不刷新页面的情况下修改了页面的 URL

我们使用这种方法修改了路径,这个时候假设我们点击浏览器的回退按钮回到上一个 URL,这需要恢复到上一个路径以及更新路由视图,因此我们还需要监听这种history变化的行为,做一些相应的处理。

historyListeners

History 变化的监听主要是通过 historyListeners 来完成的,它是 useHistoryListeners 函数的返回值,我们来看它的实现

javascript 复制代码
function useHistoryListeners(base, historyState, currentLocation, replace) {
  let listeners = [];
  let teardowns = [];
  let pauseState = null;
  const popStateHandler = ({ state }) => {
    const to = createCurrentLocation(base, location);
    const from = currentLocation.value;
    const fromState = historyState.value;
    let delta = 0;
    if (state) {
      currentLocation.value = to;
      historyState.value = state;
      if (pauseState && pauseState === from) {
        pauseState = null;
        return;
      }
      delta = fromState ? state.position - fromState.position : 0;
    } else {
      replace(to);
    }
    listeners.forEach((listener) => {
      listener(currentLocation.value, from, {
        delta,
        type: NavigationType.pop,
        direction: delta
          ? delta > 0
            ? NavigationDirection.forward
            : NavigationDirection.back
          : NavigationDirection.unknown,
      });
    });
  };
  function pauseListeners() {
    pauseState = currentLocation.value;
  }
  function listen(callback) {
    listeners.push(callback);
    const teardown = () => {
      const index = listeners.indexOf(callback);
      if (index > -1) listeners.splice(index, 1);
    };
    teardowns.push(teardown);
    return teardown;
  }
  function beforeUnloadListener() {
    const { history } = window;
    if (!history.state) return;
    history.replaceState(
      assign({}, history.state, { scroll: computeScrollPosition() }),
      ""
    );
  }
  function destroy() {
    for (const teardown of teardowns) teardown();
    teardowns = [];
    window.removeEventListener("popstate", popStateHandler);
    window.removeEventListener("beforeunload", beforeUnloadListener);
  }
  window.addEventListener("popstate", popStateHandler);
  window.addEventListener("beforeunload", beforeUnloadListener);
  return {
    pauseListeners,
    listen,
    destroy,
  };
}
  1. 还是一样,我们直接看返回的对象里的listen方法,允许你添加一些侦听器,侦听 history 的变化,同时这个方法也被挂载到了 routerHistory 对象上,这样外部就可以访问到了。

  2. 该函数内部还监听了浏览器底层Windowpopstate 事件,当我们点击浏览器的回退按钮或者是执行了 history.back 方法的时候,会触发事件的回调函数 popStateHandler,进而遍历侦听器 listeners,执行每一个侦听器函数。

那么,问题来了,Vue Router 是如何添加这些侦听器的呢?

答案就是在安装路由执行install方法时,会先执行一次初始化导航,里面调用了push方法,进而执行了 finalizeNavigation 方法。

javascript 复制代码
      // this initial navigation is only necessary on client, on server it doesn't
      // make sense because it will create an extra unnecessary navigation and could
      // lead to problems
      if (
        isBrowser &&
        // used for the initial navigation client side to avoid pushing
        // multiple times when the router is used in multiple apps
        !started &&
        currentRoute.value === START_LOCATION_NORMALIZED
      ) {
        // see above
        started = true
        push(routerHistory.location).catch(err => {
          if (__DEV__) warn('Unexpected error when starting the router:', err)
        })
      }

finalizeNavigation 的最后,会执行 markAsReady 方法,我们来看它的实现:

javascript 复制代码
function markAsReady(err) {
  if (ready) return;
  ready = true;
  setupListeners();
  readyHandlers
    .list()
    .forEach(([resolve, reject]) => (err ? reject(err) : resolve()));
  readyHandlers.reset();
}

markAsReady 内部会执行 setupListeners 函数初始化侦听器,且保证只初始化一次。我们再接着来看 setupListeners 的实现

javascript 复制代码
function setupListeners() {
  removeHistoryListener = routerHistory.listen((to, _from, info) => {
    const toLocation = resolve(to);
    pendingLocation = toLocation;
    const from = currentRoute.value;
    if (isBrowser) {
      saveScrollPosition(
        getScrollKey(from.fullPath, info.delta),
        computeScrollPosition()
      );
    }
    navigate(toLocation, from)
      .catch((error) => {
        if (
          isNavigationFailure(
            error,
            4 /* NAVIGATION_ABORTED */ | 8 /* NAVIGATION_CANCELLED */
          )
        ) {
          return error;
        }
        if (isNavigationFailure(error, 2 /* NAVIGATION_GUARD_REDIRECT */)) {
          if (info.delta) routerHistory.go(-info.delta, false);
          pushWithRedirect(error.to, toLocation).catch(noop);
          // avoid the then branch
          return Promise.reject();
        }
        if (info.delta) routerHistory.go(-info.delta, false);
        return triggerError(error);
      })
      .then((failure) => {
        failure = failure || finalizeNavigation(toLocation, from, false);
        if (failure && info.delta) routerHistory.go(-info.delta, false);
        triggerAfterEach(toLocation, from, failure);
      })
      .catch(noop);
  });
}

setupListeners函数也是执行 navigate 方法,执行路由切换过程中的一系列导航守卫函数,在 navigate 成功后执行 finalizeNavigation 完成导航,完成真正的路径切换。这样就保证了在用户点击浏览器回退按钮后,可以恢复到上一个路径以及更新路由视图。

总结

通过上述机制,Vue Router 实现了在单页面应用中无需重新加载页面即可完成路径切换的功能。它通过维护 currentRoute 对象来管理当前路径状态,利用浏览器的 history API(history.pushStatehistory.replaceState) 动态更新 URL,并通过监听 popstate 事件处理用户点击回退按钮的情况。这种设计不仅提升了用户体验,还使得路径管理更加灵活和高效。

相关推荐
SummerGao.9 分钟前
【解决】layui layer的提示框,弹出框一闪而过的问题
前端·layui
懒懒小徐14 分钟前
大厂面试-框架篇
面试·职场和发展
wayhome在哪34 分钟前
大厂必考之大文件上传
面试
天天扭码37 分钟前
从数组到对象:JavaScript 遍历语法全解析(ES5 到 ES6 + 超详细指南)
前端·javascript·面试
拉不动的猪38 分钟前
前端开发中常见的数据结构优化问题
前端·javascript·面试
街尾杂货店&39 分钟前
css word
前端·css
Мартин.42 分钟前
[Meachines] [Hard] CrimeStoppers LFI+ZIP-Shell+Firefox-Dec+DLINK+rootme-0.5
前端·firefox
冰镇生鲜42 分钟前
快速静态界面 MDC规则约束 示范
前端
技术与健康1 小时前
【解读】Chrome 浏览器实验性功能全景
前端·chrome
Bald Monkey1 小时前
【Element Plus】解决移动设备使用 el-menu 和 el-sub-menu 时,子菜单需要点击两次才会隐藏的问题
前端·elementui·vue·element plus