Работа с DOM в приложениях Vanilla JS (часть 2): создание и обновление списков

Я обещал в своем предыдущем посте, что в этом буду освещать списки. Мы рассмотрим несколько различных способов работы со списками, и эти подходы должны охватывать большинство распространенных случаев.

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

Методы, которые я расскажу:

  • Статические списки со скрытыми элементами
  • Динамические списки произвольной длины
  • Динамические списки с пулом неиспользуемых узлов

Я также расскажу о списках сортировки и поделюсь небольшим советом по CSS, чтобы улучшить производительность рендеринга списков.

Статические списки

Статические списки чаще всего используются для списков, которые никогда не меняются (например, параметры внутри списка выбора, которые известны заранее), но их также можно использовать с данными, которые сами по себе не являются статическими. Когда наши данные представляют собой список произвольной длины, мы все равно можем отобразить его в статический список, отображая только подмножество списка за раз.

Пейджинг можно использовать в этом случае, например, для облегчения выбора подмножества. В нашем HTML мы создадим достаточно элементов, чтобы покрыть одну страницу. Все эти элементы изначально скрыты. Когда мы хотим отобразить некоторые данные, мы сначала обновим каждый элемент в соответствии с данными, а затем откроем обновленные элементы.

Поскольку я заранее знаю количество визуализируемых элементов и имею их в HTML, я могу предварительно выбрать все элементы заранее.

let $$listItems = document.querySelectorAll('.list-item')

Трюк с обновлением статических списков заключается в том, чтобы всегда перебирать элементы, а не данные:

let data = []
let updateList = () => {
  $$listItems.forEach(($item, idx) => {
    let item = data[idx]
    if (itemx) {
      $item.querySelector('.title').textContent = item.title
      $item.querySelector('.price').textContent = item.price
    }
    $item.classList.toggle('hidden', item == null)
  })
}

Когда я просматриваю существующие элементы списка, я пытаюсь сопоставить их с данными. Если данных нет, то я прячу элемент. В противном случае я сначала заполняю пробелы, а затем раскрываю их.

Важно повторить, что вы хотите сначала заполнить пустое место, а затем затем показать элемент. Это уменьшает количество перекомпоновок и перерисовок.

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

let data = []
let startPage = 0
let endPage = 10
let updateList = () => {
  let shownData = data.slice(startPage, endPage + 1)
  $$listItems.forEach(($item, idx) => {
    let item = shownData[idx]
    if (itemx) {
      $item.querySelector('.title').textContent = item.title
      $item.querySelector('.price').textContent = item.price
    }
    $item.classList.toggle('hidden', item == null)
  })
}

Для постраничного списка я просто добавил переменные startPage и endPage в состояние приложения. (Предположим, я обновляю их посредством взаимодействия с пользователем.) Затем я создаю shownData как срез всего массива и сопоставляю с ним элементы списка вместо всего массива data.

Добавление прослушивателей событий

Некоторые части списка могут быть интерактивными. В этом случае нам нужно добавить прослушиватели событий. У нас есть несколько вариантов, но наиболее распространенный подход, который я использую, заключается в добавлении обработчиков событий ко всем элементам списка, а затем использовании атрибутов data-* для управления действиями обработчиков событий.

let updateList = () => {
  $$listItems.forEach(($item, idx) => {
    let item = data[idx]
    if (itemx) {
      $item.querySelector('.title').textContent = item.title
      $item.querySelector('.price').textContent = item.price
    }
    $item.classList.toggle('hidden', item == null)
    $item.dataset.index = idx
  })
}

$$listItems.forEach($item => {
  $item.querySelector('.delete').onclick = () => 
    onDeleteItem(Number($item.dataset.index))
});

Динамические списки

Иногда мы можем захотеть избежать пейджинга по причинам UX. Вместо этого мы можем загрузить дополнительные элементы в конце списка с помощью кнопки «Загрузить еще» или автоматически с помощью IntersectionObserver.

В этом случае я считаю, что проще создать узлы DOM в JavaScript, чем в HTML. Я называю этот подход «динамическим списком».

Хотя этот метод можно комбинировать со статическим списком, если количество начальных элементов всегда одинаково, я считаю, что в целом код становится проще, если я придерживаюсь того или иного подхода в более простых случаях. Я также расскажу о гибридном подходе позже, где я расскажу о случае, когда он более уместен.

