你好,我是王沛。今天我们聊聊 React 中的状态管理。

从这节课开始,我们就进入到了实战篇的训练。React Hooks 中其实有一些通用原则和常见设计模式,所以我设计了几个典型的业务场景,这样你就可以对这些原则和模式有一个具体的印象,之后在遇到类似场景时,也能从容应对。

今天我们先从状态一致性这个需求开始讲起。我在基础篇就反复提到过,React 中 UI 完全是通过状态驱动的。所以在任何时刻,整个应用程序的状态数据就完全决定了当前 UI 上的展现。毫不夸张地说,React 的开发其实就是复杂应用程序状态的管理和开发。因此,这就需要你去仔细思考,该怎么去用最优、最合理的方式,去管理你的应用程序的状态。

所以今天这节课我会带你围绕状态一致性这个需求,介绍两个基本原则,它们能帮助我们避免很多复杂的状态管理逻辑,简化应用程序的开发。

原则一:保证状态最小化

新接触 React 的同学经常会有一个错误的习惯,就是把 State 当变量用,很容易把过多的数据放到 State 里,可以说这是对 State 的一种滥用。

那到底该怎么使用 State 呢?我们需要遵循一个原则,即:**在保证 State 完整性的同时,也要保证它的最小化。**什么意思呢?

就是说,某些数据如果能从已有的 State 中计算得到,那么我们就应该始终在用的时候去计算,而不要把计算的结果存到某个 State 中。这样的话,才能简化我们的状态处理逻辑。

举个例子。你要做一个功能,需要对一个列表的结果进行关键字搜索,我们假设是一个显示电影标题的列表,需要能够对标题进行搜索。最终的效果如下图所示:

可以看到,这个功能包括一个搜索框和一个电影标题的列表。那么,在考虑怎么实现这个功能的时候,应该从哪里着手呢?

按照 React 的状态驱动 UI 的思想,第一步就是要考虑整个功能有哪几个状态。直观上来说,页面可能包含三个状态:

  1. 电影列表的数据:可能来自某个 API 请求;
  2. 用户输入的关键字:来自用户的输入;
  3. 搜索的结果数据:来自原始数据结合关键字的过滤结果。

那么很多同学这时候就会在组件中去定义这三个状态,一般的实现代码如下:

function FilterList({ data }) {
// 设置关键字的 State
const [searchKey, setSearchKey] = useState(’’);
// 设置最终要展示的数据状态,并用原始数据作为初始值
const [filtered, setFiltered] = useState(data);

// 处理用户的搜索关键字
const handleSearch = useCallback(evt => {
setSearchKey(evt.target.value);
setFiltered(data.filter(item => {
return item.title.includes(evt.target.value)));
}));
}, [filtered])
return (



{/* 根据 filtered 数据渲染 UI */}

);
}

看上去没有太大问题,整段代码也能正常工作。但是如果仔细思考,你会发现其中隐藏的一致性的问题:展示的结果数据完全由原始数据和关键字决定,而现在却作为一个独立的状态去维护了。这意味着你始终要在原始数据、关键字和结果数据之间保证一致性。

在代码中,我们已经做了一部分维护一致性的工作,那就是当关键字变化时,我们会同时更新关键字和最终结果这两个状态,从而让这两个状态始终保持一致。

不过还有部分一致性的工作没有被考虑到,那就是如果原始数据 data 属性变化了,最终的结果却没有使用新的数据。

这个时候你可能就又会问了:我在处理关键字变化的同时,再处理一下 data 属性变化的场景,这样不就可以保证三个状态的一致性了吗?比如再加上下面这段代码。

function FilterList({ data }) {
// …
// 在 data 变化的时候,也重新生成最终数据
useEffect(() => {
setFiltered(data => {…})
}, [data, searchKey])
// …
}

现在,我们终于能够保证三个状态的一致性了,整个搜索列表也能正常工作了!但是我们在获得一些成就感的同时,是不是也有一些小小的抱怨:这状态管理确实还挺复杂的,需要写这么多的逻辑来保证一致性,从而让功能正常工作。

那么,我想说的是,这种复杂性其实完全不需要。因为复杂性的根源就在于没有遵循状态最小化的原则,而是设计了一个多余的状态:过滤后的结果数据。由于这个结果数据实际上完全由原始数据和过滤关键字决定,那么我们在需要的时候每次重新计算得出就可以了。

