Written by SJTU-XHW

Reference: MDN Doc && React Doc

本人学识有限,笔记难免有错,恳请读者能够批评指正,本人将不胜感激!


Chapter 0. 5 分钟速通 React 框架

如果你只有 5 分钟时间,则只需阅读这一章;否则请从 Chapter 1 开始

建议有一定的原生 JavaScript 基础,至少包括:JS 基本类型、内置引用类型、函数表达式的各种操作,简单 DOM 操作,JavaScript 事件,JavaScript 异步(Promise)

0.1 基本概念

  • React 应用程序是由 组件 组成的。

    一个组件是 UI 的一部分,它拥有自己的逻辑和外观。组件可以小到一个按钮,也可以大到整个页面。

  • React 组件是返回标签的 JavaScript 函数

    1
    2
    3
    4
    5
    function MyButton() {
    return (
    <button>I'm a button</button>
    );
    };

    React 组件的使用方法就是直接使用 “React 特殊标签”,React 组件的标签必须以大写字母开头,而 HTML 标签则必须是小写字母:

    1
    2
    3
    4
    5
    6
    7
    8
    export default function MyApp() {
    return (
    <div>
    <h1>Welcome to my app</h1>
    <MyButton />
    </div>
    );
    }

0.2 JSX 使用规范

  • 大多数 React 项目会使用 JSX,主要是它很方便;

  • JSX 比 HTML 更加严格。你必须闭合标签(尤其是单标签),如 <br />,而不能写 <br>

  • React 组件不允许返回多个 JSX 标签。如果需要,必须将它们包裹到一个共享的父级中,比如 <div>...</div> 或使用空的 <>...</>

    1
    2
    3
    4
    5
    6
    7
    8
    function AboutPage() {
    return (
    <>
    <h1>About</h1>
    <p>Hello there.<br/>How do you do?</p>
    </>
    );
    }
  • 为组件自定义样式:和 HTML 一样,在标签里写 className,再使用 CSS / JavaScript 选择更改;

  • 使用 JavaScript 变量:{variableName}

    可以在其中写 JavaScript 语法、JSON 对象;

0.3 与 JavaScript 组合起来

举几个例子来进一步了解如何使用:

  • 根据条件选择渲染组件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    /* 三目表达式 */
    return (
    <div>
    {login ? (
    <AdminPanel/>
    ) : (
    <LoginForm/>
    )}
    </div>
    );
  • 快速渲染列表:

    1
    2
    3
    4
    5
    const listItem = pList.map((item) => {
    <li key={item.id}>{item.title}</li>
    });

    return (<ul>{listItem}</ul>);
  • 在 react 中使用事件处理函数

    • 方法 1:内联事件处理函数(纯 JavaScript 不建议);

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      function MyButton() {
      function handleClick() {
      alert('You clicked me!');
      }

      return (
      <button onClick={handleClick}>
      Click me
      </button>
      );
      }
    • 方法 2:手动绑定事件处理函数(react 中不方便使用);

  • 记忆组件:在 React 中,一般存储组件状态信息的方法是引入 React 内置的 Hook(后面介绍),这里以 useState 为例;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    import { useState } from 'react';

    function MyButton() {
    const [count, setCount] = useState(0);
    /* 得到有记忆性的 count 数据 和 改变该数据的 setCount 函数 */
    function handleClick() {
    setCount(count + 1);
    }

    return (
    <button onClick={handleClick}>
    Clicked {count} times
    </button>
    );
    }

    export default function MyApp() {
    // 在每个组件内部定义的 `count` 只对当前组件有效;
    return (
    <div>
    <MyButton/>
    <MyButton/>
    </div>
    );
    }

    Hook 在 react 内置库中是 use 开头的函数;它比普通函数更为严格,只能在组件(或其他 Hook)的 顶层 调用 Hook;

    ⚠⚠特别地,如果你想要组件间共享某个 hook 的值,可以将 hook 定义在组件共同的环境中。⚠⚠

  • React 为组件自定义属性(具体表现为 HTML 上传入的属性):

    1
    2
    3
    4
    5
    6
    7
    8
    /* 组件传入的参数使用 {} 括起 */
    function MyButton({ count, onClick }) {
    return (
    <button onClick={onClick}>
    Clicked {count} times
    </button>
    );
    }

Chapter 1. React 描述 UI

1.1 React 组件

  • React 应用程序是由 组件 组成的。

    一个组件是 UI 的一部分,它拥有自己的逻辑和外观。组件可以小到一个按钮,也可以大到整个页面。

  • React 组件是返回标签的 JavaScript 函数

    1
    2
    3
    4
    5
    function MyButton() {
    return (
    <button>I'm a button</button>
    );
    };

    React 组件的使用方法就是直接使用 “React 特殊标签”,React 组件的标签必须以大写字母开头,而 HTML 标签则必须是小写字母:

    1
    2
    3
    4
    5
    6
    7
    8
    export default function MyApp() {
    return (
    <div>
    <h1>Welcome to my app</h1>
    <MyButton />
    </div>
    );
    }

    export default 指定了文件中的主要组件。而这种 “React 特殊标签” 被称为 JSX

    JavaScript 中,export 关键字用于从模块中导出实时绑定的函数、对象或原始值,以便其他程序可以通过 import 语句使用它们。

    导出有两种方式:命名导出(一个模块中可以有任意个)、默认导出(每个模块仅一个)

    命名导出可以导出 var, let, const, function, class;不过另一个模块导入时 必须同名,或者使用重命名命名导出

    默认导出在 export 后加 default 关键字,这样 import XX from "<this script>" 中的 XX 可以随意取名;

