返回

搞定 AngularJS 动态表单:独立行数据与计算

javascript

搞定 AngularJS 动态表单:独立控制每行数据与计算

写前端的时候,动态增减表单项是个挺常见的需求,比如订单里添加多个商品。用 AngularJS 的 ng-repeat 可以很方便地加一行删一行。但有时候会踩到一个坑:改了一行的数据,结果发现所有行都跟着变了!特别是涉及到计算的时候,比如根据单价和数量算总价,本来只想算当前行的,结果把其他行的总价也给刷新了。

这篇博客就来聊聊怎么在 AngularJS 里,让动态添加的每一行表单都能“各自为政”,数据独立,计算互不干扰,最后还能把所有行的数据好好地收集起来。

问题在哪?

咱们先看看通常是怎么掉坑里的。就像下面这段(有问题的)代码片段展示的那样:

控制器 (Controller) 可能长这样:

// 假设这是控制器部分代码
app.controller("MainCtrl", function($scope, $http) {
  $scope.pcategoryA = []; // 商品列表,从后端获取
  $http.get('/api/pproduct').success(function(data) {
    $scope.pcategoryA = data;
  });

  // 用来驱动 ng-repeat 的数组,一开始只有一项
  $scope.choices = [{ id: 'choice1' }];

  // 添加新行
  $scope.addNewChoice = function() {
    var newItemNo = $scope.choices.length + 1;
    $scope.choices.push({ 'id': 'choice' + newItemNo });
  };

  // 删除最后一行
  $scope.removeChoice = function() {
    if ($scope.choices.length > 1) {
      $scope.choices.splice($scope.choices.length - 1);
    }
  };

  // 当商品或数量变化时,计算金额
  $scope.changedValue = function(selected_id, quantity) {
    if (selected_id && quantity) {
      $http.get('/api/product/' + selected_id).success(function(data) {
        // 问题:直接修改了 $scope.amount
        // 这会导致所有行的 amount 输入框都显示同一个值
        $scope.amount = parseFloat(data.price * quantity);
      });
    }
  };
});

HTML 视图可能长这样:

<form>
  <!-- ... 其他表单项 ... -->
  <div ng-controller="MainCtrl">
    <fieldset data-ng-repeat="choice in choices track by $index">
      <!-- 问题:ng-model 都绑定到了 $scope 的直接属性上 -->
      <input type="text" placeholder="数量" ng-model="quantity" />
      <select ng-model="selected_id" ng-options="c.Value as c.Text for c in pcategoryA"
              ng-change="changedValue(selected_id, quantity)">
        <option value="">-- 选择商品 --</option>
      </select>
      <input type="text" placeholder="金额" ng-model="amount" ng-readonly="true" />
      <button ng-show="$last" ng-click="removeChoice()">-</button>
      <hr>
    </fieldset>
    <button type="button" ng-click="addNewChoice()">添加销售项</button>
  </div>
  <!-- ... 提交按钮等 ... -->
</form>

问题根源分析:

看看 HTML 里 ng-repeat 内部的 ng-modelng-model="quantity", ng-model="selected_id", ng-model="amount"。它们都直接绑定到了控制器 MainCtrl$scope 下的 quantity, selected_id, amount 这几个属性上。

ng-repeat 虽然会为 choices 数组里的每个元素创建一套 DOM 元素,但是这些 DOM 里的 ng-model 如果直接写成这样,它们指向的都是同一个爹——$scope 上的那几个属性。

这就导致了:

  1. 你在任何一行的数量输入框里打字,修改的都是同一个 $scope.quantity。其他行的数量输入框因为也绑定到它,所以显示的值也跟着变。selected_id 同理。
  2. changedValue 函数被调用时,它计算出的金额被赋给了 $scope.amount。同样,所有行的金额输入框都绑定到了这个 $scope.amount,所以它们会显示相同的、最后一次计算出来的值。

简单来说,所有动态生成的行,共享了同一套数据模型变量,这肯定不行。

解决方案:给每一行创建独立的数据空间

