textContent, innerText и innerHTML

изменение≈ 7 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Три свойства делают на первый взгляд одно и то же — кладут текст внутрь элемента. На самом деле разница между ними принципиальная: textContent кладёт строку как строку, innerText показывает текст «как его видит человек», а innerHTML отдаёт вашу строку HTML-парсеру — и вот здесь ошибка стоит дороже всего.

Разделы темы:·

Самый прямой способ узнать, что написано внутри элемента, — свойство textContent. Оно собирает текст всех потомков в одну строку. Присваивание работает в обратную сторону: строка становится единственным содержимым элемента.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Слово «единственным» тут ключевое. Запись в textContent сносит всех детей элемента и ставит на их место один текстовый узел. И самое важное: угловые скобки в строке остаются обычными символами — браузер не видит в них разметку.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

textContent живёт в дереве документа и про экран ничего не знает. Он честно вернёт текст скрытого элемента, текст внутри <style> и все пробелы ровно так, как они записаны в файле. innerText — наоборот, отвечает на вопрос «что бы человек выделил мышкой и скопировал»: он спрашивает у браузера про раскладку и пропускает то, что не видно.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Вторая разница — пробелы. В HTML подряд идущие пробелы и переводы строк схлопываются при отрисовке, но в самом тексте они никуда не деваются. textContent отдаёт их как есть, innerText — уже схлопнутыми.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

innerText — единственный из трёх, кто советуется с CSS. Ради ответа браузер пересчитывает раскладку, поэтому в цикле на тысячу элементов он заметно дороже textContent.

innerHTML работает не с текстом, а с разметкой. На чтении он сериализует содержимое элемента обратно в строку HTML, на записи — отдаёт вашу строку тому же парсеру, который разбирает страницу при загрузке. Из строки получаются настоящие узлы дерева.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Отсюда вытекает ловушка, на которую натыкаются все. Запись el.innerHTML += "..." выглядит как «дописать в конец», но на деле это чтение всего поддерева в строку, склейка и полный повторный разбор. Старые узлы выбрасываются, вместо них создаются новые — похожие, но другие. Всё, что было привязано к старым узлам (обработчики событий, состояние полей, ссылки в переменных), теряется.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Спецификация помечает скрипты, созданные разбором фрагмента, как «уже запущенные», поэтому вставленный так <script> в дерево попадает, а код в нём — нет.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Только защитой это считать нельзя: атрибуты-обработчики вроде onerror и onload прекрасно выполняются. Об этом — следующий раздел.

Пока в innerHTML уходит разметка, которую вы написали сами, всё в порядке. Проблема начинается там, где в строку подмешиваются данные, которые вы не контролируете: имя из формы, комментарий, ответ сервера, кусок адреса страницы. Для парсера нет разницы между «вашей» и «чужой» частью строки — он разбирает всё.

Это и есть XSS: злоумышленник передаёт вместо имени разметку, а браузер послушно превращает её в живой элемент с работающим обработчиком. Дальше код выполняется от лица вашей страницы — со всеми её куками, токенами и правами.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Лечится это не фильтрацией слова «script» и не вырезанием кавычек — обойти самодельные фильтры умеют лучше, чем их писать. Лечится тем, что чужие данные вообще не попадают в парсер: структуру строим узлами, текст кладём через textContent.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Правило умещается в одну фразу: текст — через textContent, разметку — только свою. Если в строке есть хоть один символ, пришедший снаружи, innerHTML отпадает. innerText берут тогда, когда действительно нужен текст «как на экране», — например, скопировать видимое содержимое таблицы.

Что нужноЧем делать
Положить в элемент строку — любую, хоть от пользователяtextContent
Прочитать текст ровно как в разметке, с пробелами и скрытымtextContent
Прочитать текст так, как его видит человек на экранеinnerText
Вставить разметку, целиком написанную вамиinnerHTML
Собрать разметку, в которой есть чужие данныеcreateElement + textContent
Часто менять текст в циклеtextContent

И маленькая оговорка про производительность: innerHTML на чтении сериализует всё поддерево, а innerText заставляет браузер посчитать раскладку. В горячем цикле оба заметны, textContent — нет.

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

insertAdjacentHTML(позиция, строка) добавляет разметку рядом с существующей, не перестраивая соседей, — честная замена для innerHTML +=. Но к чужим данным он так же беспощаден, поэтому правило из таблицы на него распространяется полностью.

Если вставить чужой HTML всё-таки нужно (редактор, письмо, статья из CMS), строку сначала прогоняют через санитайзер — библиотеку вроде DOMPurify или встроенный setHTML там, где он уже доступен. Свой собственный санитайзер писать не стоит. Почему самодельный фильтр пропускает onerror и что setHTML делает с картинками — на полке браузера.

  • textContent — текст и только текст: читает всё, включая скрытое и лишние пробелы, а при записи кладёт строку как строку и стирает прежних детей элемента.
  • innerText отвечает на вопрос «что видно на экране»: пропускает скрытое, схлопывает пробелы и ради этого заставляет браузер пересчитать раскладку.
  • innerHTML отдаёт строку HTML-парсеру и получает настоящие узлы; innerHTML += не дописывает, а перестраивает всё поддерево и теряет обработчики.
  • Чужая строка в innerHTML — это исполняемый код: тег script не запустится, но onerror и onload сработают, и это классический XSS.
  • Правило на каждый день: свою разметку можно через innerHTML, чужие данные — только textContent или createElement.

1/3Элемент содержит текст «a», затем дочерний span с атрибутом hidden и текстом «b», затем текст «c». Что вернёт innerText этого элемента?

2/3В el.textContent записали строку, внутри которой есть тег b. Что окажется на странице?

3/3К кнопке внутри panel добавили обработчик клика, а потом выполнили panel.innerHTML += "<span>хвост</span>". Что стало с обработчиком?

Приветствие, которое нельзя сломать

В name приходит имя, введённое пользователем — а значит, там может быть что угодно. Сейчас код собирает строку разметки, и браузер честно создаёт из имени картинку с обработчиком. Перепишите вывод так, чтобы на странице появился текст «Привет, ...!», а любая разметка внутри имени осталась просто буквами.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Строку с чужими данными нельзя отдавать парсеру. Достаточно поменять свойство: textContent создаст один текстовый узел, и угловые скобки останутся символами.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

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

Список комментариев без сюрпризов

Нужно вывести три комментария как элементы <li>. Соблазн собрать строку через map и join и присвоить её в innerHTML — ровно та ошибка, из-за которой второй комментарий станет жирным, а мог бы стать и скриптом. Сделайте так, чтобы в списке было три пункта, а теги внутри строк остались текстом.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Структуру создаём узлами, текст кладём через textContent — тогда чужая строка физически не может стать разметкой.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Обратите внимание: append в цикле здесь дешевле, чем innerHTML +=, — каждый шаг добавляет один узел, а не перестраивает весь список заново.