注意要点:

  • 不应该在组件中定义组件(但是可以嵌套使用):性能降低、引发 Bug;

  • 合理地将组件拆分为不同文件,利用 “默认导出”、“命名导出” 等方法进行组件的导入导出,提高代码的可读性和可维护性;

1.2 JSX 的逻辑 和 规则

为什么要有 JSX ?有些人说它把 HTML 和 JavaScript 混在了一起。我们在之前 Chapter 0 中看到,就事件处理器而言,原本 JavaScript 因为和 HTML 分离而不建议使用内联事件处理器,到了 JSX 中就建议使用了,这就是组件定义的位置的变化。

因为随着 Web 的交互性越来越强,逻辑越来越决定页面中的内容。JavaScript 开始负责 HTML 的内容,这也是为什么 在 React 中,渲染逻辑和标签共同存在于同一个地方——组件。

要注意的是,JSX 和 React 相互独立,前者是语法扩展,后者是 JavaScript 的库。

JSX 用在 JavaScript 中,比 HTML 语言更为严格,具体有下面的要求:

  1. 只能返回一个根元素。如果想要在一个组件中包含多个元素,需要用一个父标签把它们包裹起来

  2. 标签必须闭合,单标签必须尾缀 / 以示结束;

  3. 标签属性必须以驼峰命名法命名(HTML 原生属性中含有 a-b 的在 JSX 以 aB 代替);

    • JSX 中,考虑到 JavaScript 有关键字 class,因此 JSX 中的 class 属性名使用 className 代替

    • JSX 中,如果传入内联样式 style 属性,那么其中的键值也应该使用驼峰命名法,因为它仍然是 JSX;

      例如在 HTML 中:

      1
      2
      3
      <ul class="test-type", style="{background-color: black};">
      <!-- ... -->
      </ul>

      在 JSX 中应该写成:

      1
      <ul className="test-type", style={{backgroundColor: 'black'}}></ul>

1.3 JSX 中使用 JavaScript

一言以蔽之:JSX 的 {} 中可以运行任何 JavaScript 合法表达式

那么在 JSX 中只能什么时候用呢?

  • 用作 JSX 标签内的文本<h1>{name}'s To Do List</h1> 是有效的;

    但是 <{tag}>Gregorio Y. Zara's To Do List</{tag}> 无效;

  • 用作紧跟在 = 符号后的 属性src={avatar} 会读取 avatar 变量;

    但是 src="{avatar}" 只会传一个字符串 {avatar}

1.4 React 自定义组件传入属性

React 通过允许自定义组件传入的属性,实现了 “父组件向子组件传递数据” 的需求。

你可能会想,既然 React 组件就是一个返回标签的 JavaScript 函数嘛,那不直接给函数加几个参数就行?

可惜事与愿违,你还要在参数外面包一层 {}。例如:

1
2
3
4
5
6
7
8
9
10
11
function Avatar({ person, size }) {
return (
<img
className="avatar"
src={getImageUrl(person)}
alt={person.name}
width={size}
height={size}
/>
);
}

这是为什么?因为在 HTML 中,这些都是定义在元素内的属性。在经过 React 库的包装之后,相当于 “对象解包” 的过程(类比 Python 的 arg 形参要加 * 的原因)。

你也可以这么做:

1
2
3
4
5
6
7
8
9
10
11
function Avatar(prop) {
return (
<img
className="avatar"
src={getImageUrl(prop.person)}
alt={prop.person.name}
width={prop.size}
height={prop.size}
/>
);
}

这个语法很自由,允许指定默认值:

function Avatar({ person, size = 100}) { ... }

⚠⚠有种特殊情况,当组件的形参是 children 时,就是该组件内部的子组件。⚠⚠

这说明了:

1
2
3
<Widget1>
<Widget2/>
</Widget1>

Widget1.children 就是 Widget2


还有一个点需要注意:不能在组件内更改传入的 prop 属性!! 为什么?

我们知道,通过传入 prop 可以给子组件以数据。但是,prop 永远是不可变数据类型,下一次渲染时如果父组件传递的数据改变,那么传入的 prop 将是个新的对象,旧的对象将在合适时机被 JavaScript 引擎回收。

1.5 条件渲染

很简单,在组件定义函数中加入条件判断,根据条件返回对应的元素即可。

同时,我们可以充分利用 JavaScript 的三目运算符、逻辑运算符的特点来简化代码。

有一点需要注意,允许返回 null / false,这相当于告诉渲染器此处什么都不要渲染

1.6 从数据渲染列表

注意几件事情:

  • 生成列表时,应该为每个项准备全局唯一的 Key,因为这些 key 会告诉 React,每个组件对应着数组里的哪一项,所以 React 可以把它们匹配起来。这在数组项进行移动(例如排序)、插入或删除等操作时非常重要。一个合适的 key 可以帮助 React 推断发生了什么,从而得以正确地更新 DOM 树。

  • 用作 key 的值应该在数据中提前就准备好,并且和数据绑定不作变化,而不是在运行时才随手生成;

这里的 “列表” 是广义上的列表,只要你利用了 map、手动构建数组等方法生成了一个重复的表状结构