那时候你可能又有疑问了,如果每次都计算,不是会有性能问题吗?其实在第 4 讲我已经提到,React 提供的 useMemo 这个 Hook 正是为了解决这个问题,可以缓存计算的结果。所以实现的代码可以修改如下:

import React, { useState, useMemo } from “react”;

function FilterList({ data }) {
const [searchKey, setSearchKey] = useState("");

// 每当 searchKey 或者 data 变化的时候,重新计算最终结果
const filtered = useMemo(() => {
return data.filter((item) =>
item.title.toLowerCase().includes(searchKey.toLowerCase())
);
}, [searchKey, data]);

return (


Movies


<input
value={searchKey}
placeholder=“Search…”
onChange={(evt) => setSearchKey(evt.target.value)}
/>
<ul style={{ marginTop: 20 }}>
{filtered.map((item) => (
  • {item.title}

  • ))}


    );
    }

    可以看到,除了通过属性传递进来的 data 状态,我们只定义了一个 searchKey 这个状态。然后通过计算的方式,就可以得到最终需要展现的结果。这样,状态的一致性就得到了天然的保证。你看,通过使用状态最小化的原则,管理就变得非常简单了。

    虽然这是一个比较简单的例子,但是在实际开发的过程中,很多复杂场景之所以变得复杂,如果抽丝剥茧来看,你会发现它们都有定义多余状态现象的影子,而问题的根源就在于它们没有遵循状态最小化的原则

    所以我们在定义一个新的状态之前,都要再三拷问自己:**这个状态是必须的吗?是否能通过计算得到呢?**在得到肯定的回答后,我们再去定义新的状态,就能避免大部分多余的状态定义问题了,也就能在简化状态管理的同时,保证状态的一致性。

    原则二:避免中间状态,确保唯一数据源

    上面的例子其实定义的多余状态比较明显,但在有的场景下,特别是原始状态数据来自某个外部数据源,而非 state 或者 props 的时候,冗余状态就没那么明显。这时候你就需要准确定位状态的数据源究竟是什么,并且在开发中确保它始终是唯一的数据源,以此避免定义中间状态。

    还是拿刚才讲的可搜索电影列表的例子来说,我们需要在用户体验上做一个改进,要让搜索的结果做到可分享。这个功能就类似 Baidu 这样的搜索引擎,通过一个链接就能分享搜索结果。比如说,你通过“http://www.baidu.com/s?wd= 极客时间”就可以看到极客时间的搜索结果。

    想象一下,如果搜索引擎没有这个功能,那使用起来会有多么不方便。每次要分享一个搜索结果,都必须告诉别人关键字是什么,让他 / 她自己打开 Baidu 去搜索。所以将关键字放到 URL 中也是实际开发中经常遇到的一个需求。

    那么要实现这个功能,我们就需要让 URL 中包含搜索关键字的信息,这样任何人用这个 URL,就都能看到和我一样的搜索关键字和结果了。

    在考虑这个功能实现,尤其是基于已有功能做改进的时候,通常来说,直观的思路都是:首先把 URL 上的参数数据保存在一个 State 中,当 URL 变化时,就去改变这个 State。然后在组件中再根据这个 State 来实现搜索的业务逻辑。

    按照这个思路来改进电影列表的话,我们就可以用类似下面的代码来实现:

    // getQuery 函数用户获取 URL 的查询字符串
    import getQuery from ‘./getQuery’;
    // history 工具可以用于改变浏览器地址
    import history from ‘./history’;

    function SearchBox({ data }) {
    // 定义关键字这个状态,用 URL 上的查询参数作为初始值
    const [searchKey, setSearchKey] = useState(getQuery(‘key’));
    // 处理用户输入的关键字
    const handleSearchChange = useCallback(evt => {
    const key = evt.target.value;
    // 设置当前的查询关键状态
    setSearchKey(key);
    // 改变 URL 的查询参数
    history.push(/movie-list?key=${key});
    })
    // ….
    return (



    {/* 其它渲染逻辑*/}

    );
    }

    可以看到,组件的核心逻辑基本没变,只是做了两个小的变化:

    1. 把 URL 上的查询参数作为关键字的默认值;
    2. 当用户输入搜索关键字时,我们不但更新了内部 State,同时还改变了 URL 的查询参数。

    通过这两个小的变化,我们就实现了搜索结果的可分享。看上去似乎没有什么问题:保证了关键字状态,还有 URL 参数的一致性。但是正如我刚才强调的,一旦涉及到主动保持一致性的逻辑,我们就要考虑状态是否真的有必要。

    所以我们再仔细思考下,就会发现上面的逻辑是有漏洞的。因为从 URL 参数到内部 State 的同步只有组件第一次渲染才会发生,而后面的同步则是由输入框的 onChange 事件保证的,那么一致性就很容易被破坏。

    也就是说,如果 URL 不是由用户在组件内搜索栏去改变的,而是其它地方,比如说组件外的某个按钮去触发改变的,那么组件由于已经渲染过了,其实内部的 searchKey 这个 State 是不会被更新的,一致性就会被破坏。

    要解决这个问题,一个比较容易想到的思路就是我们要有更加完善的机制,让在 URL 不管因为什么原因而发生变化的时候,都能同步查询参数到 searchKey 这个 State。但是如果沿着这个思路,那么状态管理就会一下子变得非常复杂。因为我们需要维护三个状态的一致性。

    其实,从根源上来说,产生这个复杂度的问题在于我们定义了 searchKey 这样一个多余的中间状态,而且这个 searchKey 状态来源于两个数据源:一是用户输入;二是 URL 上的参数。这就导致逻辑非常复杂。

    那么如果我们遵循唯一数据源这个原则,把 URL 上的查询关键字作为唯一数据源,逻辑就会变得简单了:只需要在用户输入查询关键字时,直接去改变 URL 上的查询字符串就行。这个转变可以用下面的图做一个对比,你会更直观地理解:

    通过对比可以看到,左边引入了一个多余的 State 作为关键字这个状态,而为了保证一致性,就需要很多复杂的同步逻辑,比如说以下几点:

    1. URL 变化时,同步查询关键字到 State;
    2. State 变化时,同步查询关键字到输入框;
    3. 用户在输入框输入的时候,同步关键字到 URL 和 State。

    但是,在去掉多余的 State 后,我们就只需要在输入框和 URL 的查询参数之间做一个同步。那么实现的代码可以简化如下:

    import React, { useCallback, useMemo } from “react”;
    import { useSearchParam } from “react-use”;

    function SearchBox({ data }) {
    // 使用 useSearchParam 这个 Hook 用于监听查询参数变化
    const searchKey = useSearchParam(“key”) || “”;
    const filtered = useMemo(() => {
    return data.filter((item) =>
    item.title.toLowerCase().includes(searchKey.toLowerCase())
    );
    }, [searchKey, data]);

    const handleSearch = useCallback((evt) => {
    // 当用户输入时,直接改变 URL
    window.history.pushState(
    {},
    “”,
    ${window.location.pathname}?key=${evt.target.value}
    );
    }, []);
    return (


    Movies (Search key from URL)



    <ul style={{ marginTop: 20 }}>
    {filtered.map((item) => (
  • {item.title}

  • ))}


    );
    }

    可以看到,当用户输入参数的时候,我们是直接改变当前的 URL,而不是去改变一个内部的状态。所以当 URL 变化的时候,我们使用了 useSearchParam 这样一个第三方的 Hook 去绑定查询参数,并将其显示在输入框内,从而实现了输入框内容和查询关键字这个状态的同步。

    从本质上来说,这个例子展示了确保状态唯一数据源的重要性。我们是直接将 URL 作为唯一的数据来源,那么状态的读取和修改都是对 URL 直接进行操作,而不是通过一个中间的状态。这样就简化了状态的管理,保证了状态的一致性。

    实战演练:创建自定义受控组件

    在前面两个例子中,你看到了状态管理的两个很重要的原则,一个是确保状态最小化,另一个则是找到正确的状态来源,并直接使用这个来源,避免中间状态。那么接下来我再通过一个日常开发中非常常见的例子,来帮助你理解和掌握这两个原则的实际应用。

    这个例子就是创建一个受控表单组件。比如,一个用于输入价格的表单组件,需要用户既能输入价格的数量,还能选择货币的种类,最终的效果类似下面这个图:

    首先我要解释下什么是受控组件。在 React 中,对表单组件的处理可以分为两种,受控组件和非受控组件:

    1. 受控组件:组件的展示完全由传入的属性决定。比如说,如果一个输入框中的值完全由传入的 value 属性决定,而不是由用户输入决定,那么就是受控组件,写法是:< input value={value} onChange={handleChange} />。这也是为什么只给 < input/> 传了一个 value 值但是没有传 onChange 事件,那么键盘怎么输入都没有反应。
    2. 非受控组件:表单组件可以有自己的内部状态,而且它的展示值是不受控的。比如 input 在非受控状态下的写法是:< input onChange={handleChange}/>。也就是说,父组件不会把 value 直接传递给 input 组件。

    在日常开发中,大部分的表单元素其实都是受控组件,我们会通过外部的状态完全控制当前组件的行为。

    那么对于这个例子,价格输入框作为一个受控组件,它需要定义两个属性:value 和 onChange。这样它就和一个普通的表单元素具有相同用法了。其中 value 的值是一个对象,同时包含数值和货币两个属性,比如:

    {
    amount: 0,
    currency: ‘rmb’,
    }

    那现在我们就来看如何实现这个受控组件。在这里我就不再演示错误的写法是什么样的了,而是直接给你看正确的实现方式。

    在实际项目中,我经常看到很多同学会把这个简单的功能做得逻辑非常复杂,甚至还不断出现 Bug,总不能保证 UI 和数据的一致性。这其实都是因为没有仔细思考状态的准确来源是什么,以及是否定义了多余的状态。

    我们先直接来看正确的实现:

    import React, { useState, useCallback } from “react”;

    function PriceInput({
    // 定义默认的 value 的数据结构
    value = { amount: 0, currency: “rmb” },
    // 默认不处理 onChange 事件
    onChange = () => {}
    }) {
    // 定义一个事件处理函数统一处理 amount 或者 currency 变化的场景
    const handleChange = useCallback(
    (deltaValue) => {
    // 直接修改外部的 value 值,而不是定义内部 state
    onChange({
    …value,
    …deltaValue
    });
    },
    [value, onChange]
    );
    return (


    {/* 输入价格的数量 /}
    <input
    value={value.amount}
    onChange={(evt) => handleChange({ amount: evt.target.value })}
    />
    {/
    选择货币种类*/}
    <select
    value={value.currency}
    onChange={(evt) => handleChange({ currency: evt.target.value })}
    >




    );
    }

    可以看到,这个自定义组件包含了 input 和 select 两个基础组件,分别用来输入价格数量和选择货币。在它们发生变化的时候,直接去触发 onChange 事件让父组件去修改 value 值;同样的,它们自己显示的值,则完全来自于传递进来的 value 属性。所以这其中的思考逻辑在于:

    1. 避免多余的状态:我们不需要在 PriceInput 这个自定义组件内部,去定义状态用于保存的 amount 或者 currency。
    2. 找到准确的唯一数据源:这里内部两个基础组件的值,其准确且唯一的来源就是 value 属性,而不是其它的任何中间状态。

    因此,通过这样的做法,整个自定义的组件逻辑就变得非常简单,甚至不需要任何内部的状态,就实现了这样一个非常常见的需求。

    而在我看到的实际项目代码中,发现很多同学都习惯于用多余的内部状态去分别保存 amount 和 currency,然后再和外部传进来的 value 属性进行同步,以此来保证一致性,这就造成了状态逻辑的复杂。所以我们在做类似功能的时候,一定要避免这种不合理的做法,尽量用最简洁的逻辑去实现需要的功能。

    小结

    好了,这节课的内容就是这些,我简单小结一下。你可以把 React 的开发看作是复杂状态的管理和维护。那么为了保证状态的一致性,我们就一定要简化状态处理的逻辑。其中有两个重要的原则需要遵循:

    1. 一个是状态最小化原则,也就是说要避免冗余的状态;
    2. 另一个则是唯一数据源原则,避免中间状态。

    所以,在任何时候想要定义新状态的时候,都要问自己一下:这个状态有必要吗?是否能通过计算得到?是否只是一个中间状态?只有每次都仔细思考了,才能找到需要定义的最本质的状态。然后围绕这个最本质的状态去思考某个功能具体的实现,从而让 React 的开发更加简洁和高效。

    这节课所有的示例代码,你可以通过 codesandbox 查看:https://codesandbox.io/s/react-hooks-course-20vzg.

    思考题

    在第二个在 URL 中包含查询关键字的例子中,我们用到了 userSearchParams 这样一个第三方的 Hook,用于绑定 URL 上的查询字符串参数。如果让你实现这个 Hook,你会怎么做呢?

    欢迎在评论区写下你的思考和想法,我会和你交流讨论。如果今天的实战演练让你有所收获,也欢迎把课程分享给你的同事、朋友,我们共同进步!