background

This happened about half a year ago. Someone put up an issue about vue-Router in vite’s warehouse:

Vue-router has an unsolved bug that is affecting its upcoming project. The maintainer of vue-Router has not been able to fix this bug.

However, the questioner went to vite warehouse to issue this issue, which seems very inappropriate.

Clearly upset about the move, Utah responded with a defiant response:

  1. Don’t mention unrelated issues in unrelated warehouses.

  2. Everyone is busy, no time is no time, don’t rush.

  3. Repeat offenders will be blocked.

The reason why the questioner came here to issue an issue is because Utah is very active in the Vite community. In my opinion, Utah university should have known about this issue long ago, because the maintainer of vue-Router is also one of the core members of VUE, and they must have talked about this issue privately.

There are two reasons, I suspect, for the delay in solving this problem:

  1. The problem itself is not easy to solve and may involve quite a few code changes.

  2. The Vue team is doing what they think is important and urgent, which is a low priority.

So what is the problem with the questioner? Why do I pay attention to this issue? Because I recently had a similar problem.

Something like that

In the QUESTION and answer section of my Vue3 developing Enterprise-class Music App, a student reported a problem: Created hook function of the singer detail page of the secondary route will be executed twice after RouterView is used with KeepAilve component.

I did find this problem after testing. At first, I suspected it was a bug in Vue3 or vuE-Router version, so I upgraded Vue3 and VUE-Router to the latest version and found the problem still existed.

So, could it be my business code writing? My gut tells me no. In order to find the root cause of the problem and reduce the complexity of debugging, I wrote a demo that minimized the recurrence problem.

The Demo page has two-level routes, which are defined as follows:

import { createRouter, createWebHashHistory } from 'vue-router'
const Home = import('.. /views/Home.vue')
const HomeSub = import('.. /views/HomeSub.vue')
const Sub = import('.. /views/Sub.vue')
const About = import('.. /views/About.vue')

const routes = [
  {
    path: '/'.redirect: '/home'
  },
  {
    path: '/home'.name: 'Home'.component: Home,
    children: [{path: 'sub'.component: HomeSub
      }
    ]
  },
  {
    path: '/about'.name: 'About'.component: About,
    children: [{path: 'sub'.component: Sub
      }
    ]
  }
]

const router = createRouter({
  history: createWebHashHistory(),
  routes
})

export default router
Copy the code

Note here that both main routing pages must have nested child routes.

Let’s look at the definitions of several Vue components of a page, where app. Vue is the page entry component:

<template>
  <div id="nav">
    <router-link to="/">Home</router-link> |
    <router-link to="/about">About</router-link>
  </div>
  <router-view v-slot="{ Component }">
    <keep-alive>
      <component :is="Component"/>
    </keep-alive>
  </router-view>
</template>
Copy the code

Home.vue is a level 1 routing component:

<template> <div class="home"> <img alt="Vue logo" src=".. /assets/logo.png"> <button @click="showSub">click me</button> <router-view></router-view> </div> </template> <script> export default { name: 'Home', created() { console.log('home page created') }, methods: { showSub() { this.$router.push('/home/sub') } } } </script>Copy the code

HomeSub is a secondary routing component of the Home component:

<template>
  <div>This is home sub</div>
</template>

<script>
  export default {
    name: 'HomeSub',
    created() {
      console.log('home sub created')
    }
  }
</script>
Copy the code

About. Vue is a level 1 routing component:

<template>
  <div class="about">
    <h1>This is an about page</h1>
    <button @click="showSub">click me</button>
    <router-view></router-view>
  </div>
</template>
<script>
  export default {
    name: 'About',
    created() {
      console.log('about page created')
    },
    methods: {
      showSub() {
        this.$router.push('/about/sub')
      }
    }
  }
</script>
Copy the code

Sub.vue is the secondary routing component of the About component:

<template>
  <div>This is sub</div>
</template>

<script>
  export default {
    name: 'Sub',
    created() {
      console.log('sub created')
    }
  }
</script>
Copy the code

The steps for replicating are simple. First go to the Home page:

Then click on the About TAB to enter the About page:

Then click the button to render the sub-routing component:

The page rendering is fine, but we see that the Sub component’s Created hook function executes twice and prints Sub Created twice. This is equivalent to rendering the Sub component twice, which is obviously problematic.

Bug analysis

I turned on the debug method, set a debugger breakpoint in the Sub component’s Created hook function, and worked my way up the function’s call stack.

Obviously, the debugger is inside a created hook function that executes during the component mount phase. What triggers the component mount?

Moving up the call stack, we found that the final cause was a change in the currentRoute in the route, which triggered the setter, which triggered the re-rendering of the RouterView component, and which eventually triggered the rendering of the Sub component.

So why would a currentRoute change trigger a re-rendering of the RouterView component? We’ll start with how the RouterView works:

