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

构建含两层嵌套FormArray的Angular表单遇无限循环问题求助

问题:构建嵌套FormArray的Angular表单适配API结构

我正在构建一个包含多层嵌套FormArray的Angular表单,需要适配API预期的JSON结构,该结构由三个自定义TypeScript类型定义。

TypeScript类型定义

type Response = {
  label: string;
  questions?: Question[];
}

type Question = {
  label: string;
  responses?: Response[];
}

type Scenario = {
  title: string;
  description: Question[];
}

示例JSON结构

{
    "title": "Cool Services",
    "description": [
        {
            "label": "Assistance ménagère. Nos produits ?",
            "responses": [
                {
                    "label": "Gaz domestique",
                    "questions": [
                        {
                            "label": "Quantité ?",
                            "responses": [
                                {
                                    "label": "7Kg",
                                    "questions": [
                                        {
                                            "label": "Livraison à domicile ?",
                                            "responses": [
                                                {
                                                    "label": "Oui",
                                                    "questions": [
                                                        {
                                                            "label": "Nom du quartier ?"
                                                        },
                                                        {
                                                            "label": "Numéro de téléphone ?"
                                                        }
                                                    ]
                                                },
                                                {
                                                    "label": "Non",
                                                    "questions": [
                                                        {
                                                            "label": "Numéro de téléphone ?"
                                                        }
                                                    ]
                                                }
                                            ]
                                        }
                                    ]
                                },
                                {
                                    "label": "13Kg",
                                    "questions": [
                                        {
                                            "label": "Livraison à domicile ?",
                                            "responses": [
                                                {
                                                    "label": "Oui",
                                                    "questions": [
                                                        {
                                                            "label": "Nom du quartier ?"
                                                        },
                                                        {
                                                            "label": "Numéro de téléphone ?"
                                                        }
                                                    ]
                                                },
                                                {
                                                    "label": "Non",
                                                    "questions": [
                                                        {
                                                            "label": "Votre numéro de téléphone ?"
                                                        }
                                                    ]
                                                }
                                            ]
                                        }
                                    ]
                                },
                                {
                                    "label": "18 Kg",
                                    "questions": [
                                        {
                                            "label": "Livraison à domicile ?",
                                            "responses": [
                                                {
                                                    "label": "Oui",
                                                    "questions": [
                                                        {
                                                            "label": "Nom du quartier ?"
                                                        },
                                                        {
                                                            "label": "Votre numéro de téléphone ?"
                                                        }
                                                    ]
                                                },
                                                {
                                                    "label": "Non",
                                                    "questions": [
                                                        {
                                                            "label": "Votre numéro de téléphone ?"
                                                        }
                                                    ]
                                                }
                                            ]
                                        }
                                    ]
                                }
                            ]
                        }
                    ]
                },
                {
                    "label": "Marmites",
                    "questions": [
                        {
                            "label": "Catégorie ?",
                            "responses": [
                                {
                                    "label": "Marmite chauffante",
                                    "questions": [
                                        {
                                            "label": "Contenance ?",
                                            "responses": [
                                                {
                                                    "label": "3L",
                                                    "questions": [
                                                        {
                                                            "label": "Nom du quartier ?"
                                                        },
                                                        {
                                                            "label": "Votre numéro de téléphone ?"
                                                        }
                                                    ]
                                                },
                                                {
                                                    "label": "5L",
                                                    "questions": [
                                                        {
                                                            "label": "Nom du quartier ?"
                                                        },
                                                        {
                                                            "label": "Votre numéro de téléphone ?"
                                                        }
                                                    ]
                                                }
                                            ]
                                        }
                                    ]
                                },
                                {
                                    "label": "Marmite ordinaire",
                                    "questions": [
                                        {
                                            "label": "Contenance ?",
                                            "responses": [
                                                {
                                                    "label": "5L",
                                                    "questions": [
                                                        {
                                                            "label": "Nom du quartier ?"
                                                        },
                                                        {
                                                            "label": "Votre numéro de téléphone ?"
                                                        }
                                                    ]
                                                },
                                                {
                                                    "label": "10L",
                                                    "questions": [
                                                        {
                                                            "label": "Nom du quartier ?"
                                                        },
                                                        {
                                                            "label": "Votre numéro de téléphone ?"
                                                        }
                                                    ]
                                                }
                                            ]
                                        }
                                    ]
                                }
                            ]
                        }
                    ]
                }
            ]
        }
    ]
}

当前组件代码(存在无限循环问题)

export class ScenarioFormComponent implements OnInit {
  
  scenarioForm!: FormGroup;
  questionsForm!: FormGroup;
  responsesForm!: FormGroup;

