ngFor + ngModel:如何将值取消转移到我正在迭代的数组中?
ngFor + ngModel: How can I unshift values to the array I am iterating?
我有一个元素数组,用户不仅可以编辑,还可以添加和删除完整的数组元素。这很好用,除非我尝试将一个值添加到数组的开头(例如使用 unshift
)。
这是一个证明我的问题的测试:
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
@Component({
template: `
<form>
<div *ngFor="let item of values; let index = index">
<input [name]="'elem' + index" [(ngModel)]="item.value">
</div>
</form>`
})
class TestComponent {
values: {value: string}[] = [{value: 'a'}, {value: 'b'}];
}
fdescribe('ngFor/Model', () => {
let component: TestComponent;
let fixture: ComponentFixture<TestComponent>;
let element: HTMLDivElement;
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [FormsModule],
declarations: [TestComponent]
});
fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;
element = fixture.nativeElement;
fixture.detectChanges();
await fixture.whenStable();
});
function getAllValues() {
return Array.from(element.querySelectorAll('input')).map(elem => elem.value);
}
it('should display all values', async () => {
// evaluation
expect(getAllValues()).toEqual(['a', 'b']);
});
it('should display all values after push', async () => {
// execution
component.values.push({value: 'c'});
fixture.detectChanges();
await fixture.whenStable();
// evaluation
expect(getAllValues()).toEqual(['a', 'b', 'c']);
});
it('should display all values after unshift', async () => {
// execution
component.values.unshift({value: 'z'});
fixture.detectChanges();
await fixture.whenStable();
// evaluation
console.log(JSON.stringify(getAllValues())); // Logs '["z","z","b"]'
expect(getAllValues()).toEqual(['z', 'a', 'b']);
});
});
前两个测试顺利通过。然而,第三次测试失败了。在第三个测试中,我尝试在我的输入前添加 "z",这是成功的,但是第二个输入也显示 "z",它不应该。
(请注意,网络上存在数百个类似的问题,但在其他情况下,人们只是没有独特的 name
属性,他们也只是附加,而不是前置)。
为什么会发生这种情况,我该怎么办?
关于 trackBy
的注释
到目前为止,答案只有 "use trackBy"。但是 trackBy 的文档指出:
By default, the change detector assumes that the object instance identifies the node in the iterable
因为我没有提供明确的 trackBy
-Function,这意味着 angular 应该通过身份进行跟踪,(在上面的例子中)绝对正确地识别每个对象并且是符合文档的预期。
Morphyish 的回答基本上是说按身份跟踪的功能已损坏,建议使用 id
-属性。起初它似乎是一个解决方案,但后来证明它只是一个错误。使用 id-属性 表现出与我上面的测试完全相同的行为。
penleychan 的回答按索引跟踪,这导致 angular 认为,在我取消移动一个值后 angular 认为实际上我推了一个值,而数组中的所有值恰好已更新。它可以解决这个问题,但它违反了 track-By 合同,并且违背了 track-by-function 的目的(减少 DOM 中的流失)。
解决了问题。使用 trackBy
The issue is if the value changes, the differ reports a change. So if the default function returns object references, it will not match the current item if the object reference has changed.
关于trackBy
的解释
编辑
经过进一步调查,问题实际上并非来自 ngFor
。这是 ngModel
使用输入的 name
属性。
在循环中,使用数组索引生成name
属性。但是,当在数组的开头放置一个新元素时,我们突然有一个同名的新元素。
这可能与多个 ngModel
在内部观察相同输入造成冲突。
在数组开头添加多个输入时,可以进一步观察到此行为。最初使用相同 name
属性创建的所有输入都将采用正在创建的新输入的值。不管它们各自的值是否改变。
要解决此问题,您只需为每个输入指定一个唯一的 name
。通过使用唯一的 id
,如下面的示例
<input [name]="'elem' + item.id" [(ngModel)]="item.value">
或者通过使用独特的 name/id 生成器(类似于 Angular Material 所做的)。
原回答
如 penleychan 所述,问题是您的 ngFor
指令中缺少 trackBy
。
您可以找到您正在寻找的工作示例here
使用您示例中的更新代码
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
@Component({
template: `
<form>
<div *ngFor="let item of values; let index = index; trackBy: trackByFn">
<input [name]="'elem' + index" [(ngModel)]="item.value">
</div>
</form>`
})
class TestComponent {
values: {id: number, value: string}[] = [{id: 0, value: 'a'}, {id: 1, value: 'b'}];
trackByFn = (index, item) => item.id;
}
fdescribe('ngFor/Model', () => {
let component: TestComponent;
let fixture: ComponentFixture<TestComponent>;
let element: HTMLDivElement;
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [FormsModule],
declarations: [TestComponent]
});
fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;
element = fixture.nativeElement;
fixture.detectChanges();
await fixture.whenStable();
});
function getAllValues() {
return Array.from(element.querySelectorAll('input')).map(elem => elem.value);
}
it('should display all values', async () => {
// evaluation
expect(getAllValues()).toEqual(['a', 'b']);
});
it('should display all values after push', async () => {
// execution
component.values.push({id: 2, value: 'c'});
fixture.detectChanges();
await fixture.whenStable();
// evaluation
expect(getAllValues()).toEqual(['a', 'b', 'c']);
});
it('should display all values after unshift', async () => {
// execution
component.values.unshift({id: 2, value: 'z'});
fixture.detectChanges();
await fixture.whenStable();
// evaluation
console.log(JSON.stringify(getAllValues())); // Logs '["z","z","b"]'
expect(getAllValues()).toEqual(['z', 'a', 'b']);
});
});
尽管有您的评论,但它不是解决方法。 trackBy
是针对使用类型(以及性能,但两者是相关联的)而设计的。
如果您想自己看一下,可以找到 ngForOf
指令代码 here,但这是它的工作原理。
ngForOf
指令正在区分数组以确定所做的修改,但是如果没有传递特定的 trackBy
函数,它就会进行软比较。这适用于简单的数据结构,例如字符串或数字。但是当你使用 Objects
时,它会变得非常快。
除了降低性能之外,数组内的项目缺乏明确的标识会迫使数组重新呈现整个元素。
但是,如果ngForOf
指令能够清楚地判断哪些项目发生了变化,哪些项目被删除,哪些项目被添加。它可以保持所有其他项目不变,根据需要从 DOM 添加或删除模板,并仅更新需要的模板。
如果你添加 trackBy
函数并在数组的开头添加一个项目,diffing 可以意识到这正是发生的事情,并在绑定时在循环开头添加一个新模板对应的项目。
我有一个元素数组,用户不仅可以编辑,还可以添加和删除完整的数组元素。这很好用,除非我尝试将一个值添加到数组的开头(例如使用 unshift
)。
这是一个证明我的问题的测试:
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
@Component({
template: `
<form>
<div *ngFor="let item of values; let index = index">
<input [name]="'elem' + index" [(ngModel)]="item.value">
</div>
</form>`
})
class TestComponent {
values: {value: string}[] = [{value: 'a'}, {value: 'b'}];
}
fdescribe('ngFor/Model', () => {
let component: TestComponent;
let fixture: ComponentFixture<TestComponent>;
let element: HTMLDivElement;
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [FormsModule],
declarations: [TestComponent]
});
fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;
element = fixture.nativeElement;
fixture.detectChanges();
await fixture.whenStable();
});
function getAllValues() {
return Array.from(element.querySelectorAll('input')).map(elem => elem.value);
}
it('should display all values', async () => {
// evaluation
expect(getAllValues()).toEqual(['a', 'b']);
});
it('should display all values after push', async () => {
// execution
component.values.push({value: 'c'});
fixture.detectChanges();
await fixture.whenStable();
// evaluation
expect(getAllValues()).toEqual(['a', 'b', 'c']);
});
it('should display all values after unshift', async () => {
// execution
component.values.unshift({value: 'z'});
fixture.detectChanges();
await fixture.whenStable();
// evaluation
console.log(JSON.stringify(getAllValues())); // Logs '["z","z","b"]'
expect(getAllValues()).toEqual(['z', 'a', 'b']);
});
});
前两个测试顺利通过。然而,第三次测试失败了。在第三个测试中,我尝试在我的输入前添加 "z",这是成功的,但是第二个输入也显示 "z",它不应该。
(请注意,网络上存在数百个类似的问题,但在其他情况下,人们只是没有独特的 name
属性,他们也只是附加,而不是前置)。
为什么会发生这种情况,我该怎么办?
关于 trackBy
的注释
到目前为止,答案只有 "use trackBy"。但是 trackBy 的文档指出:
By default, the change detector assumes that the object instance identifies the node in the iterable
因为我没有提供明确的 trackBy
-Function,这意味着 angular 应该通过身份进行跟踪,(在上面的例子中)绝对正确地识别每个对象并且是符合文档的预期。
Morphyish 的回答基本上是说按身份跟踪的功能已损坏,建议使用 id
-属性。起初它似乎是一个解决方案,但后来证明它只是一个错误。使用 id-属性 表现出与我上面的测试完全相同的行为。
penleychan 的回答按索引跟踪,这导致 angular 认为,在我取消移动一个值后 angular 认为实际上我推了一个值,而数组中的所有值恰好已更新。它可以解决这个问题,但它违反了 track-By 合同,并且违背了 track-by-function 的目的(减少 DOM 中的流失)。
解决了问题。使用 trackBy
The issue is if the value changes, the differ reports a change. So if the default function returns object references, it will not match the current item if the object reference has changed.
关于trackBy
的解释
编辑
经过进一步调查,问题实际上并非来自 ngFor
。这是 ngModel
使用输入的 name
属性。
在循环中,使用数组索引生成name
属性。但是,当在数组的开头放置一个新元素时,我们突然有一个同名的新元素。
这可能与多个 ngModel
在内部观察相同输入造成冲突。
在数组开头添加多个输入时,可以进一步观察到此行为。最初使用相同 name
属性创建的所有输入都将采用正在创建的新输入的值。不管它们各自的值是否改变。
要解决此问题,您只需为每个输入指定一个唯一的 name
。通过使用唯一的 id
,如下面的示例
<input [name]="'elem' + item.id" [(ngModel)]="item.value">
或者通过使用独特的 name/id 生成器(类似于 Angular Material 所做的)。
原回答
如 penleychan 所述,问题是您的 ngFor
指令中缺少 trackBy
。
您可以找到您正在寻找的工作示例here
使用您示例中的更新代码
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
@Component({
template: `
<form>
<div *ngFor="let item of values; let index = index; trackBy: trackByFn">
<input [name]="'elem' + index" [(ngModel)]="item.value">
</div>
</form>`
})
class TestComponent {
values: {id: number, value: string}[] = [{id: 0, value: 'a'}, {id: 1, value: 'b'}];
trackByFn = (index, item) => item.id;
}
fdescribe('ngFor/Model', () => {
let component: TestComponent;
let fixture: ComponentFixture<TestComponent>;
let element: HTMLDivElement;
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [FormsModule],
declarations: [TestComponent]
});
fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;
element = fixture.nativeElement;
fixture.detectChanges();
await fixture.whenStable();
});
function getAllValues() {
return Array.from(element.querySelectorAll('input')).map(elem => elem.value);
}
it('should display all values', async () => {
// evaluation
expect(getAllValues()).toEqual(['a', 'b']);
});
it('should display all values after push', async () => {
// execution
component.values.push({id: 2, value: 'c'});
fixture.detectChanges();
await fixture.whenStable();
// evaluation
expect(getAllValues()).toEqual(['a', 'b', 'c']);
});
it('should display all values after unshift', async () => {
// execution
component.values.unshift({id: 2, value: 'z'});
fixture.detectChanges();
await fixture.whenStable();
// evaluation
console.log(JSON.stringify(getAllValues())); // Logs '["z","z","b"]'
expect(getAllValues()).toEqual(['z', 'a', 'b']);
});
});
尽管有您的评论,但它不是解决方法。 trackBy
是针对使用类型(以及性能,但两者是相关联的)而设计的。
如果您想自己看一下,可以找到 ngForOf
指令代码 here,但这是它的工作原理。
ngForOf
指令正在区分数组以确定所做的修改,但是如果没有传递特定的 trackBy
函数,它就会进行软比较。这适用于简单的数据结构,例如字符串或数字。但是当你使用 Objects
时,它会变得非常快。
除了降低性能之外,数组内的项目缺乏明确的标识会迫使数组重新呈现整个元素。
但是,如果ngForOf
指令能够清楚地判断哪些项目发生了变化,哪些项目被删除,哪些项目被添加。它可以保持所有其他项目不变,根据需要从 DOM 添加或删除模板,并仅更新需要的模板。
如果你添加 trackBy
函数并在数组的开头添加一个项目,diffing 可以意识到这正是发生的事情,并在绑定时在循环开头添加一个新模板对应的项目。