[译]RFC: React Server Components

339 阅读38分钟

概要

⚠️ 注意:我们强烈建议在阅读本 RFC 之前观看我们介绍 Server Components 的演讲

这个 RFC 讨论了 React 即将推出的功能:Server Components。Server Components 让开发者可以构建跨服务端和客户端的应用,将客户端应用的丰富交互性与传统服务端渲染的性能相结合:

  • Server Components 仅在服务端运行,对 JS 包大小的影响为零。Server Components 的代码永远不会下载到客户端,有利于减少包大小并缩短启动时间。
  • Server Components 可以访问服务端数据源,例如数据库、文件系统或(微)服务。
  • Server Components 与 Client Components(即传统的 React 组件)无缝集成。 Server Components 可以在服务端加载数据并将自己作为 props 传递给 Client Components,让客户端处理页面的交互部分。
  • Server Components 可以动态选择要渲染的 Client Components,客户端就可以只下载渲染页面所需的最少量代码。
  • Server Components 在重新加载时会保留客户端状态。这意味着重新获取 Server Components 时,客户端状态、焦点甚至正在进行的动画都不会中断或重置。
  • Server Components 支持渐进式渲染,将渲染内容通过数据流(stream)增量地传输到客户端。结合 Suspense,页面可以在等待其余部分加载的同时展示加载状态尽快显示重要内容
  • 开发者还可以在服务端和客户端之间共享代码,同一个组件既可以在服务端运行,渲染内容的静态版本,又可以在客户端运行,渲染该内容的可编辑版本。

基本示例

此示例是渲染带有标题(title)和正文(body)的 Note。它使用 Server Components 渲染 Note 的不可编辑视图,并可选地使用 Client Components(传统的 React 组件)渲染 Note 编辑器。首先,我们在服务端渲染 Note。我们的约定是使用 .server.js 后缀(或 .server.jsx, .server.tsx 等)命名 Server Components:

// Note.server.js - Server Component

import db from 'db.server'; 
// (A1) We import from NoteEditor.client.js - a Client Component.
import NoteEditor from 'NoteEditor.client';

function Note(props) {
  const {id, isEditing} = props;
  // (B) Can directly access server data sources during render, e.g. databases
  const note = db.posts.get(id);
  
  return (
    <div>
      <h1>{note.title}</h1>
      <section>{note.body}</section>
      {/* (A2) Dynamically render the editor only if necessary */}
      {isEditing 
        ? <NoteEditor note={note} />
        : null
      }
    </div>
  );
}

这个例子说明了几个关键点:

  • 这“只是”一个 React 组件:它接收 props 并渲染一个视图。Server Components 有一些限制。例如,不能使用 State 或 Effect。但总体而言,它们可以按你的预期工作。在下面的“Server / Client Components 的功能和约束”中提供了更多详细信息。
  • Server Components 可以直接访问服务端数据源,例如数据库,如 (B) 所示。这是一种通用的机制,可以创建使用各种数据源的 API。
  • Server Components 可以通过 import 并 render “客户端”组件将这部分渲染交给客户端,分别如 (A1) 和 (A2) 所示。Client Components 使用 .client.js 后缀(或 .client.jsx, .client.tsx 等)。打包工具会像对待其他动态导入一样对待这些 Client Components 的导入,可能会根据各种方法将它们拆分为另一个包。在此示例中,只有在 props.isEditing 为 true 时才会在客户端加载 NodeEditor.client.js

Client Components 就是你已经习以为常的普通组件,它们可以访问 React 的全部功能:State、Effect、对 DOM 的访问等。“Client Components” 这个名称并不意味着什么新东西,它只是用来将这些组件与 Server Components 区分开。下面继续我们的示例,以下是如何实现 Note 编辑器:

// NodeEditor.client.js - Client Component

export default function NoteEditor(props) {
  const note = props.note;
  const [title, setTitle] = useState(note.title);
  const [body, setBody] = useState(note.body);
  const updateTitle = event => {
    setTitle(event.target.value);
  };
  const updateBody = event => {
    setBody(event.target.value);
  };
  const submit = () => {
    // ...save note...
  };
  return (
    <form action="..." method="..." onSubmit={submit}>
      <input name="title" onChange={updateTitle} value={title} />
      <textarea name="body" onChange={updateBody}>{body}</textarea>
    </form>
  );
}

这看起来像一个常规的 React 组件,因为:Client Components 本来就只是常规组件。

我们在这里有一个重要的考虑,当 React 在客户端渲染 Server Components 的返回数据时,它会保留之前已经渲染的 Client Components 的状态。具体来说,React 将服务端返回的新的 props 合并到现有的 Client Components 中,并维护这些组件的状态(和 DOM),从而做到保留焦点、状态和正在进行的动画。

