背景

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

提问者的意思是 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 页:

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

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

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

bug 分析

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

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

顺着调用堆栈继续查找,我们发现最终原因是因为修改了路由中的 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] = instanceif (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.valueconst matchedRoute = matchedRouteRef.valueconst ViewComponent = matchedRoute && matchedRoute.components[props.name]const currentName = props.nameif (!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: nullconst 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.propsvar children = ref.childrenvar parent = ref.parentvar data = ref.datadata.routerView = truevar h = parent.$createElementvar name = props.namevar route = parent.$routevar cache = parent._routerViewCache || (parent._routerViewCache = {})var depth = 0var inactive = falsewhile (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 = depthif (inactive) {var cachedData = cache[name]var cachedComponent = cachedData && cachedData.componentif (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] = nullreturn 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。

相关链接

[1] vite 仓库:https://github.com/vitejs/vite
[2] vite 仓库下的 issue:https://github.com/vitejs/vite/issues/1775
[3] vue-router-next 仓库:https://github.com/vuejs/vue-router-next
[4] 《Vue3 开发企业级音乐课 App》课程:https://coding.imooc.com/class/503.html
[5] 最小化复现问题 demo :https://github.com/ustbhuangyi/vue3-keep-alive-issue
[6] 我给 Vue3 提交的 issue:https://github.com/vuejs/vue-next/issues/4708
[7] vue-router-next 仓库下的 issue:https://github.com/vuejs/vue-router-next/issues/626

往期推荐

解密初、中、高级程序员的进化之路(前端)

程序员一定会有35岁危机吗?

近 20k Star的项目说不做就不做了,但总结的内容值得借鉴

但凡早知道这28个网站,都不至于学得那么不扎实

如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:

  1. 点个「在看」,让更多的人也能看到这篇内容(喜欢不点在看,都是耍流氓 -_-)

  2. 欢迎加我微信「huab119」拉你进技术群,长期交流学习...

    关注公众号「前端劝退师」,持续为你推送精选好文,也可以加我为好友,随时聊骚。

点个在看支持我吧,转发就更好了

如果觉得这篇文章还不错,来个【转发、收藏、在看】三连吧,让更多的人也看到~

是什么事让尤大如此生气?相关推荐

  1. vue created 调用方法_深入解析 Vue 的热更新原理,偷学尤大的秘籍?

    大家都用过 Vue-CLI 创建 vue 应用,在开发的时候我们修改了 vue 文件,保存了文件,浏览器上就自动更新出我们写的组件内容,非常的顺滑流畅,大大提高了开发效率.想知道这背后是怎么实现的吗, ...

  2. 尤大直播分享:vue3生态进展和展望

    大家好,我是若川.最近组织了源码共读活动,感兴趣的可以加我微信 ruochuan12 前言 10月23日,参加了前端早早聊组织的[vue生态专场],准备写一波分享方便大家学习.早上有4个话题:vola ...

  3. Tailwindcss尤大神都fork了,是未来的趋势?

    最近Tailwindcss频繁出现在我的视野里,从单词拼写中看,多多少少与css有点关系.近几年是JS框架大行其道,CSS方面少有新的框架出现. 昨天突然看到尤大神在Github上的动态,fork了该 ...

  4. thymealf如何实现传单个变量给html_梦回2013,看尤大vue的第一行代码,如何用30行代码实现vue(超简洁,适合初学者)...

    非非非标题党,干货预警!!! 介绍 大家好,我是清池交友 app 开发日记,记录清池交友 app 开发中学习过程和踩坑日记,伪全栈[1] 技术栈:前端 js,vue,uniapp,后端 java 尤大 ...

  5. 实锤了,尤大妥妥的二次元迷弟 —— 聊聊 Vue 的进化历程

    文章目录 实锤了,尤大妥妥的二次元迷弟 -- 聊聊 Vue 的进化历程 1. 前言 2. 库阶段 2.1 阶段发展 2.2 设计重点和特征 3. 框架阶段 3.1 阶段发展 3.2 设计重点 4. 通 ...

  6. 深入解析 Vue 的热更新原理,尤大是如何巧用源码中的细节?

    大家都用过 Vue-CLI 创建 vue 应用,在开发的时候我们修改了 vue 文件,保存了文件,浏览器上就自动更新出我们写的组件内容,非常的顺滑流畅,大大提高了开发效率.想知道这背后是怎么实现的吗, ...

  7. 昨晚尤大的连麦直播,我学到了很多!!!

    昨晚朋友圈已经被连麦尤大的直播刷屏了,主要就是答答疑,聊聊天- 总共大概聊了一个半小时.给你们看看帅气的尤大 我也抱着 「学习」 .「长见识」 的态度去直播间听了一个多小时(因为前半段有事,所以没能来 ...

  8. 尤大:怎么还生啃源码呢?我这就亲手给你写个丐版Vue

    前言 很多时候我们都对源码展现出了一定的渴求,但当被问到究竟为什么想看源码时,答案无非也就那么几种: 为了面试 为了在简历上写自己会源码 了解底层原理 学习高手思路 通过源码来学习一些小技巧(骚操作) ...

  9. 祖师爷尤大说我的代码全部不加分号 | 重学JS

    点击上方 前端Q,关注公众号 回复加群,加入前端Q技术交流群 前言 在线音乐[1]戳我呀! 音乐博客源码[2]上线啦! 上篇写的想写好面向对象的代码,这篇一定要看 | 重学JS[3]提到的匿名函数提到 ...

最新文章

  1. PostgreSQL在何处处理 sql查询之四十六
  2. javascript间接实现前端非获取匹配,保留带某前缀的子串不执行替换
  3. 【2015蓝桥杯省赛】C++ B组试题
  4. javaweb基础 02--javaweb基础概念
  5. Android 系统(166)---GMO版本最近应用列表界面显示模糊的解决方案
  6. python actor_Python定义一个Actor任务
  7. 点钞机语音怎么打开_我有这些语音识别指令,你都知道吗?
  8. 行如风 Angular初识
  9. 策略模式详解(用java语言实现策略模式)
  10. pip安装包下载与安装
  11. 小程序跳转至企业微信客服wx.openCustomerServiceChat
  12. android 录屏广播,Android 录屏
  13. SYN和FIN同时设置攻击
  14. node linux cache补释放,linux下释放cache内存
  15. 计算机二级在线找答案,2016计算机二级试题及答案
  16. 【EC算法】多模态优化(multimodel)与小生境(Niching)
  17. 启动kibana报错:Elasticsearch cluster did not respond with license information
  18. PowerBI visuals共计246组2020年1月31日扒取(Power BI 视觉对象)
  19. 走进京东 | 中国空间技术研究院青年创新联盟成员莅临参观京东总部
  20. Squid访问控制实例

热门文章

  1. 第一章——概率论基本概念
  2. Mac下好用的日记、电子书阅读器、RSS订阅软件​
  3. 如何从wondows到Linux
  4. 医学图像与高光谱图像
  5. JS的正则表达式之邮箱的验证
  6. 酒店预定系统开发步骤_分享酒店预订系统小程序开发制作功能介绍
  7. 数学专业英语--函数部分
  8. nodejs下的apidoc 帮助接口文档生成
  9. SpringMvc零配置,无Web.xml
  10. Java里的构造函数(构造方法)的特点及作用