Skip to Content
VueОшибки, XSS и SSR

Ошибки, XSS и SSR

Уровень: middle. Hydration mismatch чаще спрашивают, если в стеке есть Nuxt или свой SSR.

Вопрос

Где ловить ошибку рендера, почему v-html опасен и что такое несовпадение гидратации?

Короткий ответ

Ошибки рендера, хуков и обработчиков событий ловит app.config.errorHandler. Туда ставят отправку в Sentry: аргументы — ошибка, экземпляр и строка контекста вроде 'render hook'. onErrorCaptured в компоненте ловит ошибки потомков и может нарисовать запасной блок секции. Возврат false останавливает всплытие к глобальному обработчику. Ошибки голого Promise и setTimeout этот механизм не всегда видит, их страхуют window unhandledrejection.

Интерполяция {{ text }} экранирует HTML. v-html вставляет разметку как есть. Чужой комментарий без санитизации (DOMPurify) — XSS через тег или onerror у картинки. Ссылка с :href из данных пользователя опасна схемой javascript:.

При SSR сервер отдаёт HTML, браузер показывает его сразу, затем Vue «оживляет» разметку: вешает события и связывает виртуальный DOM с реальным. Это гидратация. Hydration mismatch — серверный HTML и первый клиентский рендер не совпали, Vue не может честно навесить состояние на чужое дерево.

Разбор

Частые причины mismatch:

  • в шаблоне данные только браузера: localStorage, window.innerWidth, Math.random(), new Date() без фиксации;
  • невалидная вёрстка, которую браузер чинит сам: <p> внутри <p>, <tr> прямо в <table> без <tbody>.

Такой код прячут в <ClientOnly> в Nuxt или читают браузерное API в onMounted: хук на сервере не выполняется, первый HTML с ним совпадает.

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

На что смотрят на senior

Санитизация на клиенте — последняя линия. HTML от пользователей лучше не принимать вовсе или чистить на сервере и ещё раз перед v-html. DOMPurify не делает безопасным произвольный URL в href: схему проверяют отдельно, оставляют http, https, mailto.

errorHandler не должен бросать новую ошибку. Иначе мониторинг зацикливается. Для асинхронного кода в composable ошибку ловят у await и кладут в состояние экрана. Глобальный хук — для того, что всё равно вырвалось.

Гидратация не чинится подавлением предупреждения. Расхождение значит, что пользователь уже видел одну разметку, а Vue её пересобрал. Ищут первое динамическое выражение, которое на сервере и в браузере даёт разный текст.

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

  • Считать {{ userHtml }} защитой и параллельно скормить ту же строку в v-html.
  • Ловить ошибку запроса только в errorHandler и оставлять экран в вечном loading.
  • Рендерить «сегодняшнюю дату» и ширину окна прямо в шаблоне SSR-страницы.
  • Возвращать из onErrorCaptured ничего и удивляться, что ошибка ушла и в секцию, и в глобальный лог. Если нужно только одно место, возвращают false.

Задача

SSR-страница в шаблоне пишет new Date().toLocaleTimeString() и читает localStorage.theme. В консоли hydration mismatch. Почему HTML сервера и клиента разошлись?

Показать ответ

Сервер и браузер посчитали разное время, а localStorage на сервере нет. Первый клиентский рендер не совпал с уже отданной разметкой.

Время и тему читают в onMounted или прячут блок в ClientOnly. Пока первый рендер на клиенте повторяет серверный HTML, гидратация проходит.

Last updated on