При использовании подхода с динамическим списком у меня обычно есть отдельная функция для создания элементов элемента списка. Мы можем создавать элементы как строки или как узлы DOM. Что касается разницы в производительности между использованием строк и созданием узлов DOM верхнего уровня с использованием document.createElement, то особой разницы быть не должно. Основное различие заключается в том, получаете ли вы ссылку на узел элемента списка сразу или вам нужно выбрать их позже.

Давайте рассмотрим оба подхода.

Создание элементов списка с использованием только строк

Вот пример использования только строк:

const ITEMS_PER_PAGE = 10
let data = []
let nShown = 0
let nextPage = () => nShown += ITEMS_PER_PAGE
let $list = document.getElementById('list')
let $loadMore = document.getElementById('load-more')
let createItem = item => `
  <li class="list-item">
    <span class="item-title">${item.title}</span>
    <span class="item-price">${item.price}</span>
  </li>
`
let appendItems = () => {
  let itemsToShow = data.slice(nShown, nShown + ITEMS_PER_PAGE)
  let $t = document.createElement('template')
  $t.content.innerHTML = itemsToShow.map(createItem).join('')
  $list.append($t.content)
}
let onLoadMore = () => {
  appendItems()
  nextPage()
}
$loadMore.onclick = onLoadMore
onLoadMore()

Несколько замечаний:

Я использовал строки шаблона для встраивания данных в сгенерированный HTML. Это означает, что вы должны быть осторожны с тем, откуда берутся данные. Если это пользовательские данные, вы рискуете стать жертвой XSS-атак, поэтому вам следует предпринять дополнительные шаги, чтобы убедиться, что данные очищены (т. е. не содержат HTML).

Мы используем элемент template, чтобы сначала собрать вновь созданные элементы. Элемент template работает аналогично фрагменту документа, за исключением того, что он поддерживает свойство innerHTML, чего последний не поддерживает.

Создание элементов списка с использованием строк и узлов DOM

Вот пример использования комбинированных строк и узлов DOM:

const ITEMS_PER_PAGE = 10
let data = []
let nShown = 0
let nextPage = () => nShown += ITEMS_PER_PAGE
let $list = document.getElementById('list')
let $loadMore = document.getElementById('load-more')
let createItem = item => {
  let $el = document.createElement('li')
  $el.className = 'list-item'
  $el.innerHTML = `
    <span class="item-title">${item.title}</span>
    <span class="item-price">${item.price}</span>
  `
  return $el
}
let appendItems = () => {
  let itemsToShow = data.slice(nShown, nShown + ITEMS_PER_PAGE)
  $list.append(...itemsToShow.map(createItem))
}
let onLoadMore = () => {
  appendItems()
  nextPage()
}
$loadMore.onclick = onLoadMore
onLoadMore()

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

Добавление прослушивателей событий

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

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

Давайте посмотрим, как мы добавим прослушиватели событий в обоих случаях.

Сначала со строковым подходом:

let appendItems = () => {
  let itemsToShow = data.slice(nShown, nShown + ITEMS_PER_PAGE)
  let $t = document.createElement('template')
  $t.content.innerHTML = itemsToShow.map(createItem).join('')
  $t.content.querySelectorAll('.buy-now').forEach(($btn, idx) => {
    let dataIdx = nShown + idx
    $btn.onclick = () => onBuyNow(dataIdx)
  })
  $list.append($t.content)
}

И с гибридным подходом:

let appendItems = () => {
  let itemsToShow = data.slice(nShown, nShown + ITEMS_PER_PAGE)
  $list.append(...itemsToShow.map((item, idx) => {
    let $el = createItem(item)
    let dataIdx = nShown + idx
    $el.querySelector('.buy-now').onclick = () => onBuyNow(dataIdx)
  }))
}

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

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

let appendItems = () => {
  let itemsToShow = data.slice(nShown, nShown + ITEMS_PER_PAGE)
  let $t = document.createElement('template')
  $t.content.innerHTML = itemsToShow.map(createItem).join('')
  $list.append($t.content)
}
$list.onclick = ev => {
  let $realTarget = ev.target.closest('.buy-now')
  if ($realTarget) onBuyNow($realTarget.dataset.index)
}

В примере я выполняю проверку, чтобы определить, нужно ли мне реагировать на событие клика. Проверка с использованием Element.closest() проверяет, что элемент, по которому был сделан щелчок, является элементом .buy-now или содержится внутри него. Чтобы приведенный выше пример работал, я бы, как показано, использовал атрибут data-index на кнопке, чтобы выяснить, о каком элементе мы говорим, поскольку в противном случае прослушиватель событий не имел бы доступа к этой информации.

Динамические списки с пулом узлов

