简介
我最近遇到了一个最奇怪的问题。在开发过程中一切都很顺利,但在生产过程中,我的博客底部却在做一些......非预期的事情。

在开发工具的Elements选项卡中做了一些调查,发现了问题的症结所在......我的React组件在错误的地方渲染了
<!-- In development, things are correct -->
<main>
<div class="ContentFooter">
Last updated: <strong>Sometime</strong>
</div>
<div class="NewsletterSignup">
<form>
<!-- Newsletter signup form stuff -->
</form>
</div>
</main>
怎么会这样呢?难道我发现了React的一个错误?我检查了React Devtools的"⚛️组件 "选项卡,它讲述了一个不同的故事,在这个故事里,一切都很好,所有的部件都在它们应该在的地方。真是个骗子!
事实证明,我对React在服务器端渲染环境中的工作方式有一个根本的误解。我想很多React开发者都有这样的误解!而且,这可能会产生一些相当严重的影响。
一些有问题的代码
下面是一个可能导致上述渲染问题的代码例子。你能发现这个问题吗?
function Navigation() {
if (typeof window === 'undefined') {
return null;
}
// Pretend that this function exists,
// and returns either a user object or `null`.
const user = getUser();
if (user) {
return (
<AuthenticatedNav
user={user}
/>
);
}
return (
<nav>
<a href="/login">Login</a>
</nav>
);
};
在很长一段时间里,我相信这段代码是没有问题的。直到我的博客开始冒充毕加索的画。
本教程将窥探幕后,帮助我们了解服务器端渲染的工作原理。我们将看到为什么这里显示的逻辑会有问题,以及不同的方法如何实现同样的目标。
服务器端渲染101
为了理解这个问题,我们首先需要挖掘一下Gatsby和Next.js这样的框架与传统的用React构建的客户端应用有什么不同。
当你使用React与create-react-app这样的东西时,所有的渲染都发生在浏览器中。不管你的应用程序有多大,浏览器仍然会收到一个初始的HTML文档,看起来就像这样。
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Maybe some stuff here -->
</head>
<body>
<div id="root"></div>
<script
src="/static/bundle.js"
></script>
<script
src="/static/0.chunk.js"
></script>
<script
src="/static/main.chunk.js"
></script>
</body>
</html>
这个页面基本上是空的,但它包括几个JS脚本。一旦浏览器下载并解析了这些脚本,React就会建立一个页面应该是什么样子的图片,并注入一堆DOM节点来使其成为这样。这被称为客户端渲染,因为所有的渲染都发生在客户端(用户的浏览器)。
所有这些都需要时间,而当浏览器和React正在发挥它们的魔力时,用户却在盯着一个空白的白屏。这不是最好的体验。
聪明的人意识到,如果我们能在服务器上进行渲染,我们就能向用户发送一个完全成型的HTML文档。这样,在浏览器下载、解析和执行JS的时候,他们就有东西可以看了。这就是所谓的服务器端渲染(SSR)。
服务器端渲染可以提高性能,但问题是,这项工作仍然需要按需完成。当你请求你的网站(your-website.com)时,React必须将你的React组件转化为HTML,而你在等待的时候仍然会盯着一个空白的屏幕。只是这些工作是在服务器上完成的,而不是在用户的电脑上。
银河系大脑的认识是,许多网站和应用程序的大块内容是静态的,它们可以在编译时建立。我们可以提前在我们的开发机器上生成初始HTML,并在用户要求时立即分发。我们的React应用程序可以像普通的HTML网站一样快速加载!
这正是Gatsby所做的(在某些配置下,还有Next.js)。当你运行yarn build ,它为你网站上的每条路线生成1个HTML文档。每一个侧页、每一篇博客文章、每一个商店项目--都会为它们创建一个HTML文件,准备立即提供。
这一切只是服务器端的渲染吗?
不幸的是,这种语言有很多是互换使用的,而且可能有点难以理解。从技术上讲,Gatsby所做的是服务器端渲染,因为它使用Node.js渲染React应用程序,使用与更传统的服务器端渲染相同的ReactDOMServer API。但在我看来,它在概念上是不同的;"服务器端渲染 "实时发生在你的实时生产服务器上,以响应一个请求,而这种编译时渲染则更早发生,作为构建过程的一部分。
有些人已经开始称它为SSG,它代表 "静态网站生成 "或 "服务器端生成",这取决于你问谁。
在客户端的代码
我们现在构建的应用程序是交互式的、动态的--用户习惯于体验到仅靠HTML和CSS无法完成的效果!因此,我们仍然需要运行客户端应用程序。所以我们仍然需要运行客户端JS。
客户端JS包括在编译时用于生成它的相同的React代码。它在用户的设备上运行,并建立了一个世界应该是什么样子的图片。然后,它将其与文档中的HTML进行比较。这是一个被称为补水的过程。
重要的是,补水和渲染不是一回事。在典型的渲染中,当道具或状态发生变化时,React准备调和任何差异并更新DOM。在补水过程中,React假设DOM不会改变。它只是试图采用现有的DOM。
动态部分
这就把我们带回了我们的代码片段。作为一个提醒。
const Navigation = () => {
if (typeof window === 'undefined') {
return null;
}
// Pretend that this function exists,
// and returns either a user object or `null`.
const user = getUser();
if (user) {
return (
<AuthenticatedNav
user={user}
/>
);
}
return (
<nav>
<a href="/login">Login</a>
</nav>
);
};
这个组件被设计为有三种可能的结果。
-
如果用户已经登录,呈现
<AuthenticatedNav>。 -
如果用户没有登录,则呈现
<UnauthenticatedNav>组件。 -
如果我们不知道用户是否已经登录,就不渲染。
薛定谔的用户
在一个可怕的思想实验中,奥地利物理学家Erwin Schrödinger描述了一个情况:一只猫被放在一个装有毒素的盒子里,这个毒素在一小时内有50%的机会被释放。一小时后,猫活着或死了的概率相同。但在你打开盒子发现之前,这只猫可以被认为是既活着又死了*。
在我们的网络应用程序中,我们面临着类似的困境;在用户进入我们网站的最初几个时刻,我们不知道他们是否已经登录。
这是因为HTML文件是在编译时建立的。每个用户都会得到一个相同的HTML副本,无论他们是否登录。一旦JS包被解析和执行,我们就可以更新UI以反映用户的状态,但在这之前有一个重要的时间间隔。请记住,SSG的全部意义在于,在我们下载、解析和补给应用程序的时候,给用户一些东西看,这在缓慢的网络/设备上可能是一个漫长的过程。
许多网络应用程序选择默认显示 "注销 "状态,这导致了你可能曾经遇到过的闪烁现象。
卫报》新闻网站显示了一个 "登录 "链接,然后用 "你的账户 "代替了它。
Airbnb也犯了同样的错误,默认为登录后的导航栏。
我冒昧地建立了一个迷你Gatsby应用程序,再现了这个问题。
在3G网速下,错误的状态显示了相当长的一段时间。
一个崇高但有缺陷的尝试
在共享的代码片断中,我们试图在前几行解决这个问题。
const Navigation = () => {
if (typeof window === 'undefined') {
return null;
}
这里的想法是合理的。我们的初始编译时构建发生在Node.js,一个服务器运行时。我们可以通过检查window 是否存在来检测我们是否在服务器上进行渲染。如果不存在,我们可以提前中止渲染。
问题是,这样做的话,我们就违反了规则。😬
补水≠渲染
当一个React应用重水时,它假设DOM结构会匹配。
当React应用第一次在客户端运行时,它通过安装你的所有组件,建立了一个DOM应该是什么样子的心理图景。然后,它眯着眼睛看已经在页面上的DOM节点,并试图将两者结合起来。它不是在玩典型的更新过程中的 "发现差异 "的游戏,它只是试图将两者结合在一起,以便未来的更新能够被正确处理。
通过根据我们是否在服务器端渲染的情况下渲染不同的东西,我们是在入侵系统。我们在服务器上渲染一个东西,但然后告诉React在客户端期待另一个东西。
<!-- The initial HTML
generated at compile-time -->
<header>
<h1>Your Site</h1>
</header>
<!-- What React expects
after rehydration -->
<header>
<h1>Your Site</h1>
<nav>
<a href="/login">Login</a>
</nav>
</header>
值得注意的是,React有时仍能处理这种情况。你可能已经这样做了,而且还能逃脱。但你是在玩火。补水过程被优化为⚡️快速⚡️,而不是为了捕捉和修复不匹配。
尤其是关于盖茨比
React团队知道补水不匹配会导致有趣的问题,他们已经确保用控制台信息来强调不匹配。

不幸的是,Gatsby在为生产构建时只使用服务器端的渲染API。因为React的警告一般只在开发中出现,这意味着这些警告在用Gatsby构建时永远不会显示出来😱。
这是一个权衡的结果。通过在开发中选择不使用服务器端渲染,Gatsby正在优化一个短的反馈循环。能够迅速看到你所做的改变是非常非常重要的。Gatsby将速度置于准确性之上。
解决方案
为了避免问题,我们需要确保补水后的应用程序与原始HTML相匹配。那么我们如何管理 "动态 "数据呢?
下面是解决方案的样子。
function Navigation() {
const [hasMounted, setHasMounted] = React.useState(false);
React.useEffect(() => {
setHasMounted(true);
}, []);
if (!hasMounted) {
return null;
}
const user = getUser();
if (user) {
return (
<AuthenticatedNav
user={user}
/>
);
}
return (
<nav>
<a href="/login">Login</a>
</nav>
);
};
We initialize a piece
我们将一块状态,hasMounted ,初始化为false 。虽然它是假的,但我们并不理会渲染 "真正的 "内容。
在useEffect 的调用中,我们立即触发重新渲染,将hasMounted 设置为true 。当这个值为true 时,"真正的 "内容被渲染。
与我们之前的解决方案不同的是:**useEffect 只在组件安装后触发。**当React应用在补水期间采用DOM时,useEffect 还没有被调用,因此我们满足了React的期望。
<!-- The initial HTML
generated at compile-time -->
<header>
<h1>Your Site</h1>
</header>
在这次比较之后,我们立即触发了一次重新渲染,这使得React能够进行适当的调和。它将注意到这里有一些新的内容需要渲染--要么是一个认证的菜单,要么是一个登录链接,并相应地更新DOM。
在我们的再现案例中,这个解决方案看起来是这样的。
在最初的渲染过程中,会显示一个空白点。装载后,重新渲染时用真实的状态更新它。
双通道渲染
你有没有注意到,麦片上的保质期显然不是和盒子的其他部分同时印制的?它是印上去的,是在事后。


这里面有一个逻辑:麦片盒的印刷是一个两步的过程。首先,所有 "通用 "的东西都被印刷出来:标志、卡通小精灵、放大到显示纹理的照片、智能手表的随机照片。因为这些东西是静态的,它们可以被大规模生产,一次印刷数百万张,提前几个月。
不过,他们不能对过期的产品这样做。在那个时候,制造商不知道到期日应该是什么;装满这些盒子的麦片可能还不存在!所以他们打印了一个空的蓝色矩形。所以他们印了一个空的蓝色矩形来代替。很久以后,当麦片被生产出来并被注入盒子后,他们就可以在白色的有效期上盖章,然后打包发货了。
双程渲染也是同样的想法。第一遍,在编译时,产生所有的静态非个人内容,并在动态内容的地方留下洞。然后,在React应用安装到用户的设备上之后,第二遍会在所有依赖于客户端状态的动态部分上盖章。
性能影响
两次渲染的缺点是,它可能会延迟交互时间。强行在加载后进行渲染通常是不可取的。
这就是说,对于大多数应用来说,这不应该有很大的区别。通常情况下,动态内容的数量相对较少,并且可以快速地进行调整。如果你的应用程序有大块的动态内容,你会错过预渲染的许多好处,但这是不可避免的;根据定义,动态部分不能提前制作。
一如既往,如果你对性能有顾虑,最好自己做一些实验。
抽象
在这个博客中,我最终需要将一些渲染决定推迟到第二遍,而且我厌倦了反复编写相同的逻辑。我创建了一个<ClientOnly> 组件来抽象它。
function ClientOnly({ children, ...delegated }) {
const [hasMounted, setHasMounted] = React.useState(false);
React.useEffect(() => {
setHasMounted(true);
}, []);
if (!hasMounted) {
return null;
}
return (
<div {...delegated}>
{children}
</div>
);
}
然后你可以把它包在你想推迟的任何元素上。
<ClientOnly>
<Navigation />
</ClientOnly>
我们还可以使用一个自定义的钩子。
function useHasMounted() {
const [hasMounted, setHasMounted] = React.useState(false);
React.useEffect(() => {
setHasMounted(true);
}, []);
return hasMounted;
}
function Navigation() {
const hasMounted = useHasMounted();
if (!hasMounted) {
return null;
}
const user = getUser();
if (user) {
return (
<AuthenticatedNav
user={user}
/>
);
}
return (
<nav>
<a href="/login">Login</a>
</nav>
);
};
With this trick up m
有了这个技巧,我就能解决我的渲染问题了。
心理模型
虽然很整洁,但抽象的东西并不是这个教程最重要的部分。最关键的部分是心理模型。
当我在Gatsby/Next应用程序中工作时,我发现用两段式渲染的方式来思考是非常有帮助的。第一遍发生在编译时,提前进行,为页面打下基础,填入所有用户通用的东西。然后,在很久之后,第二遍渲染会填入因人而异的有状态的部分。