动机

Server Components 解决了我们在各种应用中看到的 React 面临的许多挑战。最初,我们为这些挑战寻找有针对性的解决方案,因为这样做通常会得到更简单的解决方案。然而,以这样的思路寻找到的解决方案并不令人满意。最根本的问题是 React 应用是以客户端为中心的,并没有充分利用服务器。如果我们可以让开发者更轻松地更多地利用他们的服务器,我们就可以解决所有这些挑战,并提供一种更强大的方法来构建或大或小的应用。

这些挑战分为两大类。首先,我们想让开发者更容易获得良好的性能。其次,我们想让 React 应用中的数据更容易获取。如果你之前使用过 React,那么你可能希望拥有以下一些功能:

Zero-Bundle-Size 的组件

开发者必须不断地选择使用第三方包。使用第三方包渲染 Markdown 或格式化日期对开发者来说很方便,但这样会增加代码大小并损害用户的性能。但是,开发者如果自己重写这些功能既费时又容易出错。虽然诸如 tree-shaking 之类的高级功能会有一定的帮助,但我们最终还是不得不给浏览器传输一些额外的代码。例如,使用 Markdown 渲染我们的 Note 示例可能需要使用超过 240K 的 JS(压缩后仍然超过 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 示例就是一个很好的例子。

Server Components 让开发者可以在服务端渲染静态内容,在充分利用 React 面向组件的模型的同时,可以自由使用第三方包,又不影响客户端包大小。如果将上面的示例迁移到 Server Components,可以让我们的功能使用完全相同的代码,但避免将其发送到客户端,代码节省超过 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
}

完全访问后端

编写 React 应用的过程中,最常见的痛点之一是决定如何访问数据 —— 或者首先在哪里存储数据。使用 React 获取数据有很多很棒的方法,还有很多很棒的数据库和数据存储可供选择。然而,这些方法中的每一种都存在一些挑战。一个共同的问题是,开发者必须开发额外的 API 来支持他们的 UI,或者使用现有 API,但这些 API 在设计时可能并没有考虑到该 UI 的使用场景。我们想要一个解决方案,既能让任何人更容易上手 又能降低大型应用的复杂性。

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

// 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} />;
}

自动代码拆分

如果你使用 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 动态导入。其次,这种方法延迟了应用开始加载组件的时间,抵消了加载更少代码的一部分收益!

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

// 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} />;
  }
}

没有“客户端 - 服务器”数据请求瀑布

应用程序有时会发出一系列串行的请求来获取数据,这是出现性能问题的常见原因。例如,数据获取的一种常见模式是首先呈现占位符,然后在 useEffect() hook 中获取数据:

// 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 部分获取数据。我们想找到一种方法来避免客户端的串行请求,同时也可以避免获取不使用的数据。

使用 Server Components,应用可以将串行请求放到服务端执行,从而实现上面的目标。问题本来不在于串行请求,而在于它们是从客户端到服务端的请求,对性能影响较大。通过将此逻辑转移到服务端,我们可以减少请求延迟并提高性能。更好的是,Server Components 还可以让开发者直接在组件中获取他们需要的最少的数据:

// 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 来优化这种场景。不管怎样,通过把串行的请求放到服务端仍然有很大的收益,因为客户端 - 服务器之间的串行请求对性能是特别不利的。

避免抽象税

React 不是模板语言,而是使用 JavaScript,它允许开发者使用函数组合和反射等语言特性来创建强大的 UI 抽象。但缺点是,当过度使用这些抽象时,可能会导致更多的代码和运行时开销。“静态”语言的 UI 框架可以利用提前编译去掉其中的一些抽象,但这种方式在 JavaScript 中用不了。

为了应对这一挑战,我们最初尝试了提前优化(AOT)方法 —— Prepack —— 但最终这个方向没有成功。我们后来才意识到许多 AOT 优化并不起作用,因为它们拿不到足够的全局信息来做判断。例如,一个组件在实际上可能是静态的,因为它总是从父级接收一个常量字符串,但编译器并不知道,所以编译期只能认为该组件是动态的。即使可以想办法使这种优化起作用,我们也发现这些优化对开发者来说是不可预测的。如果没有可预测性,开发者很难依赖它们。

Server Components 则是通过在服务端运行,来消除抽象成本,从而帮助解决这个问题。例如,如果一个被包装了多层的 Server Components 最终渲染为单个 DOM 元素,那么就只把这个元素发送到客户端即可。在下面的示例中,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>

不同的挑战,统一的解决方案

