重学React(三):状态管理

背景: 继续跟着官网的流程往后学,之前已经整理了描述UI以及添加交互两个模块,总体来说还是收获不小的,至少我一个表面上用了四五年React的前端小卡拉米对React的使用都有了新的认知。接下来就到了状态管理(React特地加了个中级的标签)的模块,那就一起学习吧~

前期回顾:
重学React(一):描述UI
重学React(二):添加交互

学习内容:

React官网教程:https://zh-hans.react.dev/learn/managing-state

其他辅助资料(看到再补充)

补充说明:这次学习更多的是以学习笔记的形式记录,看到哪记到哪

随着应用不断变大,更有意识的去关注应用状态如何组织,以及数据如何在组件之间流动会对你很有帮助。冗余或重复的状态往往是缺陷的根源。这里将学习如何组织好状态,如何保持状态更新逻辑的可维护性,以及如何跨组件共享状态。

1. 用 State 响应输入

命令式UI编程 :直接告诉计算机如何去更新 UI 的编程方式。比如打车时,你不告诉司机去哪,而是指挥他怎么走
声明式UI编程 :只需要 声明你想要显示的内容, React 就会通过计算得出该如何去更新 UI。比如打车时你只需要告诉司机去哪,怎么走司机会自己规划

对一些小的独立的系统来说,命令式地控制用户页面也能起到不错的效果,比如你坐车从村口到家门口,有时候导航效果还不如口述清楚。但当系统变得复杂的时候,比如从北京到上海,还是让司机和导航发挥来的好。

接下来我们来看一个在前端特别经典的例子------表单填写。想象一个让用户提交答案的表单:

  • 当你向表单输入数据时,"提交"按钮会随之变成可用状态
  • 当你点击"提交"后,表单和提交按钮都会随之变成不可用状态,并且会加载动画会随之出现
  • 如果网络请求成功,表单会随之隐藏,同时"提交成功"的信息会随之出现
  • 如果网络请求失败,错误信息会随之出现,同时表单又变为可用状态
html 复制代码
<form id="form">
  <h2>City quiz</h2>
  <p>
    What city is located on two continents?
  </p>
  <textarea id="textarea"></textarea>
  <br />
  <button id="button" disabled>Submit</button>
  <p id="loading" style="display: none">Loading...</p>
  <p id="error" style="display: none; color: red;"></p>
</form>
<h1 id="success" style="display: none">That's right!</h1>

<style>
* { box-sizing: border-box; }
body { font-family: sans-serif; margin: 20px; padding: 0; }
</style>
js 复制代码
async function handleFormSubmit(e) {
  e.preventDefault();
  disable(textarea);
  disable(button);
  show(loadingMessage);
  hide(errorMessage);
  try {
    await submitForm(textarea.value);
    show(successMessage);
    hide(form);
  } catch (err) {
    show(errorMessage);
    errorMessage.textContent = err.message;
  } finally {
    hide(loadingMessage);
    enable(textarea);
    enable(button);
  }
}

function handleTextareaChange() {
  if (textarea.value.length === 0) {
    disable(button);
  } else {
    enable(button);
  }
}

function hide(el) {
  el.style.display = 'none';
}

function show(el) {
  el.style.display = '';
}

function enable(el) {
  el.disabled = false;
}

function disable(el) {
  el.disabled = true;
}

function submitForm(answer) {
  // Pretend it's hitting the network.
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (answer.toLowerCase() === 'istanbul') {
        resolve();
      } else {
        reject(new Error('Good guess but a wrong answer. Try again!'));
      }
    }, 1500);
  });
}

let form = document.getElementById('form');
let textarea = document.getElementById('textarea');
let button = document.getElementById('button');
let loadingMessage = document.getElementById('loading');
let errorMessage = document.getElementById('error');
let successMessage = document.getElementById('success');
form.onsubmit = handleFormSubmit;
textarea.oninput = handleTextareaChange;

这段代码可以实现表单的生成,但是能看到,这个逻辑会比较复杂,如果想要在这基础上添加一些交互或者新的UI元素,还得从头检查一下,避免新bug的产生。接下来我们按照React 声明式UI的思想来重新整理一下这个需求,你只需要完成以下几个步骤:

  • 定位你的组件中不同的视图状态
  • 确定是什么触发了这些 state 的改变
  • 表示内存中的 state(需要使用 useState)
  • 删除任何不必要的 state 变量
  • 连接事件处理函数去设置 state

定位你的组件中不同的视图状态