  constructor(private fb: FormBuilder) {}
  
  ngOnInit(): void {
      this.initScenarioForm();
    }
  
    initScenarioForm() {
      this.scenarioForm = this.fb.group({
        title: '',
        description: this.initQuestionsForm()
      });
    }
  
    // INIT QUESTIONS & RESPONSES
    initQuestionsForm() {
      this.questionsForm = this.fb.group({
        questions: this.fb.array([this.questionForm()])
      });
      return this.questionsForm
    }
  
    initResponsesForm() {
      this.responsesForm = this.fb.group({
        responses: this.fb.array([this.responseForm()])
      });
      return this.responsesForm;
    }
  
    // QUESTIONS & RESPONSES FORM
    questionForm() {
      return this.fb.group({
        label: '',
        responses: this.fb.array([this.initResponsesForm()])
      });
    }
  
    responseForm() {
      return this.fb.group({
        label: '',
        questions: this.fb.array([this.initQuestionsForm()])
      });
    }
  
    // GET QUESTIONS AND RESPONSES
    getQuestions() {
      return this.questionsForm.controls['questions'] as FormArray; 
    }
  
    getResponses() {
      return this.responsesForm.controls['responses'] as FormArray;
    }
  
    // ADD QUESTIONS & RESPONSES
    onAddQuestions() {
      return this.getQuestions().push(this.initQuestionsForm());
    }
  
    onAddResponses() {
      return this.getResponses().push(this.initResponsesForm());
    }
  
    // DELETE QUESTIONS & RESPONSES
    onDeleteQuestions(index: number) {
      this.getQuestions().removeAt(index);
    }
  
    onDeleteResponses(index: number) {
      this.getResponses().removeAt(index);
    }
  
    // SUBMIT SCENARIO FORM
    onSubmit() {
  
    }
}

当前模板代码

<form [formGroup]="scenarioForm" (ngSubmit)="onSubmit()" >
    <div>
        <input type="text"
            placeholder="Title"
            formControlName="title"
        >
    </div>
    <div formGroupName="description">
        <div formArrayName="questions" *ngFor="let creds of getQuestions().controls; let i = index">
            <ng-container [formGroupName]="i">
                <div>
                    <input type="text"
                        placeholder="Label question"
                        formControlName="label"
                    >
                </div>

                <div formArrayName="responses" *ngFor="let cred of getResponses().controls; let j = index">
                    <ng-container [formGroupName]="j">
                        <div>
                            <input type="text"
                                placeholder="Label response"
                                formControlName="label"
                            >
                        </div>
                        <button type="button"
                                (click)="onDeleteResponses(j)">
                            Delete response
                        </button>
                    </ng-container>
                </div>
                <span (click)="onAddResponses(j)">Add response</span>

                <button type="button"
                        (click)="onDeleteQuestions(i)">
                    Delete Question
                </button>
            </ng-container>
        </div>
        <span (click)="onAddQuestions(i)">Add question</span>
    </div>

    <div>
        <button type="submit"
                [disabled]="scenarioForm.invalid">
            Submit
        </button>
    </div>
</form>

问题分析与解决方案

无限循环原因

当前代码中,questionForm()调用initResponsesForm(),而initResponsesForm()创建的数组里又调用responseForm(),responseForm()再调用initQuestionsForm(),形成循环依赖,导致初始化时无限递归,引发无限循环。同时模板中获取FormArray的方式错误,全局的getQuestions()和getResponses()只能访问顶层数组,无法处理嵌套结构;循环变量作用域混乱,导致添加/删除按钮无法精准定位控件。

修正后的组件代码

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, FormArray } from '@angular/forms';

type Response = {
  label: string;
  questions?: Question[];
}

type Question = {
  label: string;
  responses?: Response[];
}

type Scenario = {
  title: string;
  description: Question[];
}

@Component({
  selector: 'app-scenario-form',
  templateUrl: './scenario-form.component.html',
  styleUrls: ['./scenario-form.component.css']
})
export class ScenarioFormComponent implements OnInit {
  
  scenarioForm!: FormGroup;

  constructor(private fb: FormBuilder) {}
  
  ngOnInit(): void {
    this.initScenarioForm();
  }
  
  // 初始化顶层表单
  initScenarioForm() {
    this.scenarioForm = this.fb.group({
      title: '',
      description: this.fb.array([this.createQuestionForm()]) // description直接对应Question数组
    });
  }

  // 创建单个Question表单组
  createQuestionForm(question?: Question): FormGroup {
    return this.fb.group({
      label: question?.label || '',
      responses: this.fb.array(
        question?.responses?.map(res => this.createResponseForm(res)) || [this.createResponseForm()]
      )
    });
  }