1.7 React 组件渲染函数的幂等性

按照 React 规范,定义组件的渲染函数(不是事件处理函数!)对于相同的输入(即属性),表现应该始终相同。即 React 组件需要有对外的幂等性。

这意味着每个 React 组件渲染函数只应该完成自己 JSX 的计算,并且不应该更改在组件外的变量 / 对象,尤其是数组(例如定义在组件外的变量、传入组件的 propsstatecontext 等等);

这一点与我们在 JavaScript 中遇到的情况就很不一样了。比如:

假设页面上有一个组件 <h1>,希望它在午夜 0 时到清晨 6 时的时间内将 class 属性设置为 night,其余时间设置为 day;那么 JavaScript 实现的思路就很简单:

  • 定义处理函数,通过 querySelector 获取元素 DOM 对象;
  • 计算时间,在对应时间内按要求设置 class 属性内容;

  • 最后使用 setInterval 设置渲染;

但是在 React 中,如果使用 document 更改了外部元素,这就是违反规范的行为。正确的做法是 计算然后利用结果返回指定组件

1
2
3
4
5
6
7
8
9
export default function Cock({ time }) { /* 外部传入时间 */
var hour = time.getHours();
var cln = (hour >= 0 && hour <= 6 ? 'night' : 'day');
return (
<h1 className={cln}>
{time.toLocaleString()}
</h1>
);
}

Chapter 2. React 交互与事件

2.1 React 中的事件处理函数

在原生 JavaScript 中,我们使用 事件处理函数 来管理用户与界面元素的事件。通常的做法是,先找到需要处理的元素,使用 addEventListener 添加事先定义的事件处理器,这样就能起到监听事件并在适当时机调用回调函数的作用。

而在 React 中,根据规范应该使用闭包定义 + 内联事件处理器的方法。

例如为一个按钮设置点击事件:

1
2
3
4
5
6
7
8
9
10
11
export default function MyButton() {
function handleClick() {
alert("Clicked!");
}

return (
<button onClick={handleClick}>
Click me
</button>
);
}

此外,在原生 JavaScript 中,我们了解到了 “事件传播”,在 React 中,事件传播完全与原生 JavaScript 相同,包括阻止事件传播 event.stopPropagation()、阻止默认事件处理函数 event.preventDefault() 都与 JavaScript 一致。

我们一般想要阻止事件向父组件传递,就需要修改事件处理函数,“先阻止事件传播,再调用目标事件处理函数”。

还有重要的一点,事件处理函数可以不是幂等的,因为组件可能需要有个应对用户操作的状态机,因此它可以更改一些其他变量。

⚠⚠注意:如果要用 event,使用前一定要记住给函数声明一个参数!⚠⚠

2.2 React 中的状态机:useState

通常情况下,如果我们只是单独使用原生的事件处理函数,那么可能并不能满足我们的需求。即 “事件处理函数 和 渲染函数 到目前为止都是幂等的”。我们想要 Web 程序跟随用户的操作进行一系列的变化。

有人也许会说,那么在事件处理函数中修改外部的变量,让外部变量保存一些状态不就行了?就像原生 JavaScript 一样。

很遗憾,这样不行,这主要是因为 React 的渲染机制造成的。原生 JavaScript 的页面渲染大部分都交给 HTML,自己则按照事件和对象驱动地修改一部分内容,并立即渲染。但是在 React 中,HTML 和 JavaScript 的工作全部交由 JSX,这会引发几个问题:

  • 组件函数内的局部变量无法在多次渲染中持久保存。 当 React 再次渲染这个组件时,它会从头开始渲染,不会考虑之前对局部变量的任何更改;
  • 更改局部变量不会触发渲染。 React 没有意识到它需要使用新数据再次渲染组件;

提醒:你不应该将变量定义到组件函数外面。因为它们不会被导出。

所以,真正想要实现最初的功能,必须完成两件事:

  1. 把操作中产生的状态数据保存起来,不被渲染过程影响;
  2. 在修改这些状态数据后,触发重新渲染的机制;

在 React 框架内,就有这样定义好的对象 useState,它是一种 Hook,提供了:

  1. state 变量保存状态数据;
  2. state setter 函数更新 state 变量的同时触发 React 再次渲染组件

这里介绍一下 React 的 Hook 机制。在 React 中,任何以 use 开头的函数都被称为 Hook。

Hook 是一种特殊的函数,只在 React 渲染时有效

它的使用方法超级简单,就是在组件渲染函数最开头调用一次 useState(<initVal>),返回一个包含 state 变量(以 initVal 为初值)、state setter 函数的数组;

使用多少状态变量,就调用多少次 useState()

因此,React 中几乎一切需要更新、记忆状态的的过程,都可以交由 useState 完成

⚠⚠最最重要的是,如果 useState 在某个组件内被定义,那么这个属性对于该组件其他所有实例、其他所有组件(包括父组件、子组件)都是互不影响、相互独立的,这为一些复杂的状态设计提供了可能。⚠⚠

2.3 React 的渲染机制

2.3.1 渲染流程

到现在为止,我们有必要接触一些关于 React 渲染的更加详细的知识。

渲染的步骤很简单:

  1. 用户或者 Hook 调用一些函数 触发 一次渲染过程;
  2. React 库渲染组件;
  3. React 库将更改提交给 DOM;