如上所述,这些挑战的有一个共同点,就是 React 主要以客户端为中心。如果使用传统的服务端渲染 —— PHP、Rails 等 —— 无疑是解决其中一些挑战的一种方法,但这种方法会让构建丰富的交互式体验变得更加困难。这也反映了在应用开发领域长期存在的争议,这些争议甚至在 Web 出现之前就有了:是使用“瘦”客户端还是“胖”客户端。

最终,我们意识到纯服务端渲染或纯客户端渲染都不够。服务端渲染的优势是应用可以轻松访问服务端数据源并快速显示静态内容,而客户端渲染则对于需要即时反馈的交互式功能至关重要。但是混合服务端和客户端渲染通常意味着混合两种技术:用两种语言编写代码,使用两种框架,记住两种术语和生态。还意味着要处理跨语言数据传输,并且通常需要复制逻辑,一次用于服务端渲染,一次用于交互式的客户端渲染。

Server Components 允许 React 应用在使用单一语言、单一框架、同一组 API 和术语的同时,获得服务端和客户端渲染的最佳效果。我们仍在探索和完善 Server Components 的设计,但我们已经在第一个生产环境上使用,并且正在积极探索一些开放的研究领域。Server Components 的详细设计如下所述。

详细设计

⚠️ 注意:大多数 React 开发者不需要了解这里描述的所有细节。本节主要面向库或框架作者。

以上部分从开发者的视角描述了 Server Components。在这个部分,我们会提供关于 Server Components 的更多设计细节,以及如何将 Server Components 集成到应用和框架中。首先我们讨论一下上面那个 Note 示例的(简化后的)渲染生命周期。接下来,我们将更详细地讨论关于设计的几个重要方面。最后,我们会大概描述一下我们正在积极探索的一些开放的研究领域。

简化后的加载顺序

在本节中,我们将回顾上面提到的 Note 示例加载过程的各个阶段,这个示例中,root 节点是 Server Components,子节点是 Client Components。请注意,其中一些阶段需要涉及到与路由和打包过程的集成。我们希望开发者刚开始试用 Server Components 时,是通过 Next.js 等框架,因为这些框架会默认处理好跟 Server Components 的集成。这些第一批集成 Server Components 的框架可以作为集成示例,其他库可以参考这些集成示例来完成自己的集成,开发者也可以参考这些示例将 Server Components 集成到他们的应用中。

Note 示例的渲染过程大致如下:

  • 在服务端:

    • [框架] 框架的路由会将请求的 URL 匹配到 Server Components,将路由参数作为 props 传递给组件。然后使用 React 渲染组件及其 props:在这个示例中就是渲染 Note.server.js

    • [React] React 渲染作为 root 节点的 Server Components 和其他也是 Server Components 的子组件。在遇到原生元素(div、span 等)或 Client Components 时,服务端渲染停止。原生元素会被转化成 JSON,通过数据流(stream)传输给客户端。Client Components 的序列化则会包含它的 props 和该组件代码位置的引用,也是通过数据流传输给客户端。

    • 请注意,如果任何 Server Components 发生 Suspends,React 将暂停该子树的渲染并传输一个占位符给客户端。当组件能够继续(Un-suspends)时,React 重新渲染该组件,将组件的实际结果流式传输到客户端。你可以认为流式传输的数据是一个有插槽的 JSON,这些插槽是为发生 Suspends 的组件准备的,插入这些插槽中的值将在稍后补充到数据流中。

    • [框架] 随着 React 渲染 UI 的每个部分,框架负责将渲染的输出逐步地流式地传输到客户端。

    • 请注意,默认情况下,React 返回的是已渲染的 UI 的描述,而不是直接返回 HTML。这样做是为了让新获取的渲染数据能够与页面现有的 Client Components 协调合并(reconciling & merging)。框架也可以选择将 Server Components 与“服务端渲染”(SSR)结合起来,将首次渲染的 HTML 流式传输给客户端,这将加速页面的首次、不含交互的渲染。

  • 在客户端:

    • [框架] 在客户端,框架接收流式 React 响应并使用 React 将其渲染在页面上。

    • [React] React 反序列化响应数据,渲染原生元素和 Client Components。这是渐进式发生的 —— React 不需要等待整个数据流传输完成才渲染。Suspense 允许开发者在等待 Client Components 的代码加载或等待 Server Components 获取剩余数据时显示加载状态。

    • [React] 一旦加载了所有 Client Components 和所有 Server Components,最终的 UI 状态就会显示给用户。到那时,所有 Suspense 都会被取消。

更新(重新获取)时的执行顺序

