用a标签跳转前端路由会发生什么

604 阅读5分钟

学习vue-router, 完成了第一个阶段, 总结一下vue-router客户端部分的总体流程.

这次先总结客户端路由, 下次再看服务端的.

路由行为总结

路由的行为分为2个大类, 初始化与跳转.

页面初始化

打开一个新窗口, 或者刷新页面, router-view会根据路由配置展示对应的组件.

另一个场景是a标签的跳转, 页面也会重新加载页面, 但历史会记录在historyapi里.

程序控制路由

通过router-link跳转或者router的api跳转.

虽然router-link产生的是一个a标签, 但click事件绑定的其实是router的api.

另外初始化路由库的时候会监听popstate事件, 如果是由程序控制的跳转, 也会通过程序跳转回去. 这个需要在后面部分展开.

路由行为分析

分析具体行为前, 先要知道路由库加载到主程序是通过vue.use()的, 所以除了2个component外, 再观察install()方法就可以了.

router-viewrouter-link这2个componnet也是在install()方法里被加载的, 下面我们开始具体进入上面2个行为的具体流程.

router-link

router-link就是一个a标签, 根据路由匹配给标签active的类, 并且点击标签后会调用router.push()来变更路由.

从代码来看也非常简单, 也可以不看代码跳到下一节.

{
  setup(props, { slots }) {
        const link = reactive(useLink(props));
        const { options } = inject(routerKey);
        const elClass = computed(() => ({...}));
        return () => {
            const children = slots.default && slots.default(link);
            return props.custom
                ? children
                : h('a', {
                    'aria-current': link.isExactActive
                        ? props.ariaCurrentValue
                        : null,
                    href: link.href,
                    // this would override user added attrs but Vue will still add
                    // the listener, so we end up triggering both
                    onClick: link.navigate,
                    class: elClass.value,
                }, children);
        };
    }
}

代码很少, 而且很多代码是兼容了些特殊用法, 不是主要功能.

onClick绑定的link.navigate()中的link是调用useLink()得到的, 最后也是调用router.push()router.replace(), 其实是同一个东西, 后面再展开.

router-view

router-view做的是判断需要加载的组件, 然后返回对应的vdom.

下面看一下代码, 再来分析下是根据什么判断需要加载什么组件的. (这里先不看嵌套路由相关的)

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

我们可以从ViewComponent顺着往上找到是从inject(routerViewLocationKey)取值的.

然后发现这个值是在install()里提供的app.provide(routerViewLocationKey, currentRoute).

并且在finalizeNavigation()中mutate了值currentRoute.value = toLocation. (会在下面router.push章节展开)

install

插件安装就做了一些初始化, 比较简单, 这里先贴出代码, 再一起看看做了哪些事.

{
  	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 RouteLocationNormalizedLoaded
      for (const key in START_LOCATION_NORMALIZED) {
        Object.defineProperty(reactiveRoute, key, {
          get: () => currentRoute.value[key as keyof RouteLocationNormalized],
          enumerable: true,
        })
      }
      app.provide(routerKey, router)
      app.provide(routeLocationKey, shallowReactive(reactiveRoute))
      app.provide(routerViewLocationKey, currentRoute)
      // ...
    }
}
  1. 注册了router-linkrouter-view组件.
  2. 注册了变量让其他地方获取, 其中$开头的是兼容给option api用的, provide()是内部用, 以及通过use系列api暴露给用户的. (use系列api做的就是return inject())
  3. 如果是客户端的初次渲染, 则调用router.push().

页面初始化

  1. install()注册组件, 并调用router.push().
  2. push()通过then()放入微任务等待执行.
  3. 渲染页面, 包括执行所有组件的setup()和他返回的函数, 完成第一次页面渲染.

第一次渲染完成的时候router-view没有获得ViewComponent, 所以渲染的是空页面. (router-view的部分是空页面)

  1. 开始执行第二步push()的微任务, 最终执行finalizeNavigation().
  2. 通过调用routerHistory.replace()改变url.
  3. 通过mutatecurrentRoute.value触发effect更新组件, 这次ViewComponent获取到了预期的组件, router-link也给链接赋上了active类了.

