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,我们将相关逻辑收敛到一个原子性的、自追踪的更新单元中,极大地简化了数据流,使其变得健壮和可预测,最终确保了数据的正确同步。