阅读 29258

是什么事让尤大如此生气?

背景

这件事大约发生在半年前,有人在 vite 的仓库下提了一个关于 vue-routerissue

angry.jpg

提问者的意思是 vue-router 有个未解决的 bug,影响到他即将上线的项目了,而 vue-router 的维护者没能解决这个问题,希望尤大来帮忙解决。

但是,提问者跑到 vite 仓库下来发这个 issue,就显得非常不合适了。

尤大显然对这个行为很不高兴,回复也很霸气:

  1. 不要在不相关的仓库提不相关的 issue。

  2. 大家都很忙,没空管就是没空管,不要催。

  3. 再犯会被 block。

提问者跑来这里发 issue 的理由是尤大经常活跃在 vite 社区。而在我看来,尤大应该早就知道这个 issue 了,因为 vue-router 的维护者也是 Vue 核心成员之一,他们肯定私下聊过这个问题。

那么为什么迟迟不解决这个问题呢,我猜测有两点原因:

  1. 这个问题本身不好解决,可能要牵涉到不少的代码改动。

  2. Vue 团队正在全力做他们认为重要而紧急的事情,这个事情的优先级并不高。

那么,提问者究竟是遇到了什么问题呢?为何我会关注到这个 issue?因为最近我也遇到一个类似的问题。

类似的问题

在我的 《Vue3 开发企业级音乐课 App》课程问答区,有个学生反馈了一个问题:RouterView 配合 KeepAilve 组件使用后,二级路由的歌手详情页的 created 钩子函数会执行两次。

我测试后发现确实有这个问题,最开始我怀疑是 Vue3 或者是 vue-router 某个版本的 bug,于是我把 Vue3 和 vue-router 都升级到最新版本,发现这个问题仍然存在。

那么,会不会是我的业务代码写出的问题呢?直觉告诉不会,为了找出问题的根本原因,同时减少调试的复杂度,我写了个最小化复现问题的 demo

Demo 页面共有二级路由,它的定义如下:

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
复制代码

这里要注意,必须两个主路由页面都嵌套子路由

接着来看页面的几个 Vue 组件的定义,其中 App.vue 为页面入口组件:

<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>
复制代码

Home.vue 是一级路由组件:

<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>
复制代码

HomeSubHome 组件中的二级路由组件:

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

<script>
  export default {
    name: 'HomeSub',
    created() {
      console.log('home sub created')
    }
  }
</script>
复制代码

About.vue 是一级路由组件:

<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>
复制代码

Sub.vueAbout 组件中的二级路由组件:

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

<script>
  export default {
    name: 'Sub',
    created() {
      console.log('sub created')
    }
  }
</script>
复制代码

复现的步骤很简单,首先进入 Home 页:

home.jpg

然后点击 About 标签进入 About 页:

about.jpg

接着点击按钮,渲染 Sub 子路由组件:

about-sub.jpg

页面渲染都是正常的,但是我们发现 Sub 组件的 created 钩子函数执行了两次,输出了两次 sub created。这就相当于渲染了两次 Sub 组件,显然是有问题的。

bug 分析

我开启了调试大法,在 Sub 组件的 created 钩子函数中打上 debugger 断点,然后顺着函数的调用堆栈一步步往前看。

debugger.jpg

显然,debugger 是在 created 钩子函数内部,而该钩子函数的执行是在组件挂载阶段,那么是什么操作触发了组件的挂载呢?

debugger1.jpg

顺着调用堆栈继续查找,我们发现最终原因是因为修改了路由中的 currentRoute,触发了 setter,然后触发了 RouterView 组件的重新渲染,最终触发了 Sub 组件的渲染。

那么为什么 currentRoute 的修改会触发 RouterView 组件的重新渲染呢?这要从 RouterView 的实现原理说起:

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)
    }
  },
})
复制代码

RouterView 组件是基于 Composition API 实现的,我们重点看它的渲染部分,由于 setup 函数的返回值是一个函数,那这个函数就是它的渲染函数。

RouterView 主要的思路就是根据路径 route 和当前 RouterView 嵌套的深度来匹配路由配置中对应的路由组件并渲染。

在整个渲染过程中,会访问计算属性 routeToDisplay,它的定义如下:

const injectedRoute = inject(routerViewLocationKey)
const routeToDisplay = computed(() => props.route || injectedRoute.value)
复制代码

routeToDisplay 内部又会访问 injectedRoute,而 injectedRoute 注入的是 keyrouterViewLocationKey 的数据。

在执行 createRouter 创建路由的时候,内部会创建 currentRoute 响应式变量来维护当前的路径。

const currentRoute = shallowRef(START_LOCATION_NORMALIZED)
复制代码

然后在执行 createApp(App).use(router) 安装路由的时候,会执行 router 对象提供的 install 方法,其中会把 currentRoute 通过 routerViewLocationKey 提供给应用使用。

app.provide(routerViewLocationKey, currentRoute)
复制代码

因此在渲染 RouterView 组件的时候,访问了 routeToDisplay,内部会访问 injectedRoute,进而也就访问到了 currentRoute,而又由于 currentRoute 是响应式对象,进而会触发它的依赖收集过程。

这样当我们执行 router 对象的 push 方法修改路由路径时,内部会执行 finalizeNavigation 方法,然后修改了 currentRoute,就会触发所有的 RouterView 组件的重新渲染。

默认情况下,这个逻辑是没有任何问题的,那么为什么加上 KeepAlive 就有问题了呢?

