线上问题
昨天星期五,下午正在排查一个打包后 2 行样式代码变 1 行的问题,突然被拉进一个新群,有人反馈说是提交表单的一个 id 字段与预期不符,而后端排查到在页面上那个 id 是不可更改的,怀疑前端传参错误。我心里暗暗一惊:又来 bug 了!
一开始真没看懂哪里会改错那个参数,毕竟在那个弹框文件内,只能搜到一行代码会对那个id进行赋值,该代码写在 watch 中,监听的是一个 prop,该回调如果触发,外部对象必然已经发生改变。
不过后面自己还是复现了,并且写了个demo,大家可以直接访问这个链接复现问题。
复现步骤
- 刚打开页面的时候,App.vue中
injectData.id为 1,然后打开一次弹框
此时传进去的是 1,弹框内监听了传进来的对象,且立即执行,所以表单展示也是 1,正常
- 随便填写内容(可不填),然后关闭弹框
此时执行关闭弹框的回调,回调内调用了表单的
resetFields方法(这里就是一开始排查没有注意到的地方)
- 点击修改 id 的按钮
此时必然触发弹框内的 watch 回调
- 再次打开弹框
此时传进去的是 2(watch 回调函数中更新),且content字段是空的(第 2 步中表单重置),正常
- 随便填写内容(可不填),然后关闭弹框
此时再次执行关闭弹框的回调,表单再次重置
- 不要修改 id,直接再次打开弹框
此时 id 为 1,不正常
排查源码
根据上述步骤,就开始怀疑表单的resetFields方法的问题了,于是看源码:
发现会遍历
this.fields,调用内部的resetField方法。于是又想着:
this.fields是什么?resetField方法是如何实现的?
第一个问题的答案还是很好找的,就在同一个文件内:
原来this.fields的每一项都是在el.form.addField事件触发时新增的,于是全局搜索el.form.addField,很好找,只有一个地方会触发:
原来是表单项 mounted 的时候会触发这个事件,并且参数就是这个表单项,也就是说this.fields的每一项,都是表单项。
那么就继续查表单项内的resetField方法:
原来是每个表单项都会保存this.initialValue,重置的时候就重新更新为该值。
那么this.initialValue从哪里来?
其实还是上面那个表单项的mounted:
好了,现在可以知道:
当我关闭弹框的时候,会调用表单组件的resetFields方法,该方法会把表单项的值重置为表单项mounted的时候的值。
那么表单项mounted的时候,是什么值?
看表现其实可以看出来,就是第一次打开弹框时的值。刚打开页面的时候,injectData.id 是 1,如果一开始先修改injectData.id为 2,再按照上述步骤复现,会发现最后的injectData.id是 2.
反正都到这一步了,还是继续看el-dialog的源码吧:
弹框内的主体部分,有个
v-if,太熟悉了,如果变量为 false,是根本不会渲染的。那么这个值到底是不是false呢?
文件内查找,只能查找到 2 处地方。
一处是上面那里,另一处就是mounted中:
一开始刷新页面的时候,this.visible是false的,所以这个 if 进不去,this.rendered肯定是undefined,也就不会渲染了,但是 this.visible改变的时候也没看到会重新设置这个值呀,好神奇,然后我加了好多日志,才发现是el-dialog组件通过 mixin 的形式引入了el-popup(所以不要乱用 mixin),这里会调用open方法:
就是这个open方法,会修改rendered,于是就渲染了:
修复 BUG
挺累的,查了半天又写了半天……
一开始我觉得是表单组件的resetFields方法的问题,要是该方法能重置成我刚开始传入的数据就好了,不过试了下,即使业务组件中的data里是空值,由于watch里设置了immediate,也还是会在表单项组件mounted之前执行的,所以表单项拿到的数据一开始就是有值的。而如果不设置immediate,那么第一次打开必然会异常,这种情况当初开发的时候可以测出来,但是最后可能还是通过加上immediate的方式进行解决……
修复方式有:
- 把监听
propData改为监听dialogVisible,每次打开弹框时均初始化数据,关闭时无需做任何操作
目前比较倾向的方案。基于项目现状,该方案改动最小。
缺点是如果外层数据没变,重复打开弹框,回调会重复执行。
- 关闭时不调用
form组件的内置resetFields函数,而是手动有选择地重置部分字段
总觉得有点繁琐,需要指定字段,并且也感觉重置和初始化 2 个操作可以合起来
- 不使用
watch,在模板中直接展示propData.xxx,提交时手动合并form和propData的数据
但是我又觉得这样比较分离,2 个地方都要手动控制数据源,总是想合起来。并且假如表单内允许修改的话也是不行的。
- 父组件不传
prop,子组件不监听,父组件打开弹框时直接调用子组件的方法,把参数传进去初始化
从一些开源库看到的,但是感觉调私有方法好像不是太好,而且也是打开弹框时都会执行,执行频率与方法1相同
- 使弹框关闭时即销毁,每次打开弹框都重新执行一次生命周期,此时子组件
data中可以直接拿到propData的值,并且加不加监听都一样
也是从开源库看到的。
vue-element-plus-admin有个监听prop的操作,但是没找到关闭弹框后重置数据的代码,后面发现在弹框组件封装处配置了destroy-on-close。另外,写 React 较多的人可能会加
key,每次打开弹框都更新key,这时候弹框也是重新渲染的。感觉这种方案不利于组件复用,假如这是最佳实践,那么其他地方也可以这样搞,会有很多次 DOM 销毁、重建的操作,影响性能。
- 打开弹框的按钮与弹框内容写在同一个文件中,这样按钮点击事件就在子组件文件内了,可以在点击事件中初始化
form
其实更推荐这种方案,与方案 1 相比:
- 执行频率相同,都是打开弹框的时候执行(当然也有相同的缺点)
- 子组件
prop中可以删掉visible,关闭弹框时不需要向父组件传递update:visible事件,因为按钮是常显的- 方案 1 和 6 中都要在点击事件里初始化弹框数据,但是该方案可以免去
watch
大家如果有更好的办法也可以提出来~