Server Components 还支持重新加载新数据。开发者不会单独请求单个组件的重新加载,而是从某个 Server Components 开始,在给定 props 的情况下重新获取整个子树。与首次加载一样,重新加载也会涉及到与路由和打包过程的集成:

  • 在客户端:

    • [App] 应用请求重新获取某个 UI 单元,比如一个完整的路由。

    • [框架] 框架将请求发送到一个对应的 API,请求渲染结果。

  • 在服务端:

    • [框架] 框架接收到请求,匹配到合适的 Server Components。框架调用 React 渲染组件和 props,然后处理渲染后输出的数据流。

    • [React] React 渲染组件,就像首次加载时一样。

    • [框架] 框架逐步地将流式响应数据返回给客户端。

  • 在客户端:

    • [框架] 框架接收流式响应,使用新的渲染输出来触发重新渲染。

    • [React] React 将新的渲染输出与屏幕上的现有组件协调合并(reconciling & merging)。因为返回的 UI 是数据,而不是 HTML,所以 React 可以将新的 props 合并到现有组件中,保留重要的 UI 状态,例如焦点或键盘输入,或触发现有内容的 CSS transitions。这是 Server Components 将渲染的 UI 输出为数据(“虚拟 DOM”)而不是 HTML 的关键原因

这个过程的一些关键信息将在下面详细描述。

Server / Client Components 的功能和约束

⚠️ 注意:这部分可能会让人感到头大,但你无需记住所有这些规则即可使用 Server Components。我们有 lint 规则来帮助实施基于 .server.js 和 .client.js 的命名约定。React 还将为任何违反规则的行为提供明确的运行时错误。虽然规则列表看起来很长,但如果从直觉上理解,其实很简单:Client Components 无法访问文件系统等服务端才有的功能,Server Components 无法访问状态等客户端才有的功能,Client Components 只能导入其他 Client Components.

本提案中引入的新概念是 Server Components。相比之下,Client Components 是开发者已经熟悉的标准 React 组件:“Client Components” 这个名称并没有什么新的含义,纯粹是为了将它们与 Server Components 区分开。在本节中,我们将讨论这两种组件的功能之间的一些重要区别。

  • Server Components: 基本规则是,Server Components 在服务端运行,每次请求运行一次,因此它们没有状态并且不能使用仅存在于客户端上的功能。具体来说,Server Components:

    • 不可以使用 State,因为 Server Components 为每个请求执行一次。所以不支持 useState()useReducer()

    • 不可以使用渲染生命周期方法(Effect)。所以不支持 useEffect()useLayoutEffect()

    • 不可以使用浏览器的 API,例如 DOM(除非你在服务器上对它们进行 polyfill)。

    • 不可以使用依赖于 State 或 Effect 的自定义 Hook,或依赖于浏览器 API 的函数。

    • 可以使用服务端数据源,例如数据库、内部(微)服务、文件系统等。

    • 可以渲染其他 Server Components、原生元素(div、span 等)或 Client Components。

    • 服务端 Hook / Utils: 开发者可以创建为服务端设计的自定义 Hook 或 Utils。在这些 Hook 或 Utils 中,Server Components 的所有规则都适用。例如,服务端 Hook 的一个使用场景是为访问服务端数据源提供 Helper 方法。

  • Client Components: 这些是标准的 React 组件,因此跟你习以为常的使用方式是一样的。要考虑的新规则主要是它们有哪些跟 Server Components 相关的不能做的事情。Client Components:

    • 不可以导入 Server Components 或调用服务端 Hook / Utils,因为这些仅适用于服务端。

    • 但是,Server Components 可以将另一个 Server Components 作为子组件传递给 Client Components:<ClientTabBar><ServerTabContent /></ClientTabBar>。从 Client Components 的角度来看,它的子组件将是一个已经渲染的树,在这里是 ServerTabContent 的输出。这意味着 Server / Client Components 可以在组件树的任何级别上嵌套和交错。

    • 不可以使用服务端数据源。

    • 可以使用 State。

    • 可以使用 Effect。

    • 可以使用浏览器的 API。

    • 可以使用那些使用了 State、Effect 或浏览器 API 的自定义 Hook 和 Utils。

在服务端和客户端之间共享代码

⚠️ 注意:这部分可能会让人感到头大,但你无需记住所有这些规则即可使用 Server Components。我们有 lint 规则来帮助实施基于 .server.js 和 .client.js 的命名约定。React 还将为任何违反规则的行为提供明确的运行时错误。虽然规则列表看起来很长,但如果从直觉上理解,其实很简单:Client Components 无法访问文件系统等仅限服务器的功能,Server Components 无法访问状态等仅限客户端的功能,Client Components 只能导入其他 Client Components。

