React在服务器端渲染环境中的工作方式有一个根本的误解

91 阅读12分钟

简介

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

A rendering issue causes a newsletter signup form to be squashed and overlapping the article / footer content

在开发工具的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团队知道补水不匹配会导致有趣的问题,他们已经确保用控制台信息来强调不匹配。

A dev-tools console error message: “Warning: Expected server HTML to contain a matching <div> in <nav>.”

不幸的是,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。

在我们的再现案例中,这个解决方案看起来是这样的。

在最初的渲染过程中,会显示一个空白点。装载后,重新渲染时用真实的状态更新它。

双通道渲染

你有没有注意到,麦片上的保质期显然不是和盒子的其他部分同时印制的?它是印上去的,是在事后。

The top of a Lucky Charms box, showing how the expiration date is stamped imprecisely onto a large blue rectangleThe top of a Cheerios box, matching the Lucky Charms box with a blue rectangle and stamped expiration date

这里面有一个逻辑:麦片盒的印刷是一个两步的过程。首先,所有 "通用 "的东西都被印刷出来:标志、卡通小精灵、放大到显示纹理的照片、智能手表的随机照片。因为这些东西是静态的,它们可以被大规模生产,一次印刷数百万张,提前几个月。

不过,他们不能对过期的产品这样做。在那个时候,制造商不知道到期日应该是什么;装满这些盒子的麦片可能还不存在!所以他们打印了一个空的蓝色矩形。所以他们印了一个空的蓝色矩形来代替。很久以后,当麦片被生产出来并被注入盒子后,他们就可以在白色的有效期上盖章,然后打包发货了。

双程渲染也是同样的想法。第一遍,在编译时,产生所有的静态非个人内容,并在动态内容的地方留下洞。然后,在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应用程序中工作时,我发现用两段式渲染的方式来思考是非常有帮助的。第一遍发生在编译时,提前进行,为页面打下基础,填入所有用户通用的东西。然后,在很久之后,第二遍渲染会填入因人而异的有状态的部分。