一不小心又来了一个线上问题。这个错误出现在“最不可能出现问题的地方”,我的自信来源于v-else,以为不会影响现有逻辑,结果问题就在v-else。搜了网上的资料,有一些其他的情况,目前我遇到的是与v-if相关的,仅记录此例。
场景
同一个 div 上,既绑定了 v-else又绑定了一个自定义指令。
- 组件刚挂载时,
v-if不满足,显示该 div - 该 div 执行
mounted钩子函数时,自定义指令会把该元素移除 - 过了一会,
v-if条件满足,然后 vue 派发更新,此时 vue 内部抛出异常
测试文件(可以查看 demo):
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.3.4/vue.global.js"></script>
<div id="app">
<div v-if="hasData">hasData</div>
<div v-else v-permission-or="['admin']">else</div>
</div>
<script>
const { ref, nextTick } = window.Vue
// 用户的权限只有 dev
const myPermissions = ['dev']
// mock 请求
const request = () => {
return new Promise(resolve => {
setTimeout(() => {
resolve()
}, 500)
})
}
Vue.createApp({
directives: {
'permission-or': {
mounted(el, binding, vnode, prevVnode) {
if (!myPermissions.includes(binding.value)) {
// 用户是没有权限的,所以必然会执行此处
el.remove()
// 移除后,其父节点为 null
console.log('el.parentNode', el.parentNode)
}
}
}
},
setup() {
const hasData = ref(false)
request().then(() => {
hasData.value = true
})
return { hasData }
}
}).mount('#app')
</script>
源码分析
首先依赖收集部分就不赘述了,在依赖更新时,会触发effect:
componentUpdateFun函数中,会调用patch函数:
注意这里传入的第三个参数,实际取的是该节点的父元素:
重点:取到的值是什么呢?是
null,因为文档中已经不存在该节点了,虽然还能获取到该节点的引用(应该是依赖收集阶段保存的),但是其父元素的引用是无法拿到的。
继续看,到patch函数内,首先会判断新旧元素是否相同,是则直接 return。这里显然不满足,于是会继续判断isSameVNodeType,该方法的作用是判断是否为同一个节点,我们这里也不是同一个,是要从第二个 div 变为第一个 div,所以isSameVNodeType(n1, n2)为 false,这个if会进去,然后会unmount掉旧节点n1,这一行执行完后,v-if和v-else绑定的元素都没展示了,然后把n1设为 null。
接下来,当然就是要挂载新节点了。
继续往下,有个 switch,在这里面会进入default里的第一个if判断中,执行processElement方法,这里传入的container就是外面传进来的父元素null:
这个函数就很简单了,判断旧节点是否为
null,如果是则挂载n2节点。注意到刚才n1已经被修改为null了,因此会进入到if内:
然后到mountElement方法中,会执行hostInsert方法:
这个方法的定义处,与之前看到的patch第三个参数的取值,是同一个文件:
问题就在这里了,一路传下来的container为null,到insert方法中,传入了第二个参数parent,于是浏览器报错:
总结原因
所以总结下来,错误原因是: 一个节点先被我从文档中移除了,然后 vue 派发更新,又要在该节点的父节点下新增另一个节点,由于该节点不在文档中,于是获取不到父节点,因此报错。
解决方案
v-if 绑定的元素,不要自己再绑另一个指令去移除它。
如果需要绑定指令,可以放在该元素的父节点上。
其他
为什么是挂载新节点的时候出错,而不是在移除旧节点的时候报错呢?
—— 因为移除旧节点的地方,是先判断了父节点存不存在的,但是挂载新节点的时候并没有判断。关于这个问题我在 github开了一个讨论贴。
这个错误是 Promise 抛出的,一开始我还查不到日志,因为没有写全局捕获方法。不过即使捕获,也只是提供一下日志罢了,反正 vue 的代码不能继续执行的,所以页面更新也还是异常的。
——为什么是 Promise 抛出的?
因为vue3的依赖更新,本来就是通过 Promise 实现的。就像那道经典题——“nextTick”的原理……
算了,不扯了吧,我也不懂了……