跳到主要内容

· 阅读需 40 分钟

简介

亲爱的,React 诞生十年了,这让我感到惊奇和欣慰。在这十年里,React 经历了几次重大的升级迭代。React 团队 一直在尝试新的解决方案。比如,他们最近发布了 React 服务器组件,这是一次巨大的变革。服务器组件可以完全 运行在服务器端。

网上对这一新特性有很多困惑和疑问。我尝试了服务器组件,并解答了许多自己的疑问。我对它越来越激动了。它 真的很酷!

我今天的目标是帮助大家消除关于 React 服务器组件的疑惑,回答你们可能有的问题。

提示

本教程主要针对已经在使用 React 的开发者,特别是那些对 React 服务器组件感兴趣的人。你不需要是 React 专 家,但如果你才刚刚开始学习 React,可能会觉得比较困惑。

关于服务端渲染的简单介绍

为了理解 React 服务端组件,首先需要了解服务端渲染(SSR)的工作原理。如果你已经熟悉 SSR,可以直接跳到下一 节!

当我 2015 年刚开始使用 React 时,大多数 React 应用采用“客户端渲染”策略。用户会收到一个像这样的 HTML 文件:

<!DOCTYPE html>
<html>
<body>
<div id="root"></div>
<script src="/static/js/bundle.js"></script>
</body>
</html>

这个 bundle.js 脚本包含了我们需要的所有代码,包括 React、第三方依赖和我们编写的代码。

加载并解析 JS 后,React 会立即开始工作,在空的 <div id="root"> 中生成整个应用的 DOM 节点。

这个方法的问题是需要时间来完成所有工作。而在此期间,用户会面对一个空白页面。随着我们不断添加新特 性,这个问题会越来越严重,因为 JS bundle 会变得越来越大,加载和解析的时间也越长。

服务端渲染就是为了改善这一体验而设计的。服务器不再发送空白 HTML ,而是渲染我们的应用生成实际的 HTML。 这样用户可以立即看到完整的页面。

生成的 HTML 仍会包含 <script> 标签,因为我们仍需要 React 在浏览器端运行,来处理交互。但是我们会以稍 微不同的方式配置浏览器中的 React:它不再从头开始生成所有 DOM 节点,而是 采用 已存在的 HTML。这个过 程称为水化

React 核心团队成员 Dan Abramov 是这样解释水化的:

水化就像用互动和事件处理程序的“水”浇灌“干燥”的 HTML。

一旦 JS bundle 下载完成,React 会快速遍历整个应用,构建一个 UI 的虚拟示意图,并将其“套上”真实 DOM,绑定 事件处理函数,触发效果等等。

总之,这就是 SSR 的工作原理。服务器生成初始 HTML,这样用户不再需要面对空白页等待 JS 下载和解析。客户端 React 接手服务端 React 的工作,采用 DOM 并添加交互。

总结

当我们谈论服务端渲染时,通常想象的流程如下:

  1. 用户访问 myWebsite.com。

  2. Node.js 服务器接收请求,并立即渲染 React 应用,生成 HTML。

  3. 刚生成的 HTML 发送给客户端。

这是实现服务端渲染的一种方式,但并非唯一方式。另一种选择是在构建应用时生成 HTML。

通常,React 应用需要被编译,将 JSX 转换为普通 JavaScript,并打包所有模块。如果在同一过程中,我们为所有不 同的路由“预渲染”了所有 HTML,岂不很好?

这就是通常所说的静态网站生成(SSG)。它是服务端渲染的一个子变种。

在我看来,“服务端渲染”是一个总括术语,包括几种不同的渲染策略。它们有一个共同点:初始渲染发生在像Node.js 这样的服务器运行时,使用 ReactDOMServer API。渲染何时发生并不重要,无论是按需还是在编译时都算是服务端渲染。

客户端与服务端的往返

让我们来讨论 React 中的数据获取。 通常,我们会有两个相互通过网络进行通信的应用:客户端 React 应用服务端 REST API 使用像 React Query、SWR 或 Apollo 这样的库,客户端会发起网络请求到后端,后端再从数据库获取数据并通过网络发回。

我们可以用一个图来可视化这个流程:

说明

本文包含几个“网络请求图”,目的是可视化不同渲染策略下,数据在客户端(浏览器)和服务端(后端 API)之间的传递。

底部的数字代表一个假想的时间单位,并不是分钟或秒。实际的数字会根据许多不同因素而大相径庭。 这些图旨在帮助你从高层次理解相关概念,并不是在模拟任何真实数据。

主要关注数据流动的 overall 方向和 client 与 server 之间的往返次数。具体的时间数值并不重要,仅用于说明的目的。

这个第一个图展示了使用客户端渲染(CSR)策略的流程。它从客户端收到一个 HTML 文件开始。这个文件没有任何内容,只包含一个或多个 <script>标签。

