28 мая 2022 г.

Изменение документа

Модификации DOM – это ключ к созданию «живых» страниц.

Здесь мы увидим, как создавать новые элементы «на лету» и изменять уже существующие.

Пример: показать сообщение

Рассмотрим методы на примере – а именно, добавим на страницу сообщение, которое будет выглядеть получше, чем alert.

Вот такое:

<style>
.alert {
  padding: 15px;
  border: 1px solid #d6e9c6;
  border-radius: 4px;
  color: #3c763d;
  background-color: #dff0d8;
}
</style>

<div class="alert">
  <strong>Всем привет!</strong> Вы прочитали важное сообщение.
</div>

Это был пример HTML. Теперь давайте создадим такой же div, используя JavaScript (предполагаем, что стили в HTML или во внешнем CSS-файле).

Создание элемента

DOM-узел можно создать двумя методами:

document.createElement(tag)

Создаёт новый элемент с заданным тегом:

let div = document.createElement('div');
document.createTextNode(text)

Создаёт новый текстовый узел с заданным текстом:

let textNode = document.createTextNode('А вот и я');

Большую часть времени нам нужно создавать узлы элементов, такие как div для сообщения.

Создание сообщения

В нашем случае сообщение – это div с классом alert и HTML в нём:

let div = document.createElement('div');
div.className = "alert";
div.innerHTML = "<strong>Всем привет!</strong> Вы прочитали важное сообщение.";

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

Методы вставки

Чтобы наш div появился, нам нужно вставить его где-нибудь в document. Например, в document.body.

Для этого есть метод append, в нашем случае: document.body.append(div).

Вот полный пример:

<style>
.alert {
  padding: 15px;
  border: 1px solid #d6e9c6;
  border-radius: 4px;
  color: #3c763d;
  background-color: #dff0d8;
}
</style>

<script>
  let div = document.createElement('div');
  div.className = "alert";
  div.innerHTML = "<strong>Всем привет!</strong> Вы прочитали важное сообщение.";

  document.body.append(div);
</script>

Вот методы для различных вариантов вставки:

  • node.append(...nodes or strings) – добавляет узлы или строки в конец node,
  • node.prepend(...nodes or strings) – вставляет узлы или строки в начало node,
  • node.before(...nodes or strings) –- вставляет узлы или строки до node,
  • node.after(...nodes or strings) –- вставляет узлы или строки после node,
  • node.replaceWith(...nodes or strings) –- заменяет node заданными узлами или строками.

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

<ol id="ol">
  <li>0</li>
  <li>1</li>
  <li>2</li>
</ol>

<script>
  ol.before('before'); // вставить строку "before" перед <ol>
  ol.after('after'); // вставить строку "after" после <ol>

  let liFirst = document.createElement('li');
  liFirst.innerHTML = 'prepend';
  ol.prepend(liFirst); // вставить liFirst в начало <ol>

  let liLast = document.createElement('li');
  liLast.innerHTML = 'append';
  ol.append(liLast); // вставить liLast в конец <ol>
</script>

Наглядная иллюстрация того, куда эти методы вставляют:

Итоговый список будет таким:

before
<ol id="ol">
  <li>prepend</li>
  <li>0</li>
  <li>1</li>
  <li>2</li>
  <li>append</li>
</ol>
after

Эти методы могут вставлять несколько узлов и текстовых фрагментов за один вызов.

Например, здесь вставляется строка и элемент:

<div id="div"></div>
<script>
  div.before('<p>Привет</p>', document.createElement('hr'));
</script>

Весь текст вставляется как текст.

Поэтому финальный HTML будет:

&lt;p&gt;Привет&lt;/p&gt;
<hr>
<div id="div"></div>

Другими словами, строки вставляются безопасным способом, как делает это elem.textContent.

Поэтому эти методы могут использоваться только для вставки DOM-узлов или текстовых фрагментов.

А что, если мы хотим вставить HTML именно «как html», со всеми тегами и прочим, как делает это elem.innerHTML?

insertAdjacentHTML/Text/Element