router.push

页面初始化, router-link, 浏览器回退监听简单过滤后, 都会最终调用router.push(), 或者是replace(), 这2个是通过不同参数调用了pushWithRedirect().

    function pushWithRedirect(to, redirectedFrom) {
        const targetLocation = (pendingLocation = resolve(to));
        const from = currentRoute.value;
        const data = to.state;
        const force = to.force;
				// ...
        const toLocation = targetLocation;
        toLocation.redirectedFrom = redirectedFrom;
        let failure;
        if (!force && isSameRouteLocation(stringifyQuery$1, from, targetLocation)) {
            // failure = ...
        }
        return (failure ? Promise.resolve(failure) : navigate(toLocation, from))
            .catch(
        		// ...
        		)
            .then((failure) => {
            if (failure) {
                // ...
            }
            else {
                // if we fail we don't finalize the navigation
                failure = finalizeNavigation(toLocation, from, true, replace, data);
            }
            triggerAfterEach(toLocation, from, failure);
            return failure;
        });
    }

可以看到在整理了参数后, 调用了navigate(toLocation, from).

这个函数是用来跑路由钩子的, 在"页面初始化"的时候提到过.

分别顺序运行的钩子是: leavingRecords, beforeGuards, updateGuards, beforeEnter, beforeResolveGuards.

在跑完每个钩子后, 都会用then()来排队后续操作. (可能是处理同时多次调用产生的渲染顺序问题)

在跑完所有钩子没出现问题后, 会调用finalizeNavigation()来进行真正的跳转动作.

    function finalizeNavigation(toLocation, from, isPush, replace, data) {
        // ...
        const isFirstNavigation = from === START_LOCATION_NORMALIZED;
        const state = !isBrowser ? {} : history.state;
        // change URL only if the user did a push/replace and if it's not the initial navigation because
        // it's just reflecting the url
        if (isPush) {
            // on the initial navigation, we want to reuse the scroll position from
            // history state if it exists
            if (replace || isFirstNavigation)
                routerHistory.replace(toLocation.fullPath, assign({
                    scroll: isFirstNavigation && state && state.scroll,
                }, data));
            else
                routerHistory.push(toLocation.fullPath, data);
        }
        // accept current navigation
        currentRoute.value = toLocation;
        handleScroll(toLocation, from, isPush, isFirstNavigation);
        markAsReady();
    }

这里有几个关键点:

  1. routerHistory.push(): routerHistory 是工厂提供的实现, 以常用的h5为例, 就调用 history api来改变url和记录历史.
  2. mutatecurrentRoute.value来触发视图响应式, 重新渲染页面. 此时router-linkrouter-view都获取到了最新值, 就能达到预期的渲染效果了. (在此之前页面会进行一次关于路由的白屏渲染)
  3. markAsReady(): 如果第一次执行这个函数, 会添加浏览器popState监听事件, 内容就是调用和router.push()一样的navigate()finalizeNavigation().

(监听是在创建routerHistory时做的, routerHistory提供了listen()方法来添加回调事件.)

a标签与浏览器退回

正常流程已经讲完了.

通过目前对router的理解, 我们聊一下通过a标签跳转的路由, 与浏览器退回与正常操作有什么区别.

  1. js执行流程: a标签的跳转与退回, 都会与"初始化"执行的顺序一样: 先install(), 再白屏渲染, 再通过install()push()最终渲染.
  2. 浏览器地址栏: 通过a标签跳转与退回, 浏览器地址栏都在最初状态就是目标地址.

通过程序正常跳转的, 会在执行routerHistory.push()的时候才改变浏览器地址栏. (退回也是先改变地址栏.)

  1. 另外有个显而易见的, a标签不会像router-link一样给元素active类.

最后总结下浏览器退回的行为.

浏览器通过history api 跳转的退回, 也会由histroy退回. 由a标签跳转的, 退回也会刷新页面.

客户端的学习暂停一下, 下次看一下服务端是怎么做的. (和vueprovideinject, 应该比较简单, 之前没看)