ngFor + ngModel: Как я могу сместить значения в массив, который я перебираю?

У меня есть массив элементов, которые пользователь может не только редактировать, но также добавлять и удалять полные элементы массива. Это хорошо работает, если я не попытаюсь добавить значение в начало массива (например, используя 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 -attributes, и они также просто добавляли, а не добавляли).

Почему это происходит и что я могу с этим сделать?

Примечания к trackBy

До сих пор ответы были просто "использовать trackBy". Однако документация для trackBy гласит:

По умолчанию детектор изменений предполагает, что экземпляр объекта идентифицирует узел в итерируемом

Поскольку я не предоставляю явный trackBy -Function, это означает, что angular должен отслеживать по идентификатору, который (в случае выше) абсолютно правильно идентифицирует каждый объект и соответствует тому, что ожидает документация.

В ответе Морфиша в основном говорится, что функция отслеживания по идентичности не работает, и предлагается использовать id -Property. Сначала это казалось решением, но потом оказалось просто ошибкой. Использование идентификатора -Property демонстрирует то же поведение, что и мой тест выше.

Ответ от penleychan отслеживает по индексу, что заставляет angular думать, что после того, как я переместил значение, angular думает, что на самом деле я нажал значение, и все значения в массиве только что обновились. Это вроде работает вокруг проблемы, но это нарушает контракт Track-By, и это наносит ущерб цели отслеживания -Function (чтобы уменьшить отток в DOM).

Ответ 1

Изменить

После дальнейшего расследования проблема фактически не исходила от ngFor. Это был ngModel с использованием атрибута name ввода.

В цикле атрибут name генерируется с использованием индекса массива. Однако при размещении нового элемента в начале массива у нас неожиданно появляется новый элемент с тем же именем.

Вероятно, это создает конфликт с несколькими ngModel, наблюдающими один и тот же вход внутри.

Такое поведение можно наблюдать при добавлении нескольких входов в начале массива. Все входы, которые были изначально созданы с одним и тем же атрибутом name, будут принимать значение нового создаваемого входа. Независимо от того, были ли изменены их значения или нет.

Чтобы устранить эту проблему, вам просто нужно дать каждому входу уникальный name. Либо с помощью уникального id, как в моем примере ниже

<input [name]="'elem' + item.id" [(ngModel)]="item.value">

Или с помощью генератора уникальных имен/идентификаторов (аналогично тому, что делает Angular Material).


Оригинальный ответ

Проблема, как утверждает penleychan, заключается в отсутствии trackBy в вашей директиве ngFor.

Вы можете найти рабочий пример того, что вы ищете здесь

С обновленным кодом из вашего примера

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 здесь, если хотите посмотреть сами, но вот как это работает.

Директива ngForOf дифференцирует массив для определения внесенных изменений, однако, если не была передана определенная функция trackBy, она остается для мягкого сравнения. Что хорошо для простой структуры данных, такой как строки или числа. Но когда вы используете Objects, он может очень быстро испортиться.

Помимо снижения производительности, отсутствие четкой идентификации для элементов внутри массива может заставить массив перерисовать всю совокупность элементов.

Однако, если директива ngForOf способна четко определить, какой элемент был изменен, какой элемент был удален, а какой добавлен. Он может оставить все остальные элементы нетронутыми, добавлять или удалять шаблоны из DOM по мере необходимости и обновлять только те, которые должны быть.

Если вы добавите функцию trackBy и добавите элемент в начале массива, различие может понять, что это именно то, что произошло, и добавить новый шаблон в начало цикла при привязке к нему соответствующего элемента.

Ответ 2

Разобрался с проблемой. Используйте trackBy

Проблема в том, что если значение изменяется, то разница сообщает об изменении. Поэтому, если функция по умолчанию возвращает ссылки на объекты, она не будет соответствовать текущему элементу, если ссылка на объект изменилась.

Пояснение к trackBy fooobar.com/info/6350524/...

https://stackblitz.com/edit/angular-testing-gvpdhr