回答这个问题前,我们不妨先思考另一个问题:示例中,在正常情况下,路由从 Home 切到 About 后,此时我们修改 currentRoute,会触发 Home 组件内部的 RouterView 重新渲染吗?

答案是不会的,因为当路由从 Home 切到 About 时,会触发 Home 组件的卸载,进而也会触发其内部的 RouterView 组件卸载。

RouterView 组件在卸载过程中,会清除组件作用域下的所有依赖,当然也包括 currentRoute 收集的组件的 render effect。因此当我们修改 currentRoute 时,就不会触发 Home 组件内部的 RouterView 组件重新渲染了。

但是,一旦 Home 组件对应的 RouterViewKeepAlive 组件包裹后,当路由从 Home 切到 About 时,是不会执行 Home 组件的卸载过程的,也就不会卸载内部的 RouterView 组件,当然也就没有清除其作用域下的依赖。

那么当我们修改 currentRoute 时,不仅会渲染 About 组件内部的 RouterView 组件,也会触发 Home 组件内部的 RouterView 重新渲染。

由于 Home 组件内部的 RouterViewAbout 组件内部的 RouterView 都是二级路由组件,根据 RouterView 渲染的逻辑,此时 Home 组件内部的 RouterView 也会渲染成 Sub 组件,这就是为何 Sub 组件渲染两次的原因。

给 Vue3 提 issue

虽然定位出这个 bug,但一时半会儿我也想不出好的解决方案,于是我尝试给 Vue3 提了个 issue

这里顺便与你分享一下提 issue 的一些注意事项:

  1. 通常一些不错的开源项目都会有 issue template,你可以根据它的指引创建 issue。

  2. 为了让开源项目的维护者更快、更精确的定位问题,通常你需要最小化复现问题,提供一个可复现问题的 demo,而不是提供一个出问题的项目。

  3. 建议提问前能加上一些自己对问题的分析和思考,这虽然不是必要的,但这个过程会让你更加熟悉这个开源项目,而且也可以帮助维护者更容易定位问题。

  4. 如果确实是个 bug 且你有能力修复的话,提完 issue 可以顺便提一个 pull request,直接参与到开源项目的共建中,这个过程对自身的技术成长会有非常大的帮助。

不过令我尴尬的是,我在提完 issue 后还不到五分钟,issue 就被关闭了,原因是它与 vue-router-next 项目中的一个 issue 重复了。

我对该 issue 做了大致的浏览,发现它早在 2020 年 12 月 1 号就被提出了,而且有相当多的人都遇到了类似的问题。在该 issue 下面可以发现很多相关联的 issue,其中也包括文章开头提到的 issue,这就是为何我能关注到它的原因。

vue-router 的维护者也尝试解决过,但是遇到了一些麻烦,详情可以看他在 issue 中的回复。

遗憾的是到目前为止,该 issue 也没有被解决,维护者给它贴上了 help wanted 的标签,希望得到来自社区的帮助。

Vue2 也有这个问题吗?

因为我司目前还在使用 Vue2,所以我最关心的是 Vue2 是否也存在该问题。

于是我用 Vue2 写了同样的 demo,令我欣慰的是 Vue2 并未有这个 bug,那这又是什么原因呢?

由于 Vue 使用的是 vue-router 的 3.x 版本,它对应的 RouterView 组件的实现如下:

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)
  }
}
复制代码

RouterView 组件的渲染逻辑和新版本的 vue-router-next 实现一致:根据路径 route 和当前 RouterView 嵌套的深度来匹配路由配置中对应的路由组件并渲染。

不同的是,3.x 版本的 vue-router 处理了 KeepAlive 的情况:如果当前的 RouterView 组件所在的父组件实例身处 KeepAlive 构造的树中,且是 inactive 状态,那么它只会被渲染成上一次渲染的视图。

因此这里有两个关键的点:一是能够判断当前所处的环境,二是需要缓存 RouterView 上一次渲染的视图。

显然,在 vue-router-next 中,是没有对应的逻辑的,主要是因为组件实例中没有存储 KeepAlive 组件相关的 inactive 状态,RouterView 组件也没法知道自己当前所处的环境。

在我看来,如果 vue-router-next 想要解决这个问题,可能还牵涉到 Vue3 内部的一些改动,提供更多的信息数据,让 RouterView 组件在渲染的时候能够知道自己当前所处的环境。

目前解决这个问题的一个办法是在这种嵌套路由的场景下,不使用 KeepAlive 包裹 RouterView

总结

我们在做技术选型时,其实就要考虑到这层风险,当开源项目出现 bug 或者不能满足你的需求,且不能很快的响应时,你有没有办法帮助开源项目共建,或者通过魔改的方式来解决遇到的问题。

我司使用的 Vue2 CSP 版本,就是基于 Vue.js 2.6.11 版本基础上魔改的,社区不提供支持,就需要自己动手了。

对于开源项目的维护者而言,他们自然有自己的计划和考量,你不能因为自己的项目紧急就要求维护者立马帮你解决问题。当然,实在想寻求紧急帮助,情商高一点的做法是给开源维护者捐赠,通过付费的方式可能会提升 bug 被处理的优先级。

当然,最靠谱的方式还是让自己靠谱起来,遇到 bug 后不要慌,先定位到出现 bug 的根本原因,然后找到合适的解决方案。

如果你此时正在用 Vue3 开发项目,那么请务必注意这个 bug,有能力的可以好好研究,如果给 Vue3 提个 pull request 就更棒了。相比于改改拼写错误混个 contributor,我觉得能解决这类问题的人才能算真正意义上的 contributor。

文章分类
前端