总结一下,在这个表单需求里我们需要以下几个状态:

  • 无数据:表单有一个不可用状态的"提交"按钮。
  • 输入中:表单有一个可用状态的"提交"按钮。
  • 提交中:表单完全处于不可用状态,加载动画出现。
  • 成功时:显示"成功"的消息而非表单。
  • 错误时:与输入状态类似,但会多错误的消息。
    我们首先要做的就是用state去模拟这些状态
js 复制代码
// app.js
import Form from './Form.js';
let statuses = [
  'empty',
  'typing',
  'submitting',
  'success',
  'error',
];

export default function App() {
  return (
    <>
      {statuses.map(status => (
        <section key={status}>
          <h4>Form ({status}):</h4>
          <Form status={status} />
        </section>
      ))}
    </>
  );
}

// form.js
export default function Form({ status }) {
  if (status === 'success') {
    return <h1>That's right!</h1>
  }
  return (
    <form>
      <textarea disabled={
        status === 'submitting'
      } />
      <br />
      <button disabled={
        status === 'empty' ||
        status === 'submitting'
      }>
        Submit
      </button>
      {status === 'error' &&
        <p className="Error">
          Good guess but a wrong answer. Try again!
        </p>
      }
    </form>
  );
}

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

上面的代码把所有可能的状态罗列了一遍。现实中,这些状态应该都是互斥的,用户在页面中同时只能看到一个状态下的UI界面,总不可能这个表单提交既成功又失败吧(有些特定需求可能会有,但不在我们这次范围内哈),接下来是确定下有什么情况会触发state的更新:

  • 人为输入:比如点击按钮、在表单中输入内容,或导航到链接。(typing,submitting都是人为输入才会触发,empty也可以人为删除完所有输入内容触发)
  • 计算机输入:比如网络请求得到反馈、定时器被触发,或加载一张图片。(success,error可以根据计算机反馈表单提交成功与否展示)

通过 useState 表示内存中的 state

按照之前的想法,结合state的含义,只要我们用useState表示组件中这些状态,只要状态改变,视图自然会自动被更新(你只要告诉React现在要更新的是什么状态,怎么更新交给React就好)

js 复制代码
// 既然不知道要如何表示,那就先列举出来,至少不遗漏某些状态
const [isEmpty, setIsEmpty] = useState(true);
const [isTyping, setIsTyping] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);

删除任何不必要的 state 变量

全部列举出来当然是有很大可优化空间的,优化结构的最主要目的是防止出现在内存中的 state 不代表任何你希望用户看到的有效 UI 的情况,比如empty状态和禁止输入不应该同时出现。

删除不必要的state变量可以尝试问自己这三个问题:

  1. 这个 state 是否会导致矛盾?
    例如,isTyping 与 isSubmitting 的状态不能同时为 true。矛盾的产生通常说明了这个 state 没有足够的约束条件。两个布尔值有四种可能的组合,但是只有三种对应有效的状态。为了将"不可能"的状态移除,你可以将他们合并到一个 'status' 中,它的值必须是 'typing'、'submitting' 以及 'success' 这三个中的一个。
  2. 相同的信息是否已经在另一个 state 变量中存在?
    另一个矛盾:isEmpty 和 isTyping 不能同时为 true。通过使它们成为独立的 state 变量,可能会导致它们不同步并导致 bug。幸运的是,你可以移除 isEmpty 转而用 message.length === 0。
  3. 你是否可以通过另一个 state 变量的相反值得到相同的信息?
    isError 是多余的,因为你可以检查 error !== null。

这一系列检查下来,我们的state变量就可以改成这样:

js 复制代码
const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing'); // 'typing', 'submitting', or 'success'

还能不能接着优化,当然可以,不过需要引入一个新的概念------reducer,我们后面再讲

连接事件处理函数以设置 state

state状态定义好之后,最后一步就是创建事件处理函数去设置 state 变量。这样就可以实现一个优雅又健壮的代码啦~

其实这个方案也还有一些优化空间,可以先留个记号,等全部看完再看更优解是啥

js 复制代码
import { useState } from 'react';

export default function Form() {
  const [answer, setAnswer] = useState('');
  const [error, setError] = useState(null);
  const [status, setStatus] = useState('typing');

  if (status === 'success') {
    return <h1>That's right!</h1>
  }

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus('submitting');
    try {
      await submitForm(answer);
      setStatus('success');
    } catch (err) {
      setStatus('typing');
      setError(err);
    }
  }

  function handleTextareaChange(e) {
    setAnswer(e.target.value);
  }

  return (
    <>
      <h2>City quiz</h2>
      <p>
        In which city is there a billboard that turns air into drinkable water?
      </p>
      <form onSubmit={handleSubmit}>
        <textarea
          value={answer}
          onChange={handleTextareaChange}
          disabled={status === 'submitting'}
        />
        <br />
        <button disabled={
          answer.length === 0 ||
          status === 'submitting'
        }>
          Submit
        </button>
        {error !== null &&
          <p className="Error">
            {error.message}
          </p>
        }
      </form>
    </>
  );
}