一旦 JS 下载并解析后,我们的 React 应用会启动,生成一堆 DOM 节点并填充 UI。但一开始,我们还没有任何实际的数据,所以只能渲染布局(头部、底部、总体布局)和加载状态。

你可能经常见过这种模式。例如,Uber Eats 在获取需要的数据渲染实际餐厅之前,会先渲染布局:

用户会看到这个加载状态,直到网络请求完成,React 用真实内容重新渲染为止。

让我们看另一种架构方式。 这个图保持了相同的数据获取模式,但使用了服务端渲染而不是客户端渲染:

在这个新的流程中,我们在服务器上执行首次渲染。这意味着用户收到的 HTML 文件不再是完全空白的。

这是一个改进 - 相比空白页,shell 体验更好 - 但从整体来看,它并没有带来非常明显的提升。用户访问我们的应用不是为了看加载界面,而是为了查看内容(餐厅、酒店、搜索结果、消息等等)。

为了真正理解用户体验上的差异,我们在图中添加一些网页性能指标。在这两种流程中切换,并留意指标的变化:

这些标志代表了一些常用的网页性能指标。具体如下:

  1. 首次绘制 - 用户不再盯着空白屏幕。总体布局已经渲染出来,但内容还缺失。这有时也称为 FCP(首次内容绘制)。

  2. 页面可交互 - React 已下载,应用已经渲染/水化。可交互元素现在可以完全响应。这有时也称为 TTI(可交互时间)。

  3. 内容绘制 - 页面现在包含用户关心的内容。我们从数据库拉取数据并在 UI 中渲染出来。这有时也称为 LCP(最大内容绘制)。

通过在服务器上进行初始渲染,我们可以更快地绘制那个初始“shell”。这可以使加载体验感觉更快,因为它提供了进度的感觉,似乎东西在发生。

在某些情况下,这确实会是一个明显的改进。例如,用户可能只是等待头部加载以便点击一个导航链接。

但这种流程感觉有点傻,不是吗? 当我看SSR图时,我不禁注意到请求是从服务器上开始的。与需要第二次网络往返请求不同,为什么我们不在那个初始请求期间进行数据库工作呢?

换句话说,为什么不这样做呢?

通过在初始请求中获取数据,我们可以避免客户端需要进行第二次请求。这不仅改善了性能,还简化了客户端的逻辑。

总体来说,能在一个请求中完成服务端渲染和数据获取是更好的方案。这正是React服务端组件希望解决的问题。

与其客户端与服务端来回跳转,不如我们在初始请求中进行数据库查询,直接将填充了数据的 UI 发送给用户。

但问题是,我们要如何具体实现呢?

为了让这种方式可行,我们需要能够给 React 一段仅在服务端运行的代码,用来进行数据库查询。但在 React 中一直没有这样的选项......即使使用服务端渲染,我们的所有组件也会在服务端和客户端都渲染。

为解决这个问题,生态系统提出了许多方案。 一些元框架,比如 Next.js 和 Gatsby 创造了自己的方式在服务端执行代码。

例如,使用 Next.js(传统的 “页面” 路由)看起来像这样:

import db from 'imaginary-db';
// This code only runs on the server:
export async function getServerSideProps() {
const link = db.connect('localhost', 'root', 'passw0rd');
const data = await db.query(link, 'SELECT * FROM products');
return {
props: { data },
};
}
// This code runs on the server + on the client
export default function Homepage({ data }) {
return (
<>
<h1>Trending Products</h1>
{data.map((item) => (
<article key={item.id}>
<h2>{item.title}</h2>
<p>{item.description}</p>
</article>
))}
</>
);
}

让我们分解一下: 当服务端收到请求时,getServerSideProps 函数会被调用。它返回一个 props 对象。那些 props 会注入组件,组件首先在服务端渲染,然后在客户端水化。

巧妙之处在于,getServerSideProps 不会在客户端重新运行。事实上,这个函数甚至不会被打包到我们的 JavaScript 中!

这种方法非常前卫。老实说,它非常棒。但也存在一些缺点:

  1. 这种策略只适用于路由层面的组件,也就是组件树的顶层。我们无法在任意组件中使用。

  2. 每个元框架都提出了自己的解决方案。Next.js 有一套方式,Gatsby 有另一套,Remix 也不同。还没有标准化。

  3. 我们的所有 React 组件总是会在客户端水化,即使没有必要。

多年来,React 团队一直在悄悄研究这个问题,设法提出官方的解决方案。他们的解决方案被称为 React 服务端组件

React 服务器组件简介

从高层次来看,React 服务端组件 是一种全新的范例。在这种新方案中,我们可以创建只在服务端运行的组件。这使我们可以在 React 组件内直接编写数据库查询等操作!

这样就无需像以前那样在服务端和客户端编写重复的渲染逻辑。我们可以用同一组件同时处理服务端渲染和数据获取。

React 服务端组件为构建真正的同构 React 应用提供了基础。这是 React 在可扩展性和性能上的一次突破。

