Ошибки, XSS и 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, гидратация проходит.