С этим может помочь другой, довольно универсальный метод: elem.insertAdjacentHTML(where, html).

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

  • "beforebegin" – вставить html непосредственно перед elem,
  • "afterbegin" – вставить html в начало elem,
  • "beforeend" – вставить html в конец elem,
  • "afterend" – вставить html непосредственно после elem.

Второй параметр – это HTML-строка, которая будет вставлена именно «как HTML».

Например:

<div id="div"></div>
<script>
  div.insertAdjacentHTML('beforebegin', '<p>Привет</p>');
  div.insertAdjacentHTML('afterend', '<p>Пока</p>');
</script>

…Приведёт к:

<p>Привет</p>
<div id="div"></div>
<p>Пока</p>

Так мы можем добавлять произвольный HTML на страницу.

Варианты вставки:

Мы можем легко заметить сходство между этой и предыдущей картинкой. Точки вставки фактически одинаковые, но этот метод вставляет HTML.

У метода есть два брата:

  • elem.insertAdjacentText(where, text) – такой же синтаксис, но строка text вставляется «как текст», вместо HTML,
  • elem.insertAdjacentElement(where, elem) – такой же синтаксис, но вставляет элемент elem.

Они существуют, в основном, чтобы унифицировать синтаксис. На практике часто используется только insertAdjacentHTML. Потому что для элементов и текста у нас есть методы append/prepend/before/after – их быстрее написать, и они могут вставлять как узлы, так и текст.

Так что, вот альтернативный вариант показа сообщения:

<style>
.alert {
  padding: 15px;
  border: 1px solid #d6e9c6;
  border-radius: 4px;
  color: #3c763d;
  background-color: #dff0d8;
}
</style>

<script>
  document.body.insertAdjacentHTML("afterbegin", `<div class="alert">
    <strong>Всем привет!</strong> Вы прочитали важное сообщение.
  </div>`);
</script>

Удаление узлов

Для удаления узла есть методы node.remove().

Например, сделаем так, чтобы наше сообщение удалялось через секунду:

<style>
.alert {
  padding: 15px;
  border: 1px solid #d6e9c6;
  border-radius: 4px;
  color: #3c763d;
  background-color: #dff0d8;
}
</style>

<script>
  let div = document.createElement('div');
  div.className = "alert";
  div.innerHTML = "<strong>Всем привет!</strong> Вы прочитали важное сообщение.";

  document.body.append(div);
  setTimeout(() => div.remove(), 1000);
</script>

Если нам нужно переместить элемент в другое место – нет необходимости удалять его со старого.

Все методы вставки автоматически удаляют узлы со старых мест.

Например, давайте поменяем местами элементы:

<div id="first">Первый</div>
<div id="second">Второй</div>
<script>
  // нет необходимости вызывать метод remove
  second.after(first); // берёт #second и после него вставляет #first
</script>

Клонирование узлов: cloneNode

Как вставить ещё одно подобное сообщение?

Мы могли бы создать функцию и поместить код туда. Альтернатива – клонировать существующий div и изменить текст внутри него (при необходимости).

Иногда, когда у нас есть большой элемент, это может быть быстрее и проще.

  • Вызов elem.cloneNode(true) создаёт «глубокий» клон элемента – со всеми атрибутами и дочерними элементами. Если мы вызовем elem.cloneNode(false), тогда клон будет без дочерних элементов.

Пример копирования сообщения:

<style>
.alert {
  padding: 15px;
  border: 1px solid #d6e9c6;
  border-radius: 4px;
  color: #3c763d;
  background-color: #dff0d8;
}
</style>

<div class="alert" id="div">
  <strong>Всем привет!</strong> Вы прочитали важное сообщение.
</div>

<script>
  let div2 = div.cloneNode(true); // клонировать сообщение
  div2.querySelector('strong').innerHTML = 'Всем пока!'; // изменить клонированный элемент

  div.after(div2); // показать клонированный элемент после существующего div
</script>

DocumentFragment

DocumentFragment является специальным DOM-узлом, который служит обёрткой для передачи списков узлов.

