Remix部署Vercel报Hydration failed 服务端客户端UI不匹配
项目本地运行时仅出现已知警告、CSS渲染正常,但部署到Vercel后抛出如下错误:
水合失败,因为初始UI与服务端渲染的内容不匹配。
此时业务逻辑可正常运行,但CSS完全失效。
2022年6月26日15:50更新
从零新建项目逐个添加依赖,每添加一个就部署到Vercel验证,全程无报错,styled-components渲染正常,因此排除依赖版本问题。
随后通过loader逐段从数据库拉取数据,在styled-components中逐段渲染测试,发现唯一稳定复现CSS失效和报错的场景是:渲染前将datetime对象转为字符串,示例代码如下:
const DateTimeSpan = styled.span` font-size: 1rem; `; const hr = now.getHours(); const min = now.getMinutes(); <DateTimeSpan> {`${hr}:${min}`} </DateTimeSpan>
仅格式化渲染时分时间时会触发问题,格式化渲染年月日日期时完全正常,示例代码如下:
const yr = now.getFullYear(); const mth = now.getMonth(); const dd = now.getDate(); <DateTimeSpan> {`${yr}-${mth}-${dd}`} </DateTimeSpan>
该现象暂时无法解释。
2022年7月2日21:55更新
和友人基于上述最简测试项目验证发现,尝试渲染**小时(hours)**字段时就会触发styled-components的CSS失效问题,最小复现代码如下:
const hr = now.getHours(); <DateTimeSpan> {hr} </DateTimeSpan>
推测问题原因是:服务端按UTC时间渲染小时值,客户端按本地时间渲染小时值,两端渲染内容不一致导致报错。
目前无法确认这是框架bug还是需要开发者自行处理该场景,也不确定该问题应提交到Remix还是styled-components的代码仓库,已先在Remix仓库提交了相关issue。
初始发帖内容
无法确定问题根源,推测可能和以下已知issue相关:
- Remix 2570
- Remix 2947
- React 24523
查阅上述issue及其他相关资料后,尝试更新相关依赖,可能相关的依赖版本如下:
{ "react": "^18.2.0", "styled-components": "^5.3.5", "@remix-run/node": "^1.6.1", "@remix-run/react": "^1.6.1", "@remix-run/vercel": "^1.6.1", "@vercel/node": "^2.2.0" }
最初怀疑问题和styled-components相关,因为之前使用Next.js时遇到过类似问题。但项目中的app/root.tsx和app/entry.server.tsx几乎完全参照官方styled-components示例编写:
// app/root.tsx export default function App() { const data = useLoaderData(); return ( <Html lang="en"> <head> ... {typeof document === "undefined" ? "__STYLES__" : null} </head> <Body> ... </Body> </Html> ); }
// app/entry.server.tsx export default function handleRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, remixContext: EntryContext ) { const sheet = new ServerStyleSheet(); let markup = renderToString( sheet.collectStyles( <RemixServer context={remixContext} url={request.url} /> ) ); const styles = sheet.getStyleTags(); markup = markup.replace("__STYLES__", styles); responseHeaders.set("Content-Type", "text/html"); return new Response("<!DOCTYPE html>" + markup, { status: responseStatusCode, headers: responseHeaders, }); }
和官方示例最大的区别是,按照React 18的要求使用hydrateRoot做客户端水合,而非示例中使用的hydrate,不确定这是否会导致问题,客户端入口代码如下:
// app/entry.client.tsx import { RemixBrowser } from "@remix-run/react"; import { hydrateRoot } from "react-dom/client"; hydrateRoot(document, <RemixBrowser />);
Remix关于CSS-in-JS库的官方文档提到:“使用Styled Components时可能遇到水合警告,希望相关issue能尽快修复”。目前该issue尚未解决,不确定是不是这个问题暂时没有解决方案,但既然官方示例可以正常运行,也许是遗漏了某些配置?
问题原因与解决方案
根因
这个问题不是框架bug,属于SSR场景的通用问题:
服务端运行在Vercel的服务器上,默认使用UTC时区计算时间,而客户端使用用户本地时区计算时间,直接渲染getHours()返回值时,两端输出的内容不一致,直接触发React水合校验失败。styled-components的样式注入逻辑依赖水合阶段的DOM结构匹配,水合失败时会出现样式收集错位,最终导致全局CSS失效。
渲染年月日不出错只是巧合:只有当用户本地时区和UTC时差超过12小时,getFullYear()/getMonth()/getDate()的返回值才会和UTC时间计算结果不一致,大部分时区下日期值是相同的,所以没有触发问题。
另外使用React 18的hydrateRoot是正确写法,不是问题诱因,官方示例未更新属于文档滞后。
修复方案
延迟渲染本地相关动态内容(推荐)
所有依赖本地设备状态、服务端无法精准获取的值(本地时间、主题偏好、本地存储数据等),都在客户端挂载完成后再渲染,保证首次水合时两端输出完全一致:import { useState, useEffect } from 'react' function TimeDisplay() { const [mounted, setMounted] = useState(false) useEffect(() => { setMounted(true) }, []) const now = new Date() const hr = now.getHours() const min = now.getMinutes() return <DateTimeSpan> {mounted ? `${hr}:${min}` : '--:--'} </DateTimeSpan> }服务端渲染和客户端首次水合时都会输出占位符
--:--,水合完成后再替换为真实本地时间,从根源避免水合不匹配。统一两端时间计算标准
如果不想用占位符,可以在服务端从请求头中获取用户时区,服务端和客户端统一使用相同时区计算时间,保证两端输出值一致。这种方案需要处理时区兼容、边缘场景,实现成本更高,一般不推荐。补充styled-components水合兜底
在客户端入口包裹StyleSheetManager,开启客户端样式兜底注入,降低水合异常时样式完全丢失的概率:// app/entry.client.tsx import { RemixBrowser } from "@remix-run/react"; import { hydrateRoot } from "react-dom/client"; import { StyleSheetManager } from "styled-components"; hydrateRoot( document, <StyleSheetManager enableVendorPrefixes> <RemixBrowser /> </StyleSheetManager> );
内容的提问来源于stack exchange,提问作者nusantara

