likes
comments
collection
share

vue-router源码浅析

作者站长头像
站长
· 阅读数 13

关键函数

首先我们从使用路由时的几个函数调用入手观察源码执行的主干。

import { createApp } from 'vue'
const routerHistory = createWebHashHistory()
const router = createRouter({
    history: routerHistory,
    strict: true,
    routes: [{
      name: '...'
      path: '...',
      components: ...
    }]
 })
 
 const app = createApp()
 app.use(router)

createWebHashHistory与createWebHistory

createWebHashHistory内部同样是调用createWebHistory差别在于createWebHashHistory会拼接‘#’

base = location.host ? base || location.pathname + location.search : ''
  // allow the user to provide a `#` in the middle: `/base/#/app`
if (!base.includes('#')) base += '#'

两个方法最终返回RouterHistory对象

//假设请求http://localhost/api/#/index
interface RouterHistory {
  //base值/api/#
  readonly base: string
  //location /index
  readonly location: HistoryLocation
  //历史状态
  readonly state: HistoryState
  push(to: HistoryLocation, data?: HistoryState): void
  replace(to: HistoryLocation, data?: HistoryState): void
  go(delta: number, triggerListeners?: boolean): void
  listen(callback: NavigationCallback): () => void
  createHref(location: HistoryLocation): string
  //取消popstate与beforeunload事件监听,删除事件队列回调函数
  destroy(): void
}  

createWebHistory主要功能为注册popstatebeforeunload事件以及生成并返回 RouterHistory对象包含了几个router的重要方法:

go方法,功能为调用history.go

// 使用history api
 function go(delta: number, triggerListeners = true) {
    //history.go()方法会触发popstate事件,此判断为了阻止相关回调中的执行
    if (!triggerListeners) historyListeners.pauseListeners()
    history.go(delta)
 }

push方法,主要功能为通过historylocation相关api添加浏览记录:

function push(to: HistoryLocation, data?: HistoryState) {
    const currentState = assign(
      {},
      historyState.value,
      history.state as Partial<StateEntry> | null,
      {
        forward: to,
        scroll: computeScrollPosition(),
      }
    )

    changeLocation(currentState.current, currentState, true)

    const state: StateEntry = assign(
      {},
      buildState(currentLocation.value, to, null),
      { position: currentState.position + 1 },
      data
    )

    changeLocation(to, state, false)
    currentLocation.value = to
  }

上面只保留了主要代码,push内部调用changeLocation方法

function changeLocation(
  to: HistoryLocation,
  state: StateEntry,
  replace: boolean
): void {
  const hashIndex = base.indexOf('#')
  const url =
    hashIndex > -1
      ? (location.host && document.querySelector('base')
          ? base
          : base.slice(hashIndex)) + to
      : createBaseLocation() + base + to
  try {
    // BROWSER QUIRK
    // NOTE: Safari throws a SecurityError when calling this function 100 times in 30 seconds
    history[replace ? 'replaceState' : 'pushState'](state, '', url)
    historyState.value = state
  } catch (err) {
    if (__DEV__) {
      warn('Error with push/replace State', err)
    } else {
      console.error(err)
    }
    // Force the navigation, this also resets the call count
    location[replace ? 'replace' : 'assign'](url)
  }
}

(只保留了主要代码) 我们看到通过参数replace 实现使用replaceState还是pushState,因此在push函数中第一个changeLocation实际上使用replaceState替换了当前路径的history state。第二个才是真正的push。 replace函数同样也是changeLocation(to, state, true)的方式实现页面记录替换。

listen方法, 主要功能为popstate事件添加订阅者

function listen(callback: NavigationCallback) {
    // set up the listener and prepare teardown callbacks
    listeners.push(callback)

    const teardown = () => {
      const index = listeners.indexOf(callback)
      if (index > -1) listeners.splice(index, 1)
    }

    teardowns.push(teardown)
    return teardown
  }

实质上是一个观察者模式,在popStateHandler回调中调用。

通过createWebHashHistory创建的routerHistory对象作为createRouter时的参数传入为createRouter内部提供调用。

createRouter