Мы можем добавить к нему другие узлы, но когда мы вставляем его куда-то, он «исчезает», вместо него вставляется его содержимое.

Например, getListContent ниже генерирует фрагмент с элементами <li>, которые позже вставляются в <ul>:

<ul id="ul"></ul>

<script>
function getListContent() {
  let fragment = new DocumentFragment();

  for(let i=1; i<=3; i++) {
    let li = document.createElement('li');
    li.append(i);
    fragment.append(li);
  }

  return fragment;
}

ul.append(getListContent()); // (*)
</script>

Обратите внимание, что на последней строке с (*) мы добавляем DocumentFragment, но он «исчезает», поэтому структура будет:

<ul>
  <li>1</li>
  <li>2</li>
  <li>3</li>
</ul>

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

<ul id="ul"></ul>

<script>
function getListContent() {
  let result = [];

  for(let i=1; i<=3; i++) {
    let li = document.createElement('li');
    li.append(i);
    result.push(li);
  }

  return result;
}

ul.append(...getListContent()); // append + оператор "..." = друзья!
</script>

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

Устаревшие методы вставки/удаления

Старая школа
Эта информация помогает понять старые скрипты, но не нужна для новой разработки.

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

Сейчас уже нет причин их использовать, так как современные методы append, prepend, before, after, remove, replaceWith более гибкие и удобные.

Мы упоминаем о них только потому, что их можно найти во многих старых скриптах:

parentElem.appendChild(node)

Добавляет node в конец дочерних элементов parentElem.

Следующий пример добавляет новый <li> в конец <ol>:

<ol id="list">
  <li>0</li>
  <li>1</li>
  <li>2</li>
</ol>

<script>
  let newLi = document.createElement('li');
  newLi.innerHTML = 'Привет, мир!';

  list.appendChild(newLi);
</script>
parentElem.insertBefore(node, nextSibling)

Вставляет node перед nextSibling в parentElem.

Следующий пример вставляет новый элемент перед вторым <li>:

<ol id="list">
  <li>0</li>
  <li>1</li>
  <li>2</li>
</ol>
<script>
  let newLi = document.createElement('li');
  newLi.innerHTML = 'Привет, мир!';

  list.insertBefore(newLi, list.children[1]);
</script>

Чтобы вставить newLi в начало, мы можем сделать вот так:

list.insertBefore(newLi, list.firstChild);
parentElem.replaceChild(node, oldChild)

Заменяет oldChild на node среди дочерних элементов parentElem.

parentElem.removeChild(node)

Удаляет node из parentElem (предполагается, что он родитель node).

Этот пример удалит первый <li> из <ol>:

<ol id="list">
  <li>0</li>
  <li>1</li>
  <li>2</li>
</ol>

<script>
  let li = list.firstElementChild;
  list.removeChild(li);
</script>

Все эти методы возвращают вставленный/удалённый узел. Другими словами, parentElem.appendChild(node) вернёт node. Но обычно возвращаемое значение не используют, просто вызывают метод.

Несколько слов о «document.write»

Есть ещё один, очень древний метод добавления содержимого на веб-страницу: document.write.

Синтаксис:

<p>Где-то на странице...</p>
<script>
  document.write('<b>Привет из JS</b>');
</script>
<p>Конец</p>

Вызов document.write(html) записывает html на страницу «прямо здесь и сейчас». Строка html может быть динамически сгенерирована, поэтому метод достаточно гибкий. Мы можем использовать JavaScript, чтобы создать полноценную веб-страницу и записать её в документ.

Этот метод пришёл к нам со времён, когда ещё не было ни DOM, ни стандартов… Действительно старые времена. Он всё ещё живёт, потому что есть скрипты, которые используют его.

В современных скриптах он редко встречается из-за следующего важного ограничения:

Вызов document.write работает только во время загрузки страницы.

Если вызвать его позже, то существующее содержимое документа затрётся.

Например:

<p>Через одну секунду содержимое этой страницы будет заменено...</p>
<script>
  // document.write через 1 секунду
  // вызов происходит после того, как страница загрузится, поэтому метод затирает содержимое
  setTimeout(() => document.write('<b>...Этим.</b>'), 1000);
