认识 Vue-SSR 激活失败
对于 SSR 服务端渲染这个概念稍有经验的开发应该都不陌生,官方文档 Vue SSR 指南 对于什么是服务端渲染、为什么使用服务端渲染以及什么时候使用服务端渲染已经说的很清楚了,结合一张经典的构建过程总结关于 SSR 的基本知识。
1. 基本常识
什么是服务端渲染?
- 客户端渲染是在浏览器中输出 Vue 组件,进行生成 DOM 和操作 DOM,并渲染为 HTML 页面展示;
- 服务端渲染是将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器展示;
- Vue SSR 则将这些服务端渲染的静态 HTML "激活"为客户端上完全可交互的 HTML 页面,服务端与客户端渲染的 HTML 混合,体现了 Vue SSR 的一大特性--同构。
为什么使用 SSR?
- 首屏渲染速度快;
- SEO,服务端渲染的页面内容可以被搜索引擎爬虫获取;
什么场景下使用 SSR?
SSR 服务端渲染的优势主要在于首屏渲染与 SEO,那为什么不直接全面推广使用呢?主要考虑到存在以下劣势:
- 代码复杂度增加 - 为了实现前面提到的同构,应用代码中需要兼容服务端和客户端两种运行情况,那么原先只支持浏览器环境运行的 API 方法必须增加特殊处理才能在服务端渲染程序中运行;
- 涉及构建设置和部署有更多要求 - 完全静态单页面应用程序 (SPA) 可以部署在任何静态文件服务器上,而服务器渲染应用程序,需要处于 Node.js server 运行环境;
- 更多的服务器负载 - 在 Node.js 中渲染完整的应用程序,
所以是否可以使用服务端渲染 SSR,需要开发者考虑投入产出比,如果应用系统的大多数页面都不需要 SEO,且首屏时间基本可以满足需求,使用 SSR 就没有必要了。结合上面的第 2 点, SSR 的使用场景:
- 对首屏渲染时间要求高,且尽可能只把首屏内容放到服务端渲染;
- 对 SEO 要求高的内容。
2. 主要痛点问题
服务端渲染有很多好处,特别是当像 Nuxt.js 或 GridSome 这样的网站,无论是使用动态 SSR 还是生成静态网站,开发 Vue-SSR 应用程序都是一件轻而易举的事。但从另一方面来讲,由于同构带来的代码复杂性与 node 端未知错误,降低了系统应用的稳定性与可靠性,并不推荐在非必要场景下使用 SSR 开发。尽管在官方指南的指导下,以及在前人踩坑后提供了变通的解决方案,多数错误都可以避开或者解决,但仍有未知错误导致的报错致使我们无从下手。
比如我曾在 SSR 服务端渲染中遇到了一个 Bug,排查了整整 2 个小时,定位为客户端渲染失败,但导致客户端渲染失败的原因又有很多。
Error: Error while mounting app: HierarchyRequestError: Failed to execute 'appendChild' on 'Node':
This node type does not support this method. at some-file.js:1
在 SSR 应用中不断踩坑地经历让我意识到:大多数时候遇到的棘手错误都是 Vue 客户端激活失败导致的。
3. 什么是 Vue 客户端激活失败(client-side hydration)客户端激活
官方指南对 Vue 客户端激活(client-side hydration)定义很清晰:
所谓客户端激活,指的是 Vue 在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。
这里 hydration,又可以翻译为“注入”,可以了理解为将在客户端生成的虚拟 DOM 结构注入到服务端渲染出的 HTML 中使这些静态的 HTML 变为动态的。从上面的构建过程图我们也可以看到,这里的激活发生在客户端构建完成的包到渲染为 HTML 的过程中。
客户端激活失败
服务端已经渲染好了 HTML,无需将其丢弃再重新创建所有的 DOM 元素,而是去激活这些渲染好的静态 HTML,使他们成为动态的以能够响应后续的数据变化。但如果 Vue 客户端生成的虚拟 DOM 树与服务端渲染的 DOM 结构无法匹配,就会发生客户端激活失败。
这里又分为开发模式和生产模式两种情况:
在开发模式下,如果无法匹配,它将退出混合模式,丢弃现有的 DOM 并从头开始渲染;
在生产模式下,此检测会被跳过,以避免性能损耗。
这也就能说明为什么有些错误仅在生产模式下才会出现。
在排查错误问题的过程中,读到(谷歌)了一篇关于 SSR 服务中客户端激活失败梳理较为细致的文章,列举出了大多数可能出现的原因以及解决方案。
原文地址 - What to do when Vue hydration fails
二、正文 - What to do when Vue hydration fails
1. 什么是 Vue 激活失败
第一次听到"hydration"这个词时,它对我来说非常抽象,我想不出它的意思。最终,我意识到它并不像一开始听起来那么复杂:
hydration 是 Vue 转换服务器端渲染的标记并使其具有动态反应性的过程,因此它可以反映来自 Vue 的动态更改。
如果 Vue 期望与服务端渲染的 HTML 不同的标记,则 hydration 将失败(也称为“Vue 将 hydration 丢弃”)。你可以在官方的 Vue SSR 文档中阅读更多相关内容。
2. 如何识别激活失败
我们现在已经意识到激活是什么和在什么时候会失败,但是我们作为开发者要如何发现激活没有如预期一般工作呢?
有两条错误消息肯定会指出激活失败但都有限制条件。
1)第一条是仅在开发环境中出现:Parent:
Parent: <div class="container"> client-hook-3.js:1:16358
Mismatching childNodes vs. VNodes: NodeList(3) [ p, p, p ] Array [ {…} ]
[Vue warn]: The client-side rendered virtual DOM tree is not matching server-rendered content.
This is likely caused by incorrect HTML markup, for example nesting block-level elements inside <p>, or missing <tbody>.
Bailing hydration and performing full client-side render.
2)第二条错误消息是仅在生产环境下使用静态生成站点时:
Error: Error while mounting app: HierarchyRequestError: Failed to execute 'appendChild' on 'Node':
This node type does not support this method. at some-file.js:1
众所周知,激活仅在页面首先由服务端渲染时发生,因此通常仅在你应用的初始化请求中。
当通过一个标签导航时激活失败是不可见的,仅能在硬重载时才能复现,这就使得在开发环境下发现激活失败问题变得更加困难。
因此激活错误有时仅在阶段系统或者更糟,仅在生产环境下被发现。在极少数情况下,甚至不会打印出错误而仅仅时某些组件停止工作。
3. 一般引发错误的原因
现在我们了解了如何发现激活错误,接下来将研究导致 Vue 激活错误的典型原因。当然这里不可能覆盖所有可能的原因,因为这些错误原因差别很大,而且主要取决于代码。
在以下章节中,每次提到服务端渲染,它就与两种情况都相关(动态 SSR 和静态站点生成),因为从技术上讲,两者都具有服务端渲染内容(除非另有说明)。
1)不合理的 HTML当激活失败发生时,首先要检查的应该是 HTML 是否合理,官方指南有这类踩坑提示,同时一般会有错误信息提示:
This is likely caused by incorrect HTML markup, for example nesting block-level elements inside <p>, or missing <tbody>
不幸的是,不合理的 HTML 通常不是激活失败的原因。不过,你还是应该仔细检查你的标记。
另外,你还要确保检查缩小设置,因为过度的 HTML 缩小可能会导致无效的 HTML。
如果你有用户生成的输出或来自 CMS 的内容,需要验证此内容也是有效的 HTML。
最后,第三方插件或服务也可能影响和操纵 HTML。后者的一个常见示例是 Cloudflare,当你启用了它们的服务 -- 如 HTML 缩小,Rocket loader 或其他更改页面内容的功能时。
这里有一个简单的示例 codeandbox,其中包含无效的 HTML 并触发了激活失败。
2)修改 HTML 的脚本关于脚本:如果向 Vue 应用中插入第三方 JS 文件,也可以在 Vue 接收并激活来自服务器的 HTML 之前更改 HTML。
3)服务器和客户端的状态不同服务器和客户端上状态不一致是发生激活失败最常见的原因。像往常一样,不一致的原因千差万别。
日期、时间戳和随机化
当网站包含日期或者时间戳时,应尽可能小心并使其尽可能静态,尤其在网站是静态生成的情况下。如果客户端评估像 new Date() 这样的表达式,则该表达式可能会与在服务器上开发阶段检索相同日期时生成的日期不同。这也让我对公司的“关于”页面感到困惑,在该页面上,我想根据当前分钟对显示的人员进行排序。
export const deterministicRotate = (arr) => {
if (arr.length <= 1) {
return arr
}
const rotations = (new Date()).getMinutes() % arr.length
return rotations ? arr : arr.reverse()
}
如果用户打开页面的时间很奇怪,则计划将阵列反转。当使用动态 SSR 时效果很好。但当切换到静态生成的 JAMstack 站点时,该功能就会成为一个 Bug。你可以在一分钟后点击链接刷新,会发现名字和人正确的交换了,但图片和原来一致。糟糕!这是由于服务器和客户端时间不匹配导致的。在移除不确定性代码后工作恢复正常。
- 授权
不一致的另一个常见原因是用户身份验证。这适用于动态 SSR 和静态站点生成。当仅在客户端(例如,在 localStorage 中)上存储身份验证状态时,服务器“不知道身份验证”。这将不可避免地导致激活问题,因为登录时服务器和客户端信息根本不同。因此,如果服务器不知道正在静态生成页面的身份验证状态**,**则不应在服务器端呈现任何与身份验证相关的组件。你可能想知道为什么它总是适用于静态网站:因为当生成网站时,它是 HTML,而序列化的代码是“无状态的”。在构建阶段,我们无法考虑“已登录的用户状态”。这意味着必须从服务器上的渲染中排除所有与身份验证相关的组件。
- 其他原因
除了这两种情况外,还有更多边缘情况可能会打击你并引起不一致。即使未在此处列出,我们也将解决激活错误!首先,我们应该将其范围缩小到导致问题的 DOM 元素。
4. 解决激活失败问题
1)发现导致激活失败的元素我们可以使用您最喜爱的浏览器上的 devTools 缩小问题到一个特定的组件或者 DOM 元素。
- 确保你在开发环境下
- 打开开发调试工具
- 触发激活警告(通常通过重载网页)
- 展开[Vue Warn] The client side ...错误消息查看追踪堆栈(取决于浏览器,也打开弹出的 VueJS 列表)
- 点击一个激活回调,将会打开 Vue 激活函数的源代码
- 现在,无论何时这个函数返回 false 都设置一个 debugger,在撰写文本时,这种情况发生了三遍:
if (process.env.NODE_ENV !== 'production') {
if (!assertNodeMatch(elm, vnode, inVPre)) {
return false //HERE
}
}
if (process.env.NODE_ENV !== 'production' &&
typeof console !== 'undefined' &&
!hydrationBailed
) {
hydrationBailed = true;
console.warn('Parent: ', elm);
console.warn('server innerHTML: ', i);
console.warn('client innerHTML: ', elm.innerHTML);
}
return false //HERE
}
if (process.env.NODE_ENV !== 'production' &&
typeof console !== 'undefined' &&
!hydrationBailed
) {
hydrationBailed = true;
console.warn('Parent: ', elm);
console.warn('Mismatching childNodes vs. VNodes: ', elm.childNodes, children);
}
return false //HERE
}
这同样允许在激活失败前检查激活函数的参数。
- 最后但同样重要的是,让激活错误复现,通常再次重载页面是有可能的但有时更困难
- 你现在看到触发了我们的一个断点,脚本停止执行了
- 现在打开调试工具的控制台,在激活失败的地方写一个元素去获取 DOM 元素。使用 DOM 元素,你将能够将激活错误追溯到你的 Vue 组件之一
- 继续执行下一步 PS:这是用户 budden73对此 StackOverflow 答案改编后的问题定位顺序。
2)确保你的 HTML 标记合理现在你发现了导致问题的代码,你首先要做的事确保你的标记(也许来自一个 API)是合理的。像这样的代码<p><p>Text</p></p> 无效,因为一个p元素不允许在其中包含其他块元素(如段落标签)。但是注意,标记不允许像
这样的标签作为子元素,这些标记是 Vue 过渡的默认标记。你可以通过<Transition tag="div">进行改变。因为一个p元素不允许在其中包含其他块元素(如段落标签)。但是注意,标记不允许像
3)解决服务器与客户端之间的不一致在 debugger 期间,你能够从服务器看到结果和重新绘制客户端侧。如果存在不同,你可以看一看,你是如何获取数据和你在服务端或客户端渲染了什么。一个常见的问题是静态网页的认证,因为 HTML 在构建时生成的是无状态的,因此不知道任何授权状态,你应用的所有和授权有关的部分都应该在客户端重新渲染。否则,在客户端有授权状态的用户,因为登录而期望从服务端获取不同的 HTML。然后只剩下一个方案...
4)最终避免措施:最后一个解决激活错误的选择是完全避免组件出现激活错误。这对于在静态生成的页面上与身份验证相关的组件来说是必须的,有时对于交付你不能更改但必须嵌入的内容的组件(例如,来自第三方应用程序)也是必需的。正如我们在一开始了解到的,激活仅发生在组件被同时渲染在服务端和客户端时。为了避免激活失败,可以通过标签避免重新渲染服务端组件。唯一的缺点:该组件不包含在服务器返回的 HTML 中,对 SEO 没有帮助。
5. 总结
这篇文章结束了!现在你了解了更多关于 vue 激活失败的知识:
- 什么是激活以及它做了什么?
- 激活怎么失败的以及如何发现激活失败?
- 激活失败的一般原因
- 如何调试激活失败及解决你的应用程序
我希望这篇文章很有见解,并且你学到了一两件事。你是否遇到了此处未描述的激活错误原因,或者我错过了一个常见原因?随时在 Twitter 上或通过邮件给我发消息。而且像往常一样 —— 如果您能宣传并与同事分享博客文章,我将很高兴。
三、踩坑小结
这篇博客讲到了大多数我在服务端渲染中遇到的客户端激活失败报错问题,甚至也包括了我没有遇到的问题。当然这篇博客也有不足,比如这是 2020 年的文章,其中一些问题也许有了新的进展,当然可能也有许多如我一般的新手还在踩坑。最后整理一下我在开发期间学习踩坑所得经验:
1. 同构
因为我们采用同构的目的是写一份尽量通用的代码, 让它运行在两端。所以我们需要熟悉不同端的运行环境, 至少要熟悉相关 API,Node.js 端是没有浏览器对象的, 所以 window, document, DOM 操作都没法执行。同理, 浏览器端是没有 process 对象的,他们各自的 API 实现也有差别, 这点需要特别留意。还有比较麻烦的就是第三方库的引入, 有时候你并不知道引入的库能不能完全运行在 Node 端/浏览器端,如果它只能运行在纯浏览器环境, 那可以在 created 阶段之后引入和执行来避开 Node.js 下执行。
2. 数据预取问题
问题:使用 v-html 注入动态获取的 HTML 内容的时候. 如果 HTML 内容有<script>
所包含的 JS 代码, 会发现 script 中的事件绑定失效。
分析:其实在这里页面被渲染了两次, 第一次是发生在 SSR 直接交给浏览器的时候: 此时<script>完整被渲染在浏览器里, 其内容正常执行, 事件绑定也正常的绑定在了当时的 DOM 元素上。而第二次渲染时, 走的是 CSR: 在这时由于是以 v-html 的方式来渲染替换 HTML, 但是 v-html 实质上是 innerHTML 操作, 这样<script>虽然会被替换上去, 但是其中的内容不会执行(innerHTML 为安全性考虑而设计)。所以经过这样两次渲染之后, 此时的 DOM 元素是第二次渲染时得到的, 而正常执行过的 JS 的事件绑定是绑在在第一次渲染出来的 DOM 元素上, 这样就出现了虽然 DOM 存在,但是无法触发该 DOM 上的事件的情况。
解决方法: 将获取到的 HTML 内容进行匹配, 剔出<script>内容, 无论第一次或第二次渲染, 只将内容交给 v-html 页面, 然后单独在生命周期 updated(页面已将 HTML 内容渲染完成)中将<script>创建出来, 添加到页面里自动执行, 实现绑定。并在下一次页面重渲染(可能是页面跳转来到)时判断<script>是否存在, 如果真则先删去再添加, 这样避免添加多余的<script>块。
** 那么页面为什么会渲染两次呢? **
这是由于 Vue SSR 在初始页面渲染完成后会有一次 hydration 过程, 在这个过程中会照常执行流程 mounted 等生命周期。此时会判定是否此时的组件渲染出的内容与 SSR 渲染得到的内容一致, 如果出现不一致就会单独执行一次额外的 CSR(客户端激活失败), 以达到页面被能正确地渲染。而因为我们使用了 v-html, 这个过程只有在 CSR 时才会被执行, 所以导致了两次渲染出来的内容不一致, 触发了 Vue SSR 的”补偿渲染机制”, 进而执行了第二次渲染。
参考文章
- [Vue SSR 指南] (https://ssr.vuejs.org/zh/guide/hydration.html)
- [What to do when Vue hydration fails] (https://blog.lichter.io/posts/vue-hydration-error/)
- [彻底理解服务端渲染 - SSR 原理 ] (https://github.com/yacan8/blog/issues/30)
- [Vue.js 服务端渲染(SSR)不完全指北] (https://lqs469.com/Vue.js%E6%9C%8D%E5%8A%A1%E7%AB%AF%E6%B8%B2%E6%9F%93(SSR)%E4%B8%8D%E5%AE%8C%E5%85%A8%E6%8C%87%E5%8C%97/)
- [从头开始,彻底理解服务端渲染原理(8 千字汇总长文)] (https://juejin.cn/post/6844903881390964744#heading-16)