You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Angular集合更新时模型绑定丢失问题求助

食谱计算器表单绑定异常问题

我开发了一个简易食谱计算器,支持用户向列表中添加食材并设置数量。食材列表为数组,用户点击“Add ingredient”按钮时,应创建一个新的空食材项,供用户选择食材和设置数量。

初始状态下功能正常:应用加载后添加第一个食材时,可正常选择食材并设置数量。但添加第二个食材(向数组中新增空项)后,表单似乎丢失了与模型的绑定。不过查看模型的JSON输出显示正常,无法定位问题所在。

添加第一个食材时的效果:
第一个食材正常绑定效果
可以看到输入框绑定到了正确的字段。

添加第二个食材后:
第二个食材添加后绑定异常
JSON显示正常,第一个食材信息保留,数组中新增了第二个项,但输入框重置为空值。

组件模板代码

<form #recipesForm="ngForm" (ngSubmit)="onSubmit(recipesForm)" class="qc-panel" *ngIf="showRecipesForm()">
  <div class="mb-3">
    <label for="name" class="form-label">Recipes name</label>
    <input type="text" class="form-control" id="name"
           required
           [(ngModel)]="model.name" name="name"
           #name="ngModel">
    <div [hidden]="name.valid || name.pristine" class="alert alert-danger">
      Name is required
    </div>
  </div>

  <ul class="mb-3 ingredients-list">
    <li *ngFor="let ingredient of model.ingredients; let i = index; trackBy: trackByIndex" class="ingredients-list__item">
      <select class="form-select qc-input--lg" [(ngModel)]="model.ingredients[i].definition" name="ingredient">
        <option [value]="{}" disabled>Please select</option>
        <option *ngFor="let definition of definitions" [ngValue]="definition" [disabled]="disableOption(definition)">{{ definition.name }}</option>
      </select>

      <div class="input-group qc-input--md" [hidden]="!showQuantity(ingredient)">
        <input type="number" class="form-control"
               [(ngModel)]="model.ingredients[i].quantity" name="quantity" />
        <span class="input-group-text">{{ ingredient.definition.measurement }}</span>
      </div>

      <a [routerLink]="[]" (click)="removeIngredient(model, ingredient)" class="qc-action"><fa-icon [icon]="faTrash"></fa-icon></a>
    </li>
  </ul>

  <button type="button" class="btn btn-primary d-block" [disabled]="disableAdd()" (click)="addIngredient()">Add ingredient</button>

  <button type="submit" class="btn btn-primary d-block mt-5" [disabled]="disableSave()">Save recipe</button>

  <div [hidden]="!showRecipeExists || recipesForm.pristine" class="alert alert-danger mb-3">
    A recipe already exists with this name
  </div>

</form>

组件后端代码

import { Component } from '@angular/core';
import { NgForm } from '@angular/forms';

import { faTrash } from '@fortawesome/free-solid-svg-icons';
import { Definition } from '../definition';
import { DefinitionService } from '../definition.service';
import { Ingredient } from '../ingredient';
import { Recipe } from '../recipe';
import { RecipeService } from '../recipe.service';

@Component({
  selector: 'app-recipes',
  templateUrl: './recipes.component.html',
  styleUrls: ['./recipes.component.scss', '../../assets/sass/shared.scss']
})
export class RecipesComponent {
  definitions: Definition[] = [];
  recipes: Recipe[] = [];

  constructor(private definitionService: DefinitionService, private recipeService: RecipeService) { }

  // Icons
  faTrash = faTrash;

  model: Recipe = {
    name: '',
    ingredients: [] as Ingredient[]
  };

  showRecipeExists = false;

  ngOnInit(): void {
    this.getDefinitions();
    this.getRecipes();
  }

  showRecipesForm(): boolean {
    return this.definitions.length > 0;
  }

  showRecipesTable(): boolean {
    return this.recipes.length > 0;
  }

  disableAdd(): boolean {
    if (this.definitions.length === this.model.ingredients.length)
      return true;

    if (this.model.ingredients.length > 0) {
      let count = 0;
      for (let i = 0; i < this.model.ingredients.length; i++) {
        if (Object.keys(this.model.ingredients[i].definition).length === 0)
          count++;
      }

      if (count !== 0)
        return true;
    }

    return false;
  }

  disableSave(): boolean {
    if (this.model.ingredients.length === 0)
      return true;

    let ingredientNotSet = false;
    for (let i = 0; i < this.model.ingredients.length; i++) {
      if (Object.keys(this.model.ingredients[i].definition).length === 0)
        return true;
    }

    if (ingredientNotSet)
      return true;

    return false;
  }

