前端知库

Vue Router 的原理是什么?怎样手写一个 mini router?

路由器做三件事:监听地址变化、把路径匹配成路由记录、把当前路由放进响应式数据让 RouterView 重新渲染。手写版用一个 history 对象封装 hash 或 pushState,用 shallowRef 保存当前路由,再实现 RouterView、RouterLink 和 install。

参考答案

简答

点一下导航里的“用户详情”,地址栏变成 /user/1,页面中间换成用户组件,整个页面却没有重新加载。Vue Router 做的就是这件事。

它由三部分组成。history 层负责改地址、听地址变化,hash 模式靠 hashchange,HTML5 模式靠 pushState 加 popstate。匹配层把路径和路由表比对,找出对应的组件,顺便解析出 :id 这样的参数。渲染层把当前路由存在响应式数据里,RouterView 读它,所以路由一变,RouterView 就渲染新的组件。

手写时,router.push 调 history 改地址,再匹配出新路由赋给响应式变量;RouterLink 渲染一个 <a>,点击时阻止默认跳转改调 push;install 里注册这两个组件,再用 provide 把 router 交给组件使用。

要注意,pushState 不会触发 popstate,所以 push 之后要自己更新当前路由;HTML5 模式下刷新页面会真的请求这个路径,服务器要回退到 index.html。

先拆成三层

官方文档列出了 app.use(router) 做的事:全局注册 RouterView 和 RouterLink,添加 $router 和 $route,启用 useRouter()、useRoute(),并触发初始路由的解析。手写版只要实现其中的主干就够了。

hash 和 history 两种模式怎样监听地址、服务器怎样配置回退,已经在《hash 路由和 history 路由的原理是什么?服务器要怎样配置回退?》里讲过,这里把它们看成一个统一的 history 对象:能读当前路径,能跳转,能在用户前进后退时通知路由器。导航守卫的执行顺序见《Vue Router 有哪些导航守卫?完整的导航流程是怎样的?》,手写版只保留 beforeEach。

匹配层:把路径变成路由

匹配层和浏览器无关,可以单独用 Node 运行。下面把 /user/:id 这样的路径编译成正则,冒号开头的段落变成捕获组,匹配成功时再按顺序取出参数。

JavaScript
function createMatcher(routes) {
  const records = routes.map((route) => {
    const keys = [];
    const pattern = route.path.replace(/:(\w+)/g, (_, key) => {
      keys.push(key);
      return '([^/]+)';
    });
    return { route, keys, regex: new RegExp('^' + pattern + '$') };
  });

  return function match(path) {
    for (const { route, keys, regex } of records) {
      const result = regex.exec(path);
      if (result) {
        const params = {};
        keys.forEach((key, index) => {
          params[key] = decodeURIComponent(result[index + 1]);
        });
        return { ...route, path, params };
      }
    }
    return { path, name: 'not-found', params: {} };
  };
}

const match = createMatcher([
  { path: '/', name: 'home' },
  { path: '/user/:id', name: 'user' },
  { path: '/user/:id/post/:postId', name: 'post' },
]);

console.log(match('/').name); // home
console.log(match('/user/42').params.id); // 42
console.log(match('/user/42/post/7').params.postId); // 7
console.log(match('/about').name); // not-found

正则两头加了 ^ 和 $,所以 /user/42 不会被 / 匹配上,/user/42/post/7 也不会被 /user/:id 截走。([^/]+) 只匹配到下一个斜杠为止,一个参数不会吃掉后面的路径。

手写版按书写顺序找第一个匹配的路由,真正的 Vue Router 匹配语法更丰富,还支持自定义参数正则、可选参数和重复参数。找不到时返回 not-found,对应官方建议配置的 /:pathMatch(.*) 兜底路由。

路由器:push 之后自己更新当前路由

路由器把 history 和匹配器连起来。当前路由放在 shallowRef 里,因为每次导航都整体换成一个新对象,不需要深层响应。