除了纯 Server Components 和纯 Client Components 之外,开发者还可以创建能同时在服务端和客户端工作的组件和 Hook。只要组件满足 Server / Client Components 的所有约束,这允许跨环境共享逻辑。因此,两端共享的组件和 Hook:

  • 不可以使用 State。

  • 不可以使用 Effect 等渲染生命周期钩子。

  • 不可以使用浏览器的 API。

  • 不可以使用依赖于 State、Effect 或浏览器 API 的自定义 Hook 或 Utils。

  • 不可以使用服务端数据源。

  • 不可以渲染 Server Components 或使用服务端 Hook。

  • 可以用于服务器和客户端。

尽管共享组件的限制最多,但我们发现在实践中其实许多组件已经能够满足这些规则,并且可以在服务端和客户端之间使用而无需修改。许多组件只是简单地根据某些条件转换一些 props,而不使用 State 或加载额外的数据。这就是为什么共享组件是默认的并且没有特殊的文件扩展名。

共享组件的典型示例类似于 Markdown 渲染器。如果我们正在加载一个路由来查看一些用 Markdown 编写的内容(但不编辑它),那么在服务端渲染 Markdown 并避免将可能很大的 Markdown 渲染库下载到客户端会更有效。但是如果用户想要编辑内容,可以按需下载 Markdown 库,以便在用户编辑时提供实时预览。使用共享组件可以让客户端和服务端在渲染时使用同一份代码。

Server / Client Components 的命名约定

注意:这个约定不是最终版本。请参阅我们的相关 RFC 以提供有关这些约定的反馈。我们认识到这种选择可能会对 JS 生态系统产生影响,我们希望避免任何意外的负面后果。

Bundler、Linter 和其他工具需要一种方法来区分 Server Components 和 Client Components。我们目前使用的命名约定是 .server.js 代表 Server Components、.client.js 代表 Client Components,所有其他 .js 文件都被视为两端共享代码。此外,我们正在探索一些关于 package.json 导出的额外约定,以允许根据使用者是 Server Components 还是 Client Components 来使用一个包的不同版本。

请参阅相关的 RFC 以提供反馈。

开放研究领域

尽管我们已经弄清楚了 Server Components 的许多重要基础知识并开始在生产环境中进行试验,但仍有几个领域需要我们继续研究。未来对这些领域的研究有进展时,我们会继续分享信息:

  • 开发者工具。我们认为有一流的开发者工具是正式发布任何 React 功能(包括 Server Components)的先决条件。我们的目标是为编写跨服务端和客户端的 React 应用提供集成的开发者体验。例如,组件检查器可能会显示 Server Components 的层次结构,即使这些组件实际上并不存在于客户端的组件树中。同样,理想情况下,开发者能够在客户端开发者工具的控制台上看到服务端的错误和日志,还能够从客户端开发者工具的源代码面板中对服务端代码进行断点。我们正在积极探索一些方法,以开发者友好和安全的方式实现这些想法。

  • 路由。如上所述,应用通常需要与路由集成,以便将页面请求映射到某个 Server Components,并将其渲染的输出传输到客户端进行最终显示。对于首个版本,我们计划将 Server Components 集成到一个或多个框架中,然后这些框架可以作为指导其他人集成路由的指南。我们会继续探索一些开放性问题,关于这种路由如何与这些框架集成。

  • 打包。Server Components 必须能够动态地将 Client Components 发送到客户端,包括客户端如何从静态资源服务器(例如 CDN)获取合适的 bundle 文件所需的任何元数据。如“Server / Client Components 的命名约定”中所述,打包工具还需要了解 .server.js.client.js 文件的命名约定,以及在 package.json 中专门为 Server Components 设计的一些约定。这是我们与 Webpack 和 Parcel 等项目正在一起积极探索的领域。这种探索包括研究不同的启发式方法以找到最有效的打包策略。

  • 分页和部分重新获取。如上面“更新(重新获取)时的执行顺序”中所述,重新加载页面的典型方法是完全重新加载它。但是,在某些情况下这是不可取的,例如在分页期间。理想情况下,我们的应用只会获取接下来的 N 个条目,而不是重新获取用户在之前看到的所有条目。我们仍在研究如何最好地使用 Server Components 建模分页。例如,我们在内部通过 GraphQL 加载 Server Components,并使用我们现有的 GraphQL 分页基础设施来解决 Server Components 中缺少分页的问题。然而,我们致力于在 React 中为这个用例开发一个通用的解决方案。

  • 变更和失效。在我们最初的演示中,我们只需在发生更新时清除所有缓存的 Server Components。这是一种适用于许多应用的简单方法,但某些应用可能需要更复杂的失效策略。我们仍在研究如何支持更细粒度的缓存失效的通用机制以及支持“乐观”变更的方法。

  • 预渲染。 改善页面转换时间的一种方法是预渲染用户可能与之交互的内容。例如,如果用户将鼠标悬停在链接上,应用程序可能会开始预先加载该页面。我们仍在研究如何让使用 Server Components 的应用支持开箱即用的预渲染。

  • 静态站点生成。与 Client Components 一样,Server Components 可以在运行时(在服务端)、构建时(在本地或服务端)渲染,甚至可以采用某种混合方法。我们仍在研究如何最好地集成 Server Components 和静态站点生成。例如,Server Components 输出流可以转换为 HTML 流,允许“静态”站点仍然受益于特意设计的加载状态。