这是“服务器组件”的一个简单示例:


import db from 'imaginary-db';
async function Homepage() {
const link = db.connect('localhost', 'root', 'passw0rd');
const data = await db.query(link, 'SELECT * FROM products');
return (
<>
<h1>Trending Products</h1>
{data.map((item) => (
<article key={item.id}>
<h2>{item.title}</h2>
<p>{item.description}</p>
</article>
))}
</>
);
}
export default Homepage;

作为使用React多年的开发者,起初看到这种代码我觉得非常惊讶。 😅

我的直觉尖叫着:“等等!函数组件不可以是异步的!我们也不允许在渲染中直接有副作用!”

需要理解的关键是: 服务端组件永远不会重新渲染。它们在服务器上运行一次来生成UI。渲染后的值被发送到客户端并固定住。从 React 的角度看,这个输出是不变的,永远不会改变。*

这意味着 React 的大部分 API 与服务端组件不兼容。例如,我们不能使用 state,因为 state 可以改变,但服务端组件无法重新渲染。我们也不能使用 effects,因为 effects 只会在渲染之后运行,在客户端上,而服务端组件永远不会到达客户端。

这也意味着我们在规则上有更多灵活性。例如,在传统 React 中,我们需要把副作用放进 useEffect 回调或事件处理函数中,以防它们在每次渲染时重复执行。但是如果组件只运行一次,我们就不需要担心这个问题!

服务端组件本身非常简单,但“React 服务端组件”范式要复杂得多。这是因为我们仍然有通常的组件,它们的组合方式可能会让人困惑。

在这种新范式中,我们熟悉的“传统” React 组件称为客户端组件。坦白说,我不太喜欢这个名称。😅

“客户端组件”这个名称意味着这些组件只会在客户端渲染,但实际上不是这样。客户端组件会在客户端和服务端都渲染

我知道所有的这些术语都很容易让人困惑,所以我来概括一下:

  • React 服务端组件 是这种新范式的名字。
  • 在这种新范式中,我们熟知和喜爱的“标准” React 组件被重新命名为客户端组件。这是旧事物的新名字。
  • 这个新范式引入了一种新的组件类型,服务端组件。这些新组件仅在服务端渲染。它们的代码不会被打包到 JS 中,也不会水化或重新渲染。
React 服务端组件 vs 服务端渲染

让我们澄清另一个常见的困惑:React 服务端组件不是服务端渲染的替代品。你不应该认为 React 服务端组件是“SSR 2.0”。

相反,我喜欢把它看作是两个可以完美嵌合的拼图片段,两种互补的风味。

我们仍然依赖服务端渲染来生成初始 HTML。React 服务端组件在此基础上,允许我们从客户端 JavaScript 包中省略某些组件,确保它们只在服务器上运行。

事实上,即使不使用服务端渲染,也可以使用 React 服务端组件,尽管在实践中,把两者结合使用可以获得更好的效果。如果你想看例子,React 团队已经建立了一个不使用 SSR 的最小化 RSC 演示。

兼容环境

通常情况下,当推出新的React功能时,我们可以通过将React依赖项升级到最新版本来在现有项目中开始使用它。快速运行npm install react@latest命令,就可以开始使用了。

不幸的是,React服务端组件并不像这样工作。

据我了解,React服务端组件需要与React之外的许多其他内容进行紧密集成,例如打包工具、服务器和路由器。

截至我撰写此文时,开始使用React服务端组件的唯一方法是使用Next.js 13.4+,并使用其全新重新架构的“App Router”。

希望在将来,更多基于React的框架将开始整合React服务端组件。一个核心的React功能只在一个特定的工具中可用,这感觉有些尴尬!React文档中有一个“Bleeding-edge frameworks”(最前沿的框架)部分,列出了支持React服务端组件的框架;我计划不时查看该页面,以了解是否有新的选择可用。

指定客户端组件

在这个新的“React 服务器组件”范例中,默认情况下所有组件都被假定为服务器组件。我们必须“选择加入”客户端组件。

我们通过指定一个全新的指令来做到这一点:

'use client';

import React from 'react';

function Counter() {
const [count, setCount] = React.useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Current value: {count}
</button>
);
}
export default Counter;

在顶部的独立字符串'use client'是我们向React发出的信号,表明该文件中的组件是客户端组件,它们应该包含在我们的JS捆绑包中,以便在客户端上重新渲染。

这可能看起来是一种非常奇怪的指定组件类型的方式,但在这种情况下有一个先例:JavaScript中的“use strict”指令,它选择进入“严格模式”。

在我们的服务器组件中,我们不需要指定'use server'指令;在React服务端组件范例中,默认情况下将组件视为服务器组件。实际上,'use server'用于服务器操作,这是一个完全不同的功能,超出了本博文的范围。

应该使用客户端组件还是服务端组件?

你可能会猜测:我应该如何决定一个组件应该是服务端组件还是客户端组件?