function submitForm(answer) {
  // Pretend it's hitting the network.
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      let shouldError = answer.toLowerCase() !== 'lima'
      if (shouldError) {
        reject(new Error('Good guess but a wrong answer. Try again!'));
      } else {
        resolve();
      }
    }, 1500);
  });
}

2. 选择State结构

构建State的原则

当编写一个带有state的组件时,我们需要选择使用多少个 state 变量以及它们都是怎样的数据格式,选择的可能性很多,像之前的例子,我们可以每一个可能的变量都设置成state,但更加合理的构建能使代码更友好更健壮,最终目标是使 state 易于更新而不引入错误。"让你的状态尽可能简单,但不要过于简单"(这句是爱因斯坦说的)。接下来是一些构建State的原则:

  • **合并关联的 state。**如果你总是同时更新两个或更多的 state 变量,请考虑将它们合并为一个单独的 state 变量。
  • **避免互相矛盾的 state。**当 state 结构中存在多个相互矛盾或"不一致"的 state 时,你就可能为此会留下隐患。应尽量避免这种情况。
  • **避免冗余的 state。**如果你能在渲染期间从组件的 props 或其现有的 state 变量中计算出一些信息,则不应将这些信息放入该组件的 state 中。
  • **避免重复的 state。**当同一数据在多个 state 变量之间或在多个嵌套对象中重复时,这会很难保持它们同步。应尽可能减少重复。
  • **避免深度嵌套的 state。**深度分层的 state 更新起来不是很方便。如果可能的话,最好以扁平化方式构建 state。

合并关联的 state

js 复制代码
// 一个简单的例子,如果两个变量总是一起变化,比如记录鼠标移动的位置
// 这个场景下鼠标移动的位置由x,y同时构成,合并两个变量比同时更新两个变量合理
const [x, setX] = useState(0);
const [y, setY] = useState(0);

const [position, setPosition] = useState({ x: 0, y: 0 });

// 但是要记住的是,如果只更新对象中其中一个数值,记得把另一个数值也带上
setPosition({ x: 100 }) // ❌ 这样会丢失y的值
setPosition({ ...position, x: 100 }) ✅

避免矛盾的 state

考虑之前的例子,其实在删除不必要的state变量时,就考虑了避免矛盾的state方法

js 复制代码
const [isEmpty, setIsEmpty] = useState(true);
const [isTyping, setIsTyping] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);

// 其中isTyping,isSuccess和isError 都可以归纳成status
const [status, setStatus] = useState('typing'); // 'typing', 'submitting', or 'success'

避免冗余的 state

还是同样的例子,我们可以发现,isError变量是多余的,因为我们在进行setError的时候,可以通过error是否为空判断isError

关键逻辑是:如果能在渲染期间从组件的 props 或其现有的 state 变量中计算出一些信息,则不应该把这些信息放到该组件的 state 中

不要在 state 中镜像 props

有一个很常见的场景,是state 变量被初始化为 prop 值