const RouterViewImpl = defineComponent({
  name: 'RouterView'.inheritAttrs: false.props: {
    name: {
      type: String.default: 'default',},route: Object,},setup(props, { attrs, slots }){ (process.env.NODE_ENV ! = ='production') && warnDeprecatedUsage()
    const injectedRoute = inject(routerViewLocationKey)
    const routeToDisplay = computed(() = > props.route || injectedRoute.value)
    const depth = inject(viewDepthKey, 0)
    const matchedRouteRef = computed(() = > routeToDisplay.value.matched[depth])
    provide(viewDepthKey, depth + 1)
    provide(matchedRouteKey, matchedRouteRef)
    provide(routerViewLocationKey, routeToDisplay)
    const viewRef = ref()
    watch(() = > [viewRef.value, matchedRouteRef.value, props.name], ([instance, to, name], [oldInstance, from, oldName]) = > {
      if (to) {
        to.instances[name] = instance
        if (from && from! == to && instance && instance === oldInstance) {if(! to.leaveGuards.size) { to.leaveGuards =from.leaveGuards
          }
          if(! to.updateGuards.size) { to.updateGuards =from.updateGuards
          }
        }
      }
      if (instance &&
        to &&
        (!from| |! isSameRouteRecord(to,from) | |! oldInstance)) { (to.enterCallbacks[name] || []).forEach(callback= > callback(instance))
      }
    }, { flush: 'post' })
    return () = > {
      const route = routeToDisplay.value
      const matchedRoute = matchedRouteRef.value
      const ViewComponent = matchedRoute && matchedRoute.components[props.name]
      const currentName = props.name
      if(! ViewComponent) {return normalizeSlot(slots.default, { Component: ViewComponent, route })
      }
      const routePropsOption = matchedRoute.props[props.name]
      const routeProps = routePropsOption
        ? routePropsOption === true
          ? route.params
          : typeof routePropsOption === 'function'
            ? routePropsOption(route)
            : routePropsOption
        : null
      const onVnodeUnmounted = vnode= > {
        if (vnode.component.isUnmounted) {
          matchedRoute.instances[currentName] = null}}const component = h(ViewComponent, assign({}, routeProps, attrs, {
        onVnodeUnmounted,
        ref: viewRef,
      }))
      return (
        normalizeSlot(slots.default, { Component: component, route }) ||
        component)
    }
  },
})
Copy the code

The RouterView component is implemented based on the Composition API, and we’ll focus on its rendering. Since the Setup function returns a function, this function is its rendering function.

The main idea of the RouterView is to match and render routing components in the routing configuration based on the nested depth of the route and the current RouterView.

Throughout the rendering process, the calculated property routeToDisplay is accessed, which is defined as follows:

const injectedRoute = inject(routerViewLocationKey)
const routeToDisplay = computed(() = > props.route || injectedRoute.value)
Copy the code

RouteToDisplay accesses injectedRoute, and injectedRoute injects data with a routerViewLocationKey.

When createRouter is executed to create a route, the currentRoute reactive variable is created internally to maintain the current path.

const currentRoute = shallowRef(START_LOCATION_NORMALIZED)
Copy the code

When createApp(App).use(router) is executed to install a route, the Install method provided by the Router object is executed. CurrentRoute is provided to the application through the routerViewLocationKey.

app.provide(routerViewLocationKey, currentRoute)
Copy the code

So when you render the RouterView, routeToDisplay, injectedRoute, currentRoute, because currentRoute is a reactive object, This triggers its dependency collection process.

So when we used the Push method of the Router object to modify the routing path, we used the finalizeNavigation method internally and then modified currentRoute, which triggered the re-rendering of all the RouterView components.

By default, there is nothing wrong with this logic, so why is it wrong with KeepAlive?

Before answering this question, let’s consider another question: In the example, would modifying currentRoute trigger a re-rendering of the RouterView inside the Home component when the route is normally cut from Home to About?

The answer is no, because when the route is cut from Home to About, it triggers the uninstallation of the Home component, which in turn triggers the uninstallation of the RouterView component within it.

During the uninstallation of the RouterView component, all dependencies in the component scope are cleared, including the Render effect of the component collected by currentRoute. So when we modify currentRoute, we do not trigger the re-rendering of the RouterView component inside the Home component.

However, once the RouterView of the Home component is wrapped by the KeepAlive component, the uninstallation of the Home component is not performed when the route is cut from Home to About, and the internal RouterView component is not unloaded. Of course, it does not clear the dependencies under its scope.

So when we modify currentRoute, we will not only render the RouterView component inside the About component, but also trigger a re-rendering of the RouterView component inside the Home component.

The RouterView inside the Home component and the RouterView inside the About component are both secondary routing components. According to the logic rendered by the RouterView, the RouterView inside the Home component is also rendered as a Sub component. This is why Sub components are rendered twice.

To carry Vue3 issue

Although I located this bug, I couldn’t come up with a good solution for a while, so I tried to propose an issue for Vue3.

Here, by the way, I would like to share with you some considerations of raising “issue” :

  1. Usually good open source projects have an issue template that you can follow to create an issue.

  2. In order for maintainers of open source projects to locate problems faster and more accurately, you often need to minimize recurring problems and provide a demo of recurring problems rather than a broken project.

  3. It is not necessary to add your own analysis and reflection to your questions, but this process will familiarize you with the open source project and will help maintainers locate problems more easily.

  4. If it is indeed a bug and you have the ability to fix it, you can mention a pull request after raising the issue and directly participate in the construction of open source projects. This process will be of great help to your own technical growth.

