React关键概念——渲染列表和条件内容

114 阅读28分钟

简介

到本书的这一部分,你已经熟悉了几个关键概念,包括组件、props、state 和事件,掌握了构建各种不同的 React 应用和网站所需的核心工具。你还学会了如何将动态值和结果输出到用户界面的一部分。

然而,关于输出动态数据,有两个话题还没有深入讨论:条件性输出内容和渲染列表数据。由于你构建的大多数(如果不是全部的话)网站和 Web 应用都至少需要这两个概念中的一个,了解如何处理条件内容和列表数据是至关重要的。

因此,在本章中,你将学习如何根据动态条件渲染和显示不同的用户界面元素(甚至是整个用户界面部分)。此外,你还将学习如何输出数据列表(例如带有项目的待办事项列表),并根据列表中的项目动态渲染 JSX 元素。本章还将探讨与输出列表和条件内容相关的重要最佳实践。

什么是条件内容和列表数据?

在深入研究输出条件内容或列表数据的技术之前,理解这些术语的具体含义非常重要。

条件内容指的是只有在特定情况下才显示的内容。以下是一些示例:

  • 只有在用户在表单中提交错误数据时才显示的错误覆盖层
  • 一旦用户选择输入额外细节(例如商业细节),才出现的附加表单输入字段
  • 在数据被发送或从后台服务器获取时显示的加载旋转器
  • 当用户点击菜单按钮时滑入视图的侧边导航菜单

这只是一些简短的示例,当然,你可以列举出数百个其他的示例。但这些示例的核心是:这些视觉元素或整个用户界面部分只有在满足某些条件时才会显示。

在第一个示例(错误覆盖层)中,条件是用户在表单中输入了错误数据。然后,条件性显示的内容就是错误覆盖层。

条件内容是非常常见的,因为几乎所有网站和 Web 应用都有类似前述示例的内容。

除了条件内容,许多网站还会输出数据列表。虽然这可能不会立刻显现出来,但如果你仔细想想,几乎没有一个网站不展示某种类型的列表数据。以下是一些可能在网站上输出的列表数据示例:

  • 在线商店显示的产品列表或网格
  • 活动预订网站显示的活动列表
  • 显示购物车内商品的购物车页面
  • 显示订单的订单页面
  • 显示博客文章列表及其下方的评论列表
  • 页眉中的导航项列表

这里可以列出无数(毫不夸张)的例子,列表在互联网上无处不在。正如前述示例所示,许多(甚至可能大多数)网站在同一网站上都有多个包含各种数据类型的列表。

以在线商店为例。在这里,你会看到一个产品列表(或网格,实际上这只是另一种类型的列表),一个购物车项目列表,一个订单列表,一个页眉中的导航项列表,当然还有许多其他列表。这就是为什么你需要了解如何在 React 驱动的用户界面中输出任何类型的数据列表。

条件性渲染内容

假设以下场景。你有一个按钮,当点击时,应该显示一个额外的文本框,如下所示:

image.png

点击按钮后,显示另一个框:

image.png

这是一个非常简单的例子,但并不不现实。许多网站的用户界面部分都像这样。点击按钮后显示额外的信息(或类似的交互)是一种常见的模式。比如在一个食品订单网站上,餐品下方显示营养信息,或者在FAQ部分,选择问题后显示答案。

那么,如何在 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>
  );
}

这个组件没有任何条件代码,因此,按钮和额外的信息框始终都会显示。

在这个例子中,如何让显示条款摘要的段落文本是条件性的(也就是说,只有在按钮被点击后才显示)呢?

通过之前章节,特别是第4章《处理事件和状态》中学到的知识,你已经掌握了只在按钮点击后显示文本的技能。下面的代码展示了如何重写组件,使其仅在按钮点击后显示完整的文本:

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 的值是条件性设置的,借助 if 语句和 showTerms 状态中的值。

然而,<p> 元素本身并没有条件性。无论它包含完整的句子还是一个空字符串,它始终存在。如果你打开浏览器开发者工具并检查页面的这个区域,你将看到一个空的段落元素,如下图所示:

image.png

在 DOM 中有一个空的 <p> 元素并不是理想的做法。尽管它对用户不可见,但它是一个额外的元素,需要由浏览器进行渲染。性能影响可能是微乎其微的,但这仍然是应该避免的。一个网页并不会从包含无内容的空元素中受益。

然而,你可以将关于条件值(例如段落文本)的知识应用到条件性元素上。除了将标准值(如文本或数字)存储在变量中,你还可以将 JSX 元素存储在变量中。这是可能的,因为正如第1章《React – 什么是和为什么》中提到的,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>
  );
}

在这个例子中,如果 showTermstrueparagraph 变量不再存储文本,而是存储一个完整的 JSX 元素(即 <p> 元素)。在返回的 JSX 代码中,通过 {paragraph} 动态输出存储在 paragraph 变量中的值。如果 showTermsfalseparagraph 存储的是 undefined,并且什么都不会被渲染到 DOM 中。因此,在 JSX 代码中插入 nullundefined 会导致 React 不输出任何内容。但如果 showTermstrue,完整的段落将作为值保存,并输出到 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 语句时要简短一些。paragraph 常量要么包含段落(包括文本内容),要么为 nullnull 用作替代值,因为它可以安全地插入到 JSX 代码中,因为它会导致 React 渲染时不显示任何内容。

三元表达式的一个缺点是,尤其是在使用嵌套三元表达式时,可能会影响可读性和理解性,如以下例子所示:

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

这与之前的例子相同,只是现在更简洁,因为你避免了使用 paragraph 常量,而是直接在 JSX 片段中利用三元表达式。这种写法让组件代码相对简洁,因此在 React 应用中,使用三元表达式是非常常见的做法。

滥用 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)意味着如果条件(showDetailsshowTerms)不成立,则不渲染任何内容。

这就是为什么 React 开发者中流行一种不同的模式:

<div>
  {showDetails && <h1>Product Details</h1>}
</div>

这是实现预期结果的最简洁方式,仅当 showDetailstrue 时渲染 <h1> 元素及其内容。

这段代码利用了 JavaScript 逻辑运算符的一个有趣特性,特别是 &&(逻辑与)运算符。在 JavaScript 中,&& 运算符在第一个值(即 && 前面的值)为 true 或真值时,返回第二个值(即 && 后面的值)(即不为 falseundefinednull0 等)。通常,你会在 if 语句或三元表达式中使用 && 运算符。然而,在 React 和 JSX 中,你可以利用上述行为来条件性地输出真值。这种技术也叫做短路运算。

例如,以下代码会输出 'Hello'

console.log(1 === 1 && 'Hello');

这种行为可以用来写出非常简短的表达式,检查条件后再输出其他值,如前面的例子所示。

注意:
值得注意的是,如果你使用 && 运算符与非布尔条件值(即 && 前面的值是非布尔值)一起使用时,可能会导致意外的结果。如果 showDetails0 而不是 false(无论什么原因),数字 0 会被显示在屏幕上。因此,你应该确保作为条件的值返回 nullfalse,而不是任意的假值。例如,你可以通过添加 !! 强制转换为布尔值(例如 !!showDetails)。如果条件值已经是 nullfalse,则不需要这么做。

发挥创意!