js 复制代码
function Message({ messageColor }) {
  const [color, setColor] = useState(messageColor);

这个例子的问题在于,state 仅在第一次渲染期间初始化,如果父组件稍后传递不同的 messageColor 值(例如,将其从 'blue' 更改为 'red'),则 color state 变量将不会更新

如果想要单纯把变量名缩短,直接使用常量声明就好const color = messageColor;

这种把prop的值作为state初始化值的场景,只适用于想要 忽略特定 props 属性的所有更新时。

避免重复的 state

请看下面的例子

js 复制代码
import { useState } from 'react';

const initialItems = [
  { title: 'pretzels', id: 0 },
  { title: 'crispy seaweed', id: 1 },
  { title: 'granola bar', id: 2 },
];

export default function Menu() {
  const [items, setItems] = useState(initialItems);
  const [selectedItem, setSelectedItem] = useState(
    items[0]
  );

  return (
    <>
      <h2>What's your travel snack?</h2>
      <ul>
        {items.map(item => (
          <li key={item.id}>
            {item.title}
            {' '}
            <button onClick={() => {
              setSelectedItem(item);
            }}>Choose</button>
          </li>
        ))}
      </ul>
      <p>You picked {selectedItem.title}.</p>
    </>
  );
}

这个代码的问题是什么呢,假设一下,如果我们先点击choose,然后修改当前选择的snack的名称,你会发现修改的名称没办法同步到selectedItem.title中。在这个场景中,selectedItem这个对象是重复的,重复声明最容易出问题的原因是,每一次的更新,都需要确保每一个state都被同步。一个简单的解法是,id是一直不变的,所以我们只需要记住被选中的id,每次都从items中渲染对应id的最新值,这样就能避免多次同步更新的问题。

js 复制代码
import { useState } from 'react';

const initialItems = [
  { title: 'pretzels', id: 0 },
  { title: 'crispy seaweed', id: 1 },
  { title: 'granola bar', id: 2 },
];

export default function Menu() {
  const [items, setItems] = useState(initialItems);
  const [selectedId, setSelectedId] = useState(0);

  const selectedItem = items.find(item =>
    item.id === selectedId
  );

  function handleItemChange(id, e) {
    setItems(items.map(item => {
      if (item.id === id) {
        return {
          ...item,
          title: e.target.value,
        };
      } else {
        return item;
      }
    }));
  }

  return (
    <>
      <h2>What's your travel snack?</h2>
      <ul>
        {items.map((item, index) => (
          <li key={item.id}>
            <input
              value={item.title}
              onChange={e => {
                handleItemChange(item.id, e)
              }}
            />
            {' '}
            <button onClick={() => {
              setSelectedId(item.id);
            }}>Choose</button>
          </li>
        ))}
      </ul>
      <p>You picked {selectedItem.title}.</p>
    </>
  );
}

避免深度嵌套的 state

想象一个很大有多层嵌套的数据,比如我国的省市区GDP数据,如果想更新某个省某个市某个区的GDP,那需要一层一层嵌套的复制上去,会变得很麻烦。

如果 state 嵌套太深,难以轻松更新,可以考虑将其"扁平化"。以下是官方的一个例子,关键思想是:让每个节点的 place 作为数组保存 其子节点的 ID。然后存储一个节点 ID 与相应节点的映射关系。

js 复制代码
import { useState } from 'react';
// 原始数据
export const initialTravelPlan = {
  0: {
    id: 0,
    title: '(Root)',
    childIds: [1, 42, 46],
  },
  1: {
    id: 1,
    title: 'Earth',
    childIds: [2, 10, 19, 26, 34]
  },
  2: {
    id: 2,
    title: 'Africa',
    childIds: [3, 4, 5, 6 , 7, 8, 9]
  }, 
  3: {
    id: 3,
    title: 'Botswana',
    childIds: []
  },
  4: {
    id: 4,
    title: 'Egypt',
    childIds: []
  },
  5: {
    id: 5,
    title: 'Kenya',
    childIds: []
  },
  6: {
    id: 6,
    title: 'Madagascar',
    childIds: []
  }, 
  7: {
    id: 7,
    title: 'Morocco',
    childIds: []
  },
  8: {
    id: 8,
    title: 'Nigeria',
    childIds: []
  },
  9: {
    id: 9,
    title: 'South Africa',
    childIds: []
  },
  10: {
    id: 10,
    title: 'Americas',
    childIds: [11, 12, 13, 14, 15, 16, 17, 18],   
  },
  11: {
    id: 11,
    title: 'Argentina',
    childIds: []
  },
  12: {
    id: 12,
    title: 'Brazil',
    childIds: []
  },
  13: {
    id: 13,
    title: 'Barbados',
    childIds: []
  }, 
  14: {
    id: 14,
    title: 'Canada',
    childIds: []
  },
  15: {
    id: 15,
    title: 'Jamaica',
    childIds: []
  },
  16: {
    id: 16,
    title: 'Mexico',
    childIds: []
  },
  17: {
    id: 17,
    title: 'Trinidad and Tobago',
    childIds: []
  },
  18: {
    id: 18,
    title: 'Venezuela',
    childIds: []
  },
  19: {
    id: 19,
    title: 'Asia',
    childIds: [20, 21, 22, 23, 24, 25],   
  },
  20: {
    id: 20,
    title: 'China',
    childIds: []
  },
  21: {
    id: 21,
    title: 'India',
    childIds: []
  },
  22: {
    id: 22,
    title: 'Singapore',
    childIds: []
  },
  23: {
    id: 23,
    title: 'South Korea',
    childIds: []
  },
  24: {
    id: 24,
    title: 'Thailand',
    childIds: []
  },
  25: {
    id: 25,
    title: 'Vietnam',
    childIds: []
  },
  26: {
    id: 26,
    title: 'Europe',
    childIds: [27, 28, 29, 30, 31, 32, 33],   
  },
  27: {
    id: 27,
    title: 'Croatia',
    childIds: []
  },
  28: {
    id: 28,
    title: 'France',
    childIds: []
  },
  29: {
    id: 29,
    title: 'Germany',
    childIds: []
  },
  30: {
    id: 30,
    title: 'Italy',
    childIds: []
  },
  31: {
    id: 31,
    title: 'Portugal',
    childIds: []
  },
  32: {
    id: 32,
    title: 'Spain',
    childIds: []
  },
  33: {
    id: 33,
    title: 'Turkey',
    childIds: []
  },
  34: {
    id: 34,
    title: 'Oceania',
    childIds: [35, 36, 37, 38, 39, 40, 41],   
  },
  35: {
    id: 35,
    title: 'Australia',
    childIds: []
  },
  36: {
    id: 36,
    title: 'Bora Bora (French Polynesia)',
    childIds: []
  },
  37: {
    id: 37,
    title: 'Easter Island (Chile)',
    childIds: []
  },
  38: {
    id: 38,
    title: 'Fiji',
    childIds: []
  },
  39: {
    id: 39,
    title: 'Hawaii (the USA)',
    childIds: []
  },
  40: {
    id: 40,
    title: 'New Zealand',
    childIds: []
  },
  41: {
    id: 41,
    title: 'Vanuatu',
    childIds: []
  },
  42: {
    id: 42,
    title: 'Moon',
    childIds: [43, 44, 45]
  },
  43: {
    id: 43,
    title: 'Rheita',
    childIds: []
  },
  44: {
    id: 44,
    title: 'Piccolomini',
    childIds: []
  },
  45: {
    id: 45,
    title: 'Tycho',
    childIds: []
  },
  46: {
    id: 46,
    title: 'Mars',
    childIds: [47, 48]
  },
  47: {
    id: 47,
    title: 'Corn Town',
    childIds: []
  },
  48: {
    id: 48,
    title: 'Green Hill',
    childIds: []
  }
};

export default function TravelPlan() {
  const [plan, setPlan] = useState(initialTravelPlan);

  function handleComplete(parentId, childId) {
    const parent = plan[parentId];
    // 创建一个其父级地点的新版本
    // 但不包括子级 ID。
    const nextParent = {
      ...parent,
      childIds: parent.childIds
        .filter(id => id !== childId)
    };
    // 更新根 state 对象...
    setPlan({
      ...plan,
      // ...以便它拥有更新的父级。
      [parentId]: nextParent
    });
  }

  const root = plan[0];
  const planetIds = root.childIds;
  return (
    <>
      <h2>Places to visit</h2>
      <ol>
        {planetIds.map(id => (
          <PlaceTree
            key={id}
            id={id}
            parentId={0}
            placesById={plan}
            onComplete={handleComplete}
          />
        ))}
      </ol>
    </>
  );
}

function PlaceTree({ id, parentId, placesById, onComplete }) {
  const place = placesById[id];
  const childIds = place.childIds;
  return (
    <li>
      {place.title}
      <button onClick={() => {
        onComplete(parentId, id);
      }}>
        Complete
      </button>
      {childIds.length > 0 &&
        <ol>
          {childIds.map(childId => (
            <PlaceTree
              key={childId}
              id={childId}
              parentId={id}
              placesById={placesById}
              onComplete={onComplete}
            />
          ))}
        </ol>
      }
    </li>
  );
}

3. 在组件间共享状态

有时候会存在两个组件的状态始终同步更改。要实现这一点,可以将相关 state 从这两个组件上移除,并把 state 放到它们的公共父级,再通过 props 将 state 传递给这两个组件。这被称为"状态提升"。

还是直接看例子:

js 复制代码
import { useState } from 'react';

function Panel({ title, children }) {
  const [isActive, setIsActive] = useState(false);
  return (
    <section className="panel">
      <h3>{title}</h3>
      {isActive ? (
        <p>{children}</p>
      ) : (
        <button onClick={() => setIsActive(true)}>
          显示
        </button>
      )}
    </section>
  );
}

export default function Accordion() {
  return (
    <>
      <h2>哈萨克斯坦,阿拉木图</h2>
      <Panel title="关于">
        阿拉木图人口约200万,是哈萨克斯坦最大的城市。它在 1929 年到 1997 年间都是首都。
      </Panel>
      <Panel title="词源">
        这个名字来自于 <span lang="kk-KZ">алма</span>,哈萨克语中"苹果"的意思,经常被翻译成"苹果之乡"。事实上,阿拉木图的周边地区被认为是苹果的发源地,<i lang="la">Malus sieversii</i> 被认为是现今苹果的祖先。
      </Panel>
    </>
  );
}

这段代码本身是没有任何问题的,点击展开会打开当前的Panel。但是在实际的开发过程中,我们经常会遇到这样的需求:希望一次只能展开一个Panel,点击某个展开其余的内容就会自动收起。因为两个Panel之间的组件是相互不影响的,为了实现这个功能,最直接的方式就是将这个控制是否展开的state放到父组件中,由父组件传入props的方式来控制。

可以分成三步来进行代码改造:

  1. 从子组件中 移除 state 。
  2. 从父组件 传递 硬编码数据。
  3. 为共同的父组件添加 state ,并将其与事件处理函数一起向下传递。
从子组件中 移除 state

简单一句话,将子组件的state声明删除,isActive 改成从props传入。这样就可以通过父组件中传入的isActive控制Panel组件是否展开

从公共父组件传递硬编码数据

也是简单一句话,找到公共的父组件,把isActive硬编码成true 或者false,传入到Panel组件中,看看是否生效

为公共父组件添加状态

把硬编码改成状态。状态提升通常会改变原状态的数据存储类型。原本isActive是个布尔值,但请记住需求是实现每次只能打开一个Panel,也就意味着需要记录当前打开的panel是哪个,实现方式其实也很简单,直接看代码吧

js 复制代码
import { useState } from 'react';

export default function Accordion() {
// 当 activeIndex 为 0 时,激活第一个面板,为 1 时,激活第二个面板
  const [activeIndex, setActiveIndex] = useState(0);
  // 在任意一个 Panel 中点击"显示"按钮都需要更改 Accordion 中的激活索引值
  // Accordion 组件需要显式允许 Panel 组件通过 将事件处理程序作为 prop 向下传递 来更改其状态,也就是这个onShow方法
  const onShow = (index)=>setActiveIndex(index)
  return (
    <>
      <h2>哈萨克斯坦,阿拉木图</h2>
      <Panel
        title="关于"
        isActive={activeIndex === 0}
        onShow={() =>onShow(0)}
      >
        阿拉木图人口约200万,是哈萨克斯坦最大的城市。它在 1929 年到 1997 年间都是首都。
      </Panel>
      <Panel
        title="词源"
        isActive={activeIndex === 1}
        onShow={() => onShow(1)}
      >
        这个名字来自于 <span lang="kk-KZ">алма</span>,哈萨克语中"苹果"的意思,经常被翻译成"苹果之乡"。事实上,阿拉木图的周边地区被认为是苹果的发源地,<i lang="la">Malus sieversii</i> 被认为是现今苹果的祖先。
      </Panel>
    </>
  );
}

function Panel({
  title,
  children,
  isActive,
  onShow
}) {
  return (
    <section className="panel">
      <h3>{title}</h3>
      {isActive ? (
        <p>{children}</p>
      ) : (
        <button onClick={onShow}>
          显示
        </button>
      )}
    </section>
  );
}

如果还是有点不太明白的可以看看这个图解:

受控组件和非受控组件

通常把包含"不受控制"状态的组件称为"非受控组件",例如,最开始带有 isActive 状态变量的 Panel 组件就是不受控制的,因为其父组件无法控制面板的激活状态

当组件中的重要信息是由 props 而不是其自身状态驱动时,就可以认为该组件是"受控组件"。最后带有 isActive 属性的 Panel 组件是由 Accordion 组件控制的

每个状态都对应唯一的数据源

在 React 应用中,很多组件都有自己的状态。有些组件的状态只和自己有关系,例如输入框。有些组件的状态则是从上层上层再上层传入的,例如,客户端路由库也是通过将当前路由存储在 React 状态中,利用 props 将状态层层传递下去来实现的。

对于每个独特的状态,都应该存在且只存在于一个指定的组件中作为 state。这一原则也被称为拥有 "可信单一数据源"。它并不意味着所有状态都存在一个地方------对每个状态来说,都需要一个特定的组件来保存这些状态信息。你应该 将状态提升 到公共父级,或 将状态传递 到需要它的子级中,而不是在组件之间复制共享的状态

可信单一数据源: 在系统中,某类数据只在一个地方进行维护和存储,这个地方被视为该数据的唯一真实、可信来源。所有其他使用该数据的地方都必须从这个数据源中读取,而不能自行复制或维护一份副本。

4. 对 state 进行保留和重置

各个组件的 state 是各自独立的。根据组件在 UI 树中的位置,React 可以跟踪哪些 state 属于哪个组件。在重新渲染过程中可以控制何时对 state 进行保留和重置。

状态与渲染树中的位置相关

状态是由 React 保存的。React 通过组件在渲染树中的位置将它保存的每个状态与正确的组件关联起来。

js 复制代码
import { useState } from 'react';

export default function App() {
  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)}>
        加一
      </button>
    </div>
  );
}