To my embarrassment, the issue was closed less than five minutes after I mentioned it because it duplicated an issue in the Vue-router-Next project.

I did a brief review of the issue and found that it was put forward as early as December 1, 2020, and quite a few people have encountered similar problems. Many related issues can be found under this issue, including the issue mentioned at the beginning of this article, which is why I can pay attention to it.

The vue- Router maintainer also tried to fix it, but ran into some trouble, see his reply in issue for details.

Unfortunately, so far, the issue has not been resolved either, and the maintainer tagged it as Help wanted, hoping for help from the community.

Does Vue2 also have this problem?

Because our company is still using Vue2, what I care most is whether Vue2 also has this problem.

So I wrote the same demo with Vue2. To my relief, Vue2 doesn’t have this bug. What’s the reason?

Since Vue uses version 3.x of vue-Router, its corresponding RouterView component is implemented as follows:

var View = {
  name: 'RouterView'.functional: true.props: {
    name: {
      type: String.default: 'default'}},render: function render(_, ref) {
    var props = ref.props
    var children = ref.children
    var parent = ref.parent
    var data = ref.data

    data.routerView = true

    var h = parent.$createElement
    var name = props.name
    var route = parent.$route
    var cache = parent._routerViewCache || (parent._routerViewCache = {})

    var depth = 0
    var inactive = false
    while(parent && parent._routerRoot ! == parent) {var vnodeData = parent.$vnode ? parent.$vnode.data : {}
      if (vnodeData.routerView) {
        depth++
      }
      if (vnodeData.keepAlive && parent._directInactive && parent._inactive) {
        inactive = true
      }
      parent = parent.$parent
    }
    data.routerViewDepth = depth
    
    if (inactive) {
      var cachedData = cache[name]
      var cachedComponent = cachedData && cachedData.component
      if (cachedComponent) {
        if (cachedData.configProps) {
          fillPropsinData(cachedComponent, data, cachedData.route, cachedData.configProps)
        }
        return h(cachedComponent, data, children)
      } else {
        return h()
      }
    }

    var matched = route.matched[depth]
    var component = matched && matched.components[name]
    
    if(! matched || ! component) { cache[name] =null
      return h()
    }
    
    cache[name] = { component: component }
    
    data.registerRouteInstance = function(vm, val) {
      var current = matched.instances[name]
      if( (val && current ! == vm) || (! val && current === vm) ) { matched.instances[name] = val } } (data.hook || (data.hook = {})).prepatch =function(_, vnode) {
      matched.instances[name] = vnode.componentInstance
    }
    
    data.hook.init = function(vnode) {
      if(vnode.data.keepAlive && vnode.componentInstance && vnode.componentInstance ! == matched.instances[name] ) { matched.instances[name] = vnode.componentInstance } handleRouteEntered(route) }var configProps = matched.props && matched.props[name]
    if (configProps) {
      extend(cache[name], {
        route: route,
        configProps: configProps
      })
      fillPropsinData(component, data, route, configProps)
    }

    return h(component, data, children)
  }
}
Copy the code

The rendering logic of the RouterView component is the same as that of vue-Router-Next: It matches and renders the routing components in the routing configuration based on the route route and the nesting depth of the current RouterView.

The difference is that the 3.x version of vue-Router handles KeepAlive cases: If the parent instance of the current RouterView component is in the Inactive state of the KeepAlive tree, it will only be rendered as the last rendered view.

So there are two key points: the ability to determine the current environment, and the need to cache the last rendered view of the RouterView.

Obviously, in Vue-Router-Next, there is no corresponding logic, mainly because there is no inactive state associated with KeepAlive stored in the component instance, and the RouterView component has no way of knowing its current environment.

In my opinion, if Vue-Router-Next were to solve this problem, it would probably involve some changes within Vue3 to provide more information so that RouterView components know where they are when rendering.

One current solution to this problem is to not wrap the RouterView with KeepAlive in such nested scenarios.

conclusion

When we choose the technology, we should consider this layer of risk. When the open source project has bugs or can’t meet your needs and can’t respond quickly, do you have any way to help the open source project jointly build or solve the problems by magic modification?

The Vue2 CSP version used by our company is based on vue.js 2.6.11 version. The community does not provide support, so you need to do it yourself.

As for the maintainers of open source projects, they naturally have their own plans and considerations, and you can’t ask them to solve your problems just because your project is urgent. Of course, if you’re looking for urgent help, an emotionally intelligent way to do this is to donate to an open source maintainer, and paying for it may increase the priority of bug handling.

Of course, the most reliable way is to make yourself reliable, do not panic when you encounter a bug, first locate the root cause of the bug, and then find the appropriate solution.

If you are currently working on a Vue3 project, please be aware of this bug. If you have the ability to investigate it, a pull request for Vue3 would be even better. I think those who can solve these problems can be real contributor, rather than change spelling errors and get a contributor.