到目前为止,你已经了解了三种不同的条件性渲染内容的方法(常规的 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 prop 的值输出 'de-DE''en-US''en-GB'。这个结果是通过使用 JavaScript 的动态属性选择语法实现的。与通过点语法(如 person.name)选择特定属性不同,你可以通过括号语法选择属性值。使用这种语法,你可以传递一个具体的属性名(例如 languages['de-DE']),或者传递一个生成属性名的表达式(例如 languages[country])。

像这样动态选择属性值是另一种常见的从值映射中选择值的模式。因此,它是替代多个 if 语句或三元表达式的另一种方式。

另外,一般来说,你可以使用任何在标准 JavaScript 中有效的方法——因为 React 毕竟在本质上只是标准 JavaScript。

哪种方法最合适?

已经讨论了多种设置和输出内容的条件性方法,但哪种方法最好呢?

这真的取决于你(以及适用时的你的团队)。已经突出了每种方法的优缺点,但最终,还是由你来决定。如果你更喜欢三元表达式,例如,选择它们而不是逻辑 && 运算符也是完全没问题的。

这也将取决于你要解决的具体问题。如果你有一个值映射(例如国家及其语言代码的列表),那么使用动态属性选择而不是多个 if 语句可能更合适。另一方面,如果你有一个单一的真/假条件(例如 age > 18),使用标准的 if 语句或逻辑 && 运算符可能是最好的选择。

条件性设置元素标签

条件性输出内容是一个非常常见的场景。但有时,你还需要根据条件选择输出的 HTML 标签类型。通常,这种情况发生在你构建的组件主要任务是包装和增强内置组件时。

以下是一个例子:

function Button({ isButton, config, children }) {
  if (isButton) {
    return <button {...config}>{children}</button>;
  }
  return <a {...config}>{children}</a>;
};

这个 Button 组件检查 isButton prop 的值是否为真,如果是,返回一个 <button> 元素。config prop 被期望是一个 JavaScript 对象,使用标准的 JavaScript 展开运算符(...)将 config 对象中的所有键值对作为 props 添加到 <button> 元素。如果 isButton 不是一个真值(可能因为没有提供 isButton 的值,或者值是 false),则激活 else 条件。此时返回的是一个 <a> 元素,而不是 <button> 元素。

注意
使用展开运算符(...)将一个对象的属性(键值对)转化为组件 props 是另一种常见的 React 模式(在第 3 章《组件和 props》中介绍)。展开运算符不是 React 专用的运算符,但用于这个特殊目的时是 React 常见的做法。

例如,当将对象 { link: 'https://some-url.com', isButton: false } 展开到 <a> 元素上(通过 <a {...obj}>),结果与逐一设置所有 props(即 <a link="https://some-url.com" isButton={false}>)是相同的。

这种模式特别常见于构建自定义包装组件的情况,这些组件包装一个通用的核心组件(例如 <button><input><a>),以便为其添加某些样式或行为,同时仍然允许该组件像内置组件一样使用(即,你可以设置所有默认的 props)。

前面例子中的 Button 组件根据 isButton prop 的值返回两个完全不同的 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!
};

在这个例子中,products 数组可能如下所示:

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 值推送到一个值的数组中。由于这是 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()

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 的状态概念,如第 4 章《处理事件和状态》中所解释的那样),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>Learn React</li><li>Recommend this book</li>)。但是,一旦点击按钮并执行 handleAddTodo,期望的结果(显示另一个待办事项项)并不会实现。

这是因为执行 todos.push('A new todo') 会更新 todos 数组,但 React 并不会注意到这一点。请记住,你必须仅通过 useState() 返回的状态更新函数来更新状态;否则,React 不会重新评估组件函数。

那么,下面的代码呢?

function handleAddTodo() {
  setTodos(todos.push('A new todo'));
};

这也是不正确的,因为状态更新函数(在本例中为 setTodos)应该接收新的状态(即应该设置的状态)作为参数。然而,push() 方法并不会返回更新后的数组。相反,它是直接在原数组上进行修改。即使 push() 返回更新后的数组,使用上面的方法仍然是错误的,因为数据会在幕后被修改(变异),而在状态更新函数执行之前没有通知 React。由于数组是对象,因此是引用数据类型,从技术上讲,数据在告知 React 之前已经发生变化。根据 React 的最佳实践,这种做法应当避免。

因此,在更新数组(或者一般情况下对象)时,你应该以不可变的方式进行更新(即,不修改原数组或对象)。相反,应该创建一个新的数组或对象。这个新数组可以基于旧数组,并包含所有旧数据以及任何新数据或更新的数据。

因此,todos 数组应该像这样更新:

function handleAddTodo() {
  setTodos(curTodos => [...curTodos, 'A new todo']);
  // 另一种方式:使用 concat() 而不是展开运算符:
  // concat() 与 push() 不同,它返回一个新的数组
  // setTodos(curTodos => curTodos.concat('A new todo'));
};