上面的代码渲染了两个Counter组件,下面是它们完整的树形结构。这是两个独立的 counter,因为它们在树中被渲染在了各自的位置。也就是说,在这棵树中,它们每一个组件都是有自己的位置的。所以操作其中一个Counter,并不会影响到另外的Counter。

只有当在树中相同的位置渲染相同的组件时,React 才会一直保留着组件的 state。

我们再来看下面这段代码:

js 复制代码
import { useState } from 'react';

export default function App() {
  const [showB, setShowB] = useState(true);
  return (
    <div>
      <Counter />
      {showB && <Counter />} 
      <label>
        <input
          type="checkbox"
          checked={showB}
          onChange={e => {
            setShowB(e.target.checked)
          }}
        />
        渲染第二个计数器
      </label>
    </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)}>
        加一
      </button>
    </div>
  );
}

在渲染出来的结果里,先点击第二个Counter,然后取消复选框的勾选,再重新勾选,你会发现第二个Counter中的值被清空了,也就是state回到了初始状态。这是因为React在移除一个组件时,也会销毁它的state

在React的机制里,只要一个组件还被渲染在 UI 树的相同位置,React 就会保留它的 state。 如果它被移除,或者一个不同的组件被渲染在相同的位置,那么 React 就会丢掉它的 state。

