textContent, innerText и innerHTML
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Три свойства делают на первый взгляд одно и то же — кладут текст внутрь элемента. На самом деле разница между ними принципиальная: textContent кладёт строку как строку, innerText показывает текст «как его видит человек», а innerHTML отдаёт вашу строку HTML-парсеру — и вот здесь ошибка стоит дороже всего.
Самый прямой способ узнать, что написано внутри элемента, — свойство textContent. Оно собирает текст всех потомков в одну строку. Присваивание работает в обратную сторону: строка становится единственным содержимым элемента.
Слово «единственным» тут ключевое. Запись в textContent сносит всех детей элемента и ставит на их место один текстовый узел. И самое важное: угловые скобки в строке остаются обычными символами — браузер не видит в них разметку.
textContent живёт в дереве документа и про экран ничего не знает. Он честно вернёт текст скрытого элемента, текст внутри <style> и все пробелы ровно так, как они записаны в файле. innerText — наоборот, отвечает на вопрос «что бы человек выделил мышкой и скопировал»: он спрашивает у браузера про раскладку и пропускает то, что не видно.
Вторая разница — пробелы. В HTML подряд идущие пробелы и переводы строк схлопываются при отрисовке, но в самом тексте они никуда не деваются. textContent отдаёт их как есть, innerText — уже схлопнутыми.
innerText — единственный из трёх, кто советуется с CSS. Ради ответа браузер пересчитывает раскладку, поэтому в цикле на тысячу элементов он заметно дороже textContent.
innerHTML работает не с текстом, а с разметкой. На чтении он сериализует содержимое элемента обратно в строку HTML, на записи — отдаёт вашу строку тому же парсеру, который разбирает страницу при загрузке. Из строки получаются настоящие узлы дерева.
Отсюда вытекает ловушка, на которую натыкаются все. Запись el.innerHTML += "..." выглядит как «дописать в конец», но на деле это чтение всего поддерева в строку, склейка и полный повторный разбор. Старые узлы выбрасываются, вместо них создаются новые — похожие, но другие. Всё, что было привязано к старым узлам (обработчики событий, состояние полей, ссылки в переменных), теряется.
Спецификация помечает скрипты, созданные разбором фрагмента, как «уже запущенные», поэтому вставленный так <script> в дерево попадает, а код в нём — нет.
Только защитой это считать нельзя: атрибуты-обработчики вроде onerror и onload прекрасно выполняются. Об этом — следующий раздел.
Пока в innerHTML уходит разметка, которую вы написали сами, всё в порядке. Проблема начинается там, где в строку подмешиваются данные, которые вы не контролируете: имя из формы, комментарий, ответ сервера, кусок адреса страницы. Для парсера нет разницы между «вашей» и «чужой» частью строки — он разбирает всё.
Это и есть XSS: злоумышленник передаёт вместо имени разметку, а браузер послушно превращает её в живой элемент с работающим обработчиком. Дальше код выполняется от лица вашей страницы — со всеми её куками, токенами и правами.
Лечится это не фильтрацией слова «script» и не вырезанием кавычек — обойти самодельные фильтры умеют лучше, чем их писать. Лечится тем, что чужие данные вообще не попадают в парсер: структуру строим узлами, текст кладём через textContent.
Правило умещается в одну фразу: текст — через 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 приходит имя, введённое пользователем — а значит, там может быть что угодно. Сейчас код собирает строку разметки, и браузер честно создаёт из имени картинку с обработчиком. Перепишите вывод так, чтобы на странице появился текст «Привет, ...!», а любая разметка внутри имени осталась просто буквами.
Строку с чужими данными нельзя отдавать парсеру. Достаточно поменять свойство: textContent создаст один текстовый узел, и угловые скобки останутся символами.
Экранировать кавычки или вырезать слово «script» тут не нужно и бесполезно: проблема не в конкретных символах, а в том, что строка вообще попадает в разбор разметки.
Список комментариев без сюрпризов
Нужно вывести три комментария как элементы <li>. Соблазн собрать строку через map и join и присвоить её в innerHTML — ровно та ошибка, из-за которой второй комментарий станет жирным, а мог бы стать и скриптом. Сделайте так, чтобы в списке было три пункта, а теги внутри строк остались текстом.
Структуру создаём узлами, текст кладём через textContent — тогда чужая строка физически не может стать разметкой.
Обратите внимание: append в цикле здесь дешевле, чем innerHTML +=, — каждый шаг добавляет один узел, а не перестраивает весь список заново.