要解决这个问题,核心思路是:不能让所有行共享 $scope 上的几个简单属性,而是要让 choices 数组里的每一个对象,都拥有自己独立的 quantityselected_idamount 属性。

第一步:改造数据结构 (choices 数组)

咱们需要调整 choices 数组。之前它里面只存了个 id,这不够。现在,每个元素都需要包含该行所需的所有数据字段。

修改控制器 (MainCtrl)

appcat.controller("MainCtrl", ['$scope', '$http', '$location', function ($scope, $http, $location) {

  // 获取商品列表
  $http.get('/api/pproduct').success(function (data) {
    $scope.pcategoryA = data;
  });

  // 重要改动:初始化 choices 数组,让每个元素包含自己的数据字段
  // 一开始就包含一行的数据结构
  $scope.choices = [
    {
      // id 可以保留,虽然在这个场景下不一定必需
      // id: 'choice1',
      quantity: null,      // 每行的数量
      selected_id: null,   // 每行选中的商品ID
      amount: 0            // 每行计算出的金额
    }
  ];

  // 计算金额的函数,现在针对特定行操作
  // 注意参数变成了 choice,代表当前行的数据对象
  $scope.changedValue = function (choice) {
    // 确保选择了商品且输入了数量
    if (choice.selected_id && choice.quantity != null && choice.quantity !== '') {
       // 将数量转为数字进行计算,避免潜在的字符串拼接问题
       var quantityNum = parseFloat(choice.quantity);
       if (isNaN(quantityNum) || quantityNum <= 0) {
           // 如果数量无效,清空金额或给个提示
           choice.amount = 0;
           // 可以在这里加一些用户反馈,比如 $scope.error = "请输入有效的正数数量";
           return; // 提前退出
       }

       // 发起请求获取价格
       $http.get('/api/product/' + choice.selected_id).success(function (data) {
         if (data && data.price) {
            // 重要改动:更新的是传入的 choice 对象的 amount 属性
            choice.amount = parseFloat(data.price * quantityNum);
         } else {
            // 处理找不到价格的情况
            choice.amount = 0;
            console.error("无法获取商品 " + choice.selected_id + " 的价格");
         }
       }).error(function(error){
            // 处理请求错误
            choice.amount = 0;
            console.error("请求商品价格失败: ", error);
       });
    } else {
      // 如果商品或数量没填好,金额设为 0
      choice.amount = 0;
    }
  };

  // 添加新行时,推入一个包含完整数据结构的新对象
  $scope.addNewChoice = function () {
    // var newItemNo = $scope.choices.length + 1; // 如果需要唯一ID的话
    $scope.choices.push({
      // id: 'choice' + newItemNo,
      quantity: null,
      selected_id: null,
      amount: 0
    });
  };

  // 移除最后一行(逻辑不变)
  $scope.removeChoice = function () {
    if ($scope.choices.length > 1) {
      $scope.choices.splice($scope.choices.length - 1);
    }
  };

  // --- 新增:收集所有行数据的功能 ---
  // 这个函数可以在提交表单时调用
  $scope.collectAllSalesData = function() {
    // $scope.choices 数组本身就包含了所有行的数据
    console.log("收集到的所有销售项数据:", $scope.choices);
    // 在这里,你可以将 $scope.choices 发送到后端服务器
    // 比如 $http.post('/api/saveSales', $scope.choices).then(...)
    //
    // 你可能需要做一些验证,比如检查是否有行的必填项为空
    let isValid = true;
    $scope.choices.forEach(function(choice, index){
        if (!choice.selected_id || choice.quantity == null || choice.quantity <= 0) {
            isValid = false;
            console.warn(`第 ${index + 1} 行数据不完整或无效。`);
            // 可以给用户更明确的提示
        }
    });

    if (isValid) {
        console.log("数据验证通过,准备提交...");
        // 执行提交操作
    } else {
        console.error("表单数据包含无效项,请检查。");
        // 阻止提交或提示用户
    }
  };

}]);