相同位置的相同组件会使得 state 被保留下来

还是Counter的例子,先看代码

js 复制代码
import { useState } from 'react';

export default function App() {
  const [isFancy, setIsFancy] = useState(false);
  return (
    <div>
      {isFancy ? (
        <Counter isFancy={true} /> 
      ) : (
        <Counter isFancy={false} /> 
      )}
      <label>
        <input
          type="checkbox"
          checked={isFancy}
          onChange={e => {
            setIsFancy(e.target.checked)
          }}
        />
        使用好看的样式
      </label>
    </div>
  );
}

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

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

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

执行这段代码会发生什么?跑完之后会很神奇的发现,复选框勾选与否并不会重置state中的值。这跟想象中的不太一样,看上去这不也是两个Counter么?(这是我之前没想到的,果然常看常新)

这个代码和之前的区别在于,在DOM中通过if来渲染Counter,这意味着不管渲染的是if还是else中的代码,它们都是根组件 App 返回的 div 的第一个子组件。位于相同位置的相同组件,对 React 来说,就是同一个计数器。

对 React 来说重要的是组件在 UI 树中的位置,而不是在 JSX 中的位置!

相同位置的不同组件会使 state 重置

之前的例子在相同位置渲染的是同一个组件,现在来看看渲染不同的组件会发生什么