  disableOption(definition: Definition): boolean {
    for (let i = 0; i < this.model.ingredients.length; i++) {
      if (this.model.ingredients[i].definition.name === definition.name)
        return true;
    }

    return false;
  }

  showQuantity(ingredient: Ingredient): boolean {
    if (Object.keys(ingredient.definition).length !== 0)
      return true;

    return false;
  }

  addIngredient(): void {
    let ingredient: Ingredient = {
      quantity: 0,
      definition: {} as Definition
    }
    this.model.ingredients.push(ingredient);
  }

  removeIngredient(recipe: Recipe, ingredient: Ingredient): void {
    // Remove the ingredient from the array
    for (let i = recipe.ingredients.length - 1; i >= 0; i--) {
      if (Object.keys(ingredient.definition).length === 0 || recipe.ingredients[i].definition.name === ingredient.definition.name) {
        recipe.ingredients.splice(i, 1);
      }
    }
  }

  getDefinitions(): void {
    this.definitionService.getDefinitions()
      .subscribe(definitions => {
        this.definitions = definitions
      });
  }

  getRecipes(): void {
    this.recipeService.getRecipes()
      .subscribe(recipes => {
        this.recipes = recipes
      });
  }

  trackByIndex(index: number, obj: any): any {
    return index;
  }

  onSubmit(form: NgForm): void {

    if (this.recipeService.existsWithName(form.value as Recipe)) {
      this.showRecipeExists = true;
      return;
    }

    this.recipeService.addRecipe(form.value as Recipe)
      .subscribe(recipe => {
        this.recipes.push(recipe);
      });

    form.resetForm({
      name: '',
      ingredients: [] as Ingredient[]
    });

    this.getRecipes();
  }

  clearRecipes(): void {
    if (confirm('Are you sure you want to clear all recipes?')) {
      this.recipeService.clearRecipes();
      this.getRecipes();
    }
  }

  removeDefinition(definition: Definition) {
    this.definitionService.removeDefinition(definition);
    this.getDefinitions();
  }

  removeRecipe(recipe: Recipe) {
    this.recipeService.removeRecipe(recipe);
    this.getRecipes();
  }
}

请问有人能指出问题所在吗?谢谢


问题原因及修复方案

问题根源

Angular模板驱动表单中,每个表单控件的name属性必须唯一。你当前的select和input控件的name都是固定值(name="ingredient"、name="quantity"),当通过*ngFor循环生成多个食材项时,所有控件的name完全重复,导致Angular无法正确区分不同的表单控件,进而破坏ngModel的绑定关系。

修复步骤

修改模板中select和input的name属性,结合循环索引i生成唯一名称:

  1. 修改select的name:
<select class="form-select qc-input--lg" [(ngModel)]="model.ingredients[i].definition" name="ingredient-{{i}}">
  1. 修改quantity输入框的name:
<input type="number" class="form-control"
       [(ngModel)]="model.ingredients[i].quantity" name="quantity-{{i}}" />

修改后的完整食材项模板:

<li *ngFor="let ingredient of model.ingredients; let i = index; trackBy: trackByIndex" class="ingredients-list__item">
  <select class="form-select qc-input--lg" [(ngModel)]="model.ingredients[i].definition" name="ingredient-{{i}}">
    <option [value]="{}" disabled>Please select</option>
    <option *ngFor="let definition of definitions" [ngValue]="definition" [disabled]="disableOption(definition)">{{ definition.name }}</option>
  </select>

  <div class="input-group qc-input--md" [hidden]="!showQuantity(ingredient)">
    <input type="number" class="form-control"
           [(ngModel)]="model.ingredients[i].quantity" name="quantity-{{i}}" />
    <span class="input-group-text">{{ ingredient.definition.measurement }}</span>
  </div>

  <a [routerLink]="[]" (click)="removeIngredient(model, ingredient)" class="qc-action"><fa-icon [icon]="faTrash"></fa-icon></a>
</li>

为什么这样能解决问题?

Angular模板驱动表单依赖name属性来追踪每个控件的状态和绑定关系。当多个控件使用相同的name时,Angular会将它们视为同一个控件,导致后续新增的控件覆盖之前的绑定上下文,表现为输入框重置、绑定失效的现象。通过在name中加入索引i,确保每个控件拥有唯一标识,Angular就能正确维护每个食材项的表单绑定。

内容的提问来源于stack exchange,提问作者sterix24

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.01 05:36:00