Предыдущие примеры динамических списков реализуют добавление новых элементов только в конце. Как насчет случаев, когда нам нужно также удалить элементы или вставить новые где-то посередине? Как насчет перемещения предметов из одного места в другое? Для этого я бы использовал немного другой подход к поддержке пула узлов.

Пул узлов — это просто массив, содержащий ссылки на узлы DOM, так что ссылки находятся в том же порядке, что и базовые данные. Это похоже на то, что мы сделали со статическими списками, где мы создали ссылку на набор узлов для элементов на странице HTML, но вместо этого мы делаем это для динамически созданных.

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

const ITEMS_PER_PAGE = 10
// State
let data = []
let nShown = 0
// State manipulation
let nextPage = () => nShown += ITEMS_PER_PAGE
// DOM references
let $list = document.getElementById('list')
let $$listItems = []
let $loadMore = document.getElementById('load-more')
// DOM manipulation
let createItem = (item) => {
  let $el = document.createElement('li')
  $el.className = 'list-item'
  $el.innerHTML = `
    <span class="item-title">${item.title}</span>
    <span class="item-price">${item.price}</span>
  `
  return $el
}
let appendItems = () => {
  let itemsToShow = data.slice(nShown, nShown + ITEMS_PER_PAGE)
  let $$items = itemsToShow.map(createItem)
  $list.append(...$$items)
  $$listItems.push(...$items)
}
// Event handlers
let onLoadMore = () => {
  appendItems()
  nextPage()
}
// Event bindings
$loadMore.onclick = onLoadMore
// Initial view
onLoadMore()

При удалении элементов я одновременно удаляю их и из данных, и из пула узлов:

// State manipulation
let deleteItem = idx => {
  data.splice(idx, 1)
  nShown--
}
// DOM manipulation
let createItem = (item) => {
  let $el = document.createElement('li')
  $el.className = 'list-item'
  $el.innerHTML = `
    <span class="item-title">${item.title}</span>
    <span class="item-price">${item.price}</span>
    <button class="delete">Delete</button>
  `
  $el.querySelector('.delete').onclick = () => onDeleteItem($el)
  return $el
}
let removeListItem = $item => {
  $list.removeChild($item)
  $$listItems.splice($$listItems.indexOf($item), 1)
}
// Event handlers
let onDeleteItem = $item => {
  deleteItem($$listItems.indexOf($item))
  removeListItem($item)
}

Поскольку я всегда гарантирую, что пул узлов в массиве $$listItems синхронизирован с данными, я могу просмотреть индекс базовых данных, просмотрев индекс узла в пуле. Я также уменьшаю количество отображаемых элементов, так как один отображаемый элемент был удален. Я также мог бы показать один элемент вместо уменьшения количества элементов.

// State manipulation
let deleteItem = idx => data.splice(idx, 1)
// DOM manipulation
let removeListItem = $item => {
  $list.removeChild($item)
  $$listItems.splice($$listItems.indexOf($item), 1)
  let newItem = data[nShown - 1]
  let $newItem = createItem(newItem)
  $list.append($newItem)
  $$listItems.push($newItem)
}

При вставке или перемещении элементов мы снова будем выполнять те же операции как с данными, так и с пулом узлов. Вот пример вставки:

// State manipulation
let insertItem = (idx, title, price) => {
  data.splice(idx, 0, { title, price })
  nShown++
}
// DOM references
$titleField = document.getElementById('title-field')
$priceField = document.getElementById('price-field')
// DOM manipulation
let createItem = (item) => {
  let $el = document.createElement('li')
  $el.className = 'list-item'
  $el.innerHTML = `
    <button class="insert">Insert new item here</button>
    <span class="item-title">${item.title}</span>
    <span class="item-price">${item.price}</span>
    <button class="delete">Delete</button>
  `
  $el.querySelector('.delete').onclick = () => onDeleteItem($el)
  $el.querySelector('.insert').onclick = () => onInsertItem($el)
  return $el
}
let insertListItemBefore = $item => {
  let idx = $listItems.indexOf($item)
  let item = data[idx]
  let $newItem = createItem(item)
  $list.insertBefore($newItem, $item)
  $$listItems.splice(idx, 0, $newItem)
}
// Event handlers
let onInsertItem = $item => {
  insertItem(
    $$listItems.indexOf($item),
    $titleField.value
    $priceField.value
  )
  insertListItemBefore($item)
}

Использование пулов узлов для обмена списками

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

