cd ~

, with read , about 51min.

Vue 组件数据流重构实战:从直接修改到完全 Immutable

Vue 组件数据流重构实战:从直接修改到完全 Immutable

一次关键业务模块的架构优化之旅:如何将 Vue 组件从"能用"升级到"优雅"

前言

在开发 Vue 应用时,我们经常会遇到父子组件之间的数据传递问题。虽然 Vue 官方文档明确指出应该遵循"单向数据流"原则,但在实际项目中,为了快速实现功能,我们有时会采用一些"捷径"——比如直接修改父组件传入的引用。

这种做法在小型项目或原型开发阶段看似没有问题,但随着项目规模增长和团队协作深入,它会带来越来越多的问题:数据流不清晰、难以调试、容易产生副作用、违反 Vue 最佳实践等。

本文将分享一次真实的生产环境代码重构经历,展示如何将一个关键业务模块从"直接修改引用"模式优化为"完全 Immutable + 纯函数"架构。

背景:一个真实的业务场景

我们的项目是一个 OTA(Over-The-Air)卡套餐管理系统。其中有一个核心功能:网络运营商配置

业务复杂度

原始实现的问题

// carrier-config-dialog.vue(子组件)
open(srcData) {
  this.srcData = srcData; // ❌ 直接引用父组件数据
}

handleConfirm() {
  this.$set(this.srcData, index, newValue); // ❌ 直接修改父组件数据
  this.$emit("confirm", result);
}

// networks-viewer-configurator.vue(父组件)
handleCarrierConfigConfirm() {
  this.forceUpdateTable(); // ❌ 需要手动强制刷新
  this.$emit("input", this.networkConfigs);
}
copy success

核心问题:

  1. 子组件直接修改父组件传入的数据
  2. 数据流不清晰,难以追踪变更
  3. 需要手动强制刷新视图
  4. 违反 Vue 单向数据流原则

第一阶段:从直接修改到事件驱动

问题分析

这是一个典型的反模式(Anti-pattern)

父组件数据 (引用)
    ↓
子组件直接修改
    ↓
父组件数据被动变化
    ↓
需要手动 forceUpdate
copy success

为什么会出现这种问题?

解决方案:引入深拷贝 + 事件驱动

步骤 1:子组件使用数据副本

// ✅ 改进后
open(srcData) {
  // 创建深拷贝,避免直接引用
  this.srcData = JSON.parse(JSON.stringify(srcData));
}
copy success

关键改进:

步骤 2:返回完整数据数组

// ✅ 改进后
handleConfirm() {
  // ... 在副本上进行修改
  this.$set(this.srcData, index, newValue);
  
  // 返回完整的修改后数据数组
  this.$emit("confirm", this.srcData);
}
copy success

关键改进:

步骤 3:父组件接收并应用数据

// ✅ 改进后
handleCarrierConfigConfirm(updatedData) {
  // 更新本地数据
  this.networkConfigs = updatedData;
  
  // Vue 响应式系统自动更新视图
  this.$emit("input", this.networkConfigs);
}
copy success

关键改进:

第一阶段成果

✅ 新的数据流
父组件 networkConfigs (原始数据)
    ↓
子组件 open(srcData) 
    ↓
创建深拷贝 this.srcData
    ↓
修改副本
    ↓
emit('confirm', this.srcData)
    ↓
父组件接收 updatedData
    ↓
更新 this.networkConfigs = updatedData
    ↓
Vue 自动更新视图
copy success

优势:

第二阶段:从修改副本到完全 Immutable

进一步思考

虽然第一阶段的改进已经符合 Vue 最佳实践,但仔细审视代码,我们发现还有优化空间:

// 第一阶段的代码
applySingleRowEdit(shouldClear) {
  // ... 计算逻辑
  this.$set(this.srcData, index, newValue); // 修改副本
  // 没有返回值
}

handleConfirm() {
  this.applySingleRowEdit(shouldClear);
  this.$emit("confirm", this.srcData);
}
copy success

存在的问题:

  1. apply 方法有副作用(修改 srcData)
  2. 依赖 Vue 的 $set API
  3. 不是纯函数,难以测试
  4. 数据流不够清晰

解决方案:完全 Immutable + 纯函数

核心思想:函数不修改输入,只返回新值

改进 apply 方法:返回新数组

// ✅ 完全 Immutable
applySingleRowEdit(shouldClear) {
  const targetRowIndex = this.srcData.findIndex(
    (it) => `${it.country}-${it.network}` === this.editRowKey
  );
  
  if (targetRowIndex === -1) return this.srcData;
  
  // 使用 map 返回新数组
  return this.srcData.map((row, idx) => {
    if (idx === targetRowIndex) {
      const shouldApplyRuleConfig = 
        !shouldClear && this.preCheckIfApplyConfig(row);
      
      const result = shouldApplyRuleConfig
        ? { ...this.ruleConfigFormData, ...this.formData }
        : { ...this.cloneDefaultRuleConfig(), ...this.formData };
      
      return { ...row, ...result };
    }
    return row;
  });
}
copy success

关键改进:

改进 handleConfirm:清晰的数据流

// ✅ 清晰的数据流
async handleConfirm() {
  // ... 验证逻辑
  
  // 根据不同编辑模式获取修改后的新数据
  let updatedData;
  if (this.editRowKey && !this.mergeByCarrier) {
    updatedData = this.applySingleRowEdit(shouldClear);
  } else if (this.editRowKey && this.mergeByCarrier) {
    updatedData = this.applyMergedEdit(shouldClear);
  } else {
    updatedData = this.applyBatchEdit(shouldClear);
  }
  
  // 返回修改后的完整数据数组
  this.$emit("confirm", updatedData);
}
copy success