关键改动说明:

  1. $scope.choices 初始化: 不再是 [{ id: 'choice1' }],而是 [{ quantity: null, selected_id: null, amount: 0 }]。数组里的每个元素现在就是一个对象,包含了该行所需的数据字段。
  2. $scope.addNewChoice: push 进去的不再是只有 id 的对象,而是一个和初始元素结构相同的新对象,确保新行也有自己的数据存储空间。
  3. $scope.changedValue 函数:
    • 参数变成了 choice。这个 choice 就是 ng-repeat 当前迭代到的那个 choices 数组里的元素对象。
    • 函数内部访问的是 choice.selected_idchoice.quantity
    • 最重要的一点:计算结果赋值给了 choice.amount直接修改了传入的那个特定行的数据对象 ,而不是 $scope.amount
    • 增加了对数量有效性的基本检查(parseFloatisNaN)。
    • 添加了基本的错误处理,比如获取价格失败时的情况。

第二步:更新 HTML 绑定

控制器改好了,HTML 也要跟着改,主要是 ng-modelng-change 的绑定目标。

修改 HTML 视图

<form class="form-inline" role="form" ng-submit="collectAllSalesData()"> <!-- 假设用 ng-submit 收集数据 -->
  <strong class="error">{{ error }}</strong>

  <div class="form-group">
    <label for="name">Invoice No. : </label>
    <input type="text" class="form-control" id="name" ng-model="invoiceNo" /> <!-- 假设发票号绑定到 invoiceNo -->
  </div>

  <br /><hr />

  <!-- 注意 ng-controller="MainCtrl" 包裹范围 -->
  <div ng-controller="MainCtrl">
    <!-- 使用 track by $index 可以避免一些潜在的重复项问题,尤其是在添加/删除时 -->
    <fieldset data-ng-repeat="choice in choices track by $index">
      <div class="form-group">
        <label>数量 :</label>
        <!-- 重要改动:ng-model 绑定到 choice.quantity -->
        <!-- ng-change 也需要调用 changedValue(choice) -->
        <input type="number" class="form-control" placeholder="输入数量"
               ng-model="choice.quantity"
               ng-change="changedValue(choice)"
               min="1"  <!-- 建议加上 HTML5 验证 -->
               step="any" <!-- 或者 step="1" 如果只允许整数 -->
               />
      </div>

      <div class="form-group">
        <label class="control-label"> 商品 : </label>
        <select class="form-control"
                ng-model="choice.selected_id" <!-- 重要改动绑定到 choice.selected_id -->
                ng-options="c.Value as c.Text for c in pcategoryA"
                ng-change="changedValue(choice)"> <!-- 重要改动:传入当前的 choice 对象 -->
          <option value="">-- 选择商品 --</option>
        </select>
      </div>

      <div class="form-group">
        <label>金额 :</label>
        <!-- 重要改动:ng-model 绑定到 choice.amount -->
        <!-- 使用 number filter 可以格式化显示 -->
        <input type="text" class="form-control"
               ng-model="choice.amount"
               ng-readonly="true" />
               <!-- 可以考虑使用 | currency 过滤器来格式化金额显示: {{ choice.amount | currency }} -->
      </div>

      <!-- 移除按钮 -->
      <!-- ng-show="$last" 仅在最后一行显示移除按钮 -->
      <button type="button" class="remove"
              ng-show="choices.length > 1 && $last" <!-- 保留至少一行且只在最后一行显示 -->
              ng-click="removeChoice()"> - </button>

      <br />
      <hr />

    </fieldset>

    <!-- 添加按钮 -->
    <button type="button" class="btn btn-primary addfields" ng-click="addNewChoice()">
      + 添加销售项
    </button>

    <!-- 假设有一个总的提交按钮 -->
    <button type="submit" class="btn btn-success" style="margin-left: 10px;">
      保存所有销售记录
    </button>
  </div>
</form>

