React 的组件本质上是一个树结构。在树结构中,在某个子树中生成一个状态是很常见的:你在某个更高层的组件定义了一个状态,然后这个状态在这个组件及其子组件中被消费了。这种实践队维持代码的本地性和可复用性是很有帮助的。
但是,在特定场景中,我们有一个状态需要中两个,甚至更多个距离远的地方被消费,这就是全局状态登场的时候了。不像本地状态,全局状态并不显示的属于一个特定的组件,所以把全局状态存在哪里本身是一个值得考虑的点。
在这一章节,我们会学习本地状态,以及一些提状态提升的技巧。状态提升是把状态放在组件树更高层级的一个技巧。之后,我们会学习全局状态,并思考如何使用他们。
我们会讨论下面几个议题:
- 理解本地状态
- 如何高效使用本地状态
- 使用全局状态
理解何时使用本地状态
在讨论React之前,我们先看看JS的函数是如何工作的。JS函数可能是纯函数,也可能是非纯函数。一个纯函数之依赖输入的参数:同样的输入,永远会返回同样的输出。而依赖了状态的函数,就不再是纯函数了。React 组件也是函数,当然也可以是纯函数。如果我们在React 组件中使用了 状态,那它就不再是纯函数了。如果我们在一个 React 组件中使用状态,那么该组件就会是不纯的。然而,如果这个状态是组件局部的,它就不会影响其他组件,我们把这种特性称为 "封装性(自包含性)"。
在这个部分,我们会学习JS,以及JS函数与函数组件的相似性。之后,我们会讨论一个本地状态是如何在概念上被实现的。
函数与参数
在JS中,一个函数接受一个参数,并返回一个值。下面是一个例子:
js
const addOne = (n) => n + 1;
对于一个纯函数来说,相同的输入总会有相同的输出。人们推荐写纯函数,就是因为纯函数的行为更加可预测。
如果一个函数依赖全局变量,就是这样:
js
let base = 1;
const addBase = (n) => n + base;
只要 base
不变,这个addBase函数就和addOne运行得一样。然而,如果base
为2,它们运行地就不一样了。其实这并不是啥坏事,事实上这是一个很强的特性,你可以通过函数外的变量来修改函数内的行为。但其缺点在于,在不了解 addBase
函数依赖于外部变量的情况下,你不能简单地获取该函数并随意在其他地方使用它。如你所见,这是一种权衡。
如果 base
是一个单例(内存中的单个值),那么这并不是一种理想的模式,因为这样代码的可复用性会降低。为了避免使用单例并在一定程度上减轻其缺点,一种更具模块化的方法是创建一个容器对象,如下所示:
js
const createContainer = () => {
let base = 1;
const addBase = (n) => n + base;
const changeBase = (b) => { base = b; };
return { addBase, changeBase }
}
const { addBase, changeBase } = createContainer();
如此一来,这段代码就不是单例模式了,你想要多少个container就创造多少个了。与把base作为全局单例相比,container模式更加独立也更可复用。你可以在不影响已经有container的前提下,在其他地方用生成另一个container。
一个小提示:尽管容器中的 addBase
函数从数学意义上来说并非纯函数,但如果 base
的值没有改变,你通过调用 addBase
函数仍能得到相同的结果(这种特性有时被称为幂等性)。
React函数与属性
React在概念上而言,是一个把状态转化为用户界面的函数。当你写React代码时,React组件是一个函数,而这个函数的参数是属性。
一个展示数字的组件是这样的:
js
const Component = ({ number }) => {
return <div>{number}</div>;
}
这个组件接收一个数字作为参数,并返回一个JSX元素。
现在,我们来另一个例子:
js
const Component = ({ number }) => {
return <div>{number} + 1</div>;
}
这个组件接收一个数字number,并展示这个number + 1的结果。它就像addOne
一样,是一个纯函数。要说不同的话,就是一个返回的是数字,而另一个是JSX。
理解用useState实现本地状态
如果我们用useState
来创建本地状态,会怎样?让我们创建一个基准状态,然后让这个base
与number
属性相加:
js
const AddBase = ({ number }) => {
const [base, changeBase] = useState(1);
return <div>{number + base}</div>;
}
因为这个函数已经依赖了不属于参数里的base
,这个函数已经不是纯函数了。
AddBase
组件中的useState
是用来干嘛的?让我们回一下上一部分的createContainer
。正如createContainer
返回了base
和changeBase
,useState
也返回了base
和changeBase
。虽然我们没有看到base
和changeBase
的完整实现,但它们在概念上和 createContainer
返回的是一样的。
如果我们假定useState
的行为,即它在未被更改时返回初始值(base),那么AddBase
函数是幂等的,就如同我们在createContainer
函数中所看到的情况一样。
这个结合了useState
的AddBase
函数是封闭的,因为changeBase
仅在函数声明的作用域内可用。在该函数外部是无法更改base
的。这里对useState
的使用属于局部状态,并且由于它是封闭的,不会对组件外部的任何事物产生影响,所以保证了局部性;只要合适,这种使用方式是值得推荐的。
局部状态的局限
什么时候局部状态不合适呢?当我们想要打破局部性时,局部状态就不合适了。在AddBase
组件的例子中,当我们想从代码中完全不同的部分去更改base
时,局部状态就不合适。如果你需要从函数组件外部更改状态,这时就需要用到全局状态了。
状态变量在概念上是一个全局变量。全局变量对于从函数外部控制 JavaScript 函数的行为很有用。同样地,全局状态对于从组件外部控制 React 组件的行为也很有用。然而,使用全局状态会让组件的行为更难以预测。这是一种权衡。我们不应过度使用全局状态。应将使用局部状态作为主要方式,仅在次要情况下使用全局状态。从这个意义上说,了解局部状态能涵盖多少种用例是很重要的。
在这一部分,我们学习了 React 中的局部状态以及 JavaScript 函数。接下来,我们将学习一些使用局部状态的模式。
有效地使用本地状态
为了更有效的使用本地状态,你需要知道一些模式。在这个部分,我们需要学习如何提升状态:这意味着把状态定义在组件树中更高级的位置;还要学习如何提升内容,这意味着把内容定义在组件树中更高级的位置。
提升状态
假设我们有两个计数器组件:
js
const Component1 = () => {
const [count, setCount] = useState(0);
return (
<div>
{count}
<button onClick={() => setCount((c) => c + 1)}>
Increment Count
</button>
</div>
);
};
const Component2 = () => {
const [count, setCount] = useState(0);
return (
<div>
{count}
<button onClick={() => setCount((c) => c + 1)}>
Increment Count
</button>
</div>
);
};
因为这两个组件有着各自独立的状态,所以这两个count是独自运作的。如果我们想让这两个组件共享一套状态,我们可以创造一个父组件,并把状态放在这里。
下面是例子,父组件会把count
,setCount
作为属性传给Component1
和Component2
:
js
const Component1 = ({ count, setCount }) => {
return (
<div>
{count}
<button onClick={() => setCount((c) => c + 1)}>
Increment Count
</button>
</div>
);
};
const Component2 = ({ count, setCount }) => {
return (
<div>
{count}
<button onClick={() => setCount((c) => c + 1)}>
Increment Count
</button>
</div>
);
};
const Parent = () => {
const [count, setCount] = useState(0);
return (
<>
<Component1 count={count} setCount={setCount} />
<Component2 count={count} setCount={setCount} />
</>
)
}
因为count
定义在Parent
组件,所以这个count
状态是被Component1
和 Component2
所共享的。它是组件的一个局部状态;这个组件的子组件可以通过属性的方式消费这个状态。
这个模式适用于大多数本地状态;但是,这个模式也会带来一些性能问题。如果把状态提升了,父组件的重新渲染,会导致子组件一同重新渲染。在某些场景下,这会带来性能问题。
提升内容
在处理复杂的组件树时,我们也许有一个组件并不依赖被提升的状态。
在下面这个例子中,我们在原来的Component1
中添加一个AdditionalInfo
组件:
js
const AdditionalInfo = () => {
return <p>Some information</p>
};
const Component1 = ({ count, setCount }) => {
return (
<div>
{count}
<button onClick={() => setCount((c) => c + 1)}>
Increment Count
</button>
<AdditionalInfo />
</div>
);
};
const Component2 = ({ count, setCount }) => {
return (
<div>
{count}
<button onClick={() => setCount((c) => c + 1)}>
Increment Count
</button>
</div>
);
};
const Parent = () => {
const [count, setCount] = useState(0);
return (
<>
<Component1 count={count} setCount={setCount} />
<Component2 count={count} setCount={setCount} />
</>
)
}
如果count
变化了,Parent
重新渲染了,之后Component1
,Component2
,和 AdditionalInfo
也重新渲染。然而,AdditionalInfo
组件并不需要重新渲染,因为它并不依赖count
状态。如果AdditionalInfo
组件会带来严重的性能问题,那么其重新渲染应该被尽量减少。
为了避免重新渲染,我们可以提升这个内容:
js
const AdditionalInfo = () => {
return <p>Some information</p>
};
const Component1 = ({ count, setCount, additionalInfo }) => {
return (
<div>
{count}
<button onClick={() => setCount((c) => c + 1)}>
Increment Count
</button>
{additionalInfo}
</div>
);
};
const Component2 = ({ count, setCount }) => {
return (
<div>
{count}
<button onClick={() => setCount((c) => c + 1)}>
Increment Count
</button>
</div>
);
};
const Parent = ({ additionalInfo }) => {
const [count, setCount] = useState(0);
return (
<>
<Component1
count={count}
setCount={setCount}
additionalInfo={additionalInfo}
/>
<Component2 count={count} setCount={setCount} />
</>
)
}
const GrandParent = () => {
return <Parent additionalInfo={<AdditionalInfo />} />
}
GrandParent
组件有additionalInfo
属性,additionalInfo
属性会传递给子组件。如此一来,AdditionalInfo
组件不会因为count
的变化而重新渲染了。
这种情况的一种变体是使用children
属性。下面这个使用children
属性的示例与前面的示例等效,不过编码风格有所不同:
js
const AdditionalInfo = () => {
return <p>Some information</p>
};
const Component1 = ({ count, setCount, children }) => {
return (
<div>
{count}
<button onClick={() => setCount((c) => c + 1)}>
Increment Count
</button>
{children}
</div>
);
};
const Component2 = ({ count, setCount }) => {
return (
<div>
{count}
<button onClick={() => setCount((c) => c + 1)}>
Increment Count
</button>
</div>
);
};
const Parent = ({ children }) => {
const [count, setCount] = useState(0);
return (
<>
<Component1 count={count} setCount={setCount}>
{children}
</Component1>
<Component2 count={count} setCount={setCount} />
</>
)
}
const GrandParent = () => {
return (
<Parent>
<AdditionalInfo />
</Parent>
);
}
children
是一个特殊的属性名称,在 JSX 格式中它表现为嵌套的子元素。如果你有多个元素要传递,给属性命名会更合适。这在很大程度上是一种风格上的选择,开发人员可以采用他们喜欢的任何一种方式。
在这个部分,我们已经讨论了如何有效地使用本地状态。如果我们能够合适地提升状态和内容,我们可以用本地状态处理很多问题。接下来,我们要学习如何使用全局状态。
使用全局状态
在这个部分,我们会学习什么是全局状态,以及应该何时使用全局状态。
什么是全局状态
在本书中,一个全局状态意味着,它不是本地状态。如果一个状态属于一个组件,被封装在一个组件内,那它是局部状态。因此,如果一个状态并不属于一个组件,而且它可以被多个组件使用,它就是全局状态。
可能存在一种所有组件都依赖的应用程序级局部状态。在这种情况下,应用程序级的局部状态可以被视为全局状态。从这个意义上讲,我们无法清晰地区分局部状态和全局状态。在大多数情况下,如果你考虑某个状态在概念上属于哪里,你就能判断出它是局部状态还是全局状态。
在讨论全局状态时,我们要考虑这些方面
- 第一个是单例模式,这意味在一些上下文,状态只有一个值
- 第二个方面是共享状态,这位置这个状态被多个组件所共享,但是它需要在JS内存中只有一个值。一个全局状态并不是单例,可以有多个值。
为了说明非单例全局状态是如何工作的,这里有一个示例来展示 JavaScript 中的一个非单例变量:
js
const createContainer = () => {
let base = 1;
const addBase = (n) => n + base;
const changeBase = (b) => { base = b; };
return { addBase, changeBase };
};
const container1 = createContainer();
const container2 = createContainer();
container1.changeBase(10);
console.log(container1.addBase(2)); // shows "3"
console.log(container2.addBase(2)); // shows "12"
在这个例子中,base
是container
内的局部变量。因为base
在每一个container
内都是孤立的,所以在container1
内的base
的变化,并不会影响到container2
内的base
。
在React,概念是类似的。如果一个全局状态是单例,我们在记忆中只有一个值。如果全局状态不是单例模式,那么组件树上不同的组件就有不同的值。
何时使用全局状态
在 React 中,当出现以下两种情况时,我们就需要用到全局状态,具体准则如下:
・当不适合传递属性(props)时
・当我们在 React 之外已经有了一个状态时
当不适合传递属性(props)时
如果一个状态在组件树两个的地方被消费,而且这两个地方距离太远了,那么再使用属性传递就不合适了。
下面是一个例子,我们有一个三层深的树,我们需要自顶向下传递状态,会是这样:
js
const Component1 = ({ count, setCount }) => {
return (
<div>
{count}
<button onClick={() => setCount((c) => c + 1)}>
Increment Count
</button>
</div>
);
};
const Parent = ({ count, setCount }) => {
return (
<>
<Component1 count={count} setCount={setCount} />
</>
);
};
const GrandParent = ({ count, setCount }) => {
return (
<>
<Parent count={count} setCount={setCount} />
</>
);
};
const Root = () => {
const [count, setCount] = useState(0);
return (
<>
<GrandParent count={count} setCount={setCount} />
</>
);
};
从局部性的角度来看,这完全没问题,甚至是值得推荐的做法;然而,让中间组件专门用于传递属性可能会过于繁琐。通过多级中间组件传递属性,可能不会带来良好的开发者体验,因为这看起来像是不必要的额外工作。此外,当状态更新时,中间组件会重新渲染,这可能会影响性能。
在这个例子中,使用全局状态是更加合适的,这样就不需要跨越组件层级传递属性了.
下面是一个用伪代码实现全局全局状态的例子:
js
const Component1 = () => {
// useGlobalCountState is a pseudo hook
const [count, setCount] = useGlobalCountState();
return (
<div>
{count}
<button onClick={() => setCount((c) => c + 1)}>
Increment Count
</button>
</div>
);
};
const Parent = () => {
return (
<>
<Component1 />
</>
);
};
const GrandParent = () => {
return (
<>
<Parent />
</>
);
};
const Root = () => {
return (
<>
<GrandParent />
</>
);
};
在这个例子中,只有Component1
使用了全局状态。这样就不用烦琐的进行跨组件传值了。
当我们在 React 之外已经有了一个状态时
这一些情况下,你有些位于React外的全局状态。比如说,你的应用有在React外获得的拥护下信息。在这个情况下,全局状态应该在React外,而这些用于信息应当存在全局状态中。
下面是一个由伪代码实现的例子:
js
const globalState = {
authInfo: { name: 'React' },
};
const Component1 = () => {
// useGlobalState is a pseudo hook
const { authInfo } = useGlobalState();
return (
<div>
{authInfo.name}
</div>
);
};
在这个例子中,globalState
存在且是在 React 外部定义的。useGlobalState
是一个钩子函数,它会连接到 globalState
,并且能够在 Component1
中提供 authInfo
。
在这一部分,我们了解到全局状态是一种不能作为局部状态的状态。全局状态主要是作为局部状态的补充来使用的,并且有两种情况下使用全局状态效果很好:一种是在传递属性没有意义的情况下,另一种是应用程序中已经存在一个全局状态的情况。
概要
在本章中,我们讨论了局部状态和全局状态。只要有可能,局部状态是更可取的选择,并且我们学习了一些有效使用局部状态的技巧。然而,全局状态在局部状态无法发挥作用的地方能派上用场,这就是为什么我们要研究何时应该使用全局状态。
在接下来的三章中,我们将学习在 React 中实现全局状态的三种模式;具体来说,在下一章中,我们将从利用 React 的上下文(context)开始学习。