数据流示意:

srcData (原始副本,永不修改)
    ↓
applySingleRowEdit() → newData (返回新数组)
    ↓
emit("confirm", newData)
    ↓
父组件接收新数组
copy success

第二阶段成果

三个纯函数:

  1. applySingleRowEdit(shouldClear) - 返回单行编辑后的新数组
  2. applyMergedEdit(shouldClear) - 返回合并编辑后的新数组
  3. applyBatchEdit(shouldClear) - 返回批量编辑后的新数组

优势对比:

维度 第一阶段 第二阶段
Immutability 部分 完全 ⭐
函数纯度 有副作用 纯函数 ⭐
数据流清晰度 较好 优秀 ⭐
可测试性 一般 优秀 ⭐
Vue 依赖 依赖 $set 纯 JS ⭐
函数式风格 命令式 声明式 ⭐

技术深度解析

1. 为什么使用 map 而不是 forEach

// ❌ 命令式(forEach)
applyBatchEdit(shouldClear) {
  this.srcData.forEach((row, index) => {
    if (row.isChecked) {
      this.$set(this.srcData, index, { ...row, ...result });
    }
  });
}

// ✅ 声明式(map)
applyBatchEdit(shouldClear) {
  return this.srcData.map((row) => {
    if (row.isChecked) {
      return { ...row, ...result, isChecked: false };
    }
    return row;
  });
}
copy success

map 的优势:

2. 性能考量:深拷贝真的慢吗?

实测数据(200 条记录):

结论:

3. 边界情况处理

优秀的代码需要考虑边界情况:

applySingleRowEdit(shouldClear) {
  const targetRowIndex = this.srcData.findIndex(/* ... */);
  
  // ✅ 边界处理:找不到行时返回原数组
  if (targetRowIndex === -1) return this.srcData;
  
  return this.srcData.map(/* ... */);
}
copy success

其他边界情况:

4. 规则配置清除的巧妙处理

业务需求:
当用户配置规则后又取消,需要清除历史字段,否则会影响表格合并功能。

问题场景:

// 新加坡行(从未配置)
{ carrierAccountId: 5, carrierPlanId: 12 }

// 中国行(配置后取消)
{ 
  carrierAccountId: 5, 
  carrierPlanId: 12,
  affiliateCardCarrierAccountId: 16, // ← 历史残留字段
  affiliateCardCarrierPlanId: 23      // ← 历史残留字段
}
copy success

问题:

解决方案:

const result = shouldApplyRuleConfig
  ? { ...this.ruleConfigFormData, ...this.formData }
  : { 
      ...this.cloneDefaultRuleConfig(), // ← 关键:用默认值覆盖
      ...this.formData 
    };
copy success

原理:

// 默认配置
{
  toggle: 0,
  noUseJudgeCount: 3,
  affiliateCardCarrierAccountId: undefined, // ← undefined 覆盖旧值
  affiliateCardCarrierPlanId: undefined      // ← undefined 覆盖旧值
}

// 覆盖后
{
  carrierAccountId: 5,
  carrierPlanId: 12,
  toggle: 0,
  affiliateCardCarrierAccountId: undefined, // ← 旧值 16 被覆盖
  affiliateCardCarrierPlanId: undefined      // ← 旧值 23 被覆盖
}
copy success

现在新加坡行和中国行的数据结构完全一致,可以正常合并!

实践建议

1. 何时应该重构?

适合重构的信号:

2. 重构的步骤

推荐的渐进式重构:

  1. 第一步:添加完善的注释和文档
  2. 第二步:引入深拷贝,避免直接修改引用
  3. 第三步:改用事件驱动,返回完整数据
  4. 第四步:改用纯函数,返回新数组
  5. 第五步:编写单元测试,验证正确性

3. 团队协作建议

代码审查要点:

// ❌ 需要在代码审查中指出
props: ['data'],
methods: {
  modify() {
    this.data.push(item); // 直接修改 props
  }
}

// ✅ 推荐的做法
props: ['data'],
methods: {
  modify() {
    const newData = [...this.data, item];
    this.$emit('update', newData);
  }
}
copy success

4. 性能优化建议

针对大数据量场景:

// 如果数据量 > 1000 条,考虑优化

// 方案 1:使用 structuredClone(现代浏览器)
this.srcData = structuredClone(srcData);

// 方案 2:使用 lodash
import cloneDeep from 'lodash/cloneDeep';
this.srcData = cloneDeep(srcData);

// 方案 3:使用 Immer(大型应用)
import produce from 'immer';
const newData = produce(this.srcData, draft => {
  draft[index] = newValue;
});
copy success

总结与反思

重构成果

代码质量提升:

架构改进:

旧架构:命令式 + 有副作用 + 紧耦合
    ↓
新架构:声明式 + 纯函数 + 松耦合
copy success

技术收获

  1. 深入理解 Vue 响应式原理

    • Props 应该是只读的
    • 数据流应该是单向的
    • 响应式更新是自动的
  2. 掌握函数式编程思想

    • Immutability(不可变性)
    • Pure Function(纯函数)
    • Declarative(声明式)
  3. 提升代码设计能力

    • 单一职责原则
    • 开闭原则
    • 依赖倒置原则

给初学者的建议

不要害怕重构

遵循最佳实践

保持好奇心

参考资料

Vue 官方文档

函数式编程

工具库

cd ~
GO BACK (Backspace)
BACK TO TOP (ESC)
COMMENTS (C)