关键改动说明:

  1. ng-model 指令:ng-repeat 内部,所有的 ng-model 都从原来的 $scope 属性(如 quantity)改为了绑定到迭代变量 choice 的对应属性(如 choice.quantity, choice.selected_id, choice.amount)。这样,每个输入框/下拉框就和它所在行的那个数据对象绑定起来了。
  2. ng-change 指令: 调用 changedValue 函数时,传递的参数不再是 $scope.selected_id$scope.quantity,而是当前的行数据对象 choice,即 ng-change="changedValue(choice)"。这样 changedValue 函数就能知道是哪一行触发了变化,并只更新那一行的 amount
  3. track by $index:ng-repeat 中加上 track by $index 是个好习惯。它可以帮助 AngularJS 更好地追踪数组中的元素,尤其是在增删元素时,能提高性能并避免一些潜在的渲染问题。如果 choice 对象有唯一的 id 属性,用 track by choice.id 通常更好。
  4. 类型和验证: 对数量输入框,建议使用 type="number" 并加上 min="1" 等 HTML5 验证属性,提供基础的客户端验证。
  5. 移除按钮逻辑: 修改了 ng-show 条件,确保至少保留一行,并且移除按钮只显示在最后一行。

第三步:收集所有行的数据

现在每一行的数据都独立存在于 choices 数组的对应对象里了。当用户填完所有行,想要提交表单时,我们只需要收集整个 $scope.choices 数组即可。

控制器中添加数据收集逻辑 (已在上面代码中展示)

MainCtrl 中,可以添加一个类似 collectAllSalesData 的函数,这个函数就直接访问 $scope.choices。这个数组现在就包含了所有行的、各自独立的 quantityselected_idamount

$scope.collectAllSalesData = function() {
  console.log("准备提交的数据:", $scope.choices);
  // 在这里进行最终的数据验证...
  // 然后通过 $http.post 或类似方式发送 $scope.choices 到服务器
  // 例如: $http.post('/api/saveSales', { invoiceNo: $scope.invoiceNo, sales: $scope.choices })...
};

HTML 中触发表单提交

可以在表单的 <form> 标签上使用 ng-submit="collectAllSalesData()",或者给一个“提交”按钮添加 ng-click="collectAllSalesData()"

进阶技巧与注意事项

  1. 输入防抖 (Debounce): 如果获取商品价格的 API 调用比较耗时,或者用户在数量框里输入很快,changedValue 可能会被频繁触发。这既可能给服务器带来压力,也可能导致界面因为快速连续的更新而显得卡顿。可以考虑使用防抖技术(比如 AngularJS 的 $timeout 或者 lodash/underscore 的 _.debounce),让 changedValue 在用户停止输入一小段时间后才执行。

    // 简单示例使用 $timeout 实现防抖
    var promise = null;
    $scope.debouncedChangedValue = function(choice) {
      $timeout.cancel(promise); // 取消之前的定时器
      promise = $timeout(function() {
        $scope.changedValue(choice); // 实际执行计算
      }, 500); // 延迟 500 毫秒执行
    };
    // HTML 中 ng-change 调用 debouncedChangedValue(choice)
    
  2. 性能优化: 如果 pcategoryA 商品列表非常大,每次 ng-repeat 重新渲染时都可能有效率问题。考虑一次性加载,或者如果下拉框选项也需要动态变化,确保逻辑高效。track by 在这里很重要。

  3. 更复杂的计算: 如果金额计算逻辑更复杂,比如涉及折扣、税率等,并且这些也可能是动态的,那么 changedValue 函数的逻辑会相应变得复杂。保持函数清晰、职责单一很重要。

  4. 错误处理:$http 调用中加入 .error() 处理,给用户明确的反馈(比如“无法获取价格信息”),而不是让金额一直显示为 0 或上次的旧值。

  5. 数据初始化: 注意 choices 数组初始化的值。null0 对于后续的逻辑判断(比如 if (choice.quantity))是有区别的,选择合适的初始值。

通过将数据模型与视图的每一行正确关联,咱们就能彻底解决 AngularJS 动态表单中数据串扰的问题,让每一行的数据管理和计算都变得干净、独立。记住,关键在于:数据跟着行(item)走,而不是所有行共享顶层 scope 的几个变量。