总的来说,如果一个组件可以是服务端组件,那它就应该是服务端组件。服务端组件往往更简单,更容易推理。性能也更好:因为服务端组件不会运行在客户端,它们的代码不会被打包到 JavaScript 中。React 服务端组件范式的好处之一是它有可能改善可交互时间(TTI)指标。

也就是说,我们也不应该把使客户端组件尽可能少作为目标!我们不应该试图优化最小数量的客户端组件。值得记住,在此之前,每一个 React 应用中的每个组件都是客户端组件。

当你开始使用 React 服务端组件时,你会发现这很直观。一些组件需要在客户端运行,因为它们使用 state 或 effects。你可以在这些组件上加一个 'use client' 指令。否则,它们可以留作服务端组件。

边界

当我开始熟悉React服务端组件时,我最初遇到的一个问题是:当props发生变化时会发生什么?

例如,假设我们有一个如下所示的服务器组件:


function HitCounter({ hits }) {
return (
<div>
Number of hits: {hits}
</div>
);
}

假设在初始服务器端渲染中,点击次数等于 0。然后,该组件将生成以下标记:

<div>
Number of hits: 0
</div>

但是,如果 hits 的值改变了呢?假设它是一个 state 变量,从 0 变成了 1。HitCounter 需要重新渲染,但是它不能重新渲染,因为它是一个服务端组件!

关键是,服务端组件单独使用并不合理。 我们需要拉远视角,从更全局的角度考虑我们的应用结构。

假设我们有如下组件树:

如果所有组件都是服务端组件,那么一切都说得通。props 永远不会改变,因为没有组件会重新渲染。

但是假设 Article 组件拥有 hits 状态变量。为了使用状态,我们需要将其转换为客户端组件:

你看到问题了吗?当 Article 重新渲染时,它拥有的组件也会重新渲染,包括 HitCounter 和 Discussion。但是如果它们是服务端组件,它们就不能重新渲染。

为了防止这种不可能的情况,React 团队添加了一个规则:客户端组件只能导入其他客户端组件。这个 'use client' 指令意味着这些 HitCounter 和 Discussion 实例需要变成客户端组件。

我在学习 React 服务端组件时的最大“灵光一闪”时刻之一,是意识到这种新范式完全是关于创建客户端边界。在实践中,会发生这样的情况:

当我们在 Article 组件中添加 'use client' 指令时,我们创建了一个“客户端边界”。这个边界中的所有组件都会隐式地转换为客户端组件。即使像 HitCounter 这样的组件没有 'use client' 指令,在这种特定情况下它们也会在客户端上水化/渲染。*

这意味着我们不需要在每个需要在客户端运行的文件中添加 'use client'。在实践中,只有在创建新的客户端边界时才需要添加它。

解决方法

当我第一次了解到客户端组件不能渲染服务端组件时,我觉得这很具有限制性。如果我需要在应用的较高层使用状态怎么办?这是不是意味着所有东西都需要变成客户端组件??

结果表明,在许多情况下,我们可以通过重构应用程序以改变所有者来绕过这个限制。

这是一件很难解释的事情,所以让我们举个例子:

'use client';

import { DARK_COLORS, LIGHT_COLORS } from '@/constants.js';
import Header from './Header';
import MainContent from './MainContent';

function Homepage() {
const [colorTheme, setColorTheme] = React.useState('light');
const colorVariables = colorTheme === 'light'
? LIGHT_COLORS
: DARK_COLORS;
return (
<body style={colorVariables}>
<Header />
<MainContent />
</body>
);
}

在这个设置中,我们需要使用 React 状态允许用户在深色模式和浅色模式之间切换。这需要在应用树的较高层次发生,以便我们可以将 CSS 变量应用于 <body> 标签。

为了使用状态,我们需要将 Homepage 转换为客户端组件。由于这是应用的顶层,这意味着所有其他组件 —— HeaderMainContent —— 也会隐式地成为客户端组件。

要解决这个问题,我们可以将颜色管理逻辑提取到它自己的组件和文件中:

// /components/ColorProvider.js
'use client';

import { DARK_COLORS, LIGHT_COLORS } from '@/constants.js';

function ColorProvider({ children }) {
const [colorTheme, setColorTheme] = React.useState('light');
const colorVariables = colorTheme === 'light'
? LIGHT_COLORS
: DARK_COLORS;
return (
<body style={colorVariables}>
{children}
</body>
);
}

回到Homepage,我们像这样使用这个新组件:

// /components/Homepage.js
import Header from './Header';
import MainContent from './MainContent';
import ColorProvider from './ColorProvider';

function Homepage() {
return (
<ColorProvider>
<Header />
<MainContent />
</ColorProvider>
);
}

我们可以从 Homepage 中删除 'use client' 指令,因为它不再使用 state 或其他客户端 React 特性。这意味着 HeaderMainContent 不再会被隐式转换为客户端组件!

