服务端组件的优势

Motivation

服务器组件解决了我们在广泛的应用中看到的 React 中的一些挑战。起初,我们寻找了这些问题的针对性解决方案,因为这往往可以导致一个更简单的解决方案。然而,我们并不满意这样导致的方法。根本的挑战在于 React 应用是以客户端为中心的,没有充分利用服务器的优势。如果我们能够让开发人员轻松地更多地利用他们的服务器,我们就可以解决所有这些挑战,并提供一个更强大的方法来构建应用程序,无论大小。

这些挑战主要分为两大类。首先,我们希望让开发者更容易掉进 “成功的坑”,并在默认情况下实现良好的性能。第二,我们想让 React 应用中的数据获取更容易。如果你之前使用过 React,你很可能至少希望获得以下一些功能。

Zero-Bundle-Size Components

开发者经常要对使用第三方包做出选择。使用一个包来渲染一些标记或格式化一个日期,对我们开发者来说很方便,但它增加了代码大小,损害了用户的性能。另一方面,我们自己重写这些功能是很耗时且容易出错的。虽然树形抖动等高级功能可以提供一定的帮助,但我们最终还是要给用户运去一些额外的代码。例如,今天用 Markdown 渲染我们的 Note 例子可能需要超过 240K 的 JS(单独 gzipped 超过 74K)。

请注意,这只是你可能使用的一组库,可能还有更小或更大的替代库。即使你喜欢只有 X 个字节的替代库,你的用户仍然需要下载这 X 个字节的库。这个例子的目的不是要挑剔任何特定的库,而是要证明,作为开发者,使用库是有帮助的,但会增加捆绑的大小,并且会损害应用程序的性能。

// NoteWithMarkdown.js
// NOTE: *before* Server Components

import marked from 'marked'; // 35.9K (11.2K gzipped)
import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped)

function NoteWithMarkdown({text}) {
  const html = sanitizeHtml(marked(text));
  return (/* render */);
}

然而,应用程序的许多部分不是交互式的,不需要完全的数据一致性。例如,“详细 “页面通常显示有关产品、用户或其他实体的信息,并且不需要根据用户交互而更新。这里的 “Note “例子就是一个完美的例子。

服务器组件允许开发人员在服务器上渲染静态内容,充分利用 React 面向组件的模型,自由使用第三方包,同时对捆绑大小产生零影响。如果我们将上面的例子迁移到服务器组件中,我们可以为我们的功能使用完全相同的代码,但避免将其发送到客户端–节省了超过 240K 的代码(未压缩)。

// NoteWithMarkdown.server.js - Server Component === zero bundle size

import marked from "marked"; // zero bundle size
import sanitizeHtml from "sanitize-html"; // zero bundle size

function NoteWithMarkdown({ text }) {
  // same as before
}

Full Access to the Backend

在编写 React 应用时,最常见的痛点之一就是决定如何访问你的数据–或者说首先在哪里存储你的数据。使用 React 获取数据的方法有很多,还有很多优秀的数据库和数据存储空间可供选择。然而,这些方法都有一些挑战。一个共同的主题是,开发人员必须暴露额外的端点来支持他们的用户界面,或者使用现有的端点,而这些端点并不总是以该用户界面为设计目标。我们希望有一个解决方案,既能让任何人更容易上手,又能降低大型应用的复杂性。

例如,如果您正在创建一个新的应用程序(甚至是您的第一个应用程序!),并且不确定在哪里存储数据,您可以从文件系统开始。

// Note.server.js - Server Component
import fs from "react-fs";

function Note({ id }) {
  const note = JSON.parse(fs.readFile(`${id}.json`));
  return <NoteWithMarkdown note={note} />;
}

更复杂的应用程序同样可以利用直接后端访问使用数据库、内部(微)服务和其他仅有后端数据源。

// Note.server.js - Server Component
import db from "db.server";

function Note({ id }) {
  const note = db.notes.get(id);
  return <NoteWithMarkdown note={note} />;
}

Automatic Code Splitting

如果你已经使用 React 工作了一段时间,你可能会熟悉代码拆分的概念,它允许开发人员将他们的应用程序分解成更小的捆绑包,并向客户端发送更少的代码。常见的方法是每个路由懒加载一个捆绑包和/或在运行时根据一些标准懒加载不同的模块。例如,应用程序可能会根据用户、内容、功能标志等,懒惰地加载不同的代码(在不同的捆绑包中)。

// PhotoRenderer.js
// NOTE: *before* Server Components

import React from "react";

// one of these will start loading *when rendered on the client*:
const OldPhotoRenderer = React.lazy(() => import("./OldPhotoRenderer.js"));
const NewPhotoRenderer = React.lazy(() => import("./NewPhotoRenderer.js"));

function Photo(props) {
  // Switch on feature flags, logged in/out, type of content, etc:
  if (FeatureFlags.useNewPhotoRenderer) {
    return <NewPhotoRenderer {...props} />;
  } else {
    return <OldPhotoRenderer {...props} />;
  }
}

代码拆分对提高性能非常有帮助,但现有的代码拆分方法有两个主要限制。首先,开发人员必须记住要做到这一点,用 React.lazy 和动态导入代替常规导入语句。第二,这种方法延迟了应用程序可以开始加载所选组件的时间点,抵消了加载较少代码的一些好处!

服务器组件以两种方式解决这些限制。首先,它们使代码自动分割。服务器组件将所有导入的客户端组件视为潜在的代码分割点。其次,它们允许开发人员在服务器上更早地选择使用哪个组件,这样客户端就可以在渲染过程中更早地下载它。净效果是,服务器组件让开发者更专注于他们的应用程序,并编写自然的代码,框架默认优化应用程序的交付。