</script>

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

Это его недостаток.

Есть и преимущество. Технически, когда document.write запускается во время чтения HTML браузером, и что-то пишет в документ, то браузер воспринимает это так, как будто это изначально было частью загруженного HTML-документа.

Поэтому он работает невероятно быстро, ведь при этом нет модификации DOM. Метод пишет прямо в текст страницы, пока DOM ещё в процессе создания.

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

Итого

  • Методы для создания узлов:

    • document.createElement(tag) – создаёт элемент с заданным тегом,
    • document.createTextNode(value) – создаёт текстовый узел (редко используется),
    • elem.cloneNode(deep) – клонирует элемент, если deep==true, то со всеми дочерними элементами.
  • Вставка и удаление:

    • node.append(...nodes or strings) – вставляет в node в конец,
    • node.prepend(...nodes or strings) – вставляет в node в начало,
    • node.before(...nodes or strings) – вставляет прямо перед node,
    • node.after(...nodes or strings) – вставляет сразу после node,
    • node.replaceWith(...nodes or strings) – заменяет node.
    • node.remove() – удаляет node.
  • Устаревшие методы:

    • parent.appendChild(node)
    • parent.insertBefore(node, nextSibling)
    • parent.removeChild(node)
    • parent.replaceChild(newElem, node)

    Все эти методы возвращают node.

  • Если нужно вставить фрагмент HTML, то elem.insertAdjacentHTML(where, html) вставляет в зависимости от where:

    • "beforebegin" – вставляет html прямо перед elem,
    • "afterbegin" – вставляет html в elem в начало,
    • "beforeend" – вставляет html в elem в конец,
    • "afterend" – вставляет html сразу после elem.

    Также существуют похожие методы elem.insertAdjacentText и elem.insertAdjacentElement, они вставляют текстовые строки и элементы, но они редко используются.

  • Чтобы добавить HTML на страницу до завершения её загрузки:

    • document.write(html)

    После загрузки страницы такой вызов затирает документ. В основном встречается в старых скриптах.

Задачи

важность: 5

У нас есть пустой DOM-элемент elem и строка text.

Какие из этих 3-х команд работают одинаково?

  1. elem.append(document.createTextNode(text))
  2. elem.innerHTML = text
  3. elem.textContent = text

Ответ: 1 и 3.

Результатом обеих команд будет добавление text «как текст» в elem.

Пример:

<div id="elem1"></div>
<div id="elem2"></div>
<div id="elem3"></div>
<script>
  let text = '<b>текст</b>';

  elem1.append(document.createTextNode(text));
  elem2.innerHTML = text;
  elem3.textContent = text;
</script>
важность: 5

Создайте функцию clear(elem), которая удаляет всё содержимое из elem.

<ol id="elem">
  <li>Привет</li>
  <li>Мир</li>
</ol>

<script>
  function clear(elem) { /* ваш код */ }

  clear(elem); // очищает список
</script>

Сначала давайте посмотрим, как не надо это делать:

function clear(elem) {
  for (let i=0; i < elem.childNodes.length; i++) {
      elem.childNodes[i].remove();
  }
}

Это не будет работать, потому что вызов remove() сдвигает коллекцию elem.childNodes, поэтому элементы начинаются каждый раз с индекса 0. А i увеличивается, и некоторые элементы будут пропущены.

Цикл for..of делает то же самое.

Правильным вариантом может быть:

function clear(elem) {
  while (elem.firstChild) {
    elem.firstChild.remove();
  }
}

А также есть более простой способ сделать то же самое:

function clear(elem) {
  elem.innerHTML = '';
}
важность: 1

В примере ниже вызов table.remove() удаляет таблицу из документа.

Но если вы запустите его, вы увидите, что текст "aaa" все еще виден.

Почему так происходит?

<table id="table">
  aaa
  <tr>
    <td>Тест</td>
  </tr>
</table>

<script>
  alert(table); // таблица, как и должно быть

  table.remove();
  // почему в документе остался текст "ааа"?
</script>