createRouter方法主要功能为输出interface Router对象,router中一个重要的方法install,提供给vue实例在调用app.use(router)时对router插件安装:

install(app: App) {
      const router = this
      app.component('RouterLink', RouterLink)
      app.component('RouterView', RouterView)

      app.config.globalProperties.$router = router
      Object.defineProperty(app.config.globalProperties, '$route', {
        enumerable: true,
        get: () => unref(currentRoute),
      })

      // 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)
        })
      }

      const reactiveRoute = {} as {
        [k in keyof RouteLocationNormalizedLoaded]: ComputedRef<
          RouteLocationNormalizedLoaded[k]
        >
      }
      for (const key in START_LOCATION_NORMALIZED) {
        // @ts-expect-error: the key matches
        reactiveRoute[key] = computed(() => currentRoute.value[key])
      }

      console.log('%ccurrentRoute>>>>','color: red', {currentRoute})
      app.provide(routerKey, router)
      app.provide(routeLocationKey, reactive(reactiveRoute))
      app.provide(routerViewLocationKey, currentRoute)

      const unmountApp = app.unmount
      installedApps.add(app)
      app.unmount = function () {
        installedApps.delete(app)
        // the router is not attached to an app anymore
        if (installedApps.size < 1) {
          // invalidate the current navigation
          pendingLocation = START_LOCATION_NORMALIZED
          removeHistoryListener && removeHistoryListener()
          removeHistoryListener = null
          currentRoute.value = START_LOCATION_NORMALIZED
          started = false
          ready = false
        }
        unmountApp()
      }

      // TODO: this probably needs to be updated so it can be used by vue-termui
      if ((__DEV__ || __FEATURE_PROD_DEVTOOLS__) && isBrowser) {
        addDevtools(app, router, matcher)
      }
    }

install函数整体逻辑比较简单,主要包括: 1.注册RouterLink以及RouterView组件 2.通过app.config.globalProperties.$router向vue实例局变量$router注入路由对象 3.push由上面routerHistory从生成的location地址 4.通过vue的provide方法向实例提供几个路由相关的对象

const reactiveRoute = {} as {
   [k in keyof RouteLocationNormalizedLoaded]: ComputedRef<
       RouteLocationNormalizedLoaded[k]
   >
}
for (const key in START_LOCATION_NORMALIZED) {
   // @ts-expect-error: the key matches
   reactiveRoute[key] = computed(() => currentRoute.value[key])
}

app.provide(routerKey, router)
app.provide(routeLocationKey, reactive(reactiveRoute))
app.provide(routerViewLocationKey, currentRoute)

其中currentRouteshollowRef包装的对象指向当前路由,routeLocationKey指向的对象为对currentRoute对象属性的响应式绑定。routeLocationKeyrouterViewLocationKey将在RouterViewRouterLink中广泛使用。

RouterView组件的原理是监听currentRoute对象的变化,当调用push等方法改变当前route时,组件内响应式更新当前路由需要渲染的组件。 (RouterView源码如下)