  // 创建单个Response表单组
  createResponseForm(response?: Response): FormGroup {
    return this.fb.group({
      label: response?.label || '',
      questions: this.fb.array(
        response?.questions?.map(q => this.createQuestionForm(q)) || [] // 初始不自动创建子Question,避免循环
      )
    });
  }

  // 获取顶层的description数组(Question数组)
  get descriptionArray(): FormArray {
    return this.scenarioForm.get('description') as FormArray;
  }

  // 获取某个Question下的responses数组
  getResponsesArray(questionIndex: number): FormArray {
    return this.descriptionArray.at(questionIndex).get('responses') as FormArray;
  }

  // 获取某个Response下的questions数组
  getQuestionsArray(responseIndex: number, questionParentIndex: number): FormArray {
    return this.getResponsesArray(questionParentIndex).at(responseIndex).get('questions') as FormArray;
  }

  // 添加顶层Question
  addQuestion() {
    this.descriptionArray.push(this.createQuestionForm());
  }

  // 添加某个Question下的Response
  addResponse(questionIndex: number) {
    this.getResponsesArray(questionIndex).push(this.createResponseForm());
  }

  // 添加某个Response下的Question
  addSubQuestion(responseIndex: number, questionParentIndex: number) {
    this.getQuestionsArray(responseIndex, questionParentIndex).push(this.createQuestionForm());
  }

  // 删除顶层Question
  deleteQuestion(index: number) {
    this.descriptionArray.removeAt(index);
  }

  // 删除某个Question下的Response
  deleteResponse(questionIndex: number, responseIndex: number) {
    this.getResponsesArray(questionIndex).removeAt(responseIndex);
  }

  // 删除某个Response下的Question
  deleteSubQuestion(responseIndex: number, questionParentIndex: number, subQuestionIndex: number) {
    this.getQuestionsArray(responseIndex, questionParentIndex).removeAt(subQuestionIndex);
  }

  // 提交表单
  onSubmit() {
    console.log(this.scenarioForm.value);
    // 此处可将表单值提交至API
  }
}

修正后的模板代码

<form [formGroup]="scenarioForm" (ngSubmit)="onSubmit()">
  <!-- Title输入框 -->
  <div>
    <input type="text" placeholder="Title" formControlName="title">
  </div>

  <!-- 顶层Question列表 -->
  <div formArrayName="description">
    <div *ngFor="let question of descriptionArray.controls; let i = index" [formGroupName]="i">
      <div>
        <input type="text" placeholder="Label question" formControlName="label">
        <button type="button" (click)="deleteQuestion(i)">删除问题</button>
      </div>

      <!-- 当前Question下的Response列表 -->
      <div formArrayName="responses" style="margin-left: 20px;">
        <div *ngFor="let response of getResponsesArray(i).controls; let j = index" [formGroupName]="j">
          <div>
            <input type="text" placeholder="Label response" formControlName="label">
            <button type="button" (click)="deleteResponse(i, j)">删除回复</button>
          </div>

          <!-- 当前Response下的子Question列表 -->
          <div formArrayName="questions" style="margin-left: 20px;">
            <div *ngFor="let subQuestion of getQuestionsArray(j, i).controls; let k = index" [formGroupName]="k">
              <div>
                <input type="text" placeholder="Label sub question" formControlName="label">
                <button type="button" (click)="deleteSubQuestion(j, i, k)">删除子问题</button>
              </div>
            </div>
            <button type="button" (click)="addSubQuestion(j, i)">添加子问题</button>
          </div>
        </div>
        <button type="button" (click)="addResponse(i)">添加回复</button>
      </div>
    </div>
    <button type="button" (click)="addQuestion()">添加问题</button>
  </div>

  <!-- 提交按钮 -->
  <div>
    <button type="submit" [disabled]="scenarioForm.invalid">提交</button>
  </div>
</form>

关键修正点

  1. 打破循环依赖:创建createResponseForm时,初始仅生成空的子Question数组,只有用户手动点击"添加子问题"时才创建新控件,避免递归无限循环。
  2. 层级化访问FormArray:通过索引参数精准获取对应层级的嵌套数组,不再依赖全局变量存储单个FormGroup,适配多层嵌套结构。
  3. 修正模板作用域:每个循环的索引变量(i、j、k)作用域独立,添加/删除按钮能精准定位到目标控件。
  4. 完全匹配API结构:顶层description直接对应FormArray,每个Question/Response的结构与TypeScript类型定义完全对齐,表单值可直接提交给API。

内容的提问来源于stack exchange,提问作者DONGMO BERNARD GERAUD

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.05 08:47:02