JavaScript
import { shallowRef } from 'vue';

function createRouter({ history, routes }) {
  const match = createMatcher(routes);
  const currentRoute = shallowRef(match(history.location()));
  const guards = [];

  async function navigate(path, mode) {
    const to = match(path);
    for (const guard of guards) {
      if ((await guard(to, currentRoute.value)) === false) return;
    }
    if (mode === 'push') history.push(path);
    if (mode === 'replace') history.replace(path);
    currentRoute.value = to;
  }

  // 前进后退时地址已经变了,只需要更新当前路由
  history.listen((path) => navigate(path, 'pop'));

  return {
    currentRoute,
    push: (path) => navigate(path, 'push'),
    replace: (path) => navigate(path, 'replace'),
    beforeEach: (guard) => guards.push(guard),
    install(app) {
      app.provide('router', this);
      app.config.globalProperties.$router = this;
      app.component('RouterView', RouterView);
      app.component('RouterLink', RouterLink);
    },
  };
}

为什么 push 里改完地址还要手动给 currentRoute 赋值?MDN 说明,调用 pushState 不会触发 popstate,popstate 只在用户前进、后退等切换历史记录时触发。所以主动跳转时路由器得自己更新,被动的前进后退才交给监听器。

match 返回的对象展开了整条路由记录,路由表里写的 component 也会一起带出来,RouterView 就靠它渲染。

HTML5 模式的 history 对象大致这样写:

JavaScript
function createWebHistory() {
  return {
    location: () => window.location.pathname,
    push: (path) => window.history.pushState({}, '', path),
    replace: (path) => window.history.replaceState({}, '', path),
    listen: (callback) => {
      window.addEventListener('popstate', () => callback(window.location.pathname));
    },
  };
}

hash 模式只要把读路径换成 location.hash.slice(1) || '/',跳转改成设置 location.hash,监听改成 hashchange。设置 location.hash 本身会触发 hashchange,所以 hash 版的 push 改完地址后可以不手动更新,交给监听器统一处理,免得一次跳转更新两遍。

RouterView 和 RouterLink

RouterView 读当前路由,渲染对应的组件。它在渲染函数里读了 currentRoute.value,所以路由一换,它就重新渲染。

JavaScript
import { h, inject } from 'vue';

const RouterView = {
  setup() {
    const router = inject('router');
    return () => {
      const component = router.currentRoute.value.component;
      return component ? h(component) : null;
    };
  },
};

const RouterLink = {
  props: { to: { type: String, required: true } },
  setup(props, { slots }) {
    const router = inject('router');
    function onClick(event) {
      event.preventDefault();
      router.push(props.to);
    }
    return () => h('a', { href: props.to, onClick }, slots.default?.());
  },
};

RouterLink 仍然渲染真正的 <a href>,这样能看到链接地址、能用新标签页打开。点击时用 preventDefault 拦住浏览器的整页跳转,改调 router.push。官方文档也说,用 RouterLink 代替普通 <a>,是为了让 Vue Router 不重新加载页面就能改地址。

这两个组件用到的 h() 写法见《Vue 的渲染函数、JSX 和 h() 怎样使用?》。

使用方式和官方一样,use 要写在 mount 前面:

JavaScript
const router = createRouter({ history: createWebHistory(), routes });
createApp(App).use(router).mount('#app');

常见追问

手写版和真正的 Vue Router 差在哪里?

手写版只保留了监听地址、匹配、响应式渲染这条主线。官方版本还支持嵌套路由、命名路由、完整的导航守卫流程、路由懒加载和滚动行为控制,这些在 Vue Router 文档里各有专门章节。

memory 模式是做什么的?

createMemoryHistory() 不依赖浏览器环境,不读也不改地址栏,适合 Node 和 SSR。它也不会自动触发初始导航,要在 app.use(router) 之后自己 push 初始路由。在浏览器里用它,前进后退就没有效果。