const RouterViewImpl = defineComponent({
  name: 'RouterView',
  
  inheritAttrs: false,
  props: {
    name: {
      type: String as PropType<string>,
      default: 'default',
    },
    route: Object as PropType<RouteLocationNormalizedLoaded>,
  },

  compatConfig: { MODE: 3 },

  setup(props, { attrs, slots }) {
    __DEV__ && warnDeprecatedUsage()

    const injectedRoute = inject(routerViewLocationKey)!

    const routeToDisplay = computed<RouteLocationNormalizedLoaded>(
      () => props.route || injectedRoute.value
    )
    const injectedDepth = inject(viewDepthKey, 0)
    // The depth changes based on empty components option, which allows passthrough routes e.g. routes with children
    // that are used to reuse the `path` property
    
    // 查找当前路劲深度
    const depth = computed<number>(() => {
      let initialDepth = unref(injectedDepth)
      const { matched } = routeToDisplay.value
      let matchedRoute: RouteLocationMatched | undefined
      while (
        (matchedRoute = matched[initialDepth]) &&
        !matchedRoute.components
      ) {
        initialDepth++
      }
      return initialDepth
    })
    const matchedRouteRef = computed<RouteLocationMatched | undefined>(
      () => routeToDisplay.value.matched[depth.value]
    )

    //告诉下一次路径的路径深度下标
    provide(
      viewDepthKey,
      computed(() => depth.value + 1)
    )
    provide(matchedRouteKey, matchedRouteRef)
    provide(routerViewLocationKey, routeToDisplay)

    const viewRef = ref<ComponentPublicInstance>()

    // watch at the same time the component instance, the route record we are
    // rendering, and the name
    watch(
      () => [viewRef.value, matchedRouteRef.value, props.name] as const,
      ([instance, to, name], [oldInstance, from, oldName]) => {
        // copy reused instances
        if (to) {
          // this will update the instance for new instances as well as reused
          // instances when navigating to a new route
          to.instances[name] = instance
          // the component instance is reused for a different route or name, so
          // we copy any saved update or leave guards. With async setup, the
          // mounting component will mount before the matchedRoute changes,
          // making instance === oldInstance, so we check if guards have been
          // added before. This works because we remove guards when
          // unmounting/deactivating components
          if (from && from !== to && instance && instance === oldInstance) {
            if (!to.leaveGuards.size) {
              to.leaveGuards = from.leaveGuards
            }
            if (!to.updateGuards.size) {
              to.updateGuards = from.updateGuards
            }
          }
        }

        // trigger beforeRouteEnter next callbacks
        if (
          instance &&
          to &&
          // if there is no instance but to and from are the same this might be
          // the first visit
          (!from || !isSameRouteRecord(to, from) || !oldInstance)
        ) {
          ;(to.enterCallbacks[name] || []).forEach(callback =>
            callback(instance)
          )
        }
      },
      { flush: 'post' }
    )

    return () => {
      const route = routeToDisplay.value
      // we need the value at the time we render because when we unmount, we
      // navigated to a different location so the value is different
      const currentName = props.name
      const matchedRoute = matchedRouteRef.value
      const ViewComponent =
        matchedRoute && matchedRoute.components![currentName]

      if (!ViewComponent) {
        return normalizeSlot(slots.default, { Component: ViewComponent, route })
      }

      // props from route configuration
      const routePropsOption = matchedRoute.props[currentName]
      const routeProps = routePropsOption
        ? routePropsOption === true
          ? route.params
          : typeof routePropsOption === 'function'
          ? routePropsOption(route)
          : routePropsOption
        : null

      const onVnodeUnmounted: VNodeProps['onVnodeUnmounted'] = vnode => {
        // remove the instance reference to prevent leak
        if (vnode.component!.isUnmounted) {
          matchedRoute.instances[currentName] = null
        }
      }

      const component = h(
        ViewComponent,
        assign({}, routeProps, attrs, {
          onVnodeUnmounted,
          ref: viewRef,
        })
      )

      if (
        (__DEV__ || __FEATURE_PROD_DEVTOOLS__) &&
        isBrowser &&
        component.ref
      ) {
        // TODO: can display if it's an alias, its props
        const info: RouterViewDevtoolsContext = {
          depth: depth.value,
          name: matchedRoute.name,
          path: matchedRoute.path,
          meta: matchedRoute.meta,
        }

        const internalInstances = isArray(component.ref)
          ? component.ref.map(r => r.i)
          : [component.ref.i]

        internalInstances.forEach(instance => {
          // @ts-expect-error
          instance.__vrv_devtools = info
        })
      }

      return (
        // pass the vnode to the slot as a prop.
        // h and <component :is="..."> both accept vnodes
        normalizeSlot(slots.default, { Component: component, route }) ||
        component
      )
    }
  },
})

RouteView组件的主要逻辑为渲染RouteView插槽中的组件或者当前路径深度匹配到的组件(路由配置中的component)。 routerView通过provide( viewDepthKey, computed(() => depth.value + 1) )告知下一层匹配路径的深度。例如/children/a渲染children组件时注入viewDepthKey值为0,同过以上代码a组件注入的viewDepthKey值为1。

简单实现

以下代码只模拟主要逻辑,如push如何触发RouterView改变的机制