但是等一下。 ColorProvider,一个客户端组件,是 HeaderMainContent父组件。不管怎样,它在树中的位置仍然更高,对吧?

但是就客户端边界而言,父子关系并不重要。导入和渲染 HeaderMainContent 的是 Homepage。这意味着 Homepage 决定了这些组件的 props

记住,我们要解决的问题是服务端组件不能重新渲染,因此不能给它们的任何 props 新的值。通过这个新的设置,决定 HeaderMainContent props 的是 Homepage,而 Homepage 是一个服务端组件,所以没有问题。

这些概念很难理解。 即使有多年 React 经验,我还是觉得非常困惑😅。这需要相当多的练习来培养直觉。

更准确地说,'use client' 指令作用于文件/模块层面。在客户端组件文件中导入的任何模块也必须是客户端组件。当打包器打包我们的代码时,它会根据这些导入进行跟踪!

更改颜色主题?

在我上面的例子中,你可能已经注意到没有方法改变颜色主题。setColorTheme 从未被调用过。

我想尽可能保持最小化,所以省略了一些内容。完整的例子会使用 React 上下文使设置器函数对所有子孙组件可用。只要消费上下文的组件是一个客户端组件,一切都可以良好工作!

窥视底层

让我们从更底层来看。当我们使用服务端组件时,输出是什么样子的?到底生成了什么?服务端组件的输出是一段预渲染的静态 HTML。

让我们从一个超级简单的 React 应用程序开始:

function Homepage() {
return (
<p>
Hello world!
</p>
);
}

在React Server Components范式中,默认情况下,所有组件都是服务器组件。由于我们没有明确将此组件标记为客户端组件(或在客户端边界内渲染它),它将仅在服务器上呈现。

当我们在浏览器中访问此应用程序时,我们将收到类似以下内容的HTML文档:

<!DOCTYPE html>
<html>
<body>
<p>Hello world!</p>

<script src="/static/js/bundle.js"></script>
<script>
self.__next['$Homepage-1'] = {
type: 'p',
props: null,
children: "Hello world!",
};
</script>
</body>
</html>
作了一些修改

为了更容易理解,我对这里的内容作了一些调整。例如,在 RSC 中生成的真实 JS 使用字符串化的 JSON 数组,作为一种优化来减小这个 HTML 文档的大小。

我也去掉了 HTML 中所有非关键部分(如 <head>)。

我们看到 HTML 文档包含了 React 应用生成的 UI,“Hello world!”段落。这多亏了服务端渲染,与 React 服务端组件没有直接关系。

在下面,我们有一个 <script> 标签来加载 JS bundle。这个 bundle 包含依赖如 React,以及应用中使用的任何客户端组件。而由于我们的 Homepage 组件是一个服务端组件,该组件的代码不会被打包进来。

最后,我们有第二个 <script> 标签,其中包含一些内联 JS:

self.__next['$Homepage-1'] = {
type: 'p',
props: null,
children: "Hello world!",
};

这是真正有趣的部分。本质上,我们在这里告诉 React “嘿,我知道你缺少 Homepage 组件代码,但别担心:这里是它渲染的结果”。

通常,当 React 在客户端水化时,它会快速渲染所有组件,构建应用的虚拟表示。它无法对服务端组件这样做,因为代码没有被打包。

因此,我们发送渲染后的值,也就是服务端生成的虚拟表示。当 React 在客户端加载时,它会重用这个描述而不是重新生成。

这就是上面的 ColorProvider 例子能够工作的原因。Header 和 MainContent 的输出通过 children prop 传递给 ColorProvider 组件。ColorProvider 可以随意重新渲染,但这个数据是静态的,被服务端锁定。

如果你想了解服务端组件的序列化和通过网络发送的真实表示,可以查看开发者 Alvar Lagerlöf 的 RSC Devtools。

备注

服务端组件不需要服务端

前文中我提到,服务端渲染是许多不同渲染策略的“总称”,包括:

  • 静态:HTML 在构建应用时生成,在部署过程中。
  • 动态:HTML 是“按需”生成的,在用户请求页面时。

React 服务端组件与这两种渲染策略兼容。 当我们的服务端组件在 Node.js 运行时中渲染时,它们返回的 JavaScript 对象会被创建。这可以按需发生,也可以在构建时发生。

这意味着可以在没有服务端的情况下使用 React 服务端组件!我们可以生成一堆静态 HTML 文件,并把它们托管在任意地方。事实上,这就是 Next.js App Router 中的默认行为。除非我们确实需要按需发生,否则所有这些工作都提前在构建时完成。

备注

完全不需要 React 吗?

你可能会想:如果我们的应用中不包含任何客户端组件,那我们是否可以完全不需要下载 React?我们可以通过 React 服务端组件构建一个真正的无 JS 静态网站吗?

问题是,React 服务端组件目前仅在 Next.js 框架内可用,而该框架有大量代码需要在客户端运行,以管理路由等功能。