通过使用 concat() 或者一个新的数组,结合展开运算符(...),一个全新的数组被传递给状态更新函数。还要注意,由于新状态依赖于先前的状态,状态更新函数接收的是一个函数。

通过这种方式更新数组(或任何对象)状态值时,React 能够识别这些变化。因此,React 会重新评估组件函数,并将所需的更改应用到 DOM 中。

注意
不可变性并不是 React 特有的概念,但它在 React 应用中仍然是一个关键概念。当处理状态和引用值(即对象和数组)时,不可变性非常重要,以确保 React 能够识别变化,并且不会执行任何“隐形”的(即 React 无法识别的)状态更改。

有多种方法可以不可变地更新对象和数组,但一种常见的方法是创建新的对象或数组,然后使用展开运算符(...)将现有数据合并到这些新数组或对象中。

列表项的问题

如果你跟着自己的代码进行操作,并按照前面章节的描述输出列表数据,你可能会注意到 React 实际上在浏览器开发者工具控制台中显示了一个警告,如下图所示:

image.png

React 报告缺少键。

为了理解这个警告以及键的概念,探索一个特定的使用案例和该场景中可能出现的问题会很有帮助。假设你有一个 React 组件,负责显示一个列表项——可能是一个待办事项列表。此外,假设这些列表项可以重新排序,并且列表可以以其他方式进行编辑(例如,可以添加新项,更新现有项或删除项,等等)。换句话说,列表是动态的。

考虑这个示例用户界面,其中一个新项被添加到待办事项列表中:

image.png

在前面的图中,你可以看到最初渲染的列表(1),然后在用户输入并提交一个新的待办事项值后,列表被更新(2)。一个新的待办事项项被添加到列表的顶部(即作为列表的第一个项)(3)。

注意
这个示例应用的源代码可以在以下链接找到:github.com/mschwarzmue…
如果你在这个应用中工作并打开浏览器开发者工具(然后进入 JavaScript 控制台),你将看到之前提到的“缺少键”的警告。这个应用还帮助你理解这个警告是从哪里来的。

在 Chrome DevTools 中,切换到 Elements 标签并选择其中一个待办事项项或空的待办事项列表(即 <ul> 元素)。一旦你添加了一个新的待办事项项,任何被插入或更新的 DOM 元素都会在 Elements 标签中被 Chrome 高亮显示(通过短暂闪烁)。参考以下截图:

image.png

有趣的是,不仅是新添加的待办事项元素(即新插入的 <li> 元素)在闪烁。相反,所有现有的 <li> 元素——即那些没有变化的待办事项项——也被 Chrome 高亮显示。这意味着所有这些其他的 <li> 元素也在 DOM 中被更新了——即使没有必要进行这些更新。这些项在之前就存在,它们的内容(待办事项文本)没有变化。

由于某些原因,React 似乎会销毁现有的 DOM 节点(即现有的 <li> 项),然后立即重新创建它们。这发生在每次向列表中添加新待办事项时。正如你可能想象的那样,这并不是很高效,对于那些可能需要渲染多个列表并且每个列表中有几十或几百个项的复杂应用,这会导致性能问题。

之所以发生这种情况,是因为 React 无法知道只应插入一个 DOM 节点。它无法判断所有其他的 DOM 节点应该保持不变,因为 React 只接收到了一个全新的状态值:一个新的数组,里面填充了新的 JavaScript 对象。即使这些对象的内容没有变化,从技术上讲,它们仍然是新的对象(内存中的新值)。

作为开发者,你知道你的应用是如何工作的,并且知道待办事项数组的内容实际上并没有发生太大变化。但 React 并不知道这一点。因此,React 认为所有现有的列表项(<li> 项)必须被丢弃,并且用新的项来替换,这些新的项反映了作为状态更新一部分的新数据。这就是为什么在每次状态更新时,所有与列表相关的 DOM 节点都会被更新(即销毁并重新创建)。

键来拯救!