我们作为用户只知道有 2 种情况会触发渲染,一是在 React 应用启动时。如果你使用 create-react-app 来创建项目的话,你能在 App.js 中看到类似这样的语句:

1
2
3
4
import { createRoot } from 'react-dom/client';

const root = createRoot(document.getElementById('root'));
root.render(<App />);

第一句 createRoot 相当于使用原生 JavaScript 在页面上查找并创建一个根结点对象;

第二句就使用 render() 将指定组件在根结点上渲染。

而另一种情况就是使用了 Hook 的 setter 函数更新了组件状态,这个做法在 React 库内部会在恰当的时机触发一次重新渲染的流程。


进行一次渲染所做的工作可能有所不同

因为对于初次渲染而言,这个过程是递归的,从调用渲染根组件,在渲染函数(也就是我们定义的组件函数)内返回了子组件,那么接下来继续渲染子组件,直到没有更多的组件需要渲染为止。渲染每个组件的每个元素标签时,React 都会为它们创建一个 DOM 结点。

初次渲染完成后,React 会将创建好的 DOM 结点利用 DOM API appendChild() 将结点放在 DOM 树上,交给浏览器做 “浏览器渲染”,为了避免混淆,将 “浏览器渲染” 称为 “绘制”

而在重渲染中,React 会判断哪些属性从上次渲染以来没有更改,对没有更改的部分不会进行任何操作(增量渲染)。并且 React 不会为已经存在的、修改了属性的的结点重新创建 DOM 结点。

重渲染完成后,React 仅将改动的结点进行设置,不会影响没有修改的结点。最终还是将 DOM 交由浏览器绘制。

这也是为什么之前提到 “渲染函数必须是幂等的,唯一能使渲染函数改变输出的,只有 外部 或 事件处理器 更改了渲染函数的输入参数”,因为如果不这么做,渲染的行为将无法预测;

2.3.2 useState 与渲染的关系

了解了上一节的知识,你可能已经认为对 React 渲染的机制掌握透彻了。但是请看下面的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { useState } from 'react';

export default function Counter() {
const [number, setNumber] = useState(0);

return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
}}>+3</button>
</>
)
}

你可能认为每次点击按钮,数字会 + 3,对吗?很遗憾,其实数字在每次点击后始终 + 1。这个行为与 useState 触发重渲染的方式有关。

我们在前一节只知道 useState 这类 Hook 会内部触发重渲染,但是这是怎么触发,我们也要知道,否则就会出现上面的错误

useState 这类 Hook 一般出现在各种事件处理器中,它们执行的机制 不是立即更新,而是标记更新。准确地说,它们本身不会调用重新渲染的函数,更像是 Cache 的 Set dirty,提醒 React 在结束事件处理函数之后进行为这个组件进行重渲染。

也就是说,setter 并不会直接更改 state(本次保持不变),而是告诉 React 下次渲染时将 state 改成什么

为什么 React 要这么设计?引用官方文档的一句话:

这让你可以更新多个 state 变量——甚至来自多个组件的 state 变量——而不会触发太多的重渲染。但这也意味着只有在你的事件处理函数及其中任何代码执行完成 之后,UI 才会更新。这种特性也就是 批处理,它会使你的 React 应用运行得更快。它还会帮你避免处理只更新了一部分 state 变量的令人困惑的“半成品”渲染。

就像餐厅里帮你点菜的服务员。服务员不会在你说第一道菜的时候就跑到厨房。相反,他们会让你把菜点完,让你修改菜品,甚至会帮桌上的其他人点菜。

这样就能解释上面的行为:setNumber(number + 1) 只是重复地告诉 React 下次渲染时,该组件的 number 状态为 当前的 number + 1,等价于:

1
2
3
4
// 设 0 为 number 本次的值
setNumber(0 + 1);
setNumber(0 + 1);
setNumber(0 + 1);

更让人惊奇的是,访问 number 得到的绝不是它的引用,而总是它的值!在 JavaScript 这个遍地是引用的语言中,这就相当于是相当少见的 “值捕获”(类比 C++11 的匿名函数表达式中有值、引用两种方式捕获变量)!

看下面的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { useState } from 'react';

export default function Counter() {
const [number, setNumber] = useState(0);

return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 5);
setTimeout(() => {
alert(number);
}, 3000);
}}>+5</button>
</>
)
}

我们让 number 等待 5 秒后再传给 alert,你会发现输出的 number 就是在重渲染前的值。

了解了这些你可能会问,这种做法有好有坏。好处是在执行渲染函数时无需担心 state 突然被更改,但坏处是,我们在一次渲染中没法立即读取改变后的 state,这应该怎么办?下一节就是解决方案。

2.3.3 useState 更新队列

正如前面介绍的,在下一次渲染开始前,你无法实现多次更改状态的需求,因为 state setter 只是提醒下一次渲染要改变的内容。但 React 也给开发者提供了一种 “批处理队列” 的方法,明确让 React 逐步设置一些状态。

我们对 2.3.2 中一开始 + 3 的示例进行修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { useState } from 'react';

export default function Counter() {
const [number, setNumber] = useState(0);

return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(n => n + 1);
setNumber(n => n + 1);
setNumber(n => n + 1);
}}>+3</button>
</>
)
}

现在 React 就能够按照我们想要的动作进行 + 3 的操作了。