反直觉的是,这实际上往往能带来更好的用户体验;例如,Next 的路由器可以比典型的 <a>标签更快处理链接点击,因为它不需要加载一个全新的 HTML 文档。

一个结构良好的 Next.js 应用在 JS 下载期间也可以工作,但 JS 加载完成后会更快更好

建议

React 服务端组件是在 React 中运行只在服务端的代码的第一个“官方”方式。但是如我前面提到的,从更广泛的 React 生态来看,这并不是一个真正新的事物;自2016年起,我们就可以在 Next.js 中运行只在服务端的代码了!

最大的不同在于,我们以前没有在组件内运行只在服务端的代码的方式。

最明显的好处是性能。服务端组件不会被打包到我们的 JS 中,这减小了需要下载的 JavaScript 数量,也减少了需要水化的组件数量:

这对我来说可能是最不令人兴奋的一点。老实说,大多数 Next.js 应用在“可交互时间”方面已经够快了。

如果遵循语义化 HTML 的原则,你的应用大部分应该可以在 React 水化之前就可以工作。链接可以点击,表单可以提交,折叠面板可以展开收起(使用 <details><summary>)。对于大多数项目来说,React 水化需要几秒钟是可以接受的。

但我发现一件真正很酷的事: 我们不再需要在功能和包体积之间做出妥协!

例如,大多数技术博客都需要某种语法高亮库。在我的博客上,我使用 Prism。代码示例看起来像这样:


function exampleJavaScriptFunction(param) {
return "Hello world!"
}

一个合适的语法高亮库,支持所有流行的编程语言,可能有几兆字节,远远超过 JS 包的体积限制。因此,我们不得不做出妥协,只保留对任务至关重要的语言和特性支持。

但是,如果我们在服务端组件中执行语法高亮,那么库的代码实际上不会被打包到 JS 中。这样,我们就不需要做任何妥协,可以使用所有的特性。

这就是 Bright 的设计理念,一个面向 React 服务端组件的现代语法高亮包。

这就是我对 React 服务端组件感到兴奋的原因。之前由于考虑包体积无法引入的特性,现在可以在服务端免费运行,没有增加包的大小,还能提升用户体验。

这不仅仅是关于性能和用户体验。在使用 RSC 一段时间后,我很欣赏服务端组件的简单优雅。我们不需要再担心依赖数组、过时闭包、记忆化或其他因“变化”而导致的复杂问题。

目前还处于非常早期阶段。React 服务端组件仅在几个月前才从测试版毕业!我真的很期待在未来几年看到社区如何发展,提出像 Bright 这样的创新解决方案,利用这种新范式。这对 React 开发者来说是一个激动人心的时刻!

完整图片

· 阅读需 5 分钟
iiLsss

为了满足新老项目共同的业务需求,我们决定将使用Vue2和Webpack4的老项目升级为使用Webpack5和React17的新项目。此外,我们还希望通过使用Webpack5的模块联邦(module federation)特性,对老项目进行渐进式升级。

在此过程中,我们主要完成了以下功能:

  • 项目升级到Webpack5
  • React导出公共样式,实现组件样式隔离
  • Vue引入并展示React组件

Vue-cli和Webpack5的升级

  1. 首先,我们需要升级Vue-cli。具体步骤如下:
  // 全局安装最新版
yarn global add @vue/cli
// 在项目目录下执行
vue upgrade
  1. 接着,我们需要更新webpack和相应的loader和plugins,包括lessless-loaderpostcsspostcss-loaderterser-webpack-plugin等。
  "webpack" => 与远程库版本的最好一致
"less": "^3.9.0", => "^4.1.3",
"less-loader": "^4.1.0", => "^11.1.3",
"postcss-loader": "^3.0.0", => "^7.3.3",
"terser-webpack-plugin": "^2.1.0", => "^5.0.0"

提示:以上只是简单罗列。每个项目的依赖不同,注意引入其他webpack的loader或plugins是否支持webpack5。进行升级或替换处理

  1. 更新依赖完成后,启动项目,根据提示修改devServer配置项。
disableHostCheck: true, => allowedHosts: 'all',
openPage: pageData.openPage, open:true, => open: [],
https: {} => server: {type: }
  1. Vue-cli升级后,存在URL路径转换错误。例如,不支持如果 URL 是一个绝对路径 (例如 /images/foo.png),它将会被保留不变。

解决方案1: 修改webpack中css loader配置项。