缺点

  • 引入一种新形式的组件意味着要学习很多。
  • Server Components 的限制对所有用户来说可能并不直观,尤其是那些习惯于主要只在一种环境中工作的用户。
  • 这种方法需要一个约定来区分服务端、客户端和“共享的”组件,这可能会造成混淆或令人反感。

替代方案

我们研究了该提案的各种替代方案:

  • 仅客户端渲染
  • 仅服务器渲染
  • 服务器端渲染(SSR)
  • 静态站点生成
  • AOT 编译

这些方法中的每一种都提供了该提案的部分好处,我们预计许多应用仍将受益于结合使用这些方法。但是,仅凭这些技术都不足以同时实现好的开发者体验、用户体验和本提案提供的功能。

采用策略

如上所述,使用 Server Components 需要与应用的路由系统和打包器集成。为了帮助社区了解这是如何工作的,我们最初将专注于一个或多个框架,添加对 Server Components 的支持。这将使开发者可以轻松快速地试用 Server Components。这些框架可以作为指南,其他库作者可以参考这些集成示例来添加对 Server Components 的支持,应用开发者也可以参考这些示例将 Server Components 集成到他们的应用中。

我们如何教这个

首先,请注意,Server Components 是一项实验性功能,尚未达到可以轻松采用它们的地步。当我们准备好将 Server Components 作为稳定功能发布时,我们将更新 React 文档、提供示例并发布 lint 规则,以帮助开发者遵循上述新约束。我们还将支持一个或多个框架,以便开发者可以轻松地试验 Server Components。

致谢和现有技术

Server Components 综合了来自多个来源的想法:

  • 传统的服务器渲染(即使用 PHP、Rails 等)。具体来说,Facebook 的旧 Web 架构允许用 PHP 编写的 Server Components 呈现本地 DOM 元素或 Client Components。

  • Relay 的数据驱动依赖,允许服务器动态选择使用哪个 Client Components。

  • GraphQL,一种客户端应用加载数据时避免多次往返的方法。

Sebastian Markbage 提出了 Server Components 的初始设计,并与 Andrew Clark、Dan Abramov 和 Joe Savona 合作开发了这个想法。此外,Lauren Tan、Juan Tejada、Luna Ruan、Andrey Lunyov、Eric Faust 和 Royi Hagigi 开发了 Server Components 的第一个生产集成,并就设计提供了大量反馈。我们还要感谢我们的外部合作伙伴,Chrome 的 Aurora 团队(特别是 Gerald Monaco、Shubhie Panicker、Nicole Sullivan 和 Kara Erickson)和 Vercel 团队(特别是 Shu Ding、Jiachi Liu、Tobias Koppers、Javi Velasco 和 Tim Neutkens ),感谢他们的宝贵反馈。

FAQ

这会取代SSR吗?

不,它们是互补的。SSR 是一种快速显示 Client Components 的非交互式版本的技术。在首次加载 HTML 后,你仍然需要耗费资源来下载、解析和执行这些 Client Components。

你可以将 Server Components 和 SSR(其中的 Server Components 先渲染)结合起来,以便在它们处于 hydrated 状态时实现快速的非交互式显示。当以这种方式组合时,你仍然可以获得快速启动,但也大大减少了需要在客户端下载的 JS。

这会取代 GraphQL 吗?

不。GraphQL 是一种构建 API 的方法,它支持跨语言边界的类型安全查询,并且可以帮助应用减少获取不足或过度获取并减少往返(以及其他功能)。相反,Server Components 专注于构建用户界面。

这会取代 Apollo、Relay 或其他用于 React 的 GraphQL 客户端吗?

不。如上一个问题所述,GraphQL 是为构建 API 而设计的。因此,GraphQL 将继续成为为 Client Components 加载数据的众多好方法之一。此外,开发者可能会发现使用 GraphQL 为他们的 Server Components 加载数据也很有帮助。例如,在内部,我们将 Relay 和 GraphQL 与 Server Components 结合使用。

