1. 问题背景
在"新增/编辑数据资源"页面中,第三步"运营信息"允许用户上传一个 Logo 图片。用户成功上传图片后,页面上能够正确回显 Logo。然而,在点击"保存"时,已成功上传并回显的 resourceLogoUrl
字段并未被发送到后端接口,导致 Logo 信息丢失。
2. 失败的尝试与原因分析
在解决问题的过程中,我们进行了多次尝试。理解这些尝试为何失败,是理解最终解决方案的关键。
2.1 尝试一:watch
监听器被意外触发导致数据覆盖
怀疑点: 我们曾怀疑是其他逻辑在保存前意外地修改了 formData
,从而清空了 resourceLogoUrl
。
定位: 经过排查,我们定位到了一个 watch
监听器:
typescript
// OperationInfo.vue
watch(isSpecialUsageAllowed, (isAllowed) => {
if (!isAllowed) {
// 此处对 formData.value.dataUseMethod 的修改,
// 产生了覆盖 resourceLogoUrl 的副作用。
formData.value.dataUseMethod = formData.value.dataUseMethod?.filter(
(method) => method === '01'
);
}
});
问题根源: 当用户点击"保存"时,会触发所有表单的校验。Arco Design 的 validate()
方法在某些情况下会触发依赖于其他步骤数据的计算属性(如此处的 isSpecialUsageAllowed
)重新计算。这导致上述 watch
被意外触发。
虽然这个 watch
的意图只是修改 dataUseMethod
,但由于 formData
是通过 defineModel
创建的,对 formData
的任何属性进行赋值,都会导致整个 dataResourcePublishInfo
对象被更新回父组件。这个更新操作冲掉了刚刚由图片上传设置的 resourceLogoUrl
,导致其变回初始的空值。
改进: 我们通过增加判断,仅在 dataUseMethod
数组内容确实需要改变时才进行赋值,减少了副作用的发生频率,但并未根除问题。
diff
--- a/OperationInfo.vue
+++ b/OperationInfo.vue
@@ -3,6 +3,9 @@
formData.value.dataUseMethod = formData.value.dataUseMethod?.filter(
(method) => method === '01'
);
+ const currentMethods = formData.value.dataUseMethod || [];
+ const newMethods = currentMethods.filter((method) => method === '01');
+ if (newMethods.length !== currentMethods.length) {
+ formData.value.dataUseMethod = newMethods;
+ }
}
});
2.2 尝试二:分离 watch
逻辑导致的执行时机问题
我们曾将图片的回显和更新逻辑分离到两个独立的 watch
中:
typescript
// OperationInfo.vue - 之前的逻辑
watch(imageFile, (newFile) => {
// 逻辑1: 上传/更新
formData.value.resourceLogoUrl = newFile?.fileUrl || '';
});
watch(() => formData.value.resourceLogoUrl, (url) => {
// 逻辑2: 数据回显
if (url && imageFile.value?.fileUrl !== url) {
// ... 异步获取完整 URL 并设置 imageFile
}
});
问题根源: 这种分离使得数据流变得复杂。当 imageFile
变化时,逻辑1触发,修改了 formData.value.resourceLogoUrl
。这个修改又可能触发逻辑2。在 Vue 的更新周期中,多个 watch
的执行顺序和时机并不总是直观的,它们与其他响应式副作用(如2.1中提到的 watch
)交织在一起,使得 resourceLogoUrl
的最终值变得不可预测。在某些边界条件下,它依然会被旧值或空值覆盖。
3. 最终解决方案:watchEffect
的威力
我们最终参考了 dataProductManage
模块的实现,采用了 watchEffect
,并成功解决了问题。
最终代码 (OperationInfo.vue
)
diff
--- a/OperationInfo.vue
+++ b/OperationInfo.vue
@@ -21,29 +21,17 @@
basicData: ResourceInitDataType['dataResourceBaseInfo'];
}>();
-const formData = defineModel<ResourceInitDataType['dataResourcePublishInfo']>({} as any),
- formRef = ref(),
- imageFile = ref();
-
-}
-});
-
-watch(imageFile, (newFile) => {
- // 上传/更新:当 imageFile 变化时,同步到 formData。
- const newUrl = newFile?.fileUrl || '';
- console.log('imageFile', imageFile.value, newUrl);
- if (formData.value.resourceLogoUrl != newUrl) {
- formData.value.resourceLogoUrl = newUrl;
- }
-});
-
-watch(
- () => formData.value.resourceLogoUrl,
- (url) => {
- // 回显:当 formData 中有 url,但 imageFile 没有时,进行同步
- if (url && imageFile.value?.fileUrl !== url) {
- (async () => {
- const fullUrl = await getImageUrl(url);
- imageFile.value = { url: fullUrl, fileUrl: url };
- })();
- }
- }
-);
+const formData = defineModel<ResourceInitDataType['dataResourcePublishInfo']>(),
+ formRef = ref(),
+ imageFile = ref();
+
+watchEffect(() => {
+ // 回显:当 formData 中有 url,但 imageFile 没有时,进行同步
+ const modelUrl = formData.value?.resourceLogoUrl;
+ if (modelUrl && imageFile.value?.fileUrl !== modelUrl) {
+ (async () => {
+ const fullUrl = await getImageUrl(modelUrl);
+ imageFile.value = { url: fullUrl, fileUrl: modelUrl };
+ })();
+ }
+
+ // 上传/更新:当 imageFile 变化时,同步到 formData。
+ formData.value.resourceLogoUrl = imageFile.value?.fileUrl || '';
+});
defineExpose({ formRef });
</script>
为什么这次可以?
原子性与依赖自动追踪:
watchEffect
会立即执行一次,并自动追踪其内部访问到的所有响应式依赖(formData.value
和 imageFile.value
)。当任何一个依赖变化时,整个 effect 函数会重新完整地执行。这形成了一个原子性的更新单元,确保了回显和更新逻辑总是一起被评估。
明确的数据流向:
- 数据源:
imageFile
(来自 SelectImage 组件) 和formData.value.resourceLogoUrl
(来自父组件v-model
)。 - 数据终点:
imageFile
(用于 SelectImage 显示) 和formData.value.resourceLogoUrl
(更新父组件v-model
)。
在 watchEffect
内部,数据流向非常清晰:
- 回显:
formData.value.resourceLogoUrl
->imageFile.value
- 更新:
imageFile.value
->formData.value.resourceLogoUrl
覆盖效应与最终状态:
watchEffect
的关键在于它的最后一次赋值会成为最终状态。
-
场景1:页面加载/编辑回显
formData.value.resourceLogoUrl
从父组件获得一个 URL。watchEffect
运行,回显逻辑触发,异步设置imageFile
。- 此时
imageFile.value
可能还是undefined
,所以更新逻辑formData.value.resourceLogoUrl = ''
会执行,但紧接着 Vue 的更新周期会因为imageFile
的异步设置而再次触发watchEffect
,最终将resourceLogoUrl
设置为正确的值。
-
场景2:用户上传新图片
imageFile.value
被 SelectImage 组件更新,包含新的fileUrl
。watchEffect
重新运行。回显逻辑的if
条件不满足。- 更新逻辑
formData.value.resourceLogoUrl = imageFile.value.fileUrl
执行,将最新的fileUrl
赋给formData
。这是本次 effect 的最后一次赋值,它覆盖了所有其他可能存在的旧值,确保了resourceLogoUrl
的正确性。
watchEffect
的这种"重新运行并以最终结果为准"的特性,有效地消除了由多个 watch
相互作用或被意外触发所带来的不确定性,保证了在任何响应式变化后,resourceLogoUrl
的状态都是根据 imageFile
的最新状态计算得出的,从而彻底解决了数据被意外覆盖的问题。
4. 结论
resourceLogoUrl
丢失问题的根源在于多个响应式副作用 (watch
) 之间复杂的相互作用和非预期的触发时机。通过采用 watchEffect
,我们将相关逻辑收敛到一个原子性的、自追踪的更新单元中,极大地简化了数据流,使其变得健壮和可预测,最终确保了数据的正确同步。