HTML в задаче некорректен. В этом всё дело.

Браузер исправил ошибку автоматически. Но внутри <table> не может быть текста: в соответствии со спецификацией допускаются только табличные теги. Поэтому браузер показывает "aaa" до <table>.

Теперь очевидно, что когда мы удаляем таблицу, текст остаётся.

На этот вопрос можно легко ответить, изучив DOM, используя инструменты браузера. Вы увидите "aaa" до элемента <table>.

Вообще, в стандарте HTML описано, как браузеру обрабатывать некорректный HTML, так что такое действие браузера является правильным.

важность: 4

Напишите интерфейс для создания списка.

Для каждого пункта:

  1. Запрашивайте содержимое пункта у пользователя с помощью prompt.
  2. Создавайте элемент <li> и добавляйте его к <ul>.
  3. Продолжайте до тех пор, пока пользователь не отменит ввод (нажатием клавиши Esc или введя пустую строку).

Все элементы должны создаваться динамически.

Если пользователь вводит HTML-теги, они должны обрабатываться как текст.

Демо в новом окне

Обратите внимание на использование textContent для добавления содержимого в <li>.

Открыть решение в песочнице.

важность: 5

Напишите функцию createTree, которая создаёт вложенный список ul/li из объекта.

Например:

let data = {
  "Рыбы": {
    "форель": {},
    "лосось": {}
  },

  "Деревья": {
    "Огромные": {
      "секвойя": {},
      "дуб": {}
    },
    "Цветковые": {
      "яблоня": {},
      "магнолия": {}
    }
  }
};

Синтаксис:

let container = document.getElementById('container');
createTree(container, data); // создаёт дерево в контейнере

Результат (дерево):

Выберите один из двух способов решения этой задачи:

  1. Создать строку, а затем присвоить через container.innerHTML.
  2. Создавать узлы через методы DOM.

Если получится – сделайте оба.

P.S. Желательно, чтобы в дереве не было лишних элементов, в частности -– пустых <ul></ul> на нижнем уровне.

Открыть песочницу для задачи.

Самый лёгкий способ – это использовать рекурсию.

  1. Решение с innerHTML.
  2. Решение через DOM.
важность: 5

Есть дерево, организованное в виде вложенных списков ul/li.

Напишите код, который добавит каждому элементу списка <li> количество вложенных в него элементов. Узлы нижнего уровня, без детей – пропускайте.

Результат:

Открыть песочницу для задачи.

Чтобы добавить текст к каждому <li>, мы можем изменить текстовый узел data.

Открыть решение в песочнице.

важность: 4

Напишите функцию createCalendar(elem, year, month).

Вызов функции должен создать календарь для заданного месяца month в году year и вставить его в elem.

Календарь должен быть таблицей, где неделя – это <tr>, а день – это <td>. У таблицы должен быть заголовок с названиями дней недели, каждый день – <th>, первым днём недели должен быть понедельник.

Например, createCalendar(cal, 2012, 9) сгенерирует в cal следующий календарь:

P.S. В этой задаче достаточно сгенерировать календарь, кликабельным его делать не нужно.

Открыть песочницу для задачи.

Для решения задачи сгенерируем таблицу в виде строки: "<table>...</table>", а затем присвоим в innerHTML.

Алгоритм:

  1. Создать заголовок таблицы с <th> и именами дней недели.
  2. Создать объект даты d = new Date(year, month-1). Это первый день месяца month (с учётом того, что месяцы в JS начинаются от 0, а не от 1).
  3. Ячейки первого ряда пустые от начала и до дня недели d.getDay(), с которого начинается месяц. Заполним <td></td>.
  4. Увеличить день в d: d.setDate(d.getDate()+1). Если d.getMonth() ещё не в следующем месяце, то добавим новую ячейку <td> в календарь. Если это воскресенье, то добавим новую строку «</tr><tr>».
  5. Если месяц закончился, но строка таблицы ещё не заполнена, добавим в неё пустые <td>, чтобы сделать в календаре красивые пустые квадратики.

Открыть решение в песочнице.

важность: 4