css: {
loaderOptions: {
css: {
url: {
filter: (url) => {
if (url[0] === '/') {
return false;
}
return true;
},
}
},
// ...
}

解决方案2:将采用绝对路径的图片/images/foo.png,增加域名或放到项目目录下。

提示:如果图片资源单独存在于域名根路径下,会引起此问题。

  1. 修改上述问题后,启动项目,打包,查看输入日志是否存在问题,进行修改。

React支持模块联邦

模块联邦主要用于组件打包,类似于Antd组件。如果组件存在语言等配置项,需要在每个导出的组件中增加配置。

  1. 创建configLayout组件,根据业务进行添加或扩展。非必须。
// ... 省略代码 ...

export function ConfigLayout({ children, lang = 'zh-CN' }) {

return (
<ConfigProvider locale={antI18n[lang]}>
<p>当前语言:{lang}</p>
{children}
</ConfigProvider>
)
}

export default ConfigLayout
  1. 导出组件List。
// ... 省略代码 ...

const Index: React.FC<Props> = (props) => {
return (
<ConfigLayout {...props}>
<List />
</ConfigLayout>
)
}

export default Index
  1. 修改webpack配置。
// ... 省略代码 ...

new ModuleFederationPlugin({
name: 'react',
filename: "remoteEntry.js",
exposes: {
'./Todo': './src/components/Todo/index.js',
},
shared: {
react: {
eager: true,
singleton: true,
requiredVersion: '18.2.0',
},
'react-dom': {
eager: true,
singleton: true,
requiredVersion: '18.2.0',
}
}
}),

Vue-cli支持模块联邦

  1. 安装相关依赖npm i vuereact-combined,react/react-dom 版本与React项目版本一致 npm i react@18.2.0 react-dom@18.2.0

  2. 新建bootstrap.js,复制main.js中的内容。

// ... 省略代码 ...

new Vue({
render: h => h(App),
}).$mount('#app')
  1. main.js修改内容为 import('./bootstrap')

  2. vue.config.js增加ModuleFederationPlugin。

// ... 省略代码 ...

new ModuleFederationPlugin({
name: 'saas',
filename: 'remoteEntry.js',
remotes: {
"remote": `remote@http://localhost:8080/remoteEntry.js`
},
shared: {
react: {
eager: true,
singleton: true,
requiredVersion: '18.2.0',
},
'react-dom': {
eager: true,
singleton: true,
requiredVersion: '18.2.0',
}
},
}),
  1. 新增组件展示。
<template>
<div id="app">
<div class="vue-content">
我是本身的vue组件
<HelloWorld />
</div>
<div class="react-content">
我是react组件
<List lang="en" />
</div>
</div>
</template>

<script>
import {applyReactInVue} from "vuereact-combined"

import List from 'remote/List'
import HelloWorld from './components/HelloWorld'
export default {
name: 'App',
components: {
HelloWorld,
List: applyReactInVue(List)
}
}
</script>

<style>
// ... 省略代码 ...
</style>

至此,我们已经成功地在Vue-cli项目中引入了React组件,实现了技术架构的升级和项目的渐进式升级。

· 阅读需 4 分钟
iiLsss

当我们需要将 JavaScript 对象从一个上下文复制到另一个上下文时,例如在 Web Workers 之间传递消息,通常我们需要深拷贝。深拷贝意味着复制对象及其所有可达属性,这样得到的副本与原始对象相互独立。在本文中,我们将介绍 structuredClone() 方法,这是一个用于执行深拷贝的实用方法。

· 阅读需 5 分钟

在JavaScript中,异步任务的执行通常需要使用回调函数或者Promise。然而,如果需要执行大量的异步任务,并且要求这些任务必须按照一定的顺序依次执行,这时候就需要使用串行执行的方式。

本文将介绍如何使用Array.reducefor...of实现大量异步任务串行执行的技术。这种方式不仅能够保证任务的有序执行,而且代码结构也相对简单。

callback回调实现

首先,我们来看一下串行执行的代码示例:

// 创建异步任务
function createAsync(data, callback) {
setTimeout(function() {
console.log(data);
callback();
}, 1000);
}

// 按顺序执行四个任务
createAsync('task1',function() {
createAsync('task2',function() {
createAsync('task3', function() {
createAsync('task4', function() {
console.log('All tasks completed');
});
});
});
});

上面的代码定义了四个异步任务,分别是task1task2task3task4。这些任务必须按照顺序依次执行,而且每个任务执行完后都需要调用回调函数。为了保证任务按照顺序执行,我们使用了嵌套回调函数的方式。

现在,我们来看一下如何使用Array.reducefor...of来简化上面的代码。

Array.prototype.reduce实现

首先,我们需要将任务封装成Promise,这样才能在reduce函数中使用。下面是封装后的代码:

function createAsync(data) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(data)
}, 200)
})
}

接下来,我们可以使用Array.reduce来串行执行这些任务。reduce函数可以将一个数组中的元素依次处理,并返回一个累加的结果。

下面是使用reduce函数的代码:

const tasks = [
() => createAsync(1),
() => createAsync(2),
() => createAsync(3),
() => createAsync(4),
() => createAsync(5),
() => createAsync(6),
// ... 100 个以上的异步任务
];
// 使用reduce串行执行异步任务
function runAsyncTasks(tasks) {
return tasks.reduce((promiseChain, currentTask) => {
return promiseChain.then(chainResults =>
currentTask().then(currentResult => [...chainResults, currentResult])
)
}, Promise.resolve([]))
}
// 执行任务
runAsyncTasks(tasks).then(results => {
console.log('All async tasks done:', results);
});