这只是解决 JavaScript 中缺少编译器的问题吗?

不,静态编译器可以帮助解决一些问题,但我们发现许多现实中的应用都有很多动态分支,例如用户设置、A/B 测试、功能开关等,这使得静态优化达到了极限。有关更多信息,请参阅“避免抽象税”。

我将资源缓存在 Service Worker 中,我还需要这个吗?

与往常一样,如果你现有的解决方案对你很有效,那么没有理由更改为其他解决方案。查看“动机”并决定这些问题是否适用于你!

我必须使用这个吗?

如果你有一个客户端应用,你可以将其全部视为 Client Components。如果 Client Components 对你够用,那非常好!Server Components 扩展了 React 以支持其他场景,并且不能替代 Client Components。

这仅在我的应用跟 Facebook 差不多规模时才有用吗?

不。正如“动机”部分所述,Server Components 旨在解决 React 开发者面临的许多挑战 —— 从编写第一个应用一直到 Facebook 的规模,以及介于两者之间的每个人。如果你正在编写一个新应用,你可以直接访问文件系统或数据库,而无需建立 API 服务器。如果你已经有一个应用,你可以更灵活地访问你的所有数据,而无需在你的 API 中公开它。

你是否正在为前端重新发明 PHP/JSP/etc?

应用程序开发的历史是“瘦”客户端和“胖”客户端之间的一系列摆动。但现实情况是,应用的某些部分更加“静态” —— 非交互式,不需要即时数据一致性 —— 而其他部分则是“动态”的,需要交互性和即时响应。纯服务端渲染或纯客户端渲染都不是适用于所有情况的理想选择。Server Components —— 当与现有的 Client Components 结合使用时 —— 允许开发者使用最合适的方法编写应用的每个部分,同时共享单一语言和框架,甚至在服务端和客户端之间共享代码。

我今天可以在我的应用中使用它吗?

不。有关状态以及如何推出的更多信息,请参阅“采用策略”。

这在 Facebook 已经用于生产环境了吗?

我们正在对单个页面上的少量用户进行实验,结果令人鼓舞(已经减少了约 30% 的产品代码大小)。我们希望一开始就使用 Server Components 设计的应用可以节省更多。

这只能用于 Next.js 吗?

不,我们正在与 Next.js 合作构建首次集成。但是,Server Components 从一开始就被设计为能与任何框架一起使用或能集成到自定义应用中。鉴于 Server Components 的范围 —— 包括路由器、打包器和服务端/客户端协调 —— 我们认为高质量的首次集成将使我们能够向其他开发者演示。

我如何采用比较好?

请参阅“采用策略”。

为什么不直接使用 HTML 而不是自定义协议?

我们确实希望使用流式 HTML 进行初始渲染,但自定义协议允许我们传输数据(组件 props)和协调树(reconcile trees),以便客户端状态以及 DOM 焦点/滚动/状态可以保留。

静态生成不是更好吗?

静态生成非常适合某些用例,在这个场景下,你可以在构建时运行 Server Components。

调试功能是什么样的?

最开始,它与你在 Node 中调试 API 是一样的,但我们正在研究在 DEV 环境下将 Server Components 运行在一个 worker 中的能力,以便你可以将它们与 Client Components 一起调试。有关更多想法,请参阅“开放研究领域”。

Server / Client Components 的组合如何工作?有什么限制吗?

请参阅“Server / Client Components 的功能和约束”以及在“服务端和客户端之间共享代码”。

这对 React Native 意味着什么?

我们已经探索了 React Native 的早期概念验证,但目前没有优先考虑它。从长远来看,React Native 和其他渲染器可以支持 Server Components。

我必须从组件中直接读取我的数据库吗?

不,尽管这样做让你在刚开始的时候很方便。我们的目标是能够自由扩展,让你在直接访问数据库、微服务或其他方法之间自由移动,而无需重写你的应用。你还可以构建 JavaScript 抽象层来管理批处理查询并提供授权层。

我可以为 Server Components 编写自己的数据层吗?

当 API 稳定时,我们会为它编写文档,但本质上你需要告诉 React 如何在你的底层库之上完成数据请求的去重和缓存。

响应格式是什么?

它类似于 JSON,但带有可以稍后填充的“插槽”。这让我们可以使用广度优先的策略,分阶段流式传输内容。Suspense 边界有意地设计了加载时的视觉状态,因此我们可以在所有结果完全准备好之前就开始显示内容。这种协议是一种使用场景更丰富的形式,也可以被转换为 HTML 流,以加速首次的非交互式渲染。