我们注意到这次传入 setNumber 的是一个函数((n) => { return n + 1; }),这就相当于向 React 状态修改队列中加入了一个执行函数,在 React 下一次渲染时,会依次运行队列中的函数,以指定的方式修改状态。

到这里,我们可以从另一角度理解 setNumber(number + 1) 的含义了,它其实等效于:

1
setNumber(x => number + 1)

由于 x 参数与结果无关,当然最后也就不会累加计算啦。

以此类推,下面的行为也就不难理解:

1
2
3
4
5
6
7
8
9
10
11
12
/* 每次 + 6 */
<button onClick={() => {
setNumber(number + 5);
setNumber(n => n + 1);
}}></button>

/* 变为 42 后不会改变 */
<button onClick={() => {
setNumber(number + 5);
setNumber(n => n + 1);
setNumber(42);
}}></button>

2.3.4 useState 与 JavaScript 原生异步结合

众所周知,原生 JavaScript 的异步大多依靠 Promise 实现。那么,如果把异步操作与 useState 结合,又有哪些需要注意的地方呢?

首先明确异步操作对 useState 没啥影响,并不妨碍正常使用,只是需要明白它的含义

下面是个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { useState } from 'react';

export default function RequestTracker() {
const [pending, setPending] = useState(0);
const [completed, setCompleted] = useState(0);

async function handleClick() {
setPending(pending + 1);
await delay(3000);
setPending(pending - 1);
setCompleted(completed + 1);
}

return (
<>
<h3>
等待:{pending}
</h3>
<h3>
完成:{completed}
</h3>
<button onClick={handleClick}>
购买
</button>
</>
);
}

function delay(ms) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}

我们想在事件处理函数中改变状态的同时加上一些异步等待的行为。

这样做是可行的,你可以认为异步的等待能在状态设置的时机被触发。如上例,在两次 setter 操作之间加一个异步 await 依然是能够等待的。

结论是,尽管 setter 只是标记改变,但能完全按照代码执行顺序,依次改变状态。

2.3.5 useState 存储可变数据类型

继续考虑 useState 的使用细节。我们之前接触到的、用于表征状态的量都是基本类型。如果状态是 引用类型(JavaScript 对象),情况有没有变化呢?

答案是有的。因为 JavaScript 原生引用类型都是 可变数据类型,和基本类型这种不可变数据类型相比有些尤其需要注意的点。

最重要的原因是,如果开发者将可变数据类型作为状态存储,那么在修改状态时,如果仅仅修改其中的属性,那么内存地址是不会发生变化的,换言之,React 无法发现这个变量被修改了,那么也就无法发挥 Hook 重渲染的作用

所以注意的问题很简单:想要将可变类型(如对象)作为状态,那么在修改状态时应该直接传入新的对象,而不是修改原来对象的属性。

这里看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { useState } from 'react';
export default function MovingDot() {
const [position, setPosition] = useState({
x: 0,
y: 0
});
return (
<div
onPointerMove={e => {
setPosition({
x: e.clientX,
y: e.clientY
});
}}
style={{
position: 'relative',
width: '100vw',
height: '100vh',
}}>
<div style={{
position: 'absolute',
backgroundColor: 'red',
borderRadius: '50%',
transform: `translate(${position.x}px, ${position.y}px)`,
left: -10,
top: -10,
width: 20,
height: 20,
}} />
</div>
);
}

除了上面提到 “创建新对象” 的要点外,我们还了解到了 JSX 中另一个可以内联事件处理器的属性:onPointerMove,这个属性对应的事件处理器在用户鼠标移过该元素时就会被触发,重要的参数有 event.clientXevent.clientY


另外,还有几个使用技巧,每次创建新的对象可能有点麻烦,尤其是这个对象属性很多,而我们只想改变一个属性的时候。我们可以使用 对象展开语法,类似 Python 的数组解包。比如,person 有 3 个属性,而我们只需要改 firstName,老方法写成这样:

1
2
3
4
5
setPerson({
firstName: e.target.value, // 从 input 中获取新的 first name
lastName: person.lastName,
email: person.email
});

新方法就可以写:

1
2
3
4
setPerson({
...person, // 复制上一个 person 中的所有字段
firstName: e.target.value // 覆盖 firstName 字段
})

很遗憾的是,这种解包的方式也是引用,所以如果你解包的内容中含有可变数据类型,那么直接这么做也不行。

例如对于多个的对象,你只能把它们拆开,直到不含有可变数据类型为止

1
2
3
4
5
6
7
8
var tmpArt = {
...person.artwork,
city: 'New Delhi'
};
setPerson({
...person
artwork: tmpArt
});

知识补充

我们对目前遇到的能够内联事件处理器的属性进行总结,主要有下面几个:

  • onClick
  • onFocus
  • onPointerMove
  • onPointerDown
  • onPointerUp
  • onChange(特定元素才有,例如 <select>);

上面的例子所介绍的可变类型大多是 object 对象,如果是数组,那么方法更多一点。因为原生 JavaScript 操作数组的方法本身就多一点。要注意的点是相同的,一定要传入新的对象。

在使用数组方法时,要注意 mapfilterconcatslice 这类函数都会创建新的数组对象,而 splicepush/popshift/unshiftsortreverse 则只会修改原先的对象。要善于使用这些函数!能让你处理数组更方便!

也要注意数组元素中含有可变数据类型,那就要单独处理