js 复制代码
import { useState } from 'react';

export default function App() {
  const [isPaused, setIsPaused] = useState(false);
  return (
    <div>
      {isPaused ? (
        <p>待会见!</p> 
      ) : (
        <Counter /> 
      )}
      <label>
        <input
          type="checkbox"
          checked={isPaused}
          onChange={e => {
            setIsPaused(e.target.checked)
          }}
        />
        休息一下
      </label>
    </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)}>
        加一
      </button>
    </div>
  );
}

这次虽然也在同一个位置上,但是移除Counter后重新添加,会发现Counter中的state被重置了。这是因为React的机制中,相同位置的不同组件会被重置,不仅如此,当你在相同位置渲染不同的组件时,组件的整个子树都会被重置。图例如下:

如果你想在重新渲染时保留 state,几次渲染中的树形结构就应该相互"匹配"。

js 复制代码
// 这里只写关键的Counter代码
// 此时不会被重置
{isPaused ? (
   <Counter />
) : (
   <Counter /> 
)}
// 此时也不会被重置,因为树结构是匹配的,都是div > Counter
{isPaused ? (
   <div> <Counter /></div>
 ) : (
   <div><Counter /> </div>
 )}
// 会被重置,因为树结构不一致了,一个是div > Counter,一个是section > Counter
{isPaused ? (
   <div> <Counter /></div>
) : (
   <section><Counter /> </section>
)}

再来看一个例子

js 复制代码
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)
      }}>点击了 {counter} 次</button>
    </>
  );
}

每次点击按钮,输入框的 state 都会消失!这是因为每次 MyComponent 渲染时都会创建一个 不同 的 MyTextField 函数。在相同位置渲染的是 不同 的组件,所以 React 将其下所有的 state 都重置了。这样会导致 bug 以及性能问题。为了避免这个问题, 永远要将组件定义在最上层并且不要把它们的定义嵌套起来。

在相同位置重置 state

在实际编码过程中,我们更多的是需要实现在相同位置重置state,上面的例子里其实已经给了一种实现方案,就是渲染一个不匹配的树结构,但是这个方案会额外增加DOM结构,看上去不优雅,接下来有两个方法可以更好的实现这个功能

将组件渲染在不同位置