Создайте цветные часы как в примере ниже:

Для стилизации используйте HTML/CSS, JavaScript должен только обновлять время в элементах.

Открыть песочницу для задачи.

Для начала придумаем подходящую HTML/CSS-структуру.

Здесь каждый компонент времени удобно поместить в соответствующий <span>:

<div id="clock">
  <span class="hour">hh</span>:<span class="min">mm</span>:<span class="sec">ss</span>
</div>

Каждый span раскрашивается при помощи CSS.

Функция update будет обновлять часы, setInterval вызывает её каждую секунду:

function update() {
  let clock = document.getElementById('clock');
  let date = new Date(); // (*)
  let hours = date.getHours();
  if (hours < 10) hours = '0' + hours;
  clock.children[0].innerHTML = hours;

  let minutes = date.getMinutes();
  if (minutes < 10) minutes = '0' + minutes;
  clock.children[1].innerHTML = minutes;

  let seconds = date.getSeconds();
  if (seconds < 10) seconds = '0' + seconds;
  clock.children[2].innerHTML = seconds;
}

В строке (*) каждый раз мы получаем текущую дату. Вызовы setInterval не надёжны: они могут происходить с задержками.

Функция clockStart для запуска часов:

let timerId;

function clockStart() { // запустить часы
  timerId = setInterval(update, 1000);
  update(); // (*)
}

function clockStop() {
  clearInterval(timerId);
  timerId = null;
}

Обратите внимание, что вызов update() не только запланирован, но и тут же производится в строке (*). Иначе посетителю пришлось бы ждать до первого выполнения setInterval, то есть целую секунду.

Открыть решение в песочнице.

важность: 5

Напишите код для вставки <li>2</li><li>3</li> между этими двумя <li>:

<ul id="ul">
  <li id="one">1</li>
  <li id="two">4</li>
</ul>

Когда нам необходимо вставить фрагмент HTML-кода, можно использовать insertAdjacentHTML, он лучше всего подходит для таких задач.

Решение:

one.insertAdjacentHTML('afterend', '<li>2</li><li>3</li>');
важность: 5

Вот таблица:

<table>
<thead>
  <tr>
    <th>Name</th><th>Surname</th><th>Age</th>
  </tr>
</thead>
<tbody>
  <tr>
    <td>John</td><td>Smith</td><td>10</td>
  </tr>
  <tr>
    <td>Pete</td><td>Brown</td><td>15</td>
  </tr>
  <tr>
    <td>Ann</td><td>Lee</td><td>5</td>
  </tr>
  <tr>
    <td>...</td><td>...</td><td>...</td>
  </tr>
</tbody>
</table>

В ней может быть больше строк.

Напишите код для сортировки по столбцу "name".

Открыть песочницу для задачи.

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

let sortedRows = Array.from(table.rows)
  .slice(1)
  .sort((rowA, rowB) => rowA.cells[0].innerHTML > rowB.cells[0].innerHTML ? 1 : -1);

table.tBodies[0].append(...sortedRows);
  1. Получим все <tr>, как table.querySelectorAll('tr'), затем сделаем массив из них, потому что нам понадобятся методы массива.

  2. Первый TR (table.rows[0]) – это заголовок таблицы, поэтому мы берём .slice(1).

  3. Затем отсортируем их по содержимому в первом <td> (по имени).

  4. Теперь вставим узлы в правильном порядке .append(...sortedRows).

    Таблицы всегда имеют неявный элемент <tbody>, поэтому нам нужно получить его и вставить в него: простой table.append(...) потерпит неудачу.

    Обратите внимание: нам не нужно их удалять, просто «вставляем их заново», они автоматически покинут старое место.

Открыть решение в песочнице.

Карта учебника

Комментарии

перед тем как писать…
  • Если вам кажется, что в статье что-то не так - вместо комментария напишите на GitHub.
  • Для одной строки кода используйте тег <code>, для нескольких строк кода — тег <pre>, если больше 10 строк — ссылку на песочницу (plnkr, JSBin, codepen…)
  • Если что-то непонятно в статье — пишите, что именно и с какого места.