← JavaScript/Изменение документа#122 из 383← ПредыдущийСледующий →+25 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: JS базаПрактика: async и сетьТермин: Closure

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

Пользователь вводит задачу и нажимает Enter — новый <li> должен появиться в списке мгновенно, без перезагрузки страницы. По клику на крестик — элемент должен исчезнуть. Это стандартная задача динамического обновления DOM: создание элементов, вставка и удаление. Именно здесь кроется классическая XSS-уязвимость при неаккуратном использовании innerHTML.

На основе предыдущих уроков

  • «DOM» — дерево элементов, узлы
  • «querySelector» — поиск родительских элементов для вставки
  • «События» — addEventListener('click', ...) на динамически созданных элементах
  • createElement — создание элемента

    const btn = document.createElement('button')
    btn.textContent = 'Добавить в корзину'
    btn.className = 'btn btn-primary'
    btn.dataset.productId = '42'

    Вставка элементов

    appendChild — добавляет в конец:

    const list = document.querySelector('.todo-list')
    const item = document.createElement('li')
    item.textContent = 'Новая задача'
    list.appendChild(item)    // вставляет в конец ul

    prepend / append — современный способ (принимают строки и узлы):

    list.prepend(item)         // в начало
    list.append(item)          // в конец
    list.append('текст', item) // несколько аргументов

    before / after — вставка рядом с элементом (не внутрь):

    const divider = document.createElement('hr')
    item.before(divider)  // перед item
    item.after(divider)   // после item

    insertAdjacentHTML — 4 позиции

    Метод парсит HTML-строку и вставляет без перезаписи существующего содержимого:

    element.insertAdjacentHTML('beforebegin', '<hr>')  // перед element
    element.insertAdjacentHTML('afterbegin',  '<b>Начало</b>')  // первый ребёнок
    element.insertAdjacentHTML('beforeend',   '<b>Конец</b>')   // последний ребёнок
    element.insertAdjacentHTML('afterend',    '<hr>')   // после element

    Удаление и замена

    item.remove()                   // удалить элемент из DOM
    item.replaceWith(newItem)       // заменить на другой элемент или строку

    innerHTML vs textContent — важно для безопасности!

    // innerHTML — парсит HTML, ОПАСНО с пользовательскими данными!
    div.innerHTML = '<b>Жирный текст</b>'        // отобразит жирный текст
    div.innerHTML = userInput                    // XSS-уязвимость!
    
    // textContent — только текст, безопасно
    div.textContent = '<b>Экранировано</b>'     // покажет как есть, без разметки
    div.textContent = userInput                 // всегда безопасно

    Никогда не вставляйте пользовательский ввод через innerHTML — это открывает путь для XSS-атак. Используйте textContent или экранирование.

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

    const card = document.querySelector('.product-card')
    
    const shallowCopy = card.cloneNode(false)  // только сам элемент, без детей
    const deepCopy    = card.cloneNode(true)   // со всем содержимым
    
    document.querySelector('.grid').appendChild(deepCopy)

    Реальный пример: динамический список задач

    function addTodo(text) {
      const li = document.createElement('li')
      li.className = 'todo-item'
    
      const span = document.createElement('span')
      span.textContent = text          // textContent — безопасно
    
      const btn = document.createElement('button')
      btn.textContent = '✕'
      btn.addEventListener('click', () => li.remove())
    
      li.append(span, btn)
      document.querySelector('.todo-list').appendChild(li)
    }

    Типичные ошибки

    1. innerHTML с пользовательским вводом — XSS-уязвимость:

    // Плохо: пользователь может ввести <script>alert('xss')</script>
    const userComment = '<img src=x onerror="stealCookies()">'
    commentDiv.innerHTML = userComment  // ВЫПОЛНЯЕТ произвольный JS!
    
    // Хорошо: textContent экранирует всё
    commentDiv.textContent = userComment  // показывает как текст, безопасно

    2. Вставка элемента в несколько мест — он перемещается, не копируется:

    const item = document.createElement('li')
    item.textContent = 'Задача'
    
    list1.appendChild(item)  // вставили в первый список
    list2.appendChild(item)  // элемент ПЕРЕМЕСТИЛСЯ из list1 в list2!
    
    // Правильно: клонировать для вставки в несколько мест
    list1.appendChild(item)
    list2.appendChild(item.cloneNode(true))

    3. Частые вставки в цикле — лучше использовать DocumentFragment:

    // Плохо: каждая вставка вызывает reflow
    items.forEach(text => {
      const li = document.createElement('li')
      li.textContent = text
      ul.appendChild(li)  // reflow на каждой итерации!
    })
    
    // Хорошо: собери в fragment, вставь один раз
    const fragment = document.createDocumentFragment()
    items.forEach(text => {
      const li = document.createElement('li')
      li.textContent = text
      fragment.appendChild(li)
    })
    ul.appendChild(fragment)  // один reflow

    В реальных проектах

  • To-do списки и чаты — добавление новых элементов без перезагрузки
  • Бесконечная прокрутка — добавление карточек при достижении конца страницы
  • Модальные окна — создание и удаление overlay-элементов
  • Шаблонизация — <template> элемент + cloneNode(true) для повторяющихся блоков
  • Виртуальный DOM — React/Vue строят абстракцию над этими же API
  • Примечание о sandbox

    Поскольку в sandbox нет настоящего DOM, примеры ниже реализуют мини-виртуальный DOM на JavaScript-объектах с методом render(), возвращающим HTML-строку.

    Примеры

    Виртуальный DOM-строитель: createEl, append, render в строку

    // Мини-виртуальный DOM
    function createEl(tag, attrs = {}, ...children) {
      return { tag, attrs, children: children.flat() }
    }
    
    function append(parent, ...nodes) {
      parent.children.push(...nodes)
      return parent
    }
    
    function prepend(parent, ...nodes) {
      parent.children.unshift(...nodes)
      return parent
    }
    
    function remove(parent, node) {
      parent.children = parent.children.filter(c => c !== node)
    }
    
    function cloneNode(node) {
      return JSON.parse(JSON.stringify(node))
    }
    
    function render(node) {
      if (typeof node === 'string') return node
      const attrStr = Object.entries(node.attrs)
        .map(([k, v]) => `${k}="${v}"`)
        .join(' ')
      const opening = attrStr ? `<${node.tag} ${attrStr}>` : `<${node.tag}>`
      const inner = node.children.map(render).join('')
      return `${opening}${inner}</${node.tag}>`
    }
    
    // Динамически строим список задач
    const ul = createEl('ul', { class: 'todo-list' })
    
    const tasks = ['Проверить почту', 'Написать тест', 'Сделать code review']
    tasks.forEach((text, i) => {
      const li = createEl('li', { class: 'todo-item', 'data-index': String(i) },
        createEl('span', {}, text),
        createEl('button', { class: 'remove-btn' }, '✕'),
      )
      append(ul, li)
    })
    
    console.log('До удаления:')
    console.log(render(ul))
    
    // Удаляем второй элемент (индекс 1)
    remove(ul, ul.children[1])
    
    console.log('\nПосле удаления второго элемента:')
    console.log(render(ul))
    
    // Клонируем первый и добавляем в конец
    const cloned = cloneNode(ul.children[0])
    cloned.attrs['data-index'] = '99'
    cloned.children[0].children[0] = 'Клонированная задача'
    append(ul, cloned)
    
    console.log('\nПосле клонирования:')
    console.log(render(ul))

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

    Пользователь вводит задачу и нажимает Enter — новый <li> должен появиться в списке мгновенно, без перезагрузки страницы. По клику на крестик — элемент должен исчезнуть. Это стандартная задача динамического обновления DOM: создание элементов, вставка и удаление. Именно здесь кроется классическая XSS-уязвимость при неаккуратном использовании innerHTML.

    На основе предыдущих уроков

  • «DOM» — дерево элементов, узлы
  • «querySelector» — поиск родительских элементов для вставки
  • «События» — addEventListener('click', ...) на динамически созданных элементах
  • createElement — создание элемента

    const btn = document.createElement('button')
    btn.textContent = 'Добавить в корзину'
    btn.className = 'btn btn-primary'
    btn.dataset.productId = '42'

    Вставка элементов

    appendChild — добавляет в конец:

    const list = document.querySelector('.todo-list')
    const item = document.createElement('li')
    item.textContent = 'Новая задача'
    list.appendChild(item)    // вставляет в конец ul

    prepend / append — современный способ (принимают строки и узлы):

    list.prepend(item)         // в начало
    list.append(item)          // в конец
    list.append('текст', item) // несколько аргументов

    before / after — вставка рядом с элементом (не внутрь):

    const divider = document.createElement('hr')
    item.before(divider)  // перед item
    item.after(divider)   // после item

    insertAdjacentHTML — 4 позиции

    Метод парсит HTML-строку и вставляет без перезаписи существующего содержимого:

    element.insertAdjacentHTML('beforebegin', '<hr>')  // перед element
    element.insertAdjacentHTML('afterbegin',  '<b>Начало</b>')  // первый ребёнок
    element.insertAdjacentHTML('beforeend',   '<b>Конец</b>')   // последний ребёнок
    element.insertAdjacentHTML('afterend',    '<hr>')   // после element

    Удаление и замена

    item.remove()                   // удалить элемент из DOM
    item.replaceWith(newItem)       // заменить на другой элемент или строку

    innerHTML vs textContent — важно для безопасности!

    // innerHTML — парсит HTML, ОПАСНО с пользовательскими данными!
    div.innerHTML = '<b>Жирный текст</b>'        // отобразит жирный текст
    div.innerHTML = userInput                    // XSS-уязвимость!
    
    // textContent — только текст, безопасно
    div.textContent = '<b>Экранировано</b>'     // покажет как есть, без разметки
    div.textContent = userInput                 // всегда безопасно

    Никогда не вставляйте пользовательский ввод через innerHTML — это открывает путь для XSS-атак. Используйте textContent или экранирование.

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

    const card = document.querySelector('.product-card')
    
    const shallowCopy = card.cloneNode(false)  // только сам элемент, без детей
    const deepCopy    = card.cloneNode(true)   // со всем содержимым
    
    document.querySelector('.grid').appendChild(deepCopy)

    Реальный пример: динамический список задач

    function addTodo(text) {
      const li = document.createElement('li')
      li.className = 'todo-item'
    
      const span = document.createElement('span')
      span.textContent = text          // textContent — безопасно
    
      const btn = document.createElement('button')
      btn.textContent = '✕'
      btn.addEventListener('click', () => li.remove())
    
      li.append(span, btn)
      document.querySelector('.todo-list').appendChild(li)
    }

    Типичные ошибки

    1. innerHTML с пользовательским вводом — XSS-уязвимость:

    // Плохо: пользователь может ввести <script>alert('xss')</script>
    const userComment = '<img src=x onerror="stealCookies()">'
    commentDiv.innerHTML = userComment  // ВЫПОЛНЯЕТ произвольный JS!
    
    // Хорошо: textContent экранирует всё
    commentDiv.textContent = userComment  // показывает как текст, безопасно

    2. Вставка элемента в несколько мест — он перемещается, не копируется:

    const item = document.createElement('li')
    item.textContent = 'Задача'
    
    list1.appendChild(item)  // вставили в первый список
    list2.appendChild(item)  // элемент ПЕРЕМЕСТИЛСЯ из list1 в list2!
    
    // Правильно: клонировать для вставки в несколько мест
    list1.appendChild(item)
    list2.appendChild(item.cloneNode(true))

    3. Частые вставки в цикле — лучше использовать DocumentFragment:

    // Плохо: каждая вставка вызывает reflow
    items.forEach(text => {
      const li = document.createElement('li')
      li.textContent = text
      ul.appendChild(li)  // reflow на каждой итерации!
    })
    
    // Хорошо: собери в fragment, вставь один раз
    const fragment = document.createDocumentFragment()
    items.forEach(text => {
      const li = document.createElement('li')
      li.textContent = text
      fragment.appendChild(li)
    })
    ul.appendChild(fragment)  // один reflow

    В реальных проектах

  • To-do списки и чаты — добавление новых элементов без перезагрузки
  • Бесконечная прокрутка — добавление карточек при достижении конца страницы
  • Модальные окна — создание и удаление overlay-элементов
  • Шаблонизация — <template> элемент + cloneNode(true) для повторяющихся блоков
  • Виртуальный DOM — React/Vue строят абстракцию над этими же API
  • Примечание о sandbox

    Поскольку в sandbox нет настоящего DOM, примеры ниже реализуют мини-виртуальный DOM на JavaScript-объектах с методом render(), возвращающим HTML-строку.

    Примеры

    Виртуальный DOM-строитель: createEl, append, render в строку

    // Мини-виртуальный DOM
    function createEl(tag, attrs = {}, ...children) {
      return { tag, attrs, children: children.flat() }
    }
    
    function append(parent, ...nodes) {
      parent.children.push(...nodes)
      return parent
    }
    
    function prepend(parent, ...nodes) {
      parent.children.unshift(...nodes)
      return parent
    }
    
    function remove(parent, node) {
      parent.children = parent.children.filter(c => c !== node)
    }
    
    function cloneNode(node) {
      return JSON.parse(JSON.stringify(node))
    }
    
    function render(node) {
      if (typeof node === 'string') return node
      const attrStr = Object.entries(node.attrs)
        .map(([k, v]) => `${k}="${v}"`)
        .join(' ')
      const opening = attrStr ? `<${node.tag} ${attrStr}>` : `<${node.tag}>`
      const inner = node.children.map(render).join('')
      return `${opening}${inner}</${node.tag}>`
    }
    
    // Динамически строим список задач
    const ul = createEl('ul', { class: 'todo-list' })
    
    const tasks = ['Проверить почту', 'Написать тест', 'Сделать code review']
    tasks.forEach((text, i) => {
      const li = createEl('li', { class: 'todo-item', 'data-index': String(i) },
        createEl('span', {}, text),
        createEl('button', { class: 'remove-btn' }, '✕'),
      )
      append(ul, li)
    })
    
    console.log('До удаления:')
    console.log(render(ul))
    
    // Удаляем второй элемент (индекс 1)
    remove(ul, ul.children[1])
    
    console.log('\nПосле удаления второго элемента:')
    console.log(render(ul))
    
    // Клонируем первый и добавляем в конец
    const cloned = cloneNode(ul.children[0])
    cloned.attrs['data-index'] = '99'
    cloned.children[0].children[0] = 'Клонированная задача'
    append(ul, cloned)
    
    console.log('\nПосле клонирования:')
    console.log(render(ul))

    Задание

    Напиши функцию buildTodoList(items), которая принимает массив строк и возвращает HTML-строку, представляющую список <ul class="todo-list"> с элементами <li class="todo-item"> для каждого элемента массива. Каждый <li> должен содержать <span> с текстом задачи и <button class="delete-btn"> с текстом "✕".

    Подсказка

    items.map(item => `<li class="todo-item"><span>${item}</span><button class="delete-btn">✕</button></li>`).join("") — оберни результат в <ul class="todo-list">...</ul>.

    Загружаем среду выполнения...
    Загружаем AI-помощника...