React 核心概念(二)
原文:
zh.annas-archive.org/md5/751c075f5f8c25e4f99f8fc75bd4f4d2译者:飞龙
第五章:渲染列表和条件内容
学习目标
到本章结束时,你将能够做到以下几件事情:
-
条件输出动态内容
-
渲染数据列表并将列表项映射到 JSX 元素
-
优化列表,以便 React 在需要时能够高效地更新用户界面
简介
到这本书的这一部分,你已经熟悉了几个关键概念,包括组件、属性、状态和事件,这些是构建各种不同 React 应用和网站所需的所有核心工具。你也已经学会了如何将动态值和结果作为用户界面的一部分输出。
然而,有两个与输出动态数据相关的话题尚未深入讨论:条件输出内容和渲染列表数据。由于你构建的大多数(如果不是所有)网站和 Web 应用都将需要这两个概念中的至少一个,因此了解如何处理条件内容和列表数据至关重要。
因此,在本章中,你将学习如何根据动态条件渲染和显示不同的用户界面元素(甚至整个用户界面部分)。此外,你还将学习如何输出数据列表(如待办事项列表及其条目)并动态渲染构成列表的 JSX 元素。本章还将探讨与输出列表和条件内容相关的重要最佳实践。
条件内容和列表数据是什么?
在深入研究输出条件内容或列表数据的技巧之前,了解这些术语的确切含义非常重要。
条件内容简单来说就是任何只在特定情况下应该显示的内容。以下是一些示例:
-
仅在用户在表单中提交错误数据时显示的错误覆盖层
-
用户选择输入额外详细信息(如业务详情)时出现的附加表单输入字段
-
在向或从后端服务器发送或获取数据时显示的加载旋转器
-
当用户点击菜单按钮时滑入视图的侧导航菜单
这只是一个包含几个示例的非常简短的列表。当然,你可以想出数百个额外的例子。但最终应该清楚所有这些示例都是关于什么的:仅在满足某些条件时才显示的视觉元素或用户界面的整个部分。
在第一个示例(错误覆盖层)中,条件是用户在表单中输入了错误数据。然后,条件显示的内容将是错误覆盖层。
条件内容非常常见,因为几乎所有的网站和 Web 应用都有一些与前面示例相似或可比的内容。
除了条件性内容外,许多网站还会输出数据列表。这不一定总是立即明显,但如果你仔细想想,几乎没有任何网站不显示某种类型的列表数据。再次,这里有一些可能在网站上输出的列表数据的例子:
-
显示产品网格或列表的在线商店
-
显示活动列表的活动预订网站
-
显示购物车中商品列表的购物车
-
显示订单列表的订单页面
-
显示博客文章列表——以及可能位于博客文章下方的评论列表
-
页眉中的导航项列表
一个没有恶意的无尽列表(例子)可以在这里创建。列表在网络上无处不在。正如前面的例子所示,许多(可能甚至大多数)网站在同一网站上都有多个列表,包含各种类型的数据。
以一个在线商店为例。在这里,你会有一个产品列表(或者一个网格,实际上它只是另一种列表形式),购物车商品列表,订单列表,页眉中的导航项列表,以及当然还有很多其他的列表。这就是为什么了解如何在 React 驱动的用户界面中输出任何类型的数据的任何类型的列表变得非常重要。
条件性渲染内容
想象以下场景。你有一个按钮,点击后应该显示一个额外的文本框,如下所示:
图 5.1:最初,屏幕上只显示按钮
点击按钮后,另一个框被显示:
图 5.2:点击按钮后,信息框被显示
这是一个非常简单的例子,但并非不切实际。许多网站的用户界面部分都像这样工作。在按钮点击(或类似交互)时显示额外信息是一种常见模式。只需想想食品订单网站上餐点下方的营养成分信息或是在选择问题后显示答案的常见问题解答部分。
那么,这个场景如何在 React 应用中实现呢?
如果你忽略了渲染某些内容条件性的要求,整个 React 组件可能看起来像这样:
function TermsOfUse() {
return (
<section>
<button>Show Terms of Use Summary</button>
<p>By continuing, you accept that we will not indemnify you for any
damage or harm caused by our products.</p>
</section>
);
}
这个组件中完全没有条件性代码,因此按钮和额外的信息框总是显示出来。
在这个例子中,如何有条件地显示包含使用条款摘要文本的段落(即,仅在按钮点击后显示)?
通过前面章节中获得的知识,特别是第四章,处理事件和状态,你已经拥有了在按钮点击后仅显示文本所需的所有技能。以下代码显示了组件如何被重写,以便仅在按钮点击后显示完整文本:
import { useState } from 'react';
function TermsOfUse() {
const [showTerms, setShowTerms] = useState(false);
function handleShowTermsSummary() {
setShowTerms(true);
}
let paragraphText = '';
if (showTerms) {
paragraphText = 'By continuing, you accept that we will not indemnify you for any damage or harm caused by our products.';
}
return (
<section>
<button onClick={handleShowTermsSummary}>
Show Terms of Use Summary
</button>
<p>{paragraphText}</p>
</section>
);
}
在此代码片段中显示的代码部分已经符合条件内容的资格。paragraphText值是根据存储在showTerms状态中的值有条件地设置的,借助一个if语句。
然而,<p>元素本身实际上不是条件性的。它始终存在,无论它包含一个完整的句子还是一个空字符串。如果你打开浏览器开发者工具并检查该页面的该区域,你会看到一个空的段落元素,如下面的图所示:
图 5.3:一个空的段落元素作为 DOM 的一部分被渲染
在 DOM 中保留那个空的<p>元素并不是理想的做法。虽然它对用户来说是不可见的,但它是一个需要浏览器渲染的额外元素。性能影响可能非常小,但仍然是你应该避免的事情。网页不会从包含无内容的空元素中受益。
您可以将关于条件值(例如段落文本)的知识翻译成条件元素。除了在变量中存储标准值,如文本或数字外,您还可以在变量中存储 JSX 元素。这是因为,如第一章,React – What and Why中提到的,JSX 只是一种语法糖。在幕后,一个 JSX 元素是一个由 React 执行的标准 JavaScript 函数。当然,函数调用的返回值也可以存储在变量或常量中。
考虑到这一点,以下代码可以用来有条件地渲染整个段落:
import { useState } from 'react';
function TermsOfUse() {
const [showTerms, setShowTerms] = useState(false);
function handleShowTermsSummary() {
setShowTerms(true);
}
let paragraph;
if (showTerms) {
paragraph = <p>By continuing, you accept that we will not indemnify you for any damage or harm caused by our products.</p>;
}
return (
<section>
<button onClick={handleShowTermsSummary}>
Show Terms of Use Summary
</button>
{paragraph}
</section>
);
}
在这个例子中,如果showTerms为true,paragraph变量不存储文本,而是存储一个完整的 JSX 元素(<p>元素)。在返回的 JSX 代码中,存储在paragraph变量中的值通过{paragraph}动态输出。如果showTerms为false,paragraph存储的值为undefined,并且不会将任何内容渲染到 DOM 中。因此,在 JSX 代码中插入null或undefined会导致 React 不输出任何内容。但如果showTerms为true,整个段落作为一个值保存并输出到 DOM 中。
这就是如何动态渲染整个 JSX 元素的方法。当然,你不仅限于单个元素。你可以在变量或常量中存储整个 JSX 树结构(如多个、嵌套或兄弟 JSX 元素)。作为一个简单的规则,任何可以由组件函数返回的内容都可以存储在变量中。
有条件地渲染内容的不同方式
在前面显示的示例中,内容是通过使用变量有条件地渲染的,该变量通过if语句设置,然后在 JSX 代码中动态输出。这是一种常见的(并且完全可行的)有条件渲染内容的技术,但并不是你唯一可以使用的方案。
或者,你也可以这样做:
-
利用三元表达式。
-
滥用 JavaScript 逻辑运算符。
-
使用任何其他有效的 JavaScript 条件选择值的方式。
以下各节将详细探讨每种方法。
利用三元表达式
在 JavaScript(以及许多其他编程语言)中,你可以使用 三元表达式(也称为 条件三元运算符)作为 if 语句的替代。三元表达式可以节省代码行数,尤其是在简单条件中,主要目标是条件性地分配一些变量值。
这里是一个直接的比较——首先从一个普通的 if 语句开始:
let a = 1;
if (someCondition) {
a = 2;
}
这里是相同的逻辑,使用三元表达式实现:
const a = someCondition ? 2 : 1;
这段代码是标准的 JavaScript 代码,并非特定于 React。然而,理解这个核心 JavaScript 功能对于理解如何在 React 应用程序中使用它非常重要。
将其翻译到先前的 React 示例中,段落内容可以通过以下三元表达式有条件地设置和输出:
import { useState } from 'react';
function TermsOfUse() {
const [showTerms, setShowTerms] = useState(false);
function handleShowTermsSummary() {
setShowTerms(true);
}
const paragraph = showTerms ? <p>By continuing, you accept that we will not indemnify you for any damage or harm caused by our products.</p> : null;
return (
<section>
<button onClick={handleShowTermsSummary}>
Show Terms of Use Summary
</button>
{paragraph}
</section>
);
}
正如你所见,整体代码比之前使用 if 语句时更短。段落常量包含段落(包括文本内容)或 null。null 被用作替代值,因为 null 可以安全地插入到 JSX 代码中,它只会导致在该位置不渲染任何内容。
三元表达式的一个缺点是可读性和可理解性可能会受到影响——尤其是在使用嵌套的三元表达式时,如下面的例子所示:
const paragraph = !showTerms ? null : someOtherCondition ? <p>By continuing, you accept that we will not indemnify you for any damage or harm caused by our products.</p> : null;
这段代码难以阅读,甚至更难以理解。因此,通常你应该避免编写嵌套的三元表达式,并在这种情况下回退到 if 语句。
然而,尽管存在这些潜在的缺点,三元表达式可以帮助你在 React 应用程序中编写更少的代码,尤其是在内联使用时,直接在某个 JSX 代码内部:
import { useState } from 'react';
function TermsOfUse() {
const [showTerms, setShowTerms] = useState(false);
function handleShowTermsSummary() {
setShowTerms(true);
}
return (
<section>
<button onClick={handleShowTermsSummary}>
Show Terms of Use Summary
</button>
{showTerms ? <p>By continuing, you accept that we will not indemnify you for any damage or harm caused by our products.</p> : null}
</section>
);
}
这与之前的例子相同,但现在它更短,因为在这里你通过在 JSX 片段中直接使用三元表达式来避免使用 paragraph 常量。这使得组件代码相对简洁,因此在 React 应用程序中,在 JSX 代码中使用三元表达式以利用这一点是非常常见的。
滥用 JavaScript 逻辑运算符
三元表达式之所以受欢迎,是因为它们允许你编写更少的代码,当在正确的地方使用(并避免嵌套多个三元表达式)时,可以帮助提高整体的可读性。
尤其是在 React 应用程序中,在 JSX 代码中你经常会写出类似这样的三元表达式:
<div>
{showDetails ? <h1>Product Details</h1> : null}
</div>
或者,像这样:
<div>
{showTerms ? <p>Our terms of use …</p> : null}
</div>
这两个代码片段有什么共同之处?
它们是不必要的长,因为在两个例子中,即使它对最终用户界面没有任何贡献,也必须指定 else 情况(: null)。毕竟,这些三元表达式的首要目的是渲染 JSX 元素(在先前的例子中是 <h1> 和 <p>)。else 情况(: null)仅仅意味着如果条件(showDetails 和 showTerms)不满足,则不渲染任何内容。
这就是为什么在 React 开发者中流行另一种模式:
<div>
{showDetails && <h1>Product Details</h1>}
</div>
这是实现预期结果的最短方式,如果showDetails是true,则仅渲染<h1>元素及其内容。
此代码使用(或滥用)了 JavaScript 逻辑运算符的一个有趣的行为,特别是&&(逻辑与)运算符。在 JavaScript 中,如果第一个值(即&&前面的值)是true或真值(即不是false、undefined、null、0等),则&&运算符返回第二个值(即&&后面的值)。通常,你会在if语句或三元表达式中使用&&运算符。然而,当与 React 和 JSX 一起工作时,你可以利用前面描述的行为有条件地输出真值。这种技术也称为短路。
例如,以下代码将输出'Hello':
console.log(1 === 1 && 'Hello');
这种行为可以用来编写非常短的检查条件并输出另一个值的表达式,如前例所示。
注意
值得注意的是,如果你使用非布尔条件值(即&&前面的值持有非布尔值)与&&一起使用,可能会导致意外结果。如果showDetails是0而不是false(无论什么原因),屏幕上会显示数字0。因此,你应该确保作为条件的值产生null或false而不是任意假值。例如,你可以通过添加!!(例如,!!showDetails)强制转换为布尔值。如果你的条件值已经持有null或false,则不需要这样做。
发挥创意!
到目前为止,你已经学习了三种不同的定义和有条件输出内容的方法(常规if语句、三元表达式和使用&&运算符)。然而,最重要的观点是 React 代码最终只是常规 JavaScript 代码。因此,任何选择条件值的方案都将有效。
如果在你的特定用例和 React 应用中合理,你也可以有一个组件,它像这样有条件地选择和输出内容:
const languages = {
de: 'de-DE',
us: 'en-US',
uk: 'en-GB'
};
function LanguageSelector({country}) {
return <p>Selected Language: {languages[country]}</p>
}
该组件根据country属性的值输出'de-DE'、'en-US'或'en-GB'。这个结果是通过使用 JavaScript 的动态属性选择语法实现的。你不需要通过点符号选择特定的属性(例如person.name),而是可以通过括号符号选择属性值。使用这种符号,你可以传递一个特定的属性名(languages['de-DE'])或者一个产生属性名的表达式(languages[country])。
以这种方式动态选择属性值是选择值从值映射中的另一种常见模式。因此,它是指定多个if语句或三元表达式的替代方案。
此外,通常你可以使用任何在标准 JavaScript 中有效的方法——因为毕竟 React 在其核心上只是标准 JavaScript。
哪种方法最好?
已经讨论了各种设置和有条件输出内容的方法,但哪种方法最好?
这完全取决于你(如果适用,还有你的团队)。最重要的优缺点已经突出显示,但最终,这是你的决定。如果你更喜欢三元表达式,选择它们而不是逻辑&&运算符也没有什么不妥。
这也将取决于你试图解决的特定问题。如果你有一个值的映射(例如国家列表及其国家语言代码),选择动态属性选择而不是多个if语句可能更可取。另一方面,如果你有一个单一的true/false条件(例如age > 18),使用标准的if语句或逻辑&&运算符可能最好。
有条件地设置元素标签
有条件地输出内容是一个非常常见的场景。但有时,你也会想要选择将要输出的 HTML 标签的类型。通常情况下,这会在你构建主要任务是对内置组件进行包装和增强的组件时发生。
这里有一个例子:
function Button({isButton, config, children}) {
if (isButton) {
return <button {...config}>{children}</button>;
}
return <a {...config}>{children}</a>;
};
这个Button组件检查isButton属性值是否为真值,如果是这样,就返回一个<button>元素。config属性预期是一个 JavaScript 对象,并且使用标准的 JavaScript 扩展运算符(...)将config对象的所有键值对作为属性添加到<button>元素。如果isButton不是真值(可能是因为没有为isButton提供值,或者值是false),则else条件变为活动状态。而不是<button>元素,返回一个<a>元素。
注意
使用扩展运算符(...)将对象的属性(键值对)转换为组件属性是另一个常见的 React 模式(并在第三章,组件和属性中介绍)。扩展运算符不是一个 React 特定的运算符,但用于这个特殊目的是。
当将类似于{link: 'https://some-url.com', isButton: false}的对象扩展到<a>元素上(通过<a {...obj}>),结果将与所有属性单独设置时相同(即<a link="https://some-url.com" isButton={false}>)。
这种模式在构建自定义包装组件的情况下特别受欢迎,这些组件包装一个常见的核心组件(例如<button>、<input>或<a>)以添加某些样式或行为,同时仍然允许组件以与内置组件相同的方式使用(即,你可以设置所有默认属性)。
上一示例中的Button组件根据isButton属性值的差异返回两个完全不同的 JSX 元素。这是一种检查条件并返回不同内容(即条件内容)的好方法。
然而,通过使用特殊的 React 行为,这个组件可以用更少的代码编写:
function Button({isButton, config, children}) {
const Tag = isButton ? 'button' : 'a';
return <Tag {...config}>{children}</Tag>;
};
特殊行为在于可以将标签名(作为字符串值)存储在变量或常量中,然后这些变量或常量可以在 JSX 代码中用作 JSX 元素(只要变量或常量的名称以大写字母开头,就像所有你的自定义组件一样)。
上一示例中的Tag常量存储的是'button'或'a'字符串。由于它以大写字母开头(Tag,而不是tag),因此它可以在 JSX 代码片段中用作自定义组件。React 将其接受为一个组件,即使它不是一个组件函数。这是因为存储了一个标准的 HTML 元素标签名,所以 React 可以渲染相应的内置组件。相同的模式也可以用于自定义组件。不是存储字符串值,而是通过以下方式存储指向你的自定义组件函数的指针:
import MyComponent from './my-component.jsx';
import MyOtherComponent from './my-other-component.jsx';
const Tag = someCondition ? MyComponent : MyOtherComponent;
这是一种非常有用的模式,可以帮助节省代码,从而使得组件更加精简。
输出列表数据
除了输出条件数据外,你还会经常处理需要在页面上输出的列表数据。如本章前面所述,一些例子包括产品列表、交易和导航项。
通常,在 React 应用中,此类列表数据以值的数组形式接收。例如,一个组件可能通过 props(从可能从后端 API 获取数据的另一个组件内部传递到组件中)接收产品数组:
function ProductsList({products}) {
// … todo!
};
在这个例子中,产品数组可能看起来像这样:
const products = [
{id: 'p1', title: 'A Book', price: 59.99},
{id: 'p2', title: 'A Carpet', price: 129.49},
{id: 'p3', title: 'Another Book', price: 39.99},
];
然而,数据不能这样输出。相反,通常的目标是将它转换成适合的 JSX 元素列表。例如,期望的结果可能是以下这样:
<ul>
<li>
<h2>A Book</h2>
<p>$59.99</p>
</li>
<li>
<h2>A Carpet</h2>
<p>$129.49</p>
</li>
<li>
<h2>Another Book</h2>
<p>$39.99</p>
</li>
</ul>
如何实现这种转换?
再次强调,忽略 React 并找到一种使用标准 JavaScript 转换列表数据的方法是个好主意。实现这一目标的一种可能方式是使用for...of循环,如下所示:
const transformedProducts = [];
for (const product of products) {
transformedProducts.push(product.title);
}
在这个例子中,产品对象列表(products)被转换成产品标题列表(即字符串值的列表)。这是通过遍历products中的所有产品项并从每个产品中提取title属性来实现的。然后,这个title属性值被推入新的transformedProducts数组中。
可以使用类似的方法将对象列表转换成 JSX 元素列表:
const productElements = [];
for (const product of products) {
productElements.push((
<li>
<h2>{product.title}</h2>
<p>${product.price}</p>
</li>
));
}
第一次看到这样的代码时,可能会觉得有点奇怪。但请记住,JSX 代码可以在任何可以使用常规 JavaScript 值(即数字、字符串、对象等)的地方使用。因此,你也可以将 JSX 值 push 到一个值数组中。由于它是 JSX 代码,你还可以在那些 JSX 元素中动态输出内容(例如 <h2>{product.title}</h2>)。
这段代码是有效的,并且是输出列表数据的重要第一步。但这是第一步,因为当前的数据已经进行了转换,但还没有通过组件返回。
那么这样的 JSX 元素数组是如何返回的呢?
答案是它可以不使用任何特殊技巧或代码而返回。实际上,JSX 接受数组值作为动态输出的值。
你可以这样输出 productElements 数组:
return (
<ul>
{productElements}
</ul>
);
当将 JSX 元素数组插入到 JSX 代码中时,该数组内的所有 JSX 元素都会相邻输出。因此,以下两个代码片段会产生相同的输出:
return (
<div>
{[<p>Hi there</p>, <p>Another item</p>]}
</div>
);
return (
<div>
<p>Hi there</p>
<p>Another item</p>
</div>
);
考虑到这一点,ProductsList 组件可以写成这样:
function ProductsList({products}) {
const productElements = [];
for (const product of products) {
productElements.push((
<li>
<h2>{product.title}</h2>
<p>${product.price}</p>
</li>
));
}
return (
<ul>
{productElements}
</ul>
);
};
这是输出列表数据的一种可能方法。正如本章前面所解释的,这完全关于使用标准的 JavaScript 功能,并将这些功能与 JSX 结合起来。
然而,这并不一定是 React 应用中输出列表数据最常见的方式。在大多数项目中,你会遇到不同的解决方案。
映射列表数据
使用 for 循环输出列表数据是可行的,正如前面示例中所见。然而,就像 if 语句和三元表达式一样,你可以用替代语法替换 for 循环,以编写更少的代码并提高组件的可读性。
JavaScript 提供了一个内置的数组方法,可以用来转换数组项:map() 方法。map() 是一个默认方法,可以在任何 JavaScript 数组上调用。它接受一个函数作为参数,并为每个数组项执行该函数。该函数的返回值应该是转换后的值。map() 然后将所有这些返回的转换值组合成一个新的数组,然后由 map() 返回这个新数组。
你可以这样使用 map():
const users = [
{id: 'u1', name: 'Max', age: 35},
{id: 'u2', name: 'Anna', age: 32}
];
const userNames = users.map(user => user.name);
// userNames = ['Max', 'Anna']
在这个例子中,map() 被用来将用户对象数组转换成用户名数组(即字符串值数组)。
map() 方法通常可以用更少的代码产生与 for 循环相同的结果。
因此,map() 也可以用来生成一个 JSX 元素数组,以及之前提到的 ProductsList 组件,可以重写如下:
function ProductsList({products}) {
const productElements = products.map(product => (
<li>
<h2>{product.title}</h2>
<p>${product.price}</p>
</li>
)
);
return (
<ul>
{productElements}
</ul>
);
};
这已经比之前的 for 循环示例更短了。然而,就像三元表达式一样,代码可以通过将逻辑直接移动到 JSX 代码中来进一步缩短:
function ProductsList({products}) {
return (
<ul>
{products.map(product => (
<li>
<h2>{product.title}</h2>
<p>${product.price}</p>
</li>
)
)}
</ul>
);
};
根据转换的复杂性(即传递给 map() 方法的内部函数中执行的代码的复杂性),出于可读性的原因,你可能想要考虑不使用这种 内联 方法(例如,当将数组元素映射到某些复杂的 JSX 结构或在进行映射过程中的额外计算时)。最终,这取决于个人偏好和判断。
由于它非常简洁,使用 map() 方法(无论是通过额外的变量或常量,还是直接在 JSX 代码中 内联)是 React 应用和 JSX 中输出列表数据的既定标准方法。
更新列表
想象一下,你有一个数据列表映射到 JSX 元素,并在某个时刻添加了一个新的列表项。或者,考虑一个场景,其中你有一个列表,其中两个列表项交换了位置(即列表被重新排序)。如何将这些更新反映在 DOM 中?
好消息是,如果你以有状态的方式(即使用 React 的状态概念,如第四章中解释的 工作与事件和状态)执行更新,React 会为你处理这些。
然而,在更新(有状态的)列表时,有几个重要的方面你应该注意。
这里有一个简单的例子,它不会按预期工作:
import { useState } from 'react';
function Todos() {
const [todos, setTodos] = useState(['Learn React', 'Recommend this book']);
function handleAddTodo() {
todos.push('A new todo');
};
return (
<div>
<button onClick={handleAddTodo}>Add Todo</button>
<ul>
{todos.map(todo => <li>{todo}</li>)}
</ul>
</div>
);
};
初始时,屏幕上会显示两个待办事项(<li>学习 React</li> 和 <li>推荐这本书</li>)。但是一旦点击按钮并执行 handleAddTodo,预期显示另一个待办事项的结果将不会实现。
这是因为执行 todos.push('一个新的待办事项') 会更新 todos 数组,但 React 不会注意到这一点。请记住,你必须只通过 useState() 返回的状态更新函数来更新状态;否则,React 不会重新评估组件函数。
那么,这个代码怎么样:
function handleAddTodo() {
setTodos(todos.push('A new todo'));
};
这也是不正确的,因为状态更新函数(在这个例子中是 setTodos)应该接收新的状态(即应该设置的状态)作为参数。然而,push() 方法不会返回更新后的数组。相反,它会原地修改现有的数组。即使 push() 会返回更新后的数组,使用前面的代码仍然是不正确的,因为状态更新函数执行之前,数据(在幕后)已经被改变(修改)。由于数组是对象,因此是引用数据类型,技术上,数据会在通知 React 之前被改变。遵循 React 的最佳实践,应该避免这种情况。
因此,在更新数组(或者,作为一个旁注,一般对象)时,你应该以不可变的方式(即不改变原始数组或对象)进行更新。相反,应该创建一个新的数组或对象。这个新数组可以基于旧数组,并包含所有旧数据,以及任何新的或更新的数据。
因此,todos数组应该这样更新:
function handleAddTodo() {
setTodos(curTodos => [...curTodos, 'A new todo']);
// alternative: Use concat() instead of the spread operator:
// concat(), unlike push(), returns a new array
// setTodos(curTodos => curTodos.concat('A new todo'));
};
通过使用concat()或新数组,结合扩展运算符,可以为状态更新函数提供一个全新的数组。注意,由于新状态依赖于前一个状态,因此将函数传递给状态更新函数。
当更新数组(或任何对象)状态值时,React 能够检测到这些变化。因此,React 将重新评估组件函数,并将任何必要的更改应用到 DOM 上。
注意
不变性不是一个 React 特有的概念,但在 React 应用中它仍然是一个关键概念。当与状态和引用值(即对象和数组)一起工作时,不变性对于确保 React 能够检测到变化以及没有“不可见”的(即不被 React 识别)状态变化执行至关重要。
更新对象和数组的不变性的方法有很多,但一种流行的方法是创建新的对象或数组,然后使用扩展运算符(...)将现有数据合并到这些新的数组或对象中。
列表项的问题
如果你正在跟随自己的代码,并且按照前几节所述输出列表数据,你可能已经注意到 React 实际上在浏览器开发者工具控制台中显示了一个警告,如下面的截图所示:
图 5.4:React 有时会生成关于缺少唯一键的警告
React 正在抱怨缺少键。
要理解这个警告和键背后的理念,探索一个特定的用例和该场景的潜在问题是有帮助的。假设你有一个负责显示项目列表的 React 组件——可能是一个待办事项列表。此外,假设这些列表项可以重新排序,并且列表可以通过其他方式编辑(例如,可以添加新项,更新或删除现有项等)。换句话说,列表不是静态的。
考虑这个示例用户界面,其中向待办事项列表中添加了一个新项:
图 5.5:通过在顶部插入新项来更新列表
在前面的图中,你可以看到最初渲染的列表(1),然后在用户输入并提交新的待办事项值后更新(2)。一个新的待办事项被添加到列表的顶部(即列表的第一个项目)(3)。
注意
这个演示应用的示例源代码可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/05-lists-conditional-code/examples/02-keys找到。
如果你在这个应用上工作并打开浏览器开发者工具(然后是 JavaScript 控制台),你会看到之前提到的“缺少键”警告。这个应用也有助于理解这个警告的来源。
在 Chrome DevTools 中,导航到元素选项卡并选择一个待办项或空待办列表(即<ul>元素)。一旦添加一个新的待办项,任何插入或更新的 DOM 元素都会在元素选项卡中由 Chrome 突出显示(通过短暂闪烁)。参考以下截图:
图 5.6:更新的 DOM 项在 Chrome DevTools 中被突出显示
有趣的是,不仅新添加的待办元素(即新插入的<li>元素)会闪烁。相反,所有现有的<li>元素,即使它们反映的待办项没有变化,也会被 Chrome 突出显示。这表明所有这些其他的<li>元素在 DOM 中也被更新了——尽管没有必要进行这种更新。这些项之前就存在,它们的内容(待办文本)并没有改变。
由于某种原因,React 似乎会销毁现有的 DOM 节点(即现有的<li>项),然后立即重新创建它们。这发生在列表中添加的每个新待办项上。正如你可能想象的那样,这并不非常高效,可能会为渲染多个列表中数十或数百项的更复杂的应用程序造成性能问题。
这是因为 React 无法知道只有一个 DOM 节点应该被插入。它无法判断所有其他 DOM 节点是否应该保持不变,因为 React 只收到了一个新的状态值:一个新的数组,其中填充了新的 JavaScript 对象。即使这些对象的内容没有改变,它们在技术上仍然是新的对象(内存中的新值)。
作为开发者,你知道你的应用是如何工作的,以及待办数组的内容实际上并没有发生太多变化。但是 React 并不知道这一点。因此,React 确定所有现有的列表项(<li>元素)都必须被丢弃,并由反映新提供的数据(作为状态更新的部分)的新项所替换。这就是为什么每次状态更新时,所有与列表相关的 DOM 节点都会被更新(即销毁并重新创建)。
键的拯救!
之前概述的问题是一个非常常见的问题。大多数列表更新都是增量更新,而不是批量更改。但是 React 无法判断你的用例和你的列表是否属于这种情况。
这就是为什么 React 在处理列表数据和渲染列表项时使用键的概念。键只是可以(并且应该)在渲染列表数据时附加到 JSX 元素上的唯一标识符值。键帮助 React 识别之前渲染且未更改的元素。通过允许对所有列表元素进行唯一标识,键还帮助 React 有效地移动(列表项)DOM 元素。
通过特殊的内置key属性将键添加到 JSX 元素中,该属性被每个组件接受:
<li key={todo.id}>{todo.text}</li>
这个特殊的属性可以添加到所有组件中,无论是内置的还是自定义的。你不需要在你的自定义组件中以任何方式接受或处理 key 属性;React 会自动为你处理。
key 属性需要一个对每个列表项都是唯一的值。没有任何两个列表项应该有相同的键。此外,良好的键直接附加到构成列表项的底层数据。因此,列表项索引是较差的键,因为索引没有附加到列表项数据。如果你在列表中重新排列项目,索引将保持不变(数组始终从索引 0 开始,然后是 1,依此类推),但数据会发生变化。
考虑以下示例:
const hobbies = ['Sports', 'Cooking'];
const reversed = hobbies.reverse(); // ['Cooking', 'Sports']
在这个例子中,'Sports' 在 hobbies 数组中的索引是 0。在 reversed 数组中,它的索引将是 1(因为它现在是第二个项目)。在这种情况下,如果使用索引作为键,数据将不会附加到它上。
良好的键是唯一的 id 值,每个 id 只对应一个值。如果该值移动或被删除,其 id 应该随之移动或消失。
通常找到良好的 id 值并不是一个大问题,因为大多数列表数据都是从数据库中获取的。无论你是在处理产品、订单、用户还是购物车项,这些数据通常都会存储在数据库中。这种数据已经具有唯一的 id 值,因为你在将数据存储在数据库中时始终有一些唯一的识别标准。
有时,值本身也可以用作键。考虑以下示例:
const hobbies = ['Sports', 'Cooking'];
爱好是 string 类型的值,并且没有唯一的 id 值附加到个别爱好上。每个爱好都是一个原始值(一个 string)。然而,在这种情况下,你通常不会有重复的值,因为在这个数组中列出爱好一次以上是没有意义的。因此,这些值本身就符合良好的键的条件:
hobbies.map(hobby => <li key={hobby}>{hobby}</li>);
在无法使用值本身且没有其他可能的键值的情况下,你可以在你的 React 应用代码中直接生成唯一的 id 值。作为最后的手段,你也可以回退到使用索引;但请注意,如果你重新排列列表项,这可能会导致意外的错误和副作用。
在列表项元素中添加键后,React 能够正确地识别所有项目。当组件状态发生变化时,它能够识别之前已经渲染的 JSX 元素。因此,这些元素不再被销毁或重新创建。
你可以通过再次打开浏览器 DevTools 来确认,检查在底层列表数据发生变化时哪些 DOM 元素被更新:
图 5.7:从多个列表项中,只有一个是 DOM 元素被更新
添加键后,在更新列表状态时,只有新的 DOM 项在 Chrome DevTools 中被突出显示。其他项被 React (正确地)忽略。
摘要和关键要点
-
与任何其他 JavaScript 值一样,JSX 元素可以根据不同的条件动态设置和更改。
-
内容可以通过
if语句、三元表达式、逻辑“与”运算符(&&)或任何在 JavaScript 中可行的其他方式来设置条件。 -
处理条件内容有多种方法——任何在纯 JavaScript 中可行的方案也可以在 React 应用中使用。
-
JSX 元素数组可以被插入到 JSX 代码中,这将导致数组元素作为同级 DOM 元素被输出。
-
列表数据可以通过
for循环、map()方法或任何其他导致类似转换的 JavaScript 方法转换为 JSX 元素数组。 -
使用
map()方法是将列表数据转换为 JSX 元素列表的最常见方式。 -
(通过
key属性)应该将键添加到列表 JSX 元素中,以帮助 React 高效地更新 DOM。
接下来是什么?
通过条件内容和列表,你现在拥有了构建简单和更复杂用户界面所需的所有关键工具,使用 React 你可以根据需要隐藏和显示元素或元素组,并且可以动态渲染和更新元素列表以输出产品列表、订单或用户列表。
当然,这并不是构建真实用户界面所需的所有内容。添加动态更改内容的逻辑是一回事,但大多数 Web 应用还需要应用于各种 DOM 元素的 CSS 样式。本书不涉及 CSS,但下一章仍将探讨 React 应用如何被样式化。特别是当涉及到动态设置和更改样式或将样式范围限定到特定组件时,有各种 React 特定的概念,每个 React 开发者都应该熟悉。
测试你的知识!
通过回答以下问题来测试你对本章涵盖的概念的了解。然后你可以将你的答案与可以在github.com/mschwarzmueller/book-react-key-concepts-e2/blob/05-lists-conditional-code/exercises/questions-answers.md找到的示例进行比较。:
-
“条件内容”是什么?
-
至少列举两种渲染 JSX 元素的条件方式。
-
哪种优雅的方法可以用来条件性地定义元素标签?
-
仅使用三元表达式(对于条件内容)的潜在缺点是什么?
-
如何将数据列表渲染为 JSX 元素?
-
为什么应该在渲染的列表项中添加键?
-
分别给出一个良好和不良键的示例。
应用你所学的知识
你现在能够使用你的 React 知识以各种方式改变动态用户界面。除了能够更改显示的文本值和数字之外,你现在也可以隐藏或显示整个元素(或元素块)并显示数据列表。
在以下章节中,你将找到两个活动,这些活动允许你应用你新获得的知识(结合在其他书籍章节中获得的知识)。
活动 5.1:显示条件错误消息
在这个活动中,你将构建一个基本表单,允许用户输入他们的电子邮件地址。在表单提交后,用户输入应该被验证,无效的电子邮件地址(为了简单起见,这里指的是不包含@符号的电子邮件地址)应该导致在表单下方显示错误消息。当无效的电子邮件地址变为有效时,可能可见的错误消息应该再次被移除。
执行以下步骤以完成此活动:
-
构建一个包含标签、输入字段(文本类型——为了演示目的,使输入错误的电子邮件地址更容易)和提交按钮的用户界面,该按钮会导致表单提交。
-
收集输入的电子邮件地址,如果电子邮件地址不包含
@符号,则在表单下方显示错误消息。
最终用户界面应该看起来和工作方式如下所示:
图 5.8:此活动的最终用户界面
注意
样式当然会有所不同。要获得截图中所显示的相同样式,请使用我准备的起始项目,你可以在这里找到:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/05-lists-conditional-code/activities/practice-1-start。
分析该项目的index.css文件,以确定如何结构化你的 JSX 代码以应用样式。
注意
你可以在这里找到完整的示例解决方案:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/05-lists-conditional-code/activities/practice-1。
活动 5.2:输出产品列表
在这个活动中,你将构建一个用户界面,其中在屏幕上显示产品列表((虚拟)产品)。界面还应包含一个按钮,当点击时,将另一个新的(虚拟)项目添加到现有的产品列表中。
执行以下步骤以完成此活动:
-
将一组虚拟产品对象(每个对象应具有 ID、标题和价格)添加到 React 组件中,并添加代码以输出这些产品项作为 JSX 元素。
-
向用户界面添加一个按钮。当按钮被点击时,应该向产品数据列表中添加一个新的产品对象。这应该导致用户界面更新并显示更新后的产品元素列表。
最终用户界面应该看起来和工作方式如下所示:
图 5.9:此活动的最终用户界面
注意
当然,样式会有所不同。要获得与截图相同的样式,请使用我准备的起始项目,您可以在以下链接找到:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/05-lists-conditional-code/activities/practice-2-start .
分析该项目中的index.css文件,以确定如何构建您的 JSX 代码以应用样式。
注意
您可以在以下链接找到完整的示例解决方案:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/05-lists-conditional-code/activities/practice-2 .
第六章:样式化 React 应用
学习目标
到本章结束时,你将能够做到以下几件事情:
-
通过内联样式赋值或使用 CSS 类来样式化 JSX 元素
-
设置内联和类样式,无论是静态的、动态的还是条件性的
-
构建可重用的组件,允许进行样式定制
-
利用 CSS 模块将样式限制在组件范围内
-
理解
styled-components这个第三方 CSS-in-JS 库背后的核心思想 -
使用 Tailwind CSS 来样式化 React 应用
简介
React.js 是一个前端 JavaScript 库。这意味着它全部关于构建(Web)用户界面和处理用户交互。
到目前为止,本书已经广泛探讨了如何使用 React 为 Web 应用添加交互性。状态、事件处理和动态内容是与这一主题相关的关键概念。
当然,网站和 Web 应用不仅仅是关于交互性的。你可以构建一个提供交互性和吸引人的功能的出色 Web 应用,但如果它缺乏吸引人的视觉元素,它可能仍然不受欢迎。展示是关键,网络也不例外。
因此,就像所有其他应用和网站一样,React 应用和网站需要适当的样式,并且在处理 Web 技术时,层叠样式表(CSS)是首选的语言。
然而,这本书不是关于 CSS 的。它不会解释或教你如何使用 CSS,因为已经有针对这一主题的专用、更好的资源(例如,developer.mozilla.org/en-US/docs/Learn/CSS 上的免费 CSS 指南)。但本章将教你如何将 CSS 代码与 JSX 和 React 概念(如状态和属性)结合使用。你将学习如何为你的 JSX 元素添加样式,样式自定义组件,并使这些组件的样式可配置。本章还将教你如何动态和条件性地设置样式,并探索流行的第三方库,如 styled-components 和 Tailwind CSS,它们可用于样式化。
React 应用中的样式是如何工作的?
到目前为止,本书中展示的应用和示例都只有最基本的美化。但至少它们有一些基本的美化,而不是完全没有美化。
但是,那种样式是如何添加的?在使用 React 时,如何将样式添加到用户界面元素(如 DOM 元素)中?
简短的回答是,“就像你对非 React 应用所做的那样。”你可以像对常规 HTML 元素一样,将 CSS 样式和类添加到 JSX 元素中。在你的 CSS 代码中,你可以使用你从 CSS 中知道的所有特性和选择器。在编写 CSS 代码时,你不需要做出任何特定的 React 更改。
到目前为止使用的代码示例(即 GitHub 上托管的活动或其他示例)总是使用常规 CSS 样式,借助 CSS 选择器,将一些基本样式应用到最终用户界面。这些 CSS 规则定义在一个index.css文件中,它是每个新创建的 React 项目的一部分(当使用 Vite 创建项目时,如第一章,React – 什么和为什么所示)。
例如,以下是前一章(第五章,渲染列表和条件内容)的活动 5.2中使用的index.css文件:
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;700&family=Rubik:ital,wght@0,300..900;1,300..900&display=swap');
body {
margin: 0;
padding: 3rem;
font-family: 'Poppins', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
background-color: #dff8fb;
color: #212324;
}
button {
padding: 0.5rem 1rem;
font-family: 'Rubik', sans-serif;
font-size: 1rem;
border: none;
border-radius: 4px;
background-color: #212324;
color: #fff;
cursor: pointer;
}
button:hover {
background-color: #3f3e40;
}
ul {
max-width: 35rem;
list-style-type: none;
padding: 0;
margin: 2rem auto;
}
li {
margin: 1rem 0;
padding: 1rem;
background-color: #5ef0fd;
border: 2px solid #212324;
border-radius: 4px;
}
实际的 CSS 代码及其含义并不重要(如前所述,这本书不是关于 CSS 的)。然而,重要的是这个代码完全不包含 JavaScript 或 React 代码。如前所述,你编写的 CSS 代码完全独立于你在应用中使用 React 的事实。
更有趣的问题是,这些代码实际上是如何应用到渲染的网页上的?它是如何导入到该页面的?
通常,你会在提供的 HTML 文件内部期望找到样式文件导入(通过<link href="…">)。由于 React 应用通常是关于构建单页应用(见第一章,React – 什么和为什么),你只有一个 HTML 文件——index.html文件。但如果你检查该文件,你不会找到任何指向index.css文件的<link href="…">导入(只有一些其他导入 favicon 的<link>元素),如下面的截图所示:
图 6.1:index.html文件的<head>部分不包含指向 index.css 文件的<link>导入
那么,index.css中的样式是如何导入并应用的?
在根入口文件(这是通过Vite生成的项目中的main.jsx文件)中,你可以找到一个import语句:
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
**import****'./index.css'****;**
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
import './index.css';语句导致 CSS 文件被导入,并且定义的 CSS 代码被应用到渲染的网页上。
值得注意的是,这不是标准的 JavaScript 行为。你不能将 CSS 文件导入到 JavaScript 中——至少,如果你只是使用纯 JavaScript 的话。
在 React 应用中,CSS 以这种方式工作,因为代码在加载到浏览器之前会被转换。因此,你不会在浏览器中执行的最终 JavaScript 代码中找到那个import语句。相反,在转换过程中,转换器识别 CSS 导入,将其从 JavaScript 文件中移除,并将 CSS 代码(或指向可能捆绑和优化的 CSS 文件的适当链接)注入到index.html文件中。
你可以通过在浏览器中检查加载的网页的渲染文档对象模型(DOM)内容来确认这一点。
要做到这一点,请选择 Chrome 开发者工具中的元素选项卡,如下所示:
图 6.2:在运行时 DOM 中可以找到注入的 CSS <style> 元素
你可以直接在index.css文件中,或在由index.css文件导入的任何其他 CSS 文件中定义要应用于你的 HTML 元素(即你的组件中的 JSX 元素)的任何样式。
你也可以将额外的 CSS 导入语句添加到main.jsx文件或任何其他 JavaScript 文件(包括存储组件的文件)中。然而,重要的是要记住,CSS 样式始终是全局的。无论你是否将 CSS 文件导入到main.jsx或组件特定的 JavaScript 文件中,该 CSS 文件中定义的样式都将应用于全局。
这意味着在goal-list.css文件中定义的样式,即使可能被导入到GoalList.jsx文件中,也可能影响在完全不同的组件中定义的 JSX 元素。在本章的后面部分,你将了解到一些技术,这些技术可以帮助你防止意外的样式冲突并实现样式作用域。
使用内联样式
你可以使用 CSS 文件来定义全局 CSS 样式,并使用不同的 CSS 选择器来针对不同的 JSX 元素(或元素组)。
尽管通常不建议这样做,但你也可以通过style属性直接在 JSX 元素上设置内联样式。
注意
如果你想知道为什么不建议使用内联样式,Stack Overflow 上的以下讨论提供了许多反对内联样式的论点:stackoverflow.com/questions/2612483/whats-so-bad-about-in-line-css。
在 JSX 代码中设置内联样式的方式如下:
function TodoItem() {
return <li style={{color: 'red', fontSize: '18px'}}>Learn React!</li>;
};
在这个例子中,向<li>元素(所有 JSX 元素都支持style属性)添加了style属性,并通过 CSS 设置了文本的color和size属性。
这种方法与仅使用 HTML(而不是 JSX)设置内联样式的方法不同。当使用纯 HTML 时,你会这样设置内联样式:
<li style="color: red; font-size: 18px">Learn React!</li>
不同之处在于,style属性期望接收一个包含样式设置的 JavaScript 对象——而不是一个普通的字符串。这是必须记住的,因为,如前所述,内联样式通常不常用。
由于style对象是一个对象而不是一个普通字符串,它被作为值放在大括号之间——就像数组、数字或任何其他非字符串值一样必须在大括号之间设置(双引号或单引号之间的任何内容都被视为字符串值)。因此,值得注意的是,前面的例子没有使用任何特殊的“双大括号”语法,而是使用一对大括号来包围非字符串值,另一对大括号来包围对象数据。
在style对象内部,可以设置底层 DOM 元素支持的任何 CSS 样式属性。属性名称是针对 HTML 元素定义的(即,与你可以用纯 JavaScript 针对和设置的目标和设置的 CSS 属性名称相同),当修改 HTML 元素时。
当在 JavaScript 代码中设置样式(如上面显示的style属性)时,必须使用 JavaScript CSS 属性名称。这些名称与你在 CSS 代码中使用的 CSS 属性名称相似,但并不完全相同。当针对由多个单词组成的属性名称(例如,font-size)时,会出现差异。在 JavaScript 中针对此类属性时,必须使用驼峰式命名法(fontSize而不是font-size),因为 JavaScript 属性不能包含破折号。或者,你也可以用引号包裹属性名称('font-size')。
注意
你可以在这里找到有关 HTML 元素样式属性和 JavaScript CSS 属性名称的更多信息:developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style。
通过 CSS 类设置样式
如前所述,通常不建议使用内联样式,因此,在 CSS 文件中定义的 CSS 样式(或在文档<head>部分的<style>标签之间)更受欢迎。
在这些 CSS 代码块中,你可以编写常规 CSS 代码并使用 CSS 选择器将 CSS 样式应用于特定元素。例如,你可以这样设置页面上的所有<li>元素(无论哪个组件可能渲染了它们)的样式:
li {
color: red;
font-size: 18px;
}
只要此代码被添加到页面中(因为定义它的 CSS 文件被导入到main.jsx等),样式就会被应用。
开发者经常试图针对特定的元素或元素组。而不是将某些样式应用于页面上的所有<li>元素,目标可能是仅针对属于特定列表的<li>元素。考虑以下渲染到页面的 HTML 结构(它可能分布在多个组件中,但这在这里并不重要):
<nav>
<ul>
<li><a href="…">Home</a></li>
<li><a href="…">New Goals</a></li>
</ul>
</nav>
...
<h2>My Course Goals</h2>
<ul>
<li>Learn React!</li>
<li>Master React!</li>
</ul>
在这个例子中,导航列表项很可能不会收到与course goal列表项相同的样式(反之亦然)。
通常,这个问题会借助 CSS 类和类选择器来解决。你可以像这样调整 HTML 代码:
<nav>
<ul>
<li><a href="…">Home</a></li>
<li><a href="…">New Goals</a></li>
</ul>
</nav>
...
<h2>My Course Goals</h2>
<ul>
<li **class****=****"goal-item"**>Learn React!</li>
<li **class****=****"goal-item"**>Master React!</li>
</ul>
以下 CSS 代码只会针对课程目标列表项,而不会针对导航列表项:
.goal-item {
color: red;
font-size: 18px;
}
这种方法在 React 应用中也几乎同样适用。
然而,如果你尝试向 JSX 元素添加 CSS 类,如前一个示例所示,你将在浏览器开发者工具中遇到警告:
图 6.3:React 输出的警告
如前图所示,你不应该将class作为属性添加,而应该使用className。实际上,如果你将class替换为className作为属性名,警告就会消失,并且类 CSS 样式将被应用。因此,正确的 JSX 代码如下:
<ul>
<li **className**="goal-item">Learn React!</li>
<li **className**="goal-item">Master React!</li>
</ul>
但为什么 React 建议你使用className而不是class?
这与在处理<label>对象时使用htmlFor而不是for类似(如第四章处理事件和状态中讨论的)。就像for一样,class是 JavaScript 中的一个关键字,因此,className被用作属性名。
动态设置样式
使用内联样式和 CSS 类(以及通常的全局 CSS 样式),有各种方法可以将样式应用于元素。到目前为止,所有示例都显示了静态样式——也就是说,一旦页面加载完成,样式就不会改变。
虽然大多数页面元素在页面加载后不会改变它们的样式,但你通常也有一些元素应该动态或条件性地设置样式。以下是一些示例:
-
一个待办事项应用,其中不同的待办事项优先级会收到不同的颜色
-
一个输入表单,其中无效的表单元素应在表单提交失败后突出显示
-
一个基于 Web 的游戏,玩家可以为他们的头像选择颜色
在这种情况下,应用静态样式是不够的,应该使用动态样式。动态设置样式很简单。再次强调,这只是应用之前覆盖的 React 关键概念(最重要的是第二章理解 React 组件和 JSX和第四章处理事件和状态中关于设置动态值的内容)。
这里有一个例子,其中段落的颜色会动态设置为用户在输入字段中输入的颜色:
function ColoredText() {
const [enteredColor, setEnteredColor] = useState('');
function handleUpdateTextColor(event) {
setEnteredColor(event.target.value);
};
return (
<>
<input type="text" onChange={handleUpdateTextColor}/>
<p style={{color: enteredColor}}>This text's color changes dynamically!</p>
</>
);
};
在<input>字段中输入的文本存储在enteredColor状态中。然后使用此状态动态设置<p>元素的color CSS 属性。这是通过传递一个style对象来实现的,其中color属性设置为enteredColor的值,作为<p>元素的style属性的值。因此,段落的文本颜色会动态设置为用户输入的值(假设用户将有效的 CSS 颜色值输入到<input>字段中)。
你不仅限于内联样式;CSS 类也可以动态设置,如下面的代码片段所示:
function TodoPriority() {
const [chosenPriority, setChosenPriority] = useState('low-prio');
function handleChoosePriority(event) {
setChosenPriority(event.target.value);
};
return (
<>
<p className={chosenPriority}>Chosen Priority: {chosenPriority}</p>
<select onChange={handleChoosePriority}>
<option value="low-prio">Low</option>
<option value="high-prio">High</option>
</select>
</>
);
};
在这个例子中,chosenPriority状态将在low-prio和high-prio之间交替,取决于下拉选择。然后状态值作为段落内的文本输出,也用作动态 CSS 类名,应用于<p>元素。当然,为了产生任何视觉效果,必须在某个 CSS 文件或<style>块中定义low-prio和high-prio CSS 类。例如,考虑以下index.css中的代码:
.low-prio {
background-color: blue;
color: white;
}
.high-prio {
background-color: red;
color: white;
}
条件样式
与动态样式密切相关的是条件样式。实际上,它们最终只是动态样式的特殊案例。在先前的例子中,内联样式值和类名被设置为等于用户选择或输入的值。
然而,你也可以根据不同的条件动态地派生样式或类名,如下所示:
function TextInput({isValid, isRecommended, ...props}) {
let cssClass = 'input-default';
if (isRecommended) {
cssClass = 'input-recommended';
}
if (!isValid) {
cssClass = 'input-invalid';
}
return <input className={cssClass} {...props} />
};
在这个例子中,围绕标准<input>元素构建了一个包装组件。(有关包装组件的更多信息,请参阅第三章,组件和属性。)这个包装组件的主要目的是为包装的<input>元素设置一些默认样式。包装组件被构建为提供可以用于应用中任何位置的预样式输入元素。实际上,提供预样式元素是构建包装组件最常见和最受欢迎的使用场景之一。
在这个具体的例子中,默认样式是通过 CSS 类应用的。如果isValid属性值为true且isRecommended属性值为false,则input-default CSS 类将应用于<input>元素,因为两个if语句都没有激活。
如果isRecommended为true(但isValid为false),则应用input-recommended CSS 类。如果isValid为false,则添加input-invalid类。当然,CSS 类必须在某些导入的 CSS 文件中定义(例如,在index.css中)。
内联样式也可以以类似的方式设置,如下面的代码片段所示:
function TextInput({isValid, isRecommended, ...props}) {
let bgColor = 'black';
if (isRecommended) {
bgColor = 'blue';
}
if (!isValid) {
bgColor = 'red';
}
return <input style={{backgroundColor: bgColor}} {...props} />
};
在这个例子中,<input>元素的背景颜色是基于通过isValid和isRecommended属性接收到的值有条件地设置的。
结合多个动态 CSS 类
在先前的例子中,一次只能动态设置一个 CSS 类。然而,遇到需要合并和添加到元素中的多个动态派生 CSS 类的情况并不少见。
考虑以下示例:
function ExplanationText({children, isImportant}) {
const defaultClasses = 'text-default text-expl';
return <p className={defaultClasses}>{children}</p>;
}
在这里,通过简单地将它们组合成一个字符串,就可以向<p>元素添加两个 CSS 类。或者,你也可以直接添加包含两个类的字符串,如下所示:
return <p className="text-default text-expl">{children}</p>;
这段代码将能正常工作,但如果目标是基于isImportant属性值(在先前的例子中被忽略)向类列表中添加另一个类名呢?
替换默认的类列表很容易,正如你所学到的:
function ExplanationText({children, isImportant}) {
let cssClasses = 'text-default text-expl';
if (isImportant) {
cssClasses = 'text-important';
}
return <p className={cssClasses}>{children}</p>;
}
但如果目标不是替换默认类列表呢?如果text-important应该作为类添加到<p>元素中,除了text-default和text-expl呢?
className属性期望接收一个字符串值,因此传递一个类数组不是一种选择。然而,你可以简单地合并多个类成一个字符串,并且有几种不同的方法可以做到这一点:
-
字符串连接:
cssClasses = cssClasses + ' text-important'; -
使用模板字符串:
cssClasses = `${cssClasses} text-important`; -
数组连接:
cssClasses = [cssClasses, 'text-important'].join(' ');
这些示例都可以在if语句(if (isImportant))中使用,根据isImportant属性值有条件地添加text-important类。所有这三种方法以及这些方法的变体都将工作,因为所有这些方法都产生一个字符串。一般来说,任何产生字符串的方法都可以用来生成className的值。
合并多个内联样式对象
当处理内联样式时,除了 CSS 类,您还可以合并多个样式对象。主要区别在于您不生成包含所有值的字符串,而是一个包含所有组合样式值的对象。
这可以通过使用标准的 JavaScript 技术将多个对象合并为一个对象来实现。最流行的技术涉及使用扩展运算符,如下例所示:
function ExplanationText({children, isImportant}) {
let defaultStyle = { color: 'black' };
if (isImportant) {
defaultStyle = { ...defaultStyle, backgroundColor: 'red' };
}
return <p style={defaultStyle}>{children}</p>;
}
在这里,您会注意到defaultStyle是一个具有color属性的对象。如果isImportant为true,它将被替换为一个包含所有先前属性(通过扩展运算符...defaultStyle)以及backgroundColor属性的对象。
注意
关于扩展运算符的功能和使用,请参阅第五章,渲染列表和条件内容。
使用可定制样式构建组件
如您现在所知,组件可以被重用。这一点得到了支持,因为它们可以通过属性进行配置。同一个组件可以在页面的不同位置使用不同的配置来产生不同的输出。
由于样式可以静态和动态设置,您也可以使组件的样式可定制。前面的示例已经展示了这种定制的作用;例如,在先前的示例中,isImportant属性被用来有条件地向段落添加红色背景色。因此,ExplanationText组件已经通过isImportant属性允许间接的样式定制。
除了这种形式的定制外,您还可以构建接受已持有 CSS 类名或样式对象的属性的组件。例如,以下包装组件接受一个className属性,该属性与默认 CSS 类(btn)合并:
function Button({children, config, className}) {
return <button {...config} className={`btn ${className}`}>{children}</button>;
};
此组件可以用以下方式在另一个组件中使用:
<Button config={{onClick: doSomething}} className="btn-alert">Click me!</Button>
如果这样使用,最终的<button>元素将同时接收btn和btn-alert类。
您不必使用className作为属性名;任何名称都可以使用,因为它是您的组件。然而,使用className并不是一个坏主意,因为这样您可以保持通过className设置 CSS 类的心理模型(对于内置组件,您将没有这样的选择)。
与将属性值与默认 CSS 类名或样式对象合并不同,您可以覆盖默认值。这允许您构建一些带有默认样式的组件,而无需强制使用该样式:
function Button({children, config, className}) {
let cssClasses = 'btn';
if (className) {
cssClasses = className;
}
return <button {...config} className={cssClasses}>{children}</button>;
};
你可以看到,本书中涵盖的所有不同概念是如何在这里汇聚的:属性允许定制,值可以设置、交换和动态条件地更改,因此可以构建高度可重用和可配置的组件。
使用固定配置选项进行定制
除了暴露className或style等属性,这些属性会与组件函数内部定义的其他类或样式合并外,你还可以构建基于其他属性值应用不同样式或类名的组件。
这在前面的示例中已经展示过,其中使用了isValid或isImportant等属性来有条件地应用某些样式。因此,这种应用样式的做法可以被称为“间接样式”(尽管这不是一个官方术语)。
两种方法在不同的环境中都能发挥作用。例如,对于包装组件,接受className或style属性(这些可以在组件内部与其他样式合并)使得组件可以像内置组件一样使用(例如,像它所包装的组件)。另一方面,如果你想要构建提供一些预定义变体的组件,间接样式可能非常有用。
一个很好的例子是,一个文本框提供了两个内置主题,可以通过特定的属性进行选择。
图 6.4:根据“mode”属性的值对 TextBox 进行样式化
TextBox组件的代码可能看起来像这样:
function TextBox({children, mode}) {
let cssClasses;
if (mode === 'alert') {
cssClasses = 'box-alert';
} else if (mode === 'info') {
cssClasses = 'box-info';
}
return <p className={cssClasses}>{children}</p>;
};
这个TextBox组件始终返回一个段落元素。如果mode属性设置为除'alert'或'info'之外的任何值,则段落不会接收任何特殊样式。但如果mode等于'alert'或'info',则会向段落添加特定的 CSS 类。
因此,这个组件不允许通过某些className或style属性进行直接样式化,但它确实提供了不同的变体或主题,可以通过特定的属性(在这种情况下是mode属性)来设置。
未限定样式的问题
如果你考虑本章中迄今为止处理的不同示例,那么有一个特定的用例出现得相当频繁:样式仅与特定组件相关。
例如,在前一节的TextBox组件中,'box-alert'和'box-info'是可能只与这个特定组件及其标记相关的 CSS 类。如果应用了'box-alert'类的任何其他 JSX 元素(尽管这不太可能),那么它可能不应该与TextBox组件中的<p>元素以相同的样式进行样式化。
来自不同组件的样式可能会相互冲突并覆盖彼此,因为样式不是限定(即,限制)在特定组件内的。CSS 样式始终是全局的,除非使用内联样式(如前所述,这是不推荐的)。
当与 React 等基于组件的库一起工作时,这种作用域缺失是一个常见问题。随着应用规模和复杂性的增长(或者说,随着越来越多的组件被添加到 React 应用的代码库中),很容易编写冲突的样式。
因此,React 社区成员已经开发了各种解决方案来解决这个问题。以下是最受欢迎的三种解决方案:
-
CSS Modules(在用 Vite 创建的 React 项目中默认支持)
-
样式化组件(使用名为
styled-components的第三方库) -
Tailwind CSS(一个流行的 CSS 库)
CSS Modules 的作用域样式
CSS Modules是一种方法,其中单个 CSS 文件与特定的 JavaScript 文件相关联,并且这些文件中定义的组件。这种链接是通过转换 CSS 类名来建立的,使得每个 JavaScript 文件都接收自己的、唯一的 CSS 类名。这种转换作为代码构建工作流的一部分自动执行。因此,给定的项目设置必须通过执行所描述的 CSS 类名转换来支持 CSS Modules。通过 Vite 创建的项目默认支持 CSS Modules。
图 6.5:CSS 模块在实际应用中的表现。在构建工作流中,CSS 类名被转换成唯一的名称
CSS Modules 通过以非常具体和明确的方式命名 CSS 文件来启用和使用:<anything>.module.css。<anything>是你选择的任何值,但文件扩展名前的.module部分是必需的,因为它向项目构建工作流发出信号,即此 CSS 文件应根据 CSS Modules 方法进行转换。
因此,像这样命名的 CSS 文件必须以特定的方式导入到组件中:
import classes from './file.module.css';
这种import语法与本节开头为index.css展示的import语法不同:
import './index.css';
当像第二个代码片段中那样导入 CSS 文件时,CSS 代码会被简单地合并到index.html文件中并全局应用。当使用 CSS Modules(第一个代码片段)时,导入的 CSS 文件中定义的 CSS 类名会被转换,使得它们对于导入 CSS 文件的 JS 文件来说是唯一的。
由于 CSS 类名被转换,因此它们不再等于你在 CSS 文件中定义的类名,所以你从 CSS 文件中导入一个对象(前例中的classes),这个对象通过匹配你在 CSS 文件中定义的 CSS 类名作为键,暴露了所有转换后的 CSS 类名。这些属性的值是转换后的类名(字符串)。
下面是一个完整的示例,从一个特定组件的 CSS 文件(TextBox.module.css)开始:
.alert {
padding: 1rem;
border-radius: 6px;
background-color: #f9bcb5;
color: #480c0c;
}
.info {
padding: 1rem;
border-radius: 6px;
background-color: #d6aafa;
color: #410474;
}
应该将 CSS 代码归属的组件的 JavaScript 文件(TextBox.jsx)看起来像这样:
import classes from './TextBox.module.css';
function TextBox({ children, mode }) {
let cssClasses;
if (mode === 'alert') {
cssClasses = classes.alert;
} else if (mode === 'info') {
cssClasses = classes.info;
}
return <p className={cssClasses}>{children}</p>;
}
export default TextBox;
注意
完整的示例代码也可以在 github.com/mschwarzmueller/book-react-key-concepts-e2/tree/06-styling/examples/01-css-modules-intro 找到。
如果你使用浏览器开发者工具检查渲染的文本元素,你会注意到应用到 <p> 元素的 CSS 类名并不匹配 TextBox.module.css 文件中指定的类名:
图 6.6:CSS 类名因为 CSS 模块的使用而转换
这是因为,如前所述,类名在构建过程中被转换成唯一的。如果其他任何 CSS 文件(由另一个 JavaScript 文件导入),定义了一个具有相同名称的类(在这个例子中是 info),那么样式就不会冲突,也不会相互覆盖,因为干扰的类名在应用到 DOM 元素之前会被转换成不同的类名。
实际上,在 GitHub 上提供的示例中,你还可以找到在 index.css 文件中定义的另一个 info CSS 类:
.info {
border: 5px solid red;
}
该文件仍然被导入到 main.jsx 中,因此其样式被应用到整个文档的全局范围内。尽管如此,.info 样式显然没有影响到由 TextBox 渲染的 <p> 元素(在 图 6.6 中文本框周围没有红色边框)。它们没有影响到该元素,因为该元素不再有 info 类;该类被构建工作流程重命名为 _info_1mtzh_8(尽管你看到的名称将不同,因为它包含一个随机元素)。
值得注意的是,index.css 文件仍然被导入到 main.jsx 中,正如本章开头所示。import 语句没有被改为 import classes from './index.css';,CSS 文件也没有被命名为 index.module.css。
注意,你还可以使用 CSS 模块将样式范围限定到组件,并且可以将 CSS 模块的使用与常规 CSS 文件混合,这些常规 CSS 文件被导入到 JavaScript 文件中而不使用 CSS 模块(即,不进行范围限定)。
使用 CSS 模块的另一个重要方面是,你只能使用 CSS 类选择器(即,在你的 .module.css 文件中),因为 CSS 模块依赖于 CSS 类。你可以编写结合类和其他选择器的选择器,例如 input.invalid,但你不能在你的 .module.css 文件中添加不使用类的选择器。例如,input { ... } 或 #some-id { ... } 选择器在这里将不起作用。
CSS 模块是将样式范围限定到(React)组件的一种非常流行的方式,本书的后续许多示例都将使用这种方式。
样式组件库
styled-components 库是一种所谓的 CSS-in-JS 解决方案。CSS-in-JS 解决方案旨在通过将它们合并到同一个文件中来消除 CSS 代码和 JavaScript 代码之间的分离。组件样式将直接定义在组件逻辑旁边。是否偏好分离(如通过使用 CSS 文件强制执行)或保持两种语言紧密相邻,这取决于个人喜好。
由于 styled-components 是一个不在新创建的 React 项目中预安装的第三方库,如果你想使用它,你必须将其作为第一步安装。这可以通过 npm(在 第一章 ,React – 什么和为什么 中与 Node.js 自动安装)来完成:
npm install styled-components
styled-components 库本质上提供了所有内置核心组件的包装组件(例如,围绕 p、a、button、input 等)。它将这些包装组件作为 标记模板 暴露出来——JavaScript 函数,它们不像常规函数那样被调用,而是通过在函数名后添加反引号(模板字面量)来执行,例如,doSomething`text data`。
注意
当你第一次看到标记模板时,可能会感到困惑,尤其是考虑到它是一个不太常用的 JavaScript 功能。你不太可能经常使用它们。更有可能的是,你以前从未构建过自定义标记模板。你可以在 MDN 的这篇优秀的文档中了解更多关于标记模板的信息:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates 。
这里是一个导入并使用 styled-components 来设置和作用域样式的组件:
import styled from 'styled-components';
const Button = styled.button`
background-color: #370566;
color: white;
border: none;
padding: 1rem;
border-radius: 4px;
`;
export default Button;
这个组件不是一个组件函数,而是一个常量,它存储了执行 styled.button 标记模板返回的值。该标记模板返回一个组件函数,该函数生成一个 <button> 元素。通过标记模板(即模板字面量内)传递的样式应用于该返回的按钮元素。如果你在浏览器开发者工具中检查按钮,就可以看到这一点:
图 6.7:渲染的按钮元素接收定义的组件样式
在 图 6.7 中,你还可以看到 styled-components 库如何将你的样式应用到元素上。它从标记模板字符串中提取你的样式定义,并将它们注入到文档 <head> 部分的 <style> 元素中。然后,通过由 styled-components 库生成(并命名)的类选择器应用注入的样式。最后,库将自动生成的 CSS 类名添加到元素(在这种情况下是 <button>)上。
styled-components库暴露的组件会将你传递给组件的任何额外属性传播到包装的核心组件上。此外,任何插入在开标签和闭标签之间的内容也会插入到包装组件的标签之间。
这就是为什么之前创建的Button可以像这样使用,而不需要添加任何额外的逻辑:
import Button from './components/button.jsx';
function App() {
function handleClick() {
console.log('This button was clicked!');
}
return <Button onClick={handleClick}>Click me!</Button>;
}
export default App;
注意
完整的示例代码可以在 GitHub 上找到,地址为github.com/mschwarzmueller/book-react-key-concepts-e2/tree/06-styling/examples/02-styled-components-intro。
你可以使用styled-components库做更多的事情。例如,你可以动态和有条件地设置样式。不过,这本书并不是主要关于这个库的。它只是 CSS Modules 的许多替代方案之一。因此,如果你想要了解更多,建议你探索官方的styled-components文档,你可以在这里找到styled-components.com/。
使用 Tailwind CSS 库进行样式设计
使用 CSS 模块或styled-component库来范围样式是一种非常有用且流行的技术。
但无论你使用哪种方法,你都必须自己编写所有的 CSS 代码。因此,当然你需要了解 CSS。
但如果你不喜欢写 CSS 代码呢?或者你根本就不想写?
在这种情况下,你可以使用许多可用的 CSS 库和框架之一——例如,Bootstrap CSS 框架或Tailwind CSS库。Tailwind 已经成为 React 项目(对于不想编写自定义 CSS 代码的开发者)非常流行的样式解决方案。
请记住,Tailwind 是一个 CSS 库,实际上并不专注于 React。相反,你可以在任何 Web 项目中使用 Tailwind 来样式化你的 HTML 代码——无论在那里使用的是哪种 JavaScript 库或框架(如果有的话)。
但 Tailwind 是 React 应用的常见选择,因为它的核心哲学与 React 的组件化模型相得益彰。这是因为当使用 Tailwind 进行样式设计时,你通常会通过将许多小的 CSS 类应用到单个 JSX 元素上来组合整体样式:
function App() {
return (
<main
className="bg-gray-200 text-gray-900 h-screen p-12 text-center">
<h1 className="font-bold text-4xl">Tailwind CSS is amazing!</h1>
<p className="text-gray-600">
It may take a while to get used to it. But it's great for people who don't want to write custom CSS code.
</p>
</main>
);
}
export default App;
当第一次遇到使用 Tailwind CSS 的代码时,长长的 CSS 类列表可能会看起来令人畏惧且混乱。但当你与 Tailwind 一起工作时,你通常会很快习惯它。
此外,因为 Tailwind 的方法提供了许多优势:
-
你不需要详细了解 CSS——理解 Tailwind 语法就足够了,它比从头开始写 CSS 要简单。
-
你通过组合 CSS 类来编写样式——类似于你在 React 中从组件组合用户界面。
-
你不需要在 JSX 文件和 CSS 文件之间切换。
-
样式更改可以非常快速地应用和测试。
如上代码片段所示,Tailwind 的核心思想是它提供了许多可组合的 CSS 类,每个类只做很少的事情。例如,bg-gray-200类仅将背景颜色设置为某种灰度的色调,没有其他作用。
因此,所有这些 CSS 类的组合才能达到某种外观,Tailwind CSS 提供了许多这样的类,你可以使用并组合。你可以在官方文档中找到完整的列表,网址为tailwindcss.com/docs/utility-first。
当你在 React 项目中使用 Tailwind 时,你可以构建 React 组件,不仅是为了重用逻辑或 JSX 标记,还可以重用样式:
**function****Item****(****{ children }****) {**
**return****<****li****className****=****'p-1 my-2 bg-stone-100'****>****{children}****</****li****>****;**
**}**
function App() {
return (
<main className="bg-gray-200 text-gray-900 h-screen p-12 text-center">
<h1 className="font-bold text-4xl">Tailwind CSS is amazing!</h1>
<p className="text-gray-600">
It may take a while to get used to it. But it's great for people who
don't want to write custom CSS code.
</p>
<section className="mt-10 border border-gray-600 max-w-3xl mx-auto p-4 rounded-md bg-gray-300">
<h2 className="font-bold text-xl">Tailwind CSS Advantages</h2>
<ul className="mt-4">
**<****Item****>****No CSS knowledge required****</****Item****>**
**<****Item****>****Compose styles by combining "small" CSS classes****</****Item****>**
**<****Item****>**
**Never leave your JSX code - no need to fiddle around in extra CSS files**
**</****Item****>**
**<****Item****>****Quickly test and apply changes****</****Item****>**
</ul>
</section>
</main>
);
}
export default App;
在这个例子中,Item组件被构建为重用应用于<li>元素的 Tailwind 样式。
注意
你还可以在 GitHub 上找到这个示例项目:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/06-styling/examples/03-tailwind。
如果你计划在 React 项目中使用 Tailwind,你必须将其作为第一步安装。官方文档中提供了针对各种项目设置的详细安装说明——这包括 Vite 项目的说明:tailwindcss.com/docs/guides/vite。
安装过程不仅仅是导入一个 CSS 文件那么简单,但仍然相对直接。由于 Tailwind 需要连接到项目构建过程以分析你的 JSX 文件并生成包含所有使用过的类名和样式规则的 CSS 代码,因此它确实需要几个设置步骤。
除了提供许多可组合的实用样式外,Tailwind 还提供了大量的自定义机会和配置选项。因此,关于 Tailwind 的书籍可以写满整本书。然而,当然这不是本书的主题。因此,如果你对在 React 项目中使用 Tailwind 感兴趣,Tailwind 的官方文档(见上面的链接)是一个学习更多的好地方。
使用其他 CSS 或 JavaScript 样式库和框架
显然,是否编写自定义 CSS 代码(可能使用 CSS Modules 或styled-components进行范围限定)或者是否使用第三方 CSS 库,如 Tailwind CSS,取决于个人喜好。没有对错之分,你会在不同的 React 项目中看到各种方法被使用。
本章中介绍的选择也不是详尽的——还有其他类型的 CSS 和 JavaScript 库:
-
解决非常具体的 CSS 问题的实用库——无论你是在 React 项目中使用它们(例如,
Animate.css,它有助于添加动画) -
其他 CSS 框架或库提供了广泛的预建 CSS 类,可以应用于元素以快速实现某种外观(例如,Bootstrap)
-
帮助进行样式或特定样式方面(例如,动画)的 JavaScript 库(例如,Framer Motion)
一些库和框架有针对 React 的特定扩展或专门支持 React,但这并不意味着你不能使用没有这种扩展的库。
概括和关键要点
-
可以使用标准 CSS 来样式化 React 组件和 JSX 元素。
-
CSS 文件通常直接导入到 JavaScript 文件中,这得益于项目构建过程,它提取 CSS 代码并将其注入到文档中(HTML 文件)。
-
作为全局 CSS 样式(使用
element、id、class或其他选择器)的替代方案,内联样式可以用来为 JSX 元素应用样式。 -
当使用 CSS 类进行样式化时,你必须使用
className属性(而不是class)。 -
样式可以静态设置,也可以使用与将其他动态或条件值注入 JSX 代码相同的语法动态或条件设置——一对大括号。
-
可以通过设置样式(或 CSS 类)基于 prop 值,或者通过合并接收到的 prop 值与其他样式或类名字符串来构建高度可配置的自定义组件。
-
当仅使用 CSS 时,CSS 类名冲突可能是一个问题。
-
CSS Modules 通过在构建工作流程中将类名转换为唯一的名称(每个组件一个)来解决此问题。
-
或者,可以使用如
styled-components之类的第三方库。这个库是一个 CSS-in-JS 库,它也有一个优点或缺点(取决于你的偏好),即消除了 JS 和 CSS 代码之间的分离。 -
Tailwind CSS 是 React 项目的另一种流行的样式选择——这是一个允许你通过组合许多小的 CSS 类来编写样式的库。
-
也可以使用其他 CSS 库或框架;React 在这方面没有施加任何限制。
接下来是什么?
在样式处理完毕后,你现在能够构建不仅功能性强而且视觉上吸引人的用户界面。即使你经常与专门的网页设计师或 CSS 专家合作,你通常也需要能够设置和分配样式(动态地)并将其传递给你。
由于样式是一个相对独立于 React 的一般概念,下一章将回到更多 React 特定的功能和主题。你将了解** portals和refs**,这两个是 React 内置的关键概念。你将发现这些概念解决了哪些问题,以及这两个功能是如何使用的。
测试你的知识!
通过回答以下问题来测试您对本章涵盖的概念的理解。您可以将您的答案与以下示例进行比较:github.com/mschwarzmueller/book-react-key-concepts-e2/blob/06-styling/exercises/questions-answers.md。
-
React 组件的样式是用哪种语言定义的?
-
与没有 React 的项目相比,在为元素分配类时,需要牢记哪些重要差异?
-
如何动态和条件性地分配样式?
-
在样式上下文中,“限定”是什么意思?
-
样式如何限定到组件中?简要解释至少一个有助于限定的概念。
应用所学知识
现在,您不仅能够构建交互式用户界面,还能够以引人入胜的方式对用户界面元素进行样式设计。您可以根据条件动态地设置和更改这些样式。
在本节中,您将找到两个活动,这些活动允许您将新获得的知识与之前章节中学到的知识相结合来应用。
活动六.1:在表单提交时提供输入有效性反馈
在本活动中,您将构建一个基本的表单,允许用户输入电子邮件地址和密码。每个输入字段的输入都会进行验证,并且验证结果会被存储(针对每个单独的输入字段)。
本活动的目的是添加一些通用的表单样式和一些条件样式,一旦提交了无效表单,这些样式就会生效。具体的样式由您决定,但为了突出显示无效的输入字段,必须更改受影响输入字段的背景颜色、边框颜色以及相关标签的文本颜色。
步骤如下:
-
创建一个新的 React 项目,并向其中添加一个表单组件。
-
在项目的根组件中输出表单组件。
-
在表单组件中,输出包含两个输入字段的表单:一个用于输入电子邮件地址,另一个用于输入密码。
-
为输入字段添加标签。
-
在表单提交时存储输入的值并检查它们的有效性(您可以在形成自己的验证逻辑方面发挥创意)。
-
从提供的
index.css文件中选择合适的 CSS 类(或者您也可以编写自己的类)。 -
一旦提交了无效值,就将它们添加到无效的输入字段及其标签上。
最终用户界面应如下所示:
图 6.8:最终用户界面,无效输入值以红色突出显示
由于本书不涉及 CSS,并且您可能不是 CSS 专家,您可以使用解决方案中的index.css文件,并专注于 React 逻辑来将适当的 CSS 类应用到 JSX 元素上。
注意
所有用于此活动的代码文件以及完整解决方案,可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/06-styling/activities/practice-1找到。
活动六.2:使用 CSS 模块进行样式作用域
在这个活动中,你将使用活动 6.1中构建的最终应用程序,并调整它以使用 CSS 模块。目标是迁移所有特定于组件的样式到一个特定于组件的 CSS 文件中,该文件使用 CSS 模块进行样式作用域。
因此,最终的用户界面看起来与上一个活动相同。然而,样式将被限制在Form组件中,这样冲突的类名就不会干扰样式。
步骤如下:
-
完成上一个活动或从 GitHub 获取完成的代码。
-
识别属于
Form组件的特定样式,并将它们移动到新的、特定于组件的 CSS 文件中。 -
将 CSS 选择器更改为类名选择器,并根据需要将类添加到 JSX 元素中(这是因为 CSS 模块需要类名选择器)。
-
使用本章中解释的特定于组件的 CSS 文件,并将 CSS 类分配给适当的 JSX 元素。
注意
所有用于此活动的代码文件以及完整解决方案,可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/06-styling/activities/practice-2找到。
第七章:门户和引用
学习目标
到本章结束时,你将能够做到以下几点:
-
使用直接 DOM 元素访问来与元素交互
-
将你的组件的函数和数据暴露给其他组件
-
控制渲染的 JSX 元素在 DOM 中的位置
简介
React.js 是关于构建用户界面的,在本书的上下文中,它特别是指构建网络用户界面。
网络用户界面最终都是关于文档对象模型(DOM)。你可以使用 JavaScript 来读取或操作 DOM。这就是允许你构建交互式网站的原因:你可以在页面加载后添加、删除或编辑 DOM 元素。这可以用来添加或删除覆盖窗口或读取输入字段中输入的值。
这在第一章,React – 什么是和为什么中已经讨论过了,正如你所学的,React 用于简化这个过程。你不需要手动操作 DOM 或从 DOM 元素中读取值,你可以使用 React 来描述所需的状态。然后 React 负责完成达到这个所需状态的步骤。
然而,在某些场景和用例中,尽管使用了 React,你仍然希望能够直接访问特定的 DOM 元素——例如,读取用户输入到输入字段中的值,或者如果你对 React 选择的 DOM 中新插入元素的位置不满意。
React 提供了一些功能,可以帮助你在这些情况下:门户和引用。尽管直接操作 DOM 仍然不是一个好主意,但正如你将在本章中学习的,这些工具可以帮助读取 DOM 元素值或更改 DOM 结构,而不会与 React 作对。
没有引用的世界
考虑以下示例:你有一个网站,它渲染一个输入字段,请求用户的电子邮件地址。它可能看起来像这样:
图 7.1:一个带有电子邮件输入字段的示例表单
负责渲染表单并处理输入的电子邮件地址值的组件的代码可能看起来像这样:
function EmailForm() {
const [enteredEmail, setEnteredEmail] = useState('');
console.log(enteredEmail);
function handleUpdateEmail(event) {
setEnteredEmail(event.target.value);
}
function handleSubmitForm(event) {
event.preventDefault();
// could send enteredEmail to a backend server
}
return (
<form className={classes.form} onSubmit={handleSubmitForm}>
<label htmlFor="email">Your email</label>
<input type="email" id="email" onChange={handleUpdateEmail} />
<button>Save</button>
</form>
);
}
如你所见,这个示例使用useState()钩子,结合change事件,来注册email输入字段中的按键,并存储输入的值。
这段代码运行良好,在你的应用程序中包含这种类型的代码也没有什么问题。但是,添加额外的事件监听器和状态,以及添加在change事件触发时更新状态的函数,对于这样一个简单的任务——获取输入的电子邮件地址——来说,是一段相当多的样板代码。
上述代码片段除了提交电子邮件地址外,没有做其他任何事情。换句话说,在示例中使用enteredEmail状态的唯一原因就是读取输入的值。
即使enteredEmail只在handleSubmitForm()函数中需要,React 也会为每次enteredEmail状态更新重新执行EmailForm组件函数,即每次在<input>字段中的按键输入。这也不是理想的,因为它会导致大量的不必要的代码执行,从而可能引起性能问题。
在这种情况下,如果你退回到一些纯 JavaScript 逻辑,可以节省大量的代码(也许还有性能):
const emailInputEl = document.getElementById('email');
const enteredEmailVal = emailInputEl.value;
这两行代码(理论上可以合并为一行)允许你获取一个 DOM 元素并读取当前存储的值。
这种代码的问题在于它没有使用 React。如果你正在构建 React 应用程序,你应该真正坚持使用 React 来处理 DOM。不要开始将你自己的纯 JavaScript 代码(访问 DOM 的代码)混合到 React 代码中。
这可能会导致意外的行为或错误,特别是如果你开始操作 DOM。它可能导致错误,因为在这种情况下 React 不会意识到你的更改;实际的渲染 UI 不会与 React 假设的 UI 同步。即使你只是从 DOM 中读取,也不应该将纯 JavaScript DOM 访问方法与你的 React 代码合并。
为了仍然允许你获取 DOM 元素并读取值,如上所示,React 为你提供了一个特殊的概念,你可以使用:引用。
Ref 代表引用,这是一个允许你存储对值引用的功能——例如,从 React 组件内部对 DOM 元素的引用。前面的纯 JavaScript 代码会做同样的事情(它也让你能够访问渲染后的元素),但是当使用引用时,你可以在不将纯 JavaScript 代码混合到 React 代码中的情况下获取访问权限。
可以使用一个特殊的 React Hook,称为useRef() Hook 来创建引用。
这个 Hook 可以被调用以生成一个ref对象:
import { useRef } from 'react';
function EmailForm() {
const emailRef = useRef(null);
// other code ...
};
在前面的例子中,这个生成的引用对象emailRef最初被设置为 null,但随后可以被分配给任何 JSX 元素。这个分配是通过一个特殊的属性(ref属性)完成的,这个属性被每个 JSX 元素自动支持:
return (
<form className={classes.form} onSubmit={handleSubmitForm}>
<label htmlFor="email">Your email</label>
<input
**ref****=****{emailRef}**
type="email"
id="email"
/>
<button>Save</button>
</form>
);
就像在第五章中引入的key属性一样,渲染列表和条件内容,ref属性是由 React 提供的。ref属性需要一个引用对象,即通过useRef()创建的。
在这个例子中,useRef()接收null作为初始值,因为当组件函数第一次执行时,它技术上还没有分配给 DOM 元素。只有在最初的组件渲染周期之后,连接才会建立。因此,在第一次组件函数执行之后,存储在引用中的值将是这个例子中<input>元素的底层 DOM 对象。
创建并分配了那个 Ref 对象之后,你可以使用它来获取连接的 JSX 元素(在这个例子中是 <input> 元素)。需要注意的是:要获取连接的元素,你必须访问创建的 Ref 对象上的特殊 current 属性。这是必需的,因为 React 将分配给 Ref 对象的值存储在一个嵌套对象中,可以通过 current 属性访问,如下所示:
function handleSubmitForm(event) {
event.preventDefault();
**const** **enteredEmail = emailRef.****current****.****value****;** // .current is mandatory!
// could send enteredEmail to a backend server
};
emailRef.current 返回为连接的 JSX 元素渲染的底层 DOM 对象。在这种情况下,因此它允许访问输入元素 DOM 对象。由于该 DOM 对象有一个 value 属性,因此可以无问题地访问这个 value 属性。
注意
关于这个主题的更多信息,请参阅 developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attributes 。
使用这种代码,你可以从 DOM 元素中读取值,而无需使用 useState() 和事件监听器。因此,最终的组件代码变得更加简洁:
function EmailForm() {
const emailRef = useRef(null);
function handleSubmitForm(event) {
event.preventDefault();
const enteredEmail = emailRef.current.value;
// could send enteredEmail to a backend server
}
return (
<form className={classes.form} onSubmit={handleSubmitForm}>
<label htmlFor="email">Your email</label>
<input
ref={emailRef}
type="email"
id="email"
/>
<button>Save</button>
</form>
);
}
Refs 与 State 的比较
由于 Refs 可以用来快速轻松地访问 DOM 元素,因此可能会出现的问题是是否应该始终使用 Refs 而不是状态。
对这个问题的明确答案是“不。”
在需要读取元素的情况,如上面所示的使用案例中,Refs 可以是一个非常好的替代品。在处理用户输入时,这种情况通常很常见。一般来说,如果你只是访问一些值来在某个函数(例如表单提交处理函数)执行时读取它,Refs 可以替代状态。一旦你需要更改值并且这些更改必须在 UI 中反映(例如,通过渲染一些条件内容),Refs 就不再适用。
在上面的例子中,如果你除了获取输入的值之外,还希望在表单提交后重置(即清除)电子邮件输入,你应该再次使用状态。虽然你可以借助 Ref 重置输入,但你不应这样做。你会开始操作 DOM,而只有 React 应该这样做——使用它自己的、内部的方法,基于你提供给 React 的声明性代码。
你应该避免像这样重置电子邮件输入:
function EmailForm() {
const emailRef = useRef(null);
function handleSubmitForm(event) {
event.preventDefault();
const enteredEmail = emailRef.current.value;
// could send enteredEmail to a backend server
**emailRef.****current****.****value** **=** **''****;** **// resetting the input value**
}
return (
<form className={classes.form} onSubmit={handleSubmitForm}>
<label htmlFor="email">Your email</label>
<input
ref={emailRef}
type="email"
id="email"
/>
<button>Save</button>
</form>
);
}
相反,你应该通过使用 React 的状态概念和遵循 React 所采用的声明性方法来重置它:
function EmailForm() {
const [enteredEmail, setEnteredEmail] = useState('');
function handleUpdateEmail(event) {
setEnteredEmail(event.target.value);
}
function handleSubmitForm(event) {
event.preventDefault();
// could send enteredEmail to a backend server
// reset by setting the state + using the value prop below
**setEnteredEmail**('');
}
return (
<form className={classes.form} onSubmit={handleSubmitForm}>
<label htmlFor="email">Your email</label>
<input
type="email"
id="email"
onChange={handleUpdateEmail}
**value****=****{enteredEmail}**
/>
<button>Save</button>
</form>
);
}
注意
作为一个规则,你应该简单地尝试在 React 项目中避免编写命令式代码。相反,告诉 React 最终 UI 应该是什么样子,然后让 React 想出如何到达那里。
通过 Refs 读取值是一个可接受的例外,并且操作 DOM 元素(无论是否使用 Refs,例如,通过直接选择 DOM 节点 document.getElementById() 或类似方法)应该避免。一个罕见的例外是调用输入元素 DOM 对象上的 focus() 方法,因为像 focus() 这样的方法通常不会引起任何可能导致 React 应用程序中断的 DOM 变化。
使用 Refs 进行更多操作
访问 DOM 元素(用于读取值)是使用 Refs 的最常见用例之一。如上所示,它可以在某些情况下帮助您减少代码。
但 Refs 不仅仅是“元素连接桥梁”;它们是可以用来存储各种值的对象——不仅仅是 DOM 对象的指针。例如,您还可以在 Ref 中存储字符串、数字或其他任何类型的值:
const passwordRetries = useRef(0);
您可以向 useRef() 传递一个初始值(本例中的 0),然后在任何时候访问或更改 Ref 所属组件中的该值:
passwordRetries.current = 1;
然而,您仍然必须使用 current 属性来读取和更改存储的值,因为,如上所述,这是 React 存储属于 Ref 的实际值的地方。
这对于存储应该“生存”组件重新评估的数据很有用。正如你在 第四章 中学到的,与事件和状态一起工作,React 每当组件状态发生变化时都会执行组件函数。由于函数再次执行,存储在函数作用域变量中的任何数据都会丢失。考虑以下示例:
function Counters() {
const [counter1, setCounter1] = useState(0);
const counterRef = useRef(0);
let counter2 = 0;
function handleChangeCounters() {
setCounter1(1);
counter2 = 1;
counterRef.current = 1;
};
return (
<>
<button onClick={handleChangeCounters}>Change Counters</button>
<ul>
<li>Counter 1: {counter1}</li>
<li>Counter 2: {counter2}</li>
<li>Counter 3: {counterRef.current}</li>
</ul>
</>
);
};
在这个例子中,当按钮被点击时,计数器 1 和 3 会变为 1。然而,计数器 2 将保持为零,尽管在 handleChangeCounters 中 counter2 变量也被更改为值 1:
图 7.2:只有三个计数器值中的两个发生了变化
在这个例子中,应该预期状态值发生变化,并且新值会反映在更新的用户界面中。毕竟,这就是状态的全部理念。
Ref(counterRef)也会在组件重新评估之间保持其更新值。这就是上面描述的行为:当周围组件函数再次执行时,Refs 不会被重置或清除。纯 JavaScript 变量(counter2)不会保持其值。尽管它在 handleChangeCounters 中被更改,但当组件函数再次执行时,会初始化一个新的变量;因此,更新值(1)会丢失。
在这个例子中,它可能看起来像 Refs 可以替代状态,但实际上这个例子很好地说明了这不是情况。尝试将 counter1 替换为另一个 Ref(这样组件中就没有剩余的状态值)并点击按钮:
import { useRef } from 'react';
function Counters() {
const counterRef1 = useRef(0);
const counterRef2 = useRef(0);
let counter2 = 0;
function handleChangeCounters() {
counterRef1.current = 1;
counter2 = 1;
counterRef2.current = 1;
}
return (
<>
<button onClick={handleChangeCounters}>Change Counters</button>
<ul>
<li>Counter 1: {counterRef1.current}</li>
<li>Counter 2: {counter2}</li>
<li>Counter 3: {counterRef2.current}</li>
</ul>
</>
}
);
export default Counters;
页面上不会发生任何变化,因为虽然按钮点击被记录并且 handleChangeCounters 函数被执行,但没有发起状态变化,状态变化(通过 setXYZ 状态更新函数调用发起)是触发 React 重新评估组件的触发器。改变 Ref 值不会这样做。
图 7.3:计数器值没有变化
正如你可以看到的,更改 Ref 值不会触发组件函数再次执行——另一方面,状态会。然而,如果一个组件函数再次运行(由于状态变化),Ref 值会被保留而不是丢弃。
因此,如果你有应该能够生存组件重新评估但不应作为状态管理的数据(因为该数据的变化不应导致组件在变化时重新评估),你可以使用一个 Ref:
const passwordRetries = useRef(0);
// later in the component ...
passwordRetries.current = 1; // changed from 0 to 1
// later ...
console.log(passwordRetries.current); // prints 1, even if the component changed
这不是一个经常使用的功能,但有时可能会有所帮助。在其他所有情况下,使用正常的状态值。
自定义组件中的 Refs
Refs 不仅可以用来访问 DOM 元素,还可以用来访问 React 组件——包括你自己的组件。
这有时可能很有用。考虑这个例子:你有一个<Form>组件,它包含一个嵌套的<Preferences>组件。后者组件负责显示两个复选框,询问用户的新闻通讯偏好:
图 7.4:一个显示两个复选框以设置新闻通讯偏好的新闻通讯注册表单
Preferences组件的代码可能看起来像这样:
function Preferences() {
const [wantsNewProdInfo, setWantsNewProdInfo] = useState(false);
const [wantsProdUpdateInfo, setWantsProdUpdateInfo] = useState(false);
function handleChangeNewProdPref() {
setWantsNewProdInfo((prevPref) => !prevPref);
}
function handleChangeUpdateProdPref() {
setWantsProdUpdateInfo((prevPref) => !prevPref);
}
return (
<div className={classes.preferences}>
<label>
<input
type="checkbox"
id="pref-new"
checked={wantsNewProdInfo}
onChange={handleChangeNewProdPref}
/>
<span>New Products</span>
</label>
<label>
<input
type="checkbox"
id="pref-updates"
checked={wantsProdUpdateInfo}
onChange={handleChangeUpdateProdPref}
/>
<span>Product Updates</span>
</label>
</div>
);
};
正如你所看到的,这是一个基本组件,它本质上输出两个复选框,添加一些样式,并通过状态跟踪选定的复选框。
Form组件的代码可能看起来像这样:
function Form() {
function handleSubmit(event) {
event.preventDefault();
}
return (
<form className={classes.form} onSubmit={handleSubmit}>
<div className={classes.formControl}>
<label htmlFor="email">Your email</label>
<input type="email" id="email" />
</div>
<Preferences />
<button>Submit</button>
</form>
);
}
现在想象一下,在表单提交(在handleSubmit函数内部)时,应该重置Preferences(即不再选择任何复选框)。此外,在重置之前,应该读取选定的值并在handleSubmit函数中使用它们。
如果复选框没有被放入一个单独的组件中,这将很简单。如果整个代码和 JSX 标记都位于Form组件中,那么可以在该组件中使用状态来读取和更改值。但在这个例子中并非如此,仅仅因为这个问题而重写代码听起来像是一个不必要的限制。
幸运的是,Refs 可以帮助这种情况。
你可以通过 Refs 将组件的功能(例如,函数或状态值)暴露给其他组件。本质上,Refs 可以用作两个组件之间的通信设备,就像它们在前面章节中用作与 DOM 元素的通信设备一样。
便利的是,你的自定义组件可以接收一个 ref 作为常规属性:
function Preferences(props) { // or function Preferences({ ref }) {}
// can use props.ref in here
// component code ...
};
export default Preferences;
因此,你可以使用这个Preferences组件并将其传递一个ref给它:
function Form() {
const preferencesRef = useRef(null);
return <Preferences ref={preferencesRef} />;
}
重要的是要注意,这段代码仅在 React 19 或更高版本中使用时才有效。当使用较旧的 React 版本时,将 Refs 作为常规属性传递给组件是不支持的。在这种情况下,你将不得不使用 React 提供的特殊forwardRef()函数将应该接收 Ref 的组件函数包装起来。
因此,在 React 18 或更早版本的 React 项目中,要接收和使用 Refs,你必须将接收组件(例如,在这个例子中的Preferences)包裹在forwardRef()中。
这可以这样操作:
const Preferences = forwardRef((props, ref) => {
// component code ...
});
export default Preferences;
这与其他本书中的所有组件看起来略有不同,因为这里使用的是箭头函数而不是function关键字。你始终可以使用箭头函数而不是“普通函数”,但在这里切换是有帮助的,因为它使得用forwardRef()包裹函数变得非常容易。或者,你也可以坚持使用function关键字,并像这样包裹函数:
function Preferences(props, ref) {
// component code ...
};
export default forwardRef(Preferences);
你可以选择你喜欢的语法。两者都有效,并且在 React 项目中都常用。
这段代码有趣的部分是,组件函数现在接收两个参数而不是一个。除了接收props,组件函数始终会这样做之外,它现在还接收一个特殊的ref参数。而这个参数之所以被接收,是因为组件函数被forwardRef()包裹。
这个ref参数将包含使用Preferences组件设置的任何ref值。例如,Form组件可以在Preferences上设置一个ref参数,如下所示:
function Form() {
**const** **preferencesRef =** **useRef****({});**
function handleSubmit(event) {
// other code ...
}
return (
<form className={classes.form} onSubmit={handleSubmit}>
<div className={classes.formControl}>
<label htmlFor="email">Your email</label>
<input type="email" id="email" />
</div>
<Preferences **ref****=****{preferencesRef**} />
<button>Submit</button>
</form>
);
}
再次强调,useRef()用于创建一个ref对象(preferencesRef),然后通过特殊的ref属性将其传递给Preferences组件。创建的 Ref 接收一个默认值为空对象({})的值;正是这个对象可以通过ref.current访问。在Preferences组件中,ref值可以像常规属性一样接收和提取(React >= 19)或必须使用 React 的forwardRef()函数来访问。在这种情况下,它通过第二个ref参数接收,这是由于forwardRef()的存在。
但这有什么好处呢?现在如何在这个preferencesRef对象内部使用Preferences来启用跨组件交互?
由于ref是一个永远不会被替换的对象,即使通过useRef()创建它的组件被重新评估(参见上面的前几节),接收组件可以将属性和方法分配给该对象,创建组件然后可以使用这些方法和属性。因此,ref对象被用作通信工具。
在这个例子中,Preferences组件可以像这样更改以使用ref对象:
function Preferences(props) { // wrap with forwardRef() for React < 19
const { ref } = props; // Extracting ref prop
const [wantsNewProdInfo, setWantsNewProdInfo] = useState(false);
const [wantsProdUpdateInfo, setWantsProdUpdateInfo] = useState(false);
function handleChangeNewProdPref () {
setWantsNewProdInfo((prevPref) => !prevPref);
}
function handleChangeUpdateProdPref() {
setWantsProdUpdateInfo((prevPref) => !prevPref);
}
function reset() {
setWantsNewProdInfo(false);
setWantsProdUpdateInfo(false);
}
**ref.****current****.****reset** **= reset;**
**ref.****current****.****selectedPreferences** **= {**
**newProductInfo****: wantsNewProdInfo,**
**productUpdateInfo****: wantsProdUpdateInfo**,
};
// also return JSX code (has not changed) ...
});
在Preferences中,状态值和指向新添加的reset函数的指针都存储在接收到的ref对象中。使用ref.current是因为 React(在使用useRef()时)创建的对象始终具有这样的current属性,并且应该使用该属性来在ref中存储实际值。
由于Preferences和Form操作的是存储在ref对象中的同一个对象,因此在Preferences中分配给该对象的属性和方法也可以在Form中使用:
function Form() {
const preferencesRef = useRef({});
function handleSubmit(event) {
event.preventDefault();
**console****.****log****(preferencesRef.****current****.****selectedPreferences**); // reading a value
**preferencesRef.****current****.****reset**(); // executing a function stored in Preferences
}
return (
<form className={classes.form} onSubmit={handleSubmit}>
<div className={classes.formControl}>
<label htmlFor="email">Your email</label>
<input type="email" id="email" />
</div>
<Preferences ref={preferencesRef} />
<button>Submit</button>
</form>
);
}
通过这种方式使用 Refs,父组件(在这种情况下是 Form)能够以命令式的方式与某些子组件(例如,Preferences)进行交互——这意味着可以访问属性并调用方法来操作子组件(或者更准确地说,触发子组件内部的一些函数和行为)。
注意
React 还提供了一个 useImperativeHandle() 钩子,它可以用来从自定义组件中暴露数据或函数。
从技术上讲,您不需要使用这个钩子,因为上面的例子已经证明了这一点。您可以通过 Refs 在组件之间进行通信,而不需要任何额外的钩子。
但您可能需要考虑使用 useImperativeHandle(),因为它将处理像缺少 ref 值(即没有提供 ref 值)这样的场景。您可以在官方文档中了解更多关于这个(可以说是小众的)钩子的使用方法:react.dev/reference/react/useImperativeHandle。
受控组件与不受控组件
将 Refs 传递给自定义组件(通过 props 或 forwardRef())是一种方法,可以用来允许 Form 和 Preferences 组件协同工作。但尽管这最初可能看起来像是一个优雅的解决方案,但对于这类问题,它通常不应成为您的默认解决方案。
如上面示例所示,使用 Refs 最终会导致更多的命令式代码。这是因为,而不是通过 JSX(这将是一种声明式方法)定义所需的用户界面状态,JavaScript 中添加了单个的逐步指令。
如果您回顾 第一章 ,React – 什么和为什么(JavaScript 的弊端部分),您会看到像 preferencesRef.current.reset()(来自上面的例子)这样的代码看起来与 buttonElement.addEventListener(…)(来自 第一章 的例子)这样的指令非常相似。这两个例子都使用了命令式代码,并且应该避免,正如 第一章 中提到的理由(逐步编写指令会导致低效的微观管理,并且通常会产生不必要的复杂代码)。
在 Form 组件内部,调用了 Preferences 的 reset() 函数。因此,代码描述了应该执行的动作(而不是预期的结果)。通常,当使用 React 时,您应该努力描述所需的(UI)状态。记住,当使用 React 时,您应该编写声明式代码,而不是命令式代码。
当使用 Refs 来读取或操作数据,如本章前面的部分所示,您正在构建所谓的不受控组件。这些组件被认为是“不受控”的,因为 React 并没有直接控制 UI 状态。相反,值是从其他组件或 DOM 中读取的。因此,DOM 控制着状态(例如,用户输入到输入字段中的值这样的状态)。
作为 React 开发者,你应该尽量减少使用非受控组件。如果你只需要收集一些输入值,使用 Refs 来节省一些代码是完全可行的。但是,一旦你的 UI 逻辑变得更加复杂(例如,如果你还想清除用户输入),你应该选择 受控组件。
这样做相当简单:组件一旦被 React 管理状态,就变为受控组件。在本章开头提到的 EmailForm 组件的例子中,在引入 Refs 之前已经展示了受控组件的方法。使用 useState() 存储用户的输入(并且每次按键更新状态)意味着 React 完全控制了输入的值。
对于前面的例子,Form 和 Preferences 组件,切换到受控组件方法可能看起来像这样:
function Preferences({newProdInfo, prodUpdateInfo, onUpdateInfo}) {
return (
<div className={classes.preferences}>
<label>
<input
type="checkbox"
id="pref-new"
checked={newProdInfo}
onChange={onUpdateInfo.bind(null, 'pref-new')}
/>
<span>New Products</span>
</label>
<label>
<input
type="checkbox"
id="pref-updates"
checked={prodUpdateInfo}
onChange={onUpdateInfo.bind(null, 'pref-updates')}
/>
<span>Product Updates</span>
</label>
</div>
);
};
在这个例子中,Preferences 组件停止管理复选框状态,而是从其父组件(Form 组件)接收属性。
在 onUpdateInfo 属性(它将接收一个函数作为值)上使用 bind() 来 预先配置 该函数以供将来执行。bind() 是一个默认的 JavaScript 方法,可以在任何 JavaScript 函数上调用,以控制在将来调用该函数时将传递哪些参数。
注意
你可以在 academind.com/tutorials/function-bind-event-execution 上了解更多关于这个 JavaScript 功能的信息。
Form 组件现在管理复选框状态,即使它不直接包含复选框元素。但现在它开始控制 Preferences 组件及其内部状态,因此将 Preferences 转换为受控组件而不是非受控组件:
function Form() {
const [wantsNewProdInfo, setWantsNewProdInfo] = useState(false);
const [wantsProdUpdateInfo, setWantsProdUpdateInfo] = useState(false);
function handleUpdateProdInfo(selection) {
// using one shared update handler function is optional
// you could also use two separate functions (passed to Preferences) as props
if (selection === 'pref-new') {
setWantsNewProdInfo((prevPref) => !prevPref);
} else if (selection === 'pref-updates') {
setWantsProdUpdateInfo((prevPref) => !prevPref);
}
}
function reset() {
setWantsNewProdInfo(false);
setWantsProdUpdateInfo(false);
}
function handleSubmit(event) {
event.preventDefault();
// state values can be used here
reset();
}
return (
<form className={classes.form} onSubmit={handleSubmit}>
<div className={classes.formControl}>
<label htmlFor="email">Your email</label>
<input type="email" id="email" />
</div>
<Preferences
newProdInfo={wantsNewProdInfo}
prodUpdateInfo={wantsProdUpdateInfo}
onUpdateInfo={handleUpdateProdInfo}
/>
<button>Submit</button>
</form>
);
}
Form 组件管理复选框的选择状态,包括通过 reset() 函数重置状态,并将管理的状态值(wantsNewProdInfo 和 wantsProdUpdateInfo)以及 handleUpdateProdInfo 函数(用于更新状态值)传递给 Preferences。现在 Form 组件控制 Preferences 组件。
如果你阅读了上面的两个代码片段,你会注意到最终的代码再次是纯声明式的。在所有组件中,状态被管理和使用来声明预期的用户界面。
在大多数情况下,使用受控组件被认为是一种良好的实践。然而,如果你只是提取一些输入的用户值,那么使用 Refs 并创建一个非受控组件是完全可行的。
React 和 DOM 中的元素位置
离开 Refs 的主题,还有一个其他重要的 React 功能可以帮助影响(间接)DOM 交互:Portals。
在构建用户界面时,有时需要条件性地显示元素和内容。这已经在第五章,渲染列表和条件内容中讨论过了。当渲染条件性内容时,React 会将该内容注入到包含条件性内容的整体组件在 DOM 中的位置。
例如,当在输入字段下方显示条件性错误信息时,该错误信息在 DOM 中正好位于输入字段下方:
图 7.5:错误信息 DOM 元素位于其所属的元素下方
这种行为是有意义的。确实,如果 React 开始在随机位置插入 DOM 元素,那将会非常令人烦恼。但在某些场景中,你可能希望(条件性)DOM 元素被插入到 DOM 中的不同位置——例如,当处理如错误对话框之类的覆盖元素时。
在前面的示例中,你可以添加逻辑以确保如果表单提交了无效的电子邮件地址,则向用户显示错误对话框。这可以通过类似于“无效的电子邮件地址!”的错误信息逻辑来实现,因此对话框元素当然也会被动态注入到 DOM 中:
图 7.6:错误对话框及其背景被注入到 DOM 中
在此屏幕截图中,错误对话框作为一个覆盖层在背景元素上方打开,而背景元素本身被添加是为了使其作为用户界面的覆盖层。
注意
外观完全由 CSS 处理,你可以在这里查看完整的项目(包括样式):github.com/mschwarzmueller/book-react-key-concepts-e2/tree/07-portals-refs/examples/05-portals-problem .
这个示例工作得很好,看起来也很不错。然而,还有改进的空间。
从语义上讲,将覆盖元素注入到 DOM 中,嵌套在元素旁边,并不完全合理。覆盖元素更接近 DOM 的根(换句话说,是
图 7.7:底部的
元素在背景之上可见在这个例子中,底部(“一个示例项目”)的<footer>元素没有被错误对话框的背景隐藏或变灰。原因是 footer 也附加了一些 CSS 样式,使其成为事实上的覆盖层(因为使用了position: fixed和left + bottom)。
作为这个问题的解决方案,你可以调整一些 CSS 样式,例如,使用z-index CSS 属性来控制覆盖层级。然而,如果覆盖元素(即,<div>背景和<dialog>错误元素)被插入到 DOM 的不同位置——例如,在<body>元素的末尾(但作为<body>的直接子元素)——将会是一个更干净的解决方案。
这正是 React 门户可以帮助你解决的问题。
门户救援
在 React 的世界里,门户是一个允许你指示 React 将 DOM 元素插入到不同于其通常插入位置的功能。
考虑到上面显示的例子,这个门户功能可以用来指示 React 不要在<form>元素内部插入属于对话框的<dialog>错误和<div>背景,而是将这些元素插入到<body>元素的末尾。
要使用这个门户功能,你首先必须定义一个可以插入元素的位置(一个“注入钩子”)。这可以在属于 React 应用的 HTML 文件中完成(例如,index.html)。在那里,你可以在<body>元素中的某个位置添加一个新元素(例如,一个<div>元素):
<body>
<div id="root"></div>
**<****div****id****=****"dialogs"****></****div****>**
<script type="module" src="img/main.jsx"></script>
</body>
在这种情况下,在<div id="root">元素之后在<body>部分添加了一个<div id="dialogs">元素,以确保插入该元素的任何组件(及其样式)都是最后评估的。这将确保它们的样式具有更高的优先级,并且插入到<div id="dialogs">中的覆盖元素不会被 DOM 中较早出现的内容覆盖。添加和使用多个钩子是可能的,但在这个例子中只需要一个注入点。你也可以使用除<div>元素之外的 HTML 元素。
调整了index.html文件后,可以通过react-dom的createPortal()函数指示 React 在指定的注入点渲染某些 JSX 元素(即,组件):
import { createPortal } from 'react-dom';
import classes from './ErrorDialog.module.css';
function ErrorDialog({ onClose }) {
return createPortal(
<>
<div className={classes.backdrop}></div>
<dialog className={classes.dialog} open>
<p>
This form contains invalid values. Please fix those errors before
submitting the form again!
</p>
<button onClick={onClose}>Okay</button>
</dialog>
</>,
document.getElementById('dialogs')
);
}
export default ErrorDialog;
在这个ErrorDialog组件内部,它由另一个组件(例如,GitHub 上的示例代码EmailForm组件)条件性地渲染,返回的 JSX 代码被createPortal()包裹。createPortal()接受两个参数:应该在 DOM 中渲染的 JSX 代码以及在index.html中内容应注入的元素的指针。
在这个例子中,新添加的<div id="dialogs">是通过document.getElementById('dialogs')选择的。因此,createPortal()确保由ErrorDialog生成的 JSX 代码在 HTML 文档的该位置渲染:
图 7.8:覆盖元素被插入到<div id="dialogs">中
在此屏幕截图中,您可以看到覆盖元素(<div>背景和<dialog>错误)确实被插入到<div id="dialogs">元素中,而不是<form>元素(如之前那样)。
由于这次更改,无需修改任何 CSS 代码,<footer>元素不再覆盖错误对话框的背景。从语义上讲,最终的 DOM 结构也更加合理,因为通常期望覆盖元素更接近根 DOM 节点。
尽管如此,使用此门户功能是可选的。通过更改一些 CSS 样式,同样可以达到相同的效果(尽管不是 DOM 结构)。不过,追求干净的 DOM 结构是一个值得追求的目标,避免不必要的复杂 CSS 代码也是一个不错的选择。
概述和关键要点
-
Refs 可用于直接访问 DOM 元素或存储在周围组件重新评估时不会被重置或更改的值。
-
仅使用此直接访问来读取值,而不是操纵 DOM 元素(让 React 来处理)。
-
通过 Refs 而不是状态和其他 React 功能获得 DOM 访问的组件被认为是未受控组件(因为 React 没有直接控制)。
-
除非您正在执行非常简单的任务,例如读取输入的值,否则请优先使用受控组件(使用状态和严格的声明式方法)而不是不受控组件。
-
使用 Refs,您还可以暴露您自己的组件功能,以便它们可以被命令式地使用。
-
当使用 React 19 或更高版本时,您可以在自定义组件上设置和使用
ref属性。 -
当使用 React < 19 时,必须在自定义组件上使用 React 的
forwardRef()函数来接收 Refs。 -
门户可以用来指示 React 在 DOM 的不同位置渲染 JSX 元素,而不是它们通常的位置。
接下来是什么?
在本书的这一部分,您已经遇到了许多可以用来构建交互式和引人入胜的用户界面的关键工具和概念。多亏了 Refs,您可以在不使用状态的情况下读取 DOM 值(从而避免不必要的组件重新评估),或者管理在组件更新之间持续存在的值。多亏了 Portals,您能够控制组件标记在 DOM 中确切的位置。
因此,您获得了一些可以用来微调您的 React 应用的新工具。您可能能够通过避免组件重新评估来提高性能,或者提高 DOM 元素的架构和语义。最终,正是所有这些工具的组合,使您能够使用 React 构建引人入胜、交互式且性能良好的 Web 应用。
但是,正如您将在下一章中了解到的那样,React 还有更多有用的核心概念可以提供:例如,处理副作用的方法。
下一章将探讨副作用究竟是什么,为什么需要特殊处理,以及 React 如何帮助您处理这些。
测试你的知识!
通过回答以下问题来测试你对本章涵盖的概念的理解。然后,你可以将你的答案与在github.com/mschwarzmueller/book-react-key-concepts-e2/blob/07-portals-refs/exercises/questions-answers.md中可以找到的示例进行比较。
-
Refs 如何帮助处理表单中的用户输入?
-
什么是无状态组件?
-
什么是受控组件?
-
你应该在什么情况下不使用 Refs?
-
端口背后的主要思想是什么?
应用你所学的知识
在学习了关于 Refs 和端口的新的知识之后,又是时候练习你所学的内容了。
下面,你将找到两个活动,允许你练习使用 Refs 和端口。一如既往,你当然还需要一些之前章节中涵盖的概念(例如,处理状态)。
活动 7.1:提取用户输入值
在这个活动中,你必须向现有的 React 组件添加逻辑以从表单中提取值。该表单包含一个输入字段和一个下拉菜单,你应该确保在表单提交时,两个值都被读取,并且为了这个模拟应用程序,输出到浏览器控制台。
使用你对 Refs 和无状态组件的了解来实现一个不使用 React 状态的解决方案。
注意
你可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/07-portals-refs/activities/practice-1-start找到这个活动的起始代码。下载此代码时,你将始终下载整个仓库。请确保然后导航到包含起始代码的子文件夹(在本例中为activities/practice-1-start),以使用正确的代码快照。
在项目文件夹中下载代码并运行npm install(安装所有必需的依赖项)之后,解决方案步骤如下:
-
创建两个 Refs,一个用于每个需要读取的输入元素(输入字段和下拉菜单)。
-
将 Refs 连接到输入元素。
-
在提交处理函数中,通过 Refs 访问连接的 DOM 元素并读取当前输入或选择的值。
-
将值输出到浏览器控制台。
预期的结果(用户界面)应如下所示:
图 7.9:浏览器开发者工具控制台输出所选值
注意
你将在这个活动中找到所有用于此活动的代码文件以及解决方案,可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/07-portals-refs/activities/practice-1找到。
活动 7.2:添加侧边抽屉
在这个活动中,你将连接一个已经存在的SideDrawer组件与主导航栏中的按钮,以便在点击按钮时打开侧边抽屉(即显示它)。侧边抽屉打开后,点击背景应再次关闭抽屉。
除了实现上述的一般逻辑外,你的目标将是确保在最终 DOM 中的正确定位,以便没有其他元素覆盖在SideDrawer之上(无需编辑任何 CSS 代码)。SideDrawer也不应嵌套在任何其他组件或 JSX 元素中。
注意
此活动附带一些起始代码,可以在以下位置找到:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/07-portals-refs/activities/practice-2-start。
下载代码并运行npm install以安装所有必需的依赖项后,解决方案步骤如下:
-
在
MainNavigation组件中添加逻辑,以有条件地显示或隐藏SideDrawer组件。 -
在 HTML 文档中为侧边抽屉添加一个注入钩子。
-
使用 React 的 portal 功能在新增的钩子中渲染
SideDrawer的 JSX 元素。
最终的用户界面应看起来和表现如下:
图 7.10:点击菜单按钮打开侧边抽屉
点击菜单按钮后,侧边抽屉打开。如果点击侧边抽屉背后的背景,它应该再次关闭。
最终的 DOM 结构(侧边抽屉已打开)应如下所示:
图 7.11:与抽屉相关的元素在 DOM 中插入到单独的位置
与侧边抽屉相关的 DOM 元素(背景<div>和<aside>)被插入到一个单独的 DOM 节点中(<div id="drawer">)。
注意
你将在这个活动中找到所有用于此活动的代码文件,以及解决方案,请访问github.com/mschwarzmueller/book-react-key-concepts-e2/tree/07-portals-refs/activities/practice-2。