这个方法其实也是上面说过的,只要不在同一个位置渲染两个组件,不就不会相互干扰了嘛,这告诉我们三目运算符或者if条件判断固然好用优雅,但也可能会出问题。

js 复制代码
import { useState } from 'react';

export default function Scoreboard() {
  const [isPlayerA, setIsPlayerA] = useState(true);
  return (
    <div>
      {isPlayerA &&
        <Counter person="Taylor" />
      }
      {!isPlayerA &&
        <Counter person="Sarah" />
      }
      <button onClick={() => {
        setIsPlayerA(!isPlayerA);
      }}>
        下一位玩家!
      </button>
    </div>
  );
}

function Counter({ person }) {
  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>{person} 的分数:{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        加一
      </button>
    </div>
  );
}

图解如下:

使用key来重置state

上一次见到key这个字段还是在渲染列表里,它被用来唯一标识出各个数组项,其实可以使用 key 来让 React 区分任何组件,如果组件没有指定key,React会默认按照父组件内部的顺序来排列,可以类比一下数组的index。但key的出现会告诉React,这个组件有一个特殊的标识符,类比于数组的唯一id,这样无论它出现在什么地方,React都会根据key来判断它是哪个组件,而不是通过顺序。

js 复制代码
import { useState } from 'react';

export default function Scoreboard() {
  const [isPlayerA, setIsPlayerA] = useState(true);
  // 有了key之后React就会识别这是Taylor的还是Sarah的Counter
  // 而不是这是第一个还是第二个Counter
  return (
    <div>
      {isPlayerA ? (
        <Counter key="Taylor" person="Taylor" />
      ) : (
        <Counter key="Sarah" person="Sarah" />
      )}
      <button onClick={() => {
        setIsPlayerA(!isPlayerA);
      }}>
        下一位玩家!
      </button>
    </div>
  );
}

指定一个 key 能够让 React 将 key 本身而非它们在父组件中的顺序作为位置的一部分。这就是为什么尽管你用 JSX 将组件渲染在相同位置,但在 React 看来它们是两个不同的计数器。因此它们永远都不会共享 state。请记住 key 不是全局唯一的。它们只能指定 父组件内部 的顺序。

利用key来重置state这个用法,在重置表单的场景中十分有用

假设一个聊天应用,切换用户聊天窗口时,聊天窗口用的是同一个chat组件,这时候需要做到不同用户聊天信息互不干扰,这时候key的使用就很关键。

但是,在真正的聊天应用中,你可能会想在用户再次选择前一个收件人时恢复输入 state。对于一个不可见的组件,有几种方法可以让它的 state "活下去":

  1. 与其只渲染现在这一个聊天,可以把 所有 聊天都渲染出来,但用 CSS 把其他聊天隐藏起来。这些聊天就不会从树中被移除了,所以它们的内部 state 会被保留下来。这种解决方法对于简单 UI 非常有效。但如果要隐藏的树形结构很大且包含了大量的 DOM 节点,那么性能就会变得很差。
  2. 进行 状态提升 并在父组件中保存每个收件人的草稿消息。这样即使子组件被移除了也无所谓,因为保留重要信息的是父组件。这是最常见的解决方法。
  3. 除了 React 的 state,你也可以使用其他数据源。例如,也许你希望即使用户不小心关闭页面也可以保存一份信息草稿。要实现这一点,让 Chat 组件通过读取 localStorage 对其 state 进行初始化,并把草稿保存在那里。
    无论采取哪种策略,与 Alice 的聊天在概念上都不同于 与 Bob 的聊天,因此根据当前收件人为 树指定一个 key 是合理的。

状态管理内容实在太多,剩下的我们下次再来~

相关推荐
GIS之路8 分钟前
GDAL 实现矢量裁剪
前端·python·信息可视化
是一个Bug12 分钟前
后端开发者视角的前端开发面试题清单(50道)
前端
Amumu1213813 分钟前
React面向组件编程
开发语言·前端·javascript
持续升级打怪中35 分钟前
Vue3 中虚拟滚动与分页加载的实现原理与实践
前端·性能优化
GIS之路39 分钟前
GDAL 实现矢量合并
前端
hxjhnct41 分钟前
React useContext的缺陷
前端·react.js·前端框架
前端 贾公子1 小时前
从入门到实践:前端 Monorepo 工程化实战(4)
前端
菩提小狗1 小时前
Sqlmap双击运行脚本,双击直接打开。
前端·笔记·安全·web安全
前端工作日常1 小时前
我学习到的AG-UI的概念
前端
韩师傅1 小时前
前端开发消亡史:AI也无法掩盖没有设计创造力的真相
前端·人工智能·后端