В следующем примере мы начинаем со статического списка на HTML-странице с достаточным количеством элементов, чтобы покрыть обычный случай. Эти элементы списка немедленно добавляются в пул. Затем пул расширяется по мере роста набора данных, но элементы никогда не удаляются, а вместо этого скрываются. Для простоты мы не будем делать часть «Загрузить еще».

let $list = document.getElementById('list')
let $$listItems = []
let createItem = item => {
  let $el = document.createElement('li')
  $el.className = 'list-item'
  $el.innerHTML = `
    <span class="item-title">${item.title}</span>
    <span class="item-price">${item.price}</span>
    <button class="delete">Delete</button>
  `
  $el.querySelector('.delete').onclick = () => onDeleteItem($el)
  return $el
}
let updateItem = ($item, item) => {
  $item.querySelector('.item-title').textContent = item.title
  $item.querySelector('.item-price').textContent = item.price
  $item.classList.remove('hidden')
}
let updateList = () => {
  let $$createdItems = []  
  data.forEach(function (item, idx) {
    let $item = $$listItems[idx]
    if ($item == null) {
      $item = createItem(item)
      $$listItems.push($item)
      $$createdItems.push($item)
    }
    else updateItem($item)
  })
  $list.append(...$createdItems)
  for (let i = data.length, l = $$listItems.length; i < l; i++) {
    $$listItems[i].classList.add('hidden')
  }
}
let deleteListItem = $item => {
  $item.classList.add('hidden')
  $$listItems.splice($$listItems.indexOf($item), 1)
  $$listItems.push($item)
}

Сортировка списков

При сортировке списков мы всегда начинаем с данных и синхронизируем отсортированные данные с DOM. Поскольку у нас есть несколько разных подходов к управлению списками, у нас также есть несколько разных подходов к тому, как мы переводим отсортированные данные в манипуляции с DOM.

Когда мы не показываем весь список в любой момент времени, мой обычный подход состоит в том, чтобы просто делать то же самое, что и при рендеринге элементов списка в целом: я сортирую данные, а затем обновляю все отображаемые элементы списка, чтобы они соответствовали данным.

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

// State
let data = []
// State manipulation
let sortByPrice = () => data.sort((a, b) => 
  a.price - b.price)
let sortByTitle = () => data.sort((a, b) => 
  a.title.localeCompare(b.title))
// DOM references
let $list = document.getElementById('list')
let $sortByTitle = document.getElementById('sort-title')
let $sortByPrice = document.getElementById('sort-price')
let listItemIndex = new Map()
// DOM manipulation
let createItem = item => {
  let $el = document.createElement('li')
  $el.className = 'list-item'
  $el.innerHTML = `
    <span class="item-title">${item.title}</span>
    <span class="item-price">${item.price.toFixed(2)}</span>
  `
  return $el
}
let appendItems = () => {
  let $$items = data.map(item => {
    let $item = createItem(item)
    listItemIndex.set(item, $item)
    return $item
  })
  $list.append(...$$items)
}
let sortListItems = () => {
  let $$sortedItems = data.map(item => listItemIndex.get(item))
  $list.append(...$$sortedItems)
}
// Event handlers
let onSortByTitle = () => {
  sortByTitle()
  sortListItems()
}
let onSortByPrice = () => {
  sortByPrice()
  sortListItems()
}
// Event bindings
$sortByTitle.onclick = onSortByTitle
$sortByPrice.onclick = onSortByPrice
// Initial view
appendItems()

Интересующая нас функция — sortListItems(), выделенная в приведенном выше фрагменте. Узлы автоматически удаляются из их предыдущего местоположения, когда они присоединяются к новому. Я пользуюсь этим и просто добавляю узлы к родительскому узлу в правильном (обновленном) порядке.

Улучшение производительности рендеринга и перекомпоновки

При работе со списками, если размер элементов списка остается постоянным независимо от содержимого, вы можете использовать для них следующее правило CSS для повышения производительности рендеринга:

.list-item {
  contain: content;
}

Вы можете прочитать больше о свойстве contain на MDN, но суть в том, что оно указывает браузеру, что элементы могут быть перерисованы независимо от остальной части страницы. Это ускоряет расчет макета.

Заключение

На этом мы завершаем серию из двух частей, посвященную манипуляциям с DOM в приложениях Vanilla JS, и мою серию статей о разработке на Vanilla JS в целом. Это не последняя статья, которую я напишу на эту тему. В будущих статьях я надеюсь поделиться более продвинутыми деталями, с которыми я столкнулся, продолжая свои поиски упрощения фронтенд-разработки (для меня).

Больше контента на plainenglish.io. Подпишитесь на нашу бесплатную еженедельную рассылку здесь.