这适用于 TypeScript 吗?

是的。有一个限制是我们目前还不能利用 TypeScript 来强制服务端/客户端组件之间的边界是可序列化的(即传递给客户端的所有 props 都需要是可序列化的)。

这与 Suspense 有什么关系?

Server Components 的数据获取 API 与 Suspense 集成。我们使用 Suspense 提供加载状态,解除对不完整内容的阻塞,以便客户端可以在整个响应完成之前显示一些内容。

这与并发模式有何关系?

并发模式是 React 内部的一系列优化,而且是与 Server Components 集成的。例如,并发模式让我们可以在数据流传输的过程中就开始渲染 Client Components,而无需等待整个响应完成。

你是怎么做路由的?

我们还不知道。这还是一个正在研究的领域。目前还缺少一个类似于 Context 的 Server Context 功能,我们需要先将其添加到 React 中。

为什么不使用 async / await ?

Demo 和 RFC 中使用的 React IO 库遵循了前面讨论过的约定,要设计与 Suspense 兼容的数据获取 API。与 Suspense 兼容的 API 在数据可用时用同步的方式返回数据,如果有错误则抛出异常,或者用“暂停(Suspend)”的方式告诉 React 它们无法马上返回值。Suspend 的机制是给出一个 Promise。React 使用这个 Promise 来知道 API 何时会准备好值(或者它已经失败),然后安排再次渲染组件。

本提案的 Suspense 设计中的一个新考虑因素是,我们希望 Server Components、Client Components 和 Shared Components 能够使用一致的 API 来访问数据。但总的来说,Suspense 的设计超出了本 RFC 的范围。我们应该在新的一年里优先把 Suspense 的设计清晰地写到一个文档中。

每当 Server Components 的 props 发生变化时,是否都会重新获取?

不,当父 Client Components 重新渲染时,它下面的 Server Components 不会重新获取。从客户端的视角来看,根本没有 Server Components —— 它只能看到已经解析的树,包括 div 和其他 Client Components。当 Server Components 需要更新时,例如有一个变更需要被展示,React 将重新获取整个 Server Components 树的输出(更细粒度的失效(invalidation)还是一个开放的研究领域)。目前,Server Components 树从应用的根节点开始,但我们计划允许从其他的入口点进行更精细的重新获取。

Server Components 是否总是重新获取整个应用?那不是很慢吗?

在演示项目中,我们重新获取整个应用。这听起来很慢,但可以认为它类似于过去我们获取 HTML 页面。我们计划引入更精细的重新获取机制,以便你可以选择仅重新获取其中的一部分,但目前尚不可用。

Server Components 的性能优势是什么?

Server Components 允许你将大部分数据获取放在服务器上,这样客户端就不需要发出很多请求。这也避免了客户端的串行请求,这是一种在使用 useEffect 触发请求时的典型情况。请注意,这实现了 GraphQL 的一些好处 - 当然,你仍然可以将 Server Components 与 GraphQL 结合使用。

Server Components 还允许你在不增加包大小的情况下在应用中添加非交互式功能。将功能从客户端移动到服务端会减少包大小和客户端 JS 解析时间。拥有更少的 Client Components 也可以减少客户端的 CPU 时间。客户端可以在协调(reconciliation)期间跳过服务端生成的部分树,因为它知道它们不可能受到任何状态更新的影响。

如果总是重新获取 UI,会不会使交互变慢?

你可以(并且应该)使用 Client Components 进行快速交互。此外,你肯定不希望总是重新获取 —— 获取的树可以保留在客户端缓存中并被重用于导航,如后退按钮。

这与 PHP/Rails/etc 有什么不同?

同一个组件可以在客户端和服务端之间被重用于不同的使用场景。例如 Markdown 渲染器,它在客户端使用时可以提供实时预览,在服务端渲染时可以用在普通的展示场景。这是可能的,因为它们是用相同的范式和语言编写的。

这与 ASP .NET WebForms 有何不同?

我们不会将程序的整个状态发送到服务器。用户交互是使用 Client Components。

这与 Phoenix LiveView 有何不同?

我们的服务器没有状态。缺点是我们使用更粗暴地重新获取。

Server Components 的缺点是什么?

参考“缺点”

为什么不用 Rx?

Rx 是管理数据流的绝佳解决方案。我们希望框架作者可能会发现 Rx 非常适合实现传输层来接收 React 在服务端创建的渲染好的 UI 数据流并将它们流式传输到客户端。

有没有考虑 XSS 呢?

用于对 Server Components 输出进行编码的流协议对用户提供的输入已经进行了编码,以防止 XSS 攻击。

原文链接:github.com/josephsavon…