likes
comments
collection
share

一半React专家都会答错的三道面试题!你能做对几道?

作者站长头像
站长
· 阅读数 13

React Server Component是React团队在2020年底惊天动地地推出的一个革命性的新特性,它可以让开发者在服务器端渲染组件,从而飞速提升性能和极大简化代码。然而,这个特性也带来了一些前所未有的挑战和问题,许多React资深工程师都对此一无所知或者误解颠倒。

RSC作为React社区的当红炸子鸡,布道师Dan Abramov在和Angular/Qwik创始人Misko对线时献祭出了React Server Component三大问题。而这三大问题揭示了React Server Component的三大特性!

在这篇文章里将剖析这三道秒杀了半数React专家的面试题。如果你想成为一个React大神,想在面试中秒杀竞争者、惊艳面试官,那么你绝对不能错过这篇文章。

一句话总结RSC的工作原理: React在服务端会把 Client Component会被渲染成脚本的引用,Server Component会被流式渲染成类JSON UI。引用和JSON再会传到浏览器中进行协调和视图更新。

Dan Abramov的三大RSC问题

第一问

你现在有一个笔记编辑的组件。

function Note({ note }) {
  return (
    <Toggle>
      <Details note={note} />
    </Toggle>
}

这些组件中,唯一的客户端组件是Toggle。它有状态(isOn,初始值为 false)。它返回 <>{isOn ? children : null}</>。 当你 setIsOn(true)了以后会发生什么?

  1. 会发起请求获取Details
  2. Details会立刻出现

第二问

现在假设 isOn 是 true。你编辑了笔记,并告诉router“刷新”路由。这会重新获取这个路由的 RSC 树,你的Note服务端组件会收到一个带有最新数据库内容的note属性。 (1)Toggle 的状态会重置吗? (2)Details 会显示最新的内容吗?

  1. 会重置,会显示
  2. 会重置,不会显示
  3. 不会重置,会显示
  4. 不会重置,不会显示

第三问

这是一个Follow up问题。 假设你有一个App组件,内容如下

<Layout
  left={<Sidebar />}
  right={<Content />}
/>

这些都是服务端组件。但是现在你想给 Layout 添加一点状态,比如列宽,它会随着鼠标拖动而改变。 你可以把 Layout 变成一个客户端组件吗?如果可以,拖动时会发生什么?

  1. 不可以,Server组件不可以被改写成Client组件
  2. 可以,Sidebar和Content组件在拖动时被重新抓取
  3. 可以,任何在拖动时都不会被抓取

相信对RSC不了解的读者,可能看完这三个问题是全懵的,根本看不懂在问什么。 接下来我们先带不了解RSC的同学回顾一下概念,已经理解的可以跳过这章节。

什么是React Server Component

什么是RSC?它解决了什么问题? 为什么说RSC是React的未来?

React Server Component是一种特殊的React组件,它不是在浏览器端运行,而是在服务器端运行。这样,它可以直接访问服务器的数据和资源,而不需要通过API或者GraphQL等方式来获取。这样,它可以减少网络请求的次数和数据的大小,从而提高页面的加载速度和用户体验。同时,它也可以简化组件的逻辑和状态管理,因为它不需要考虑客户端的渲染和交互。React Server Component是一种动态的组件,它可以根据不同的请求和参数来返回不同的内容,而不需要重新编译或者部署。

React Server Component的目的是让开发者能够构建跨越服务器和客户端的应用,结合客户端应用的丰富交互性和传统服务器渲染的优化性能。React Server Component可以解决一些现有技术无法解决或者解决不好的问题,例如:

  • 零包大小:React Server Component的代码只在服务端运行,永远不会被下载到客户端,因此不会影响客户端的包大小和启动时间。而客户端只接收RSC渲染完的结果。
  • 完全访问后端:React Server Component可以直接访问后端的数据源,例如数据库、文件系统或者微服务等,而不需要通过中间层来封装或者转换。
  • 自动代码分割:React Server Component可以动态地选择要渲染哪些客户端组件,从而让客户端只下载必要的代码。
  • 无客户端-服务器瀑布流:React Server Component可以在服务器上加载数据并作为props传递给客户端组件,从而避免了客户端-服务器瀑布流问题。 避免抽象税:React Server Component可以使用原生的JavaScript语法和特性,例如async/await等,而不需要使用特定的库或者框架来实现数据获取或者渲染逻辑。

Sever Component 和 Client Component

在知道RSC工作原理之前,我们必须先理解RSC中的两个大概念,服务端组件(Server Component)和客户端组件(Client Component)。

服务端组件

顾名思义,服务端组件在服务器上每次请求只运行一次,所以它们没有状态,也不能使用只存在于客户端的特性。具体来说:

  • ❌ 不能使用状态和副作用,因为它们(概念上)在服务器上每次请求只运行一次。所以useState()、useReducer()、useEffect()和useLayoutEffect()不被支持。也不能使用依赖于状态或者副作用的自定义hook。
  • ❌ 不能使用浏览器专用的API,例如DOM(除非你在服务器上对它们进行了polyfill)。
  • ✅ 可以使用async / await来访问服务器端的数据源,例如数据库、内部(微)服务、文件系统等。
  • ✅ 可以渲染其他的服务端组件、原生元素(divspan等)或者客户端组件。

开发者也可以创建一些为服务端设计的自定义Hook或者工具库。所有的服务端组件的规则都适用。例如,一个服务器钩子的用例是提供一些访问服务器端数据源的辅助函数。

客户端组件

Client Component是标准的 React 组件,所以以前写的所有规则都适用。要考虑的新规则主要是它们不能对服务端组件做什么。

  • ❌ 不得导入服务端组件或调用服务器Hook/工具库,因为这些只在服务器上工作。 但是,服务端组件可以将另一个服务端组件作为children传递给客户端组件。
  • ❌ 不得使用仅限服务器的数据源。
  • ✅ 可以使用状态和副作用,以及自定义的React Hook。
  • ✅ 可以使用浏览器的 API。

这里需要着重讲服务端组件和客户端组件的嵌套,虽然客户端组件不能直接import服务端组件,但是可以把服务端组件以children方式。比如可以写这样的代码<ClientTabBar><ServerTabContent/></ClientTabBar>。从客户端组件的角度来看,它的子组件将是一个已经渲染好的树,比如 ServerTabContent的输出。这意味着服务器和客户端组件可以在任何层级上嵌套和交错。我们会在后面的面试题中讲解这个设计。

RSC是怎么工作的?

在理解了服务端组件和客户端组件之后,我们可以开始理解RSC的工作原理了。RSC的渲染分为两个大阶段:初始加载和更新渲染;渲染的环境也有两种,服务器和浏览器。需要注意的是,服务端组件虽然只运行在服务器上,但是浏览器也需要感知到它。客户端组件也类似。

初始加载

1. 服务器

  • [框架] 框架的路由将请求的 URL 与服务端组件匹配,将路由参数作为 props 传递给组件。然后调用React 渲染组件及其 props。
  • [React] React 渲染根服务端组件,并且递归渲染任何也是服务端组件的子组件。
  • [React] 渲染在原生组件(divspan 等)和客户端组件处停止。原生组件以 UI 的 JSON 描述形式流式传输,客户端组件以序列化的 props 加上组件代码的引用形式流式传输。
  • [框架] 框架负责在 React 渲染每个 UI 单元时逐步将渲染输出流式传输到客户端。

默认情况下 React 返回渲染 UI 的描述,是一段类似JSON的数据结构,而不是 HTML。用JSON数据将允许新获取的数据与现有客户端组件更方便进行reconciliation。当然框架可以选择将服务端组件与“服务器端渲染”(SSR)结合使用,以便也将初始渲染作为 HTML 流式传输,这将加快页面初始非交互式显示的速度。

在服务端如果任何服务端组件挂起,React 将暂停该子树的渲染,并流式传输一个占位符值。当组件能够继续(取消挂起)时,React 将重新渲染组件,并将组件的实际结果流式传输到客户端。您可以将传输到目标的数据视为 JSON,但具有挂起的组件的插槽,其中这些插槽的值作为响应流中的附加项提供。

我们看一个渲染UI描述的例子,来帮助我们理解这段话。

假设我们要渲染一个div

<div>oh my</div>

在调用React.createElement后会生成类似如下的数据结构

{
  $$typeof: Symbol(react.element),
  type: "div",
  props: { title: "oh my" },
}

这个数据结构会流式传递给浏览器,流式结构的形态大致如下:

// Sever Component Ouptut
J0:["$","@1",null,{"children":["$","span",null,{"children":"Hello from server land"}]}]
// Client Component Reference
M1:{"id":"./src/ClientComponent.js","chunks":["client1"],"name":""}

J0代表了一个Server Component的输出,可以看到,它的输出和React的数据结构类似,只是结构不是object而是个array。 数组元素$代表createElement,而后面的元素{children: xxx}代表了props。在children中用了原生组件span,它的tag直接用string传输了。 @1是一个placeholder,在下面的输出中M1中的1和它对应,会把M1的数据填入到@1的位置。

M1的输出是一个客户端组件的引用,它包含了脚本名,chunk名字和export名字,用于浏览器的运行时(比如webpack)动态引入客户端组件代码。

这个结构就是上面说的流式传输结构。

总结来说,一个ServerComponent会被渲染成表达UI的类JSON数据,而客户端组件会转成一个表达脚本引用JSON数据。

思考题:为什么用这个类JSON的数据结构不是JSON呢?

2. 浏览器

  • [框架] 在客户端,框架接收流式 React 响应并使用 React 在页面上渲染它。
  • [React] React 反序列化响应并渲染原生元素和客户端组件。
  • [React] 一旦所有客户端组件和所有服务端组件输出都已加载,最终 UI 状态将显示给用户。至此所有 Suspense 边界已经被揭示完成。

注意浏览器渲染是逐步发生的。React 不需要等待整个流完成就能显示一些内容。Suspense 允许开发人员在加载客户端组件的代码以及服务端组件正在获取剩余数据时显示有意的加载状态。

更新渲染

服务端组件还支持重新加载以查看最新数据。请注意,开发人员不会单独获取组件:这个想法是,给定一些起始服务端组件和 props,整个子树都会被重新获取。与初始加载一样,这通常涉及与路由和脚本打包的集成:

1. 在客户端:

  • [App] 当应用程序改变状态或者改变路由,会请求服务器重新获取需要改变的Server Component 的新UI。
  • [框架] 框架协调从适当的API endpoint发送新路由和props,请求渲染结果。

2. 在服务器上:

  • [框架] 在接口接收请求并将其与请求的服务端组件匹配。并且调用 React 渲染组件和 props,并处理渲染结果的流。
  • [React] React 将组件渲染到目标,对不同的组件渲染策略和初始加载一样。
  • [框架] 框架负责逐步将流式响应数据返回给客户端。

3. 在客户端:

  • [框架] 框架接收流式响应并使用新的渲染输出触发路由的重新渲染。
  • [React] React 将新的渲染输出与屏幕上的现有组件进行reconciliation。因为 UI 的描述是数据,而不是 HTML,所以 React 可以将新的 props 合并到现有的组件中,保留重要的 UI 状态,例如焦点或输入输入,或者在现有内容上触发 CSS 过渡。这是服务端组件将渲染 UI 输出作为数据(“虚拟 DOM”)而不是 HTML 返回的一个关键原因。

小结

这节非常长,但我们一句话总结RSC的工作原理: Client Component会被渲染成脚本的引用,Server Component会被流式渲染成类JSON UI,带有async/await的ServerComponent会先用placeholder代替,在resolve后通过streaming传给浏览器。 下面的表格有更多的细节和原理解析。

运行阶段运行平台服务端组件客户端组件
初始加载服务器运行、渲染为UI JSON不运行,传递成脚本引用
初始加载浏览器不运行、接收到UI JSON渲染成dom运行,解析脚本引用并渲染为dom
更新渲染浏览器不运行、请求服务器获取新UI运行,更新状态
更新渲染服务器运行、接收到props和路由渲染成UI JSON不运行
更新渲染浏览器不运行、接收到新UI JSON更新dom运行,协调客户端状态和RSC UI JSON到dom

三大面试题揭露RSC三大特性

我们来看上面的渲染流程在面试题中是怎么被应用的?这篇文章里会结合这三个问题,解释RSC的三大特性:渲染完备(Complete)、状态统一(Consistent)、组件互通(Commutative)。

  1. Complete 渲染完备
function Note({ note }) {
  return (
    <Toggle>
      <Details note={note} />
    </Toggle>
}

我们需要扩写一下问题的组件,使问题更加清晰一点。

"use client";

import { useState } from "react"

export function Toggle(props) {
  const [isOn, setIsOn] = useState(false)
  return (
    <div>
      <button onClick={() => setIsOn(on => !on)}>Toggle</button>
      <div>{isOn ? "on" : "off"}</div>
      <div>{isOn ? props.children : <p>not showing children</p>}</div>
    </div>
  )
}
export async function Details(props) {
  const details = await getDetails(props.note);
  return <div>{details}</div>;
}

async function getDetails(note: string) {
  await new Promise((resolve) => setTimeout(resolve, 2000));
  return `Details for ${note}`;
}

在这个例子里,NoteDetails是服务端组件。Toggle虽然是客户端组件,但是它的children Details是直接出现在Note这个服务端组件下的。所以在渲染Note的时候,他会大致被渲染成

{
  $$typeof: Symbol(react.element),
  type: {
    $$typeof: Symbol(react.module.reference),
    name: "default",
    filename: "./Toggle.js"
  },  
  props: { children: [ 
   // children, note the type
   {
      $$typeof: Symbol(react.element),
      type: Details,  // Details is rendered!
      props: { note: note },
   } 
  ] },
}

注意到,Detail在服务端的时候总是被渲染并传送到客户端的。在客户端渲染Toggle的时候,虽然Details没有被使用到,但它的渲染结果依然会被传送前端。尽管Details是一个用到了async/await的异步服务端组件,由于React Server Component的流式处理,它依然可以在异步完成后再送到前端。并且在用户改变状态的时候,由于Details的props和服务端渲染的一致,客户端可以直接使用服务器预渲染的结果进行dom操作。 因此,这个问题的答案是Details会立刻出现。

这个问题揭示了React Server Componnet的“完备性”:只要组件出现在服务端组件的render函数下,会做到应渲染尽渲染。

  1. Consistent 状态一致

现在假设 isOn 是 true。你编辑了笔记,并告诉router“刷新”路由。这会重新获取这个路由的 RSC 树,你的Note服务端组件会收到一个带有最新数据库内容的note属性。

第二个问题揭示了RSC的一致性。当Toggle组件在客户端改变了props,这个变动会被同步到服务端组件和客户端组件,并且双端保持一致。 <Details note={note} /> 在note变化的时候,React检测到note的变化,向服务器发送请求,获取新的Details渲染数据。 而且,客户端组件 Toggle本身的状态是不会在浏览器里被重置的。

因此,RSC的设计保证了应用的状态在双端一致。

  1. Commutative 组件互通

对于第三个问题,我们一样扩写一下题干。

function App() {
  return (
    <Layout
      left={<Sidebar />}
      right={<Content />}
    />
  )
}

假设Layout之前的实现是个服务端组件

// Server Component
export function Layout(props: {
  left: React.ReactNode
  right: React.ReactNode
}) {
  return (
    <div>
      <div>
        <div style={{ width: `${width}px` }}>{props.left}</div>
        <div style={{ width: `${500 - width}px` }}>{props.right}</div>
      </div>
    </div>
  )
}

首先我们是能将它改写成使用状态的客户端组件的。

"use client"

import { useState } from "react"

export function Layout(props: {
  left: React.ReactNode
  right: React.ReactNode
}) {
  const [width, setWidth] = useState(200)
  return (
    <div>
      <input
        type="range"
        step={1}
        value={width}
        onChange={(e) => setWidth(Number(e.target.value))}
      />
      <div>
        <div style={{ width: `${width}px` }}>{props.left}</div>
        <div style={{ width: `${500 - width}px` }}>{props.right}</div>
      </div>
    </div>
  )
}

并且在使用的代码也是一模一样的!它唯一的差别是在渲染App组件时,序列Layout的结果不同。

 {
   $$typeof: Symbol(react.element),
-  type: Layout,
+  type: {
+    $$typeof: Symbol(react.module.reference),
+    name: "default",
+    filename: "./Layout.js"
+  },  
   props: { 
    left: {
       $$typeof: Symbol(react.element),
       type: Sidebar,  
    },
    right: {
       $$typeof: Symbol(react.element),
       type: Content,  
    }
   },
 }

可以看到,在序列化的时候,Layout从一个服务端组件被转化成了一个客户端组件,而它的子组件没有变化。 修改之前,渲染Layout的过程在服务端发生,它的子组件也是在服务端渲染,渲染结果都被发送到浏览器转化成DOM。 而修改之后,渲染Layout的过程发生在浏览器,但是子组件依然在服务端渲染,在浏览器渲染的时候,Layout把服务端子组件的结果插入到浏览器DOM中。 而子组件的props没有改变(因为他们没有props),所以服务端的渲染结果不需要重新抓取。

这一个问题的答案就是“可以转换成客户端组件,而且子组件不会被重新抓取”。

我们在RSC项目中可以把服务端组件重写成客户端组件,而不需要重写组件调用的特性叫做组件互通“commutative”。

总结

React Server Component的文档和说明非常晦涩难懂,而且不给出实际的例子,导致很多人都不知道它到底是什么。 本文试图通过一个简单的例子,来解释React Server Component的设计思路和原理。希望能够帮助大家理解这个新特性,成为少数可以让你不看Youtube视频就能学会RSC的资料。更让你了解RSC的原理和三大性能特性!渲染完备(Complete)、状态一致(Consistent)、组件互通(Commutative)。

创作不易,如果你觉得本文对你有帮助,欢迎点赞转发和打赏! github.com/sponsors/He…

参考文献

  1. React 18: React Server Components | Next.js. nextjs.org/docs/advanc….
  2. What you need to know about React Server Components. blog.logrocket.com/what-you-ne….
  3. React Server Components. - It’s not server-side rendering. | by Nathan .... blog.bitsrc.io/react-serve….
  4. What are React Server Components? - FreeCodecamp. www.freecodecamp.org/news/what-a….
  5. How React Server Compoents Wors. www.plasmic.app/blog/how-re…
  6. 45% failed Dan's Server Component Quiz www.youtube.com/watch?v=AGA…