Неверные компоненты, предоставленные Preact

Я использую Preact (для всех целей и задач, React), чтобы отобразить список элементов, сохраненных в массиве состояний. У каждого элемента есть кнопка удаления рядом с ним. Моя проблема: при нажатии кнопки правильный элемент удаляется (я проверил это несколько раз), но элементы повторно отображаются с отсутствующим элементом last, а удаленный все еще есть. Мой код (упрощенный):

import { h, Component } from 'preact';
import Package from './package';

export default class Packages extends Component {
  constructor(props) {
    super(props);
    let packages = [
      'a',
      'b',
      'c',
      'd',
      'e'
    ];
    this.setState({packages: packages});
  }

  render () {
    let packages = this.state.packages.map((tracking, i) => {
      return (
        <div className="package" key={i}>
          <button onClick={this.removePackage.bind(this, tracking)}>X</button>
          <Package tracking={tracking} />
        </div>
      );
    });
    return(
      <div>
        <div className="title">Packages</div>
        <div className="packages">{packages}</div>
      </div>
    );
  }

  removePackage(tracking) {
    this.setState({packages: this.state.packages.filter(e => e !== tracking)});
  }
}

Что я делаю неправильно? Нужно ли мне как-то активно пересматривать? Является ли это случаем n + 1 как-то?

Разъяснение. Моя проблема заключается не в синхронности состояния. В приведенном выше списке, если я выбираю удалить 'c', состояние корректно обновляется до ['a','b','d','e'], но отображаемые компоненты ['a','b','c','d']. При каждом вызове removePackage правильный шаблон удаляется из массива, отображается правильное состояние, но отображается неправильный список. (Я удалил инструкции console.log, так что это не похоже, что это моя проблема).

Ответ 1

Это классическая проблема, которая полностью недоказана документацией Preact, поэтому я хотел бы лично извиниться за это! Мы всегда ищем помощь при написании лучшей документации, если кто-то заинтересован.

Что здесь произошло, так это то, что вы используете индекс своего массива в качестве ключа (на вашей карте в рендере). Это фактически просто эмулирует, как работает VDOM diff по умолчанию - ключи всегда 0-n, где n - длина массива, поэтому удаление любого элемента просто удаляет последний ключ из списка.

Объяснение: Ключи transcend render

В вашем примере представьте, как (виртуальная) DOM будет выглядеть на начальной визуализации, а затем после удаления элемента "b" (индекс 3). Ниже приведем вид, что ваш список состоит всего из 3 предметов (['a', 'b', 'c']):

Вот то, что производит исходный рендеринг:

<div>
  <div className="title">Packages</div>
  <div className="packages">
    <div className="package" key={0}>
      <button>X</button>
      <Package tracking="a" />
    </div>
    <div className="package" key={1}>
      <button>X</button>
      <Package tracking="b" />
    </div>
    <div className="package" key={2}>
      <button>X</button>
      <Package tracking="c" />
    </div>
  </div>
</div>

Теперь, когда мы нажимаем "X" во втором элементе списка, "b" передается на removePackage(), который устанавливает state.packages в ['a', 'c']. Это вызывает наш рендер, который создает следующую (виртуальную) DOM:

<div>
  <div className="title">Packages</div>
  <div className="packages">
    <div className="package" key={0}>
      <button>X</button>
      <Package tracking="a" />
    </div>
    <div className="package" key={1}>
      <button>X</button>
      <Package tracking="c" />
    </div>
  </div>
</div>

Поскольку библиотека VDOM знает только о новой структуре, которую вы даете ей для каждого рендеринга (а не как перейти от старой структуры к новой), то, что сделали ключи, в основном говорит, что элементы 0 и 1 остался на месте - мы знаем, что это неверно, потому что мы хотели удалить элемент с индексом 1.

Помните: key имеет приоритет над семантикой переопределения дочерних различий по умолчанию. В этом примере, поскольку key всегда является только индексом массива на основе 0, последний элемент (key=2) просто выпадает, потому что он отсутствует в последующем рендере.

Исправление

Итак, чтобы исправить ваш пример - вы должны использовать что-то, что идентифицирует элемент, а не его смещение как ваш ключ. Это может быть сам элемент (любое значение приемлемо в качестве ключа) или свойство .id (предпочтительнее, поскольку оно позволяет избежать ссылок на рассеивающие объекты вокруг которых может предотвратить GC):

let packages = this.state.packages.map((tracking, i) => {
  return (
                                  // ↙️ a better key fixes it :)
    <div className="package" key={tracking}>
      <button onClick={this.removePackage.bind(this, tracking)}>X</button>
      <Package tracking={tracking} />
    </div>
  );
});

Ну, это было гораздо более длинным, чем я предполагал.

TL, DR: никогда не используйте индекс массива (индекс итерации) как key. В лучшем случае он имитирует поведение по умолчанию (перераспределение дочернего элемента вниз), но чаще всего он просто толкает все, отличные от последнего ребенка.


изменить: @tommy рекомендуется этот отличный ссылку на документы, посвященные eslint-plugin-react, которые лучше объясняют это, чем я сделал выше.