之前概述的问题是一个非常常见的问题。大多数列表更新是增量更新,而不是批量更改。但是 React 无法判断在你的使用场景和列表中是否是这种情况。

这就是为什么 React 在处理列表数据和渲染列表项时使用键的概念。键只是唯一的标识符值,在渲染列表数据时可以(并且应该)附加到 JSX 元素上。键帮助 React 识别之前渲染过的并且没有改变的元素。通过允许唯一标识所有列表元素,键还帮助 React 高效地移动(列表项)DOM 元素。

键通过特殊的内建 key prop 添加到 JSX 元素中,这个 prop 被每个组件接受:

<li key={todo.id}>{todo.text}</li>

这个特殊的 prop 可以添加到所有组件中,无论是内建组件还是自定义组件。你不需要在自定义组件中接收或处理 key prop;React 会自动为你处理。

key prop 需要一个对每个列表项唯一的值。没有两个列表项应该拥有相同的键。此外,好的键是直接附加到构成列表项的基础数据上的。因此,列表项的索引作为键是不好的,因为索引没有附加到列表项数据上。如果你重新排序列表项,索引保持不变(数组总是从索引 0 开始,接着是 1,依此类推),但数据会改变。

考虑以下示例:

const hobbies = ['Sports', 'Cooking'];
const reversed = hobbies.reverse(); // ['Cooking', 'Sports']

在这个例子中,'Sports'hobbies 数组中的索引是 0。在倒转后的数组中,它的索引将是 1(因为它现在是第二项)。在这种情况下,如果使用索引作为键,数据将无法与其绑定。

好的键是唯一的 id 值,这样每个 id 都只属于一个值。如果该值移动或被移除,它的 id 应该随之移动或消失。

找到好的 id 值通常不是一个大问题,因为大多数列表数据反正都是从数据库中获取的。无论你处理的是产品、订单、用户还是购物车项,这些数据通常都会存储在数据库中。这样的数据已经有了唯一的 id 值,因为你在数据库中存储数据时总会有某种唯一的标识标准。

有时,即使是值本身也可以作为键。考虑以下示例:

const hobbies = ['Sports', 'Cooking'];

爱好是字符串值,并且没有附加到单个爱好的唯一 id 值。每个爱好都是一个原始值(一个字符串)。然而,在这种情况下,通常不会有重复的值,因为爱好在这样的数组中不可能列出多次。因此,值本身就可以作为好的键:

hobbies.map(hobby => <li key={hobby}>{hobby}</li>);

在无法使用值本身且没有其他可能的键值时,你可以直接在 React 应用代码中生成唯一的 id 值。作为最后的手段,你也可以回退使用索引;但要注意,如果重新排序列表项,这可能会导致意外的错误和副作用。

通过将键添加到列表项元素中,React 能够正确地识别所有项。当组件状态发生变化时,它可以识别之前已经渲染过的 JSX 元素。因此,这些元素将不再被销毁或重新创建。

你可以通过再次打开浏览器开发者工具,检查在更改底层列表数据时哪些 DOM 元素被更新来确认这一点:

image.png

添加键之后,当更新列表状态时,只有新的 DOM 项在 Chrome DevTools 中被高亮显示。其他项则被 React 正确地忽略。

总结和关键要点

  • 与其他 JavaScript 值一样,JSX 元素可以根据不同的条件动态地设置和更改。
  • 内容可以通过 if 语句、三元表达式、逻辑“与”运算符(&&),或任何在 JavaScript 中有效的方式进行条件设置。
  • 处理条件内容有多种方式——任何在纯 JavaScript 中有效的方法,也可以在 React 应用中使用。
  • 包含 JSX 元素的数组可以插入到 JSX 代码中,这样数组元素就会作为兄弟 DOM 元素被输出。
  • 列表数据可以通过 for 循环、map() 方法或任何其他 JavaScript 方法转换为 JSX 元素数组。
  • 使用 map() 方法是将列表数据转换为 JSX 元素列表的最常见方式。
  • 应该将键(通过 key prop)添加到列表的 JSX 元素中,以帮助 React 高效地更新 DOM。