数组还有一点与普通对象有差别的:它们可能有顺序。在创建新数组时不应该改变元素顺序!!

我们尝试一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import { useState } from 'react';

const initialProducts = [{
id: 0,
name: 'Baklava',
count: 1,
}, {
id: 1,
name: 'Cheese',
count: 5,
}, {
id: 2,
name: 'Spaghetti',
count: 2,
}];

export default function ShoppingCart() {
const [
products,
setProducts
] = useState(initialProducts)

function handleIncreaseClick(productId) {

}


return (
<ul>
{products.map(product => (
<li key={product.id}>
{product.name}
{' '}
(<b>{product.count}</b>)
<button onClick={() => {
handleIncreaseClick(product.id);
}}>
+
</button>
</li>
))}
</ul>
);
}

如何实现 handleIncreaseClick,使得对应 product.id 的按钮一点击,该 product 的 count 数据就 +1 ?

其实最简单的方法是用 Array.map,而且它恰好能创建一个新数组对象:

1
2
3
4
5
6
7
8
9
10
function handleIncreaseClick(productId) {
var newProdList = products.map((elem)=>{
if (elem.id === productId) {
return {
...elem,
count: elem.count + 1
}
} else return item;
});
}

Chapter 3. React 状态管理

本章旨在介绍一种更好的组织 React 组件的 state 的思路,让项目写起来更加规范、容易维护。

3.1 声明式 UI 与 命令式 UI

命令式 UI 的编程方法通常非常繁琐,你必须去根据要发生的事情写一些明确的命令去操作 UI。

就像你坐在车里的某个人旁边,然后一步一步地告诉他该去哪。他并不知道你想去哪,只想跟着命令行动。(并且如果你发出了错误的命令,那么你就会到达错误的地方)正因为你必须从加载动画到按钮地“命令”每个元素,所以这种告诉计算机如何去更新 UI 的编程方式被称为命令式 UI 编程

原生 JavaScript + HTML,以及 C++ 的 Qt 框架,采用都是命令式 UI 编程。Qt 为了规避命令式 UI 编程的麻烦,将部分工作转移给 Qt Designer + uic(用户界面编译器)来做。

JavaScript 的框架 React 则采用了 声明式 UI 编程,也就是说,我们不必直接去操作 UI —— 不必直接启用、关闭、显示或隐藏组件。相反,我们只需要 声明你想要显示的内容, React 就会通过计算得出该如何去更新 UI。

就像上了一辆出租车并且告诉司机你想去哪,而不是事无巨细地告诉他该如何走。将你带到目的地是司机的工作,他们甚至可能知道一些你没有想过并且不知道的捷径(即更高效的渲染方式,例如之前的增量渲染技术)。

在 声明式 UI 编程中,我们不用直接修改 UI 组件,而是采用以下的几个步骤:

  1. 定位你的组件中不同的视图状态(设计界面状态机);

  2. 确定是什么触发了这些 state 的改变;

    根据前两个步骤,你应该能够绘制出组件的状态转换图,类似这个,这是个表单的状态转换图:

  3. 定义使用内存中的 state(useState);

    根据状态图,我们应该设计尽量少的 state,众所周知,程序越复杂越不方便维护;

  4. 删除任何不必要的 state 变量;

    这一步是有技巧的,还是以一个表单为例

    • 这个 state 是否会导致矛盾?例如,isTypingisSubmitting 的状态不能同时为 true。矛盾的产生通常说明了这个 state 没有足够的约束条件;
    • 相同的信息是否已经在另一个 state 变量中存在?另一个矛盾:isEmptyisTyping 不能同时为 true。通过使它们成为独立的 state 变量,可能会导致它们不同步并导致 bug;
    • 你是否可以通过另一个 state 变量的相反值得到相同的信息isError 是多余的,因为你可以检查 error !== null
  5. 连接事件处理函数去设置 state;

3.2 State 设计的最佳实践

前人总结过如下的经验,采集自官方文档 + 笔者自己总结:

  • 合并关联的 state。如果你总是同时更新两个或更多的 state 变量,请考虑将它们合并为一个单独的 state 变量。

    例如位置信息,x 和 y 是建议绑定在一个对象中,因为它们通常同时变化;

  • 避免互相矛盾 / 冗余的 state。当 state 结构中存在多个相互矛盾 / “不一致” / 可以互相推理出信息的 state 时,你就可能为此会留下隐患(例如不及时同步、错误地维护)。应尽量避免这种情况;

  • 避免深度嵌套的 state。深度分层的 state 更新起来不是很方便,而且降低代码可读性。如果可能的话,最好以扁平化方式构建 state;

  • 避免与其他 State 共享引用对象:⚠⚠这点请一定要注意!!!是笔者自己加的。笔者曾犯过这个错,调试半天都没找出来。⚠⚠如果你共享了,那么在更新一个 State 的时候,会不经意间影响到另一个 State 的呈现,使程序出现意外的行为。

    替代方案是,共享基本类型(不可变数据类型);

3.3 组件间共享 State

非常简单,就一句话:把 state 放入父组件,然后用参数传给子组件,即可完成共享

3.4 State 的保留与重置

为什么要讨论 State 的保留和重置?考虑一个问题。假如你移除了一个组件,之后又添加上去,State 能否保留?如果没法保留,是否有一些手段能保留它的值?

3.4.1 保留