const RouteView = defineComponent({
    setup(props, { attrs, slots }){
        const injectionRoute = inject('routeView')
        const injectedDepth = inject('viewDepthKey', 0)
        //有效深度
        const depth = computed(() => {
            let initialDepth = unref(injectedDepth)
            const { matched } = injectionRoute.value
            
            while (
                (matchedRoute = matched[initialDepth]) &&
                !matchedRoute.components
              ) {
                initialDepth++
              }
            return initialDepth
        })
        
        //当前路径深度的路由
        const matchedRouteRef = computed(() => injectionRoute.value.matched[depth.value])
        
        //注入下一次路由深度的值
        provide('injectedDepth', depth.value + 1)
        //路由名字
        const currentName = props.name
        const matchedRoute = matchedRouteRef.value
        const RouteComponent = matchedRoute && matchedRoute.components[currentName]
        
        const component = h(RouteComponent)
        
        //渲染路由组件
        return slots.default({ Component: component, route: injectionRoute.value }) || component
    )
    }
    
})

function createHistory() {
    const location = location.path + location.search + location.hash
    
    function toLocation(replace = false) {
        try {
            history[replace ? 'replaceState' : 'pushState'](state, '', url)
        } catch() {
            location[replace ? 'replace' : 'assign'](url)
        }
        
    }
    function push() {
        toLocation()
    }
    function replace() {
        toLocation(true)
    }
    return {
        location,
        push,
        replace
    }
}

function createRouterMatcher(routes) {
    const matchers = []

    function resolve(location) {
        const path = location.path
        //查找匹配路径的路由对象
        const matcher = matchers.find(m => m.re.test(path))
        const matched = []
        
        //不考虑children的情况
        if(matcher) {
            matched.unshift(matcher)
        }
        
        return {
           matched,
           path,
           name: matcher.record.name
        }
    }
    function addRoute(route) {
        const normalizedRecord = {
            path: route.path,
            name: route.name,
            props: route.props
            meta: record.meta || {},
            beforeEnter: record.beforeEnter,
            children: record.children || [],
            components:
              'components' in record
                ? record.components || null
                : record.component && { default: record.component },
        }
        const matcher = {
            record: normalizedRecord,
            children: []
        }
        
        //插入mathcers,用于后面匹配路径
        if(matcher.record.components) {
            let i = 0
            while(
                i < matchers.length &&
                matchers[i].record.path !== matcher.record.path
            ) {
                i++
            }
            matchers.splice(i, 0, mathcer)
        }
        
    }
    
    //生成matcher并加入matchers数组
    routes.forEach((route) => addRoute(route))
    
    return {
        resolve,
        addRoute
    }
}

function createRoute(option) {
    //初始状态
    const START_LOCATION = {}
    const history = option.history
    
    const currentLocation = shallowRef(START_LOCATION)
    const matcher = createRouterMatcher(option.routes)
    
    function resolve(rawLocation) {
        
        //实际上还需要处理query和hash,此处只简单模拟
        function parseUrl(rawLocation)  {
            return {
                path:rawLocation
            }
        }
        
        const normalizedLocation = parseUrl(rawLocation)
        const matchedRoute = matcher.resolve({
            path: normalizedLocation.path
        })
        
        return {
            ...normalizedLocation,
            ...matchedRoute
        }
    }
    
    function navigation(to) {
        const toLocation = resolve(to)
        history.push(to)
        currentLocation.value = toLocation
    }
    function push(to) {
        navigation(to)
    }
    
    const router = {
        install(app) {
            app.component('RouterView', RouterView)
            
            //vue组件内通过this.$router访问该router对象
            app.config.globalProperties.$router = this
            
            if(currentLocation.value === START_LOCATION) {
                push(option.location)
            }
            provide('routeView', currentLocation)
        }
    }
    
    return router
}

const router = createRoute({
    history: createHistory()
    routes: [
     { path: '/home', component: Home },
    ]
})

可以看到当调用router.pushcurrentLocation.value值被改变,由于RouterView依赖currentLocation.value因此会被触发视图更新。

总结

路由的基本原理为路由组件RouteView利用Vue的响应式原理监听当前路由变化,当push或replace等操作改变当前路由时触发视图更新。