WSの小屋

手写 Mini Vue Router:150 行代码还原路由核心逻辑

理解一个库最好的方式就是手写一个简化版。本文将用约 150 行 TypeScript 实现一个 Mini Vue Router,涵盖路由匹配、历史管理、导航守卫、<router-view><router-link> 等核心功能。读完本文,你会对 Vue Router 的内部运行机制有清晰直觉。

目标功能

我们要实现的 Mini Router 支持:

  • ✅ 路由配置与匹配(含动态参数 :id
  • ✅ Hash 和 HTML5 两种历史模式
  • ✅ 导航守卫(beforeEach / afterEach
  • <router-view> 组件
  • <router-link> 组件
  • ✅ 编程式导航(push / replace / back

第一步:类型定义

import { ref, reactive, computed, h, defineComponent, inject, provide } from 'vue'

// 路由配置
type RouteRecord = {
  path: string
  component: any
  children?: RouteRecord[]
  beforeEnter?: (to: RouteLocation, from: RouteLocation) => any
}

// 当前路由状态
type RouteLocation = {
  path: string
  params: Record<string, string>
  query: Record<string, string>
  matched: RouteRecord[]
}

// 路由匹配结果
type Matcher = {
  re: RegExp
  keys: string[]
  record: RouteRecord
  parent: Matcher | null
  score: number
}

第二步:路由匹配器

将路径模式编译为正则表达式,并构建匹配树:

function compilePath(path: string): { re: RegExp; keys: string[]; score: number } {
  const keys: string[] = []
  let score = 0

  // /users/:id/posts/:postId → /users/([^/]+)/posts/([^/]+)
  const pattern = path.replace(/:([^/]+)/g, (_, key) => {
    keys.push(key)
    score += 4  // 动态参数分值 4
    return '([^/]+'
  })

  // 静态段分值
  const staticSegments = path.split('/').filter(s => s && !s.startsWith(':'))
  score += staticSegments.length * 10

  // 编译正则
  const re = new RegExp(`^${pattern}/?$`, 'i')

  return { re, keys, score }
}

function createMatcher(routes: RouteRecord[]): Matcher[] {
  const matchers: Matcher[] = []

  function addRoute(route: RouteRecord, parent: Matcher | null) {
    const fullPath = parent
      ? parent.record.path + route.path
      : route.path

    const { re, keys, score } = compilePath(fullPath)
    const matcher: Matcher = {
      re,
      keys,
      record: route,
      parent,
      score: parent ? parent.score + score : score
    }
    matchers.push(matcher)

    // 递归处理子路由
    if (route.children) {
      route.children.forEach(child => addRoute(child, matcher))
    }
  }

  routes.forEach(route => addRoute(route, null))

  // 按分值降序排列
  matchers.sort((a, b) => b.score - a.score)
  return matchers
}

function matchRoute(
  path: string,
  matchers: Matcher[]
): { matched: RouteRecord[]; params: Record<string, string> } | null {
  for (const matcher of matchers) {
    const result = matcher.re.exec(path)
    if (result) {
      // 提取参数
      const params: Record<string, string> = {}
      matcher.keys.forEach((key, i) => {
        params[key] = decodeURIComponent(result[i + 1])
      })

      // 构建 matched 数组(含所有父级)
      const matched: RouteRecord[] = []
      let m: Matcher | null = matcher
      while (m) {
        matched.unshift(m.record)
        m = m.parent
      }

      return { matched, params }
    }
  }
  return null
}

第三步:History 管理器

抽象出通用的 History 接口,Hash 和 HTML5 模式各自实现:

type Listener = (to: RouteLocation, from: RouteLocation) => void

abstract class RouterHistory {
  listeners: Listener[] = []
  current = ref<RouteLocation>({
    path: '/',
    params: {},
    query: {},
    matched: []
  })

  abstract push(path: string): void
  abstract replace(path: string): void
  abstract destroy(): void

  listen(listener: Listener) {
    this.listeners.push(listener)
  }

  notify(to: RouteLocation, from: RouteLocation) {
    this.listeners.forEach(l => l(to, from))
    this.current.value = to
  }
}

class HashHistory extends RouterHistory {
  constructor() {
    super()
    window.addEventListener('hashchange', this.onHashChange)
    // 初始化
    if (!window.location.hash) {
      window.location.hash = '#/'
    }
  }

  private onHashChange = () => {
    const path = window.location.hash.slice(1) || '/'
    // 过渡到新路由(由 Router 处理守卫)
    this.transitionTo(path)
  }

  transitionTo(path: string) {
    // 由 Router 调用,实际触发导航
    // 这里简化处理:直接通知
  }

  push(path: string) {
    window.location.hash = '#' + path
  }

  replace(path: string) {
    const url = window.location.href.split('#')[0] + '#' + path
    window.location.replace(url)
  }

  destroy() {
    window.removeEventListener('hashchange', this.onHashChange)
  }
}

class HTML5History extends RouterHistory {
  constructor() {
    super()
    window.addEventListener('popstate', this.onPopState)
  }

  private onPopState = (e: PopStateEvent) => {
    this.transitionTo(window.location.pathname + window.location.search)
  }

  transitionTo(path: string) {}

  push(path: string) {
    window.history.pushState({ path }, '', path)
    this.transitionTo(path)
  }

  replace(path: string) {
    window.history.replaceState({ path }, '', path)
    this.transitionTo(path)
  }

  destroy() {
    window.removeEventListener('popstate', this.onPopState)
  }
}

第四步:Router 核心

整合匹配器、历史管理器和导航守卫:

type NavigationGuard = (to: RouteLocation, from: RouteLocation) => boolean | string | void | Promise<boolean | string | void>

class MiniRouter {
  matchers: Matcher[]
  history: RouterHistory
  beforeEachGuards: NavigationGuard[] = []
  afterEachHooks: ((to: RouteLocation, from: RouteLocation) => void)[] = []

  constructor(routes: RouteRecord[], mode: 'hash' | 'html5' = 'hash') {
    this.matchers = createMatcher(routes)
    this.history = mode === 'hash'
      ? new HashHistory()
      : new HTML5History()

    // 初始导航
    this.history.transitionTo = (path: string) => this.transitionTo(path)

    // 初始化当前路由
    const initialPath = mode === 'hash'
      ? window.location.hash.slice(1) || '/'
      : window.location.pathname
    this.transitionTo(initialPath)
  }

  private async transitionTo(path: string) {
    const from = this.history.current.value

    // 解析路径(分离 query)
    const [purePath, queryString] = path.split('?')
    const query: Record<string, string> = {}
    if (queryString) {
      new URLSearchParams(queryString).forEach((v, k) => query[k] = v)
    }

    // 匹配路由
    const match = matchRoute(purePath, this.matchers)
    if (!match) {
      console.warn(`[MiniRouter] No route matched: ${purePath}`)
      return
    }

    const to: RouteLocation = {
      path: purePath,
      params: match.params,
      query,
      matched: match.matched
    }

    // 执行前置守卫
    for (const guard of this.beforeEachGuards) {
      const result = await guard(to, from)
      if (result === false) return          // 取消导航
      if (typeof result === 'string') {      // 重定向
        this.push(result)
        return
      }
    }

    // 执行路由独享守卫
    for (const record of to.matched) {
      if (record.beforeEnter) {
        const result = await record.beforeEnter(to, from)
        if (result === false) return
        if (typeof result === 'string') {
          this.push(result)
          return
        }
      }
    }

    // 更新当前路由
    this.history.notify(to, from)

    // 执行后置钩子
    this.afterEachHooks.forEach(hook => hook(to, from))
  }

  // API
  push(path: string) {
    this.history.push(path)
    if (this.history instanceof HTML5History) {
      this.transitionTo(path)
    }
  }

  replace(path: string) {
    this.history.replace(path)
  }

  back() {
    window.history.back()
  }

  beforeEach(guard: NavigationGuard) {
    this.beforeEachGuards.push(guard)
    return () => {
      const i = this.beforeEachGuards.indexOf(guard)
      if (i > -1) this.beforeEachGuards.splice(i, 1)
    }
  }

  afterEach(hook: (to: RouteLocation, from: RouteLocation) => void) {
    this.afterEachHooks.push(hook)
  }

  install(app: any) {
    app.provide('router', this)
    app.provide('route', this.history.current)
    app.component('RouterView', RouterView)
    app.component('RouterLink', RouterLink)
  }
}

第五步:RouterView 与 RouterLink 组件

const RouterView = defineComponent({
  name: 'RouterView',
  setup() {
    const router = inject<MiniRouter>('router')!
    const route = inject<ReturnType<typeof ref<RouteLocation>>>('route')!

    // 深度索引,用于嵌套路由
    const depth = inject('routerViewDepth', 0)
    provide('routerViewDepth', depth + 1)

    // 根据当前匹配层级渲染对应组件
    const component = computed(() => {
      const record = route.value.matched[depth]
      return record?.component
    })

    return () => {
      const comp = component.value
      if (!comp) return null
      return h(comp, {
        key: route.value.path  // 路径变化时强制更新
      })
    }
  }
})

const RouterLink = defineComponent({
  name: 'RouterLink',
  props: {
    to: { type: [String, Object], required: true }
  },
  setup(props, { slots }) {
    const router = inject<MiniRouter>('router')!

    const href = computed(() => {
      if (typeof props.to === 'string') return props.to
      let path = props.to.path || ''
      if (props.to.params) {
        Object.entries(props.to.params).forEach(([k, v]) => {
          path = path.replace(`:${k}`, v as string)
        })
      }
      if (props.to.query) {
        const qs = new URLSearchParams(props.to.query).toString()
        path += `?${qs}`
      }
      return path
    })

    function navigate(e: Event) {
      e.preventDefault()
      router.push(href.value)
    }

    return () => {
      return h('a', {
        href: '#' + href.value,  // hash 模式
        onClick: navigate
      }, slots.default?.())
    }
  }
})

第六步:组合成 createRouter API

对外暴露与 Vue Router 4 一致的 API:

function createRouter(options: {
  routes: RouteRecord[]
  history?: 'hash' | 'html5'
}): MiniRouter {
  const router = new MiniRouter(options.routes, options.history)
  return router
}

// 导出
export { createRouter, RouterView, RouterLink, useRouter, useRoute }
export type { RouteRecord, RouteLocation }

function useRouter() {
  return inject<MiniRouter>('router')!
}

function useRoute() {
  return inject<ReturnType<typeof ref<RouteLocation>>>('route')!
}

第七步:实际使用

import { createApp, createRouter } from './mini-router'

const router = createRouter({
  routes: [
    {
      path: '/',
      component: {
        template: '<h1>首页</h1><RouterView />'
      },
      children: [
        { path: 'users', component: { template: '<h2>用户列表</h2>' } },
        { path: 'users/:id', component: { template: '<h2>用户详情:{{ $route.params.id }}</h2>' } }
      ]
    }
  ],
  history: 'hash'
})

// 导航守卫
router.beforeEach((to, from) => {
  console.log('导航:', from.path, '→', to.path)
  if (to.path.startsWith('/admin') && !isLoggedIn()) {
    return '/login'
  }
})

router.afterEach((to) => {
  document.title = `App - ${to.path}`
})

const app = createApp({
  template: '<RouterView />'
})
app.use(router)
app.mount('#app')

与真实 Vue Router 的区别

功能 Mini Router Vue Router 4
路由匹配 ✅ 基础 :param ✅ + 自定义正则、可选参数、重复参数
路由评分 ✅ 简化版 ✅ 更精细的评分系统
嵌套路由 ✅ + children 配置继承
导航守卫 ✅ beforeEach/afterEach ✅ + beforeResolve、组件内守卫
Composition API ✅ useRouter/useRoute ✅ + useLink、onBeforeRouteLeave
动态路由 ✅ addRoute/removeRoute
过渡动画 ✅ 编程式 transition
错误处理 ✅ NavigationFailureType

总结

150 行代码,我们实现了一个能用的 Mini Vue Router。核心就是三件事:

  1. 编译匹配:把路径模式编译成正则 → 按分值排序 → 逐个匹配
  2. 历史管理:封装 hashchange / popstate → 统一 push/replace 接口
  3. 守卫管线:洋葱式执行 beforeEach → 路由守卫 → 组件守卫 → afterEach

理解了这个骨架后,再看 Vue Router 4 的源码,你会发现核心逻辑就是在这个基础上增加了更多的边界处理、类型推导和功能扩展。

Comments | 0条评论