首先,React 组件的 State 与组件在渲染树的位置有关。这句话说明了几点:

  1. 即便两个组件是同一个对象的引用,只要它们在渲染树上的位置不同,State 就相互独立。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    import { useState } from 'react';

    export default function App() {
    const counter = <Counter />;
    return (
    <div>
    {counter}
    {counter}
    </div>
    );
    }

    function Counter() {
    const [score, setScore] = useState(0);
    const [hover, setHover] = useState(false);

    let className = 'counter';
    if (hover) {
    className += ' hover';
    }

    return (
    <div
    className={className}
    onPointerEnter={() => setHover(true)}
    onPointerLeave={() => setHover(false)}
    >
    <h1>{score}</h1>
    <button onClick={() => setScore(score + 1)}>
    +1
    </button>
    </div>
    );
    }

    两个 counter 指向同一个对象,但是它们在 DOM 树上被渲染成两个结点,因此它们的 State 是不会共享的。

  2. 被移除的组件,state 会被立即删除。所以哪怕在渲染树的相同位置,state 也无法保存;

    什么是 “被移除”?

    就是组件在某次返回的 JSX 中,某元素完全消失,该元素对应的组件就被移除了。

    下次即便生成在同一位置,组件也是新渲染的(不是重渲染)。

    像这种就不叫删除,叫属性的改变:

    1
    2
    3
    4
    5
    {isFancy ? (
    <Counter isFancy={true} />
    ) : (
    <Counter isFancy={false} />
    )}

    选择渲染也不会使其他组件被移除。因为其他的组件的 DOM 树没有变动:

    1
    2
    3
    4
    5
    6
    7
    <div>
    {(showHint) ? <p><i>提示:你最喜欢的城市?</i></p> : null}
    <Form />
    <button onClick={() => {
    setShowHint(!showHint);
    }}>{(showHint) ? "隐藏提示" : "显示提示"}</button>
    </div>

    这种就叫删除,因为换成了新的组件,React 会进行首次渲染:

    1
    2
    3
    4
    5
    {isPaused ? (
    <p>待会见!</p>
    ) : (
    <Counter />
    )}

    注意,如果某组件的父元素改变了,由于 DOM 会重新生成,对 React 来说,也是首次绘制,这也算删除:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    {isFancy ? (
    <div>
    <Counter isFancy={true} />
    </div>
    ) : (
    <section>
    <Counter isFancy={false} />
    </section>
    )}

以上两点就告诉我们如何保存 State:如果你想在重新渲染时保留 state,几次渲染中的树形结构就应该相互“匹配”,属性的变化则不会影响 state。

到这里就能解释早在 1.1 中的约定:组件不可嵌套定义。因为如果嵌套定义,例如下面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { useState } from 'react';

export default function MyComponent {
const [counter, setCounter] = useState(0);

function MyTextField() {
const [text, setText] = useState('');

return (
<input
value={text}
onChange={e=>setText(e.target.value)}
/>
);
}

return (
<>
<MyTextField/>
<button onCLick={()=>{
setCounter(counter + 1)
}}>Clicked {counter} times</button>
</>
);
}

那么每次渲染时,MyTextField 函数本身的内存地址每次都不一样,那么相当于每次渲染 MyTextField 组件都会被移除,从头开始渲染,这就直接导致 state 丢失

3.4.2 重置

上面讨论 state 的保留的情况。考虑实际应用时的另一种情况:
假设有一个组件收集用户输入。但是你需要实现 “切换用户” 的功能。在不销毁组件的情况下,组件的 state 会被保存。那么我们如何在 DOM 树的同一位置上重置组件的 state?

有两种思路:要么把组件渲染在不同位置,要么使用 key 在指定组件身份,确保切换 key 时 React 会移除并重新生成组件

  1. 不同位置渲染:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    {isPlayerA &&
    <Counter person="Taylor" />
    }
    {!isPlayerA &&
    <Counter person="Sarah" />
    }
    <button onClick={() => {
    setIsPlayerA(!isPlayerA);
    }}>

    如上,注意和 3.4.1 的例子的区别。我们在多个用户的情况可以通过列表组织。

  2. 使用 key 重置 state:我们不仅在列表中使用 key 来帮助 React 更好地管理 entries,在普通的组件 / 元素中也能使用,React 会根据 key 来区分组件:

    1
    2
    3
    4
    5
    {isPlayerA ? (
    <Counter key="Taylor" person="Taylor" />
    ) : (
    <Counter key="Sarah" person="Sarah" />
    )}

    值得注意的是,key 不是全局唯一的。它只能指定在同一父组件间的区别


到这里,state 的保存和重置机制的利用,我们已经很清楚了。但是如果我们就想在一个组件被移除后,仍然保留它的 state,直到它下次被创建呢

还有几种思路:

  1. 利用 CSS 隐藏不需要的组件,这样它实际上不会被移除;
  2. 使用不会被移除的父组件来保存信息;
  3. 使用 原生 JavaScript 中定义的 localStorage,即使用 cookie 来保存。这样即使关闭了页面也能恢复。

3.5 Reducer 统一状态管理

本节将遇到 第二个 React Hook,它的作用非常重要:统一管理 React 事件处理器

要知道,在 React 中使用闭包 + 内联事件处理器,这样的定义在事件处理器的数量少的时候非常方便书写。但是如果事件处理器的数量过多,那么代码可读性和可维护性将大大降低。这主要是因为 JSX 将页面布局和事件处理逻辑放在一起了。