// PhotoRenderer.server.js - Server Component

import React from "react";

// one of these will start loading *once rendered and streamed to the client*:
import OldPhotoRenderer from "./OldPhotoRenderer.client.js";
import NewPhotoRenderer from "./NewPhotoRenderer.client.js";

function Photo(props) {
  // Switch on feature flags, logged in/out, type of content, etc:
  if (FeatureFlags.useNewPhotoRenderer) {
    return <NewPhotoRenderer {...props} />;
  } else {
    return <OldPhotoRenderer {...props} />;
  }
}

No Client-Server Waterfalls

当应用程序连续请求获取数据时,一个常见的性能不佳的原因就会发生。例如,数据获取的一种模式是最初渲染一个占位符,然后在 useEffect() 钩子中获取数据。

// Note.js
// NOTE: *before* Server Components

function Note(props) {
  const [note, setNote] = useState(null);
  useEffect(() => {
    // NOTE: loads *after* rendering, triggering waterfalls in children
    fetchNote(props.id).then(noteData => {
      setNote(noteData);
    });
  }, [props.id]);
  if (note == null) {
    return "Loading";
  } else {
    return (/* render note here... */);
  }
}

然而,当父组件和子组件都使用这种方法时,子组件不能开始加载任何数据,直到父组件完成加载其数据。不过这种模式也有一些积极的方面。在单个组件中获取数据的一个好处是,它允许应用程序准确地获取它所需要的数据,并避免为 UI 中没有渲染的部分获取数据。我们希望找到一种方法来避免从客户端连续往返,同时还能避免过度获取不会使用的数据。

服务器组件允许应用程序通过将连续的往返移动到服务器来实现这一目标。问题不在于往返,而在于它们是从客户端到服务器。通过将这些逻辑转移到服务器上,我们减少了请求延迟,提高了性能。更好的是,服务器组件允许开发人员继续直接从其组件内获取他们所需的最小数据。

// Note.server.js - Server Component

function Note(props) {
  // NOTE: loads *during* render, w low-latency data access on the server
  const note = db.notes.get(props.id);
  if (note == null) {
    // handle missing note
  }
  return (/* render note here... */);
}

瀑布在服务器上还是不理想,所以我们会提供一个 API 来预加载数据请求作为优化。然而,由于客户端-服务器瀑布对性能特别不利,我们发现不用担心它们是一个重要的好处。

Avoiding the Abstraction Tax

React 使用 JavaScript 而非模板语言,允许开发人员使用诸如函数组成和反射等语言特性来创建强大的 UI 抽象。然而,当过度使用这些抽象功能时,可能会以更多的代码和更多的运行时开销为代价。静态 “语言中的 UI 框架可以利用超前编译来编译掉其中的一些抽象,但在 JavaScript 中,这个选项是不可用的。

为了解决这个挑战,我们最初尝试了一种超前(AOT)优化的方法–Prepack,但最终这个方向并没有成功。具体来说,我们意识到,很多 AOT 优化并不奏效,因为它们要么没有足够的全局知识,要么全局知识太少。例如,一个组件可能在实践中是静态的,因为它总是从父体那里接收一个常量字符串,但编译器看不到那么远,认为这个组件是动态的。即使我们能让优化工作顺利进行,我们也发现这些优化对开发者来说是不可预测的。没有可预测性,开发者很难依赖它们。

服务器组件通过消除服务器上的抽象成本来帮助解决这个问题。例如,如果一个具有多层包装器的服务器组件的可配置性最终渲染到一个单一的元素,那么这就是所有将被发送到客户端的元素。在下一个例子中,Note 使用了一个中间的 NoteWithMarkdown 抽象,它向下渲染到一个包装 <div>,因此 React 将只向客户端发送 div(及其内容)。

// Note.server.js
// ...imports...

function Note({id}) {
  const note = db.notes.get(id);
  return <NoteWithMarkdown note={note} />;
}

// NoteWithMarkdown.server.js
// ...imports...

function NoteWithMarkdown({note}) {
  const html = sanitizeHtml(marked(note.text));
  return <div ... />;
}

// client sees:
<div>
  <!-- markdown output here -->
</div>

Distinct Challenges, Unified Solution

如上所述,这些挑战的一个主题是 React 主要以客户端为中心。使用传统的服务器渲染–PHP、Rails 等–当然是解决其中一些挑战的方法之一,但这种方法使构建丰富的、交互式体验变得更加困难。这反映了应用开发世界中一个在网络之前就存在的长期紧张关系:是使用 “薄 “还是 “厚 “的客户端。

最终,我们意识到,无论是纯服务器渲染还是纯客户端渲染都是不够的。服务器渲染可以让应用程序轻松地访问服务器端数据源,并快速显示静态内容,而客户端渲染对于丰富的、交互式的功能至关重要,因为用户希望得到即时反馈。但混合服务器和客户端渲染往往意味着混合技术:用两种语言编写代码,使用两种框架,牢记两套习惯和生态系统。它还意味着要处理跨语言的数据传输,并且经常需要重复逻辑,一次用于服务器渲染的视图,一次用于交互式客户端预览。

服务器组件允许 React 应用在使用单一语言、单一框架和一套内聚的 API 和习惯用语的同时,获得服务器和客户端渲染的最佳效果。我们仍在探索 Server Components 的完整设计空间,但我们已经出货了第一个生产集成,并正在积极探索一些额外的开放研究领域。下面介绍 Server Components 的高层设计。