上面的代码中,我们首先将任务保存在一个数组中。然后使用reduce函数,将数组中的每个元素(即任务)依次处理,并返回一个Promise。在reduce函数的回调函数中,我们使用then函数将每个任务串行执行。由于每个任务返回的是一个Promise,因此我们可以使用then函数来串联多个任务。最后,在所有任务完成后,我们输出一条“All async tasks done”的消息。

接下来,我们再来看一下使用for...of来实现大量异步任务串行执行的代码。这种方式和使用reduce函数类似,但是使用了for...of循环,更加直观易懂。

for...of实现

// 创建异步任务
function createAsync(data) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('createAsync', data)
resolve(data)
}, 200)
})
}

async function runAsyncTasks(tasks) {
const results = [];

for (const task of tasks) {
const result = await task();
results.push(result);
}

return results;
}

const tasks = [
() => createAsync(1),
() => createAsync(2),
() => createAsync(3),
() => createAsync(4),
() => createAsync(5),
() => createAsync(6),
// ... 100 个以上的异步任务
];

runAsyncTasks(tasks).then(results => {
console.log('All async tasks done:', results);
});

上面的代码中,我们首先将任务保存在一个数组中。然后定义一个async函数runTasks,使用for...of循环依次执行每个任务。由于使用了async/await,我们可以保证任务按照顺序执行,并且代码结构也非常简洁。

总结一下,本文介绍了如何使用Array.reducefor...of来实现大量异步任务串行执行的技术。这种方式不仅能够保证任务的有序执行,而且代码结构也相对简单。

· 阅读需 6 分钟
iiLsss

前端项目基于 webpack、vite、rollup 等构建工具实现工程化,工程化配置中存在developmentproduction环境配置。但是此环境配置针对构建工具使用本地开发或打包构建等功能。

我们常在项目中不仅仅developmentproduction两种环境。还存在测试环境和预发布环境。每个环境都有不同的请求接口、第三方 SDK 配置项、CDN 前缀、项目跳转链接等信息。

· 阅读需 27 分钟

除非你在山顶的山洞里工作,否则你可以与其他人谈论你的工作,否则你将无法真正有效的编程。而当你这样做时,就是说,看代码不会消减代码 代码太复杂了,整体解构还不够清晰

· 阅读需 8 分钟

Turbopack 建立在新的增量架构上,以提供最快的开发体验。 在大型应用程序上,它显示更新速度比 Vite 快 10 倍,比 Webpack 快 700 倍。 在更大的应用程序上,差异更大——通常比 Vite 快 20 倍。

Turbopack 仅捆绑开发所需的最少资产,因此启动时间非常快。 在具有 3,000 个模块的应用程序上,Turbopack 需要 1.8 秒才能启动,而 Vite 需要 11.4 秒。

· 阅读需 1 分钟

之前个人网站由 hexo 搭建,后续发现 docusaurus可以通过 React 进行扩展和自定义

· 阅读需 2 分钟

环境配置

  1. 安装软件

vscode、item2

  1. 安装homebrew

  2. 安装onmyzsh

  3. 安装nginx

  4. 安装node

快捷键

你可以按下某些组合键来实现通常需要鼠标、触控板或其他输入设备才能完成的操作。

修饰键:

要使用键盘快捷键,请按住一个或多个修饰键,然后按快捷键的最后一个键。例如,要使用 Command-C(拷贝),请按住 Command 键并按 C 键,然后同时松开这两个键。Mac 菜单和键盘通常会使用符号来表示某些按键,其中包括以下修饰键:

  • Command(或 Cmd)⌘
  • Shift ⇧
  • Option(或 Alt)⌥
  • Control(或 Ctrl)⌃
  • Caps Lock ⇪
  • Fn

在 Windows PC 专用键盘上,请用 Alt 键代替 Option 键,用 Windows 标志键代替 Command 键。

常用快捷键

  • Control-Command-F:进入(退出)全屏使用 App
  • Control-Command-Q:立即锁定屏幕
  • Control-空格:切换输入法

参考资料

Mac 键盘快捷键

· 阅读需 1 分钟
Sébastien Lorber
Yangshun Tay

Docusaurus blogging features are powered by the blog plugin.

Simply add Markdown files (or folders) to the blog directory.

Regular blog authors can be added to authors.yml.

The blog post date can be extracted from filenames, such as:

  • 2019-05-30-welcome.md
  • 2019-05-30-welcome/index.md

A blog post folder can be convenient to co-locate blog post images:

Docusaurus Plushie

The blog supports tags as well!

And if you don't want a blog: just delete this directory, and use blog: false in your Docusaurus config.