而 React 库提供了一个新的 Hook,它允许你将事件处理器从 UI 声明中抽离出来,使得项目的事件处理系统更容易调试、更可读、更有可维护性

它就是 useReducer。Reducer 是处理状态的另一种方式,它相当于将我们的 事件处理函数 和 状态 打包了起来

3.5.1 useReducer 的基本使用

使用 reducers 管理状态与直接设置状态略有不同。它不是通过设置状态来告诉 React “要做什么”,而是通过事件处理程序 dispatch 一个 “action” 来指明 “用户刚刚做了什么”,而状态更新逻辑则保存在其他地方,实现了状态管理逻辑和 UI 的抽离。

1
2
3
4
5
6
7
import { useReduce } from 'react';

// ...

// `yourReducer` 是等会需要定义的总的事件管理的函数(你可以自己命名),所有事件处理函数 dispatch 都会到这里
// `initialState` 是 `state`(你也可以自己命名)的初值
const [state, dispatch] = useReducer(yourReducer, initialState);

事件处理函数只需要调用 dispatch,传入 action 对象,告诉之后的 yourReducer 发生了什么。例如:

1
2
3
4
5
6
7
8
dispath(
// action 对象(普通的 JS Object),可以自己定义,但要保证你在 yourReducer 能知道发生了什么
{
/* 描述发生事件的类别 */
type: 'what_happened',
/* 其他字段描述改变的信息 */
}
);

另一边的事件管理函数 yourReducer 则处理调用 dispatch 的动作,要求声明如下:

1
function yourReducer(state, action) => newState

该函数会被 dispatch 触发(内部触发),React 库自动添加第一个参数 state 即当前组件的当前状态,action 等同于之前传入 dispatch 的对象。

React 会将组件的 state 设置为状态管理函数 yourReducer 的返回值 newState

这样,我们可以把 yourReducer 定义在 UI 组件以外的文件中,提升代码可读性和可维护性。

3.5.2 Reducer 设计规范

  • 值得注意的是,Reducer 和 渲染函数一样,都在渲染时运行(包括你 dispatch 的 actions 也会等待到下一次渲染时进行),因此在 异步方面和 useState 不一样 —— reducers 内部不允许使用异步操作、定时器等

    alert 等函数也不能使用。因为严格模式下,React 会重复执行 2 次渲染函数,确保渲染函数具有幂等性。在 reducers 内部写 alert 极有可能会被调用 2 次。

  • useState 一致的是,允许修改状态,并且应该使用新的可变数据类型,或者不可变数据类型;

  • 每个 action 只应该对应一个用户交互动作,方便代码调试;

JavaScript JSON 对象操作小技巧:

当 JSON 中的键恰好是某个变量中的值,那么可以使用 [var] 计算出值,例如:

1
2
3
4
messages: {
...state.messages,
[state.selectedId]: action.message
}

3.6 Context: 自定义参数传播

在某些场景下,例如为深层的组件传递参数,如果采用 1.4 的普通 props 的参数传递,那么实际操作起来会非常麻烦,并且不易维护。

我们可以使用新的 Hook useContext 来实现数据在组件树上的传递。

3.6.1 useContext 基本使用

使用主要分成 3 步:

  1. 使用如下方法创建 context 上下文:

    1
    2
    3
    import { createContext } from 'react';

    export const LevelContext = createContext(<defaultValue>);
  2. 读取 context 值:

    1
    2
    3
    4
    5
    6
    7
    8
    import { useContext } from 'react';
    import { LevelContext } from './XXX.js';

    // ...

    export default function Widget() {
    const level = useContext(LevelContext);
    }

    不过现在只能读到默认值,我能否由父组件向子组件传递一定的 context 值?

  3. 父控件提供 context 值:

    1
    2
    <LevelContext.Provider value={level}>
    </LevelContext.Provider>

    这告诉 React:“如果在该组件中的任何子组件请求 LevelContext,给他们这个 level。”组件会使用 UI 树中在它上层最近的那个 <LevelContext.Provider> 传递过来的值。

3.6.2 Context 的使用场景

  • 主题: 如果你的应用允许用户更改其外观(例如暗夜模式),你可以在应用顶层放一个 context provider,并在需要调整其外观的组件中使用该 context。
  • 当前账户: 许多组件可能需要知道当前登录的用户信息。将它放到 context 中可以方便地在树中的任何位置读取它。某些应用还允许你同时操作多个账户(例如,以不同用户的身份发表评论)。在这些情况下,将 UI 的一部分包裹到具有不同账户数据的 provider 中会很方便。
  • 路由: 大多数路由解决方案在其内部使用 context 来保存当前路由。这就是每个链接“知道”它是否处于活动状态的方式。如果你创建自己的路由库,你可能也会这么做。
  • 状态管理: 随着你的应用的增长,最终在靠近应用顶部的位置可能会有很多 state。许多遥远的下层组件可能想要修改它们。通常将 reducer 与 context 搭配使用来管理复杂的状态并将其传递给深层的组件来避免过多的麻烦。

对于最后一条,也是最难的一条,我们将单独描述。

Context 不仅可以存放不可变数据类型,还能放 state + dispatch,这样能够实现组件树下的各个子组件都可以直接读取 states 和 dispatch,无需担心引用问题。