1、React 为什么要发明 Hooks?
React作为目前最为主流的前端框架之一,为什么在16.8版本中推出了全新的Hooks,本章主要探讨它的过人之处究竟是什么;为什么要发明 Hooks;以及它所解决的问题是什么。
1.1、react组件的本质
React 组件的模型其实很直观,就是从 Model 到 View 的映射,这里的 Model 对应到 React 中就是 state 和 props。如下图所示:
Class 作为 React 组件的载体,也许并不是最适合,反而函数是更适合去描述 State => View 这样的一个映射。
1.2、Hooks的诞生
最初,函数组件无法存在内部状态,必须是纯函数,而且也无法提供完整的生命周期机制。在 React 中,Hooks 就是把某个目标结果钩到某个可能会变化的数据源或者事件源上,那么当被钩到的数据或事件发生变化时,产生这个目标结果的代码会重新执行,产生更新后的结果。
对于函数组件,这个结果是最终的 DOM 树;对于 useCallback、useMemo 这样与缓存相关的组件,则是在依赖项发生变化时去更新缓存。所以 Hooks 的结构可以如下图所示:
从图中可以看到,一个执行过程(Execution),例如是函数组件本身,可以绑定在(钩在)传统意义的 State,或者 URL,甚至可以是窗口的大小。这样当 State、URL、窗口大小发生变化时,都会重新执行某个函数,产生更新后的结果。
1.3、Hooks带来的优势
1.3.1、逻辑复用
你可以使用 Hook 从组件中提取状态逻辑,使得这些逻辑可以单独测试并复用。Hook 使你在无需修改组件结构的情况下复用状态逻辑。 这使得在组件间或社区内共享 Hook 变得更便捷。
// 根据浏览器窗口大小,重新布局
//实现一,高阶组件
/* * 高阶组件其实是一个函数,传进去的一个组件,返回一个新组件
* 实现不同组件中的逻辑复用,
* 将一些可以单独抽离的逻辑处理给要返回的新组件里面复用
* 然后将单独的组件,传递给新组件
* */
const withWindowSize = (Component) =>{
// 产生一个高阶组件WrappedComponent,只包含监听窗口大小的逻辑
class WrappedComponent extends React.PureComponent {
constructor(props){
super(props);
this.state = {
size: this.getSize()
}
}
componentDidMount(){
window.addEventListener("resize", this.handleResize);
}
componentWillUnmount(){
window.removeEventListener("resize", this.handleResize);
}
getSize() {
return window.innerWidth > 1000 ? "large" :"small";
}
handleResize = ()=> {
const currentSize = this.getSize();
this.setState({
Size: this.getSize()
});
}
render() {
// 将窗口大小传递给真正的业务逻辑组件
return <Component size={this.state.size} />;
}
}
return WrappedComponent;
}
// 使用
class MyComponent extends React.Component{
render() {
const { size } = this.props;
if (size === "small") return <SmallComponent />;
else return <LargeComponent />;
}
}
// 使用 withWindowSize 产生高阶组件,用于产生 size 属性传递给真正的业务组件
export default withWindowSize(MyComponent);
// 使用用 Hooks 和函数组件实现
const getSize = ()=>{
return window.innerWidth > 1000 ? 'large' : 'small'
}
const useWindowSize = ()=>{
const [size,setSize] = useState(getSize())
useEffect(()=>{
const handler = ()=>{
setSize(getSize())
}
window.addEventListener('resize',handler);
return ()=>{
window.removeEventListener('resize',handler);
}
},[])
return size
}
const Demo = () => {
const size = useWindowSize();
if (size === "small") return <SmallComponent />;
else return <LargeComponent />;
};
复制代码
1.3.2、有助于关注分离
Hooks 能够让针对同一个业务逻辑的代码尽可能聚合在一块儿。这是过去在 Class 组件中很难做到的。因为在 Class 组件中,你不得不把同一个业务逻辑的代码分散在类组件的不同生命周期的方法中。所以通过 Hooks 的方式,把业务逻辑清晰地隔离开,能够让代码更加容易理解和维护。
2、内置hooks
React 提供的 Hooks 其实非常少,一共只有 10 个,比如 useState、useEffect、useCallback、useMemo、useRef、useContext 等等。我们知道纯函数是没有组件状态和生命周期,结下来,通过学习内置hooks了解使用函数组件进行开发
2.1、useState(让函数组件具有维持状态的能力)
直接看例子,明白useState是怎样维持状态的
// class 内组件
lass Example extends React.Component {
constructor(props) {
super(props);
this.state = {
count: 0
};
}
render() {
return (
<div>
<p>You clicked {this.state.count} times</p>
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
Click me
</button>
</div>
);
}
}
// function 组件
import React, { useState } from 'react';
function Example() {
// 创建一个保存 count 的 state,并给初始值 0
const [count, setCount] = useState(0);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>
+
</button>
</div>
);
}
复制代码
2.1.1、用法总结
useState(initialState) 的参数 initialState 是创建 state 的初始值,它可以是任意类型,比如数字、对象、数组等等。useState() 的返回值是一个有着两个元素的数组。第一个数组元素用来读取 state 的值,第二个则是用来设置这个 state 的值。如果要创建多个 state,那么我们就需要多次调用 useState。
2.1.2、注意事项
1、 能用其他状态计算出来就不用单独声明状态
const Example = (props) => {
const [source, setSource] = useState([1,2,3,4,5,6,7,8,9,10])
// minSource,maxSource可以通过source计算得出,所以不需要单独声明
// const [minSource, setMinSource] = useState([])
// const [maxSource, setMaxSource] = useState([])
// useEffect(() => {
// setMinSource(source.filter(item => item <= 5))
// setMaxSource(source.filter(item => item > 5))
// }, [source])
// 使用useMemo优化性能
const minSource = useMemo(()=> source.filter(item => item <= 5), [source]);
const maxSource = useMemo(()=> source.filter(item => item > 5), [source]);
return (
<div>
.....
</div>
)
}
复制代码
2、 保证数据源唯一(props,url,cookie,localStorage等)
function App({ data }) {
// 读取 URL 中的参数
// const [searchKey, setSearchKey] = useState(getQuery('key'));
// 读取 localStorage 中的参数
// const [searchName, setSearchName] = useState(getlocalStorage('name'));
// const handleSearchChange = e => {
// const key = e.target.value;
// setSearchKey(key);
// history.push(`/movie-list?key=${key}`);
// }
// const handleSearchNameChange = e => {
// const name = e.target.value;
// setSearchName(key);
// localStorage.setItem('name',name)
// }
// 使用一下方法进行值的获取
const searchKey = parse(localtion.search)?.key;
const handleSearchChange = e => {
const key = e.target.value;
history.push(`/movie-list?key=${key}`);
}
const searchName = localStorage.getItem('name')
const handleSearchNameChange = e => {
const name = e.target.value;
localStorage.setItem('name',name)
}
return (
<input
value={searchKey}
placeholder="Search..."
onChange={handleSearchChange}
/>
<input
value={searchName}
placeholder="Search..."
onChange={handleSearchNameChange}
/>
);
}
复制代码
3、 useState 适当合并变量
这里使用 useState ,每次更新都是独立的,const [number,setNumber] = useState(0)
,也就是说每次都会生成一个新的值(哪怕这个值没有变化),即使使用了 React.memo
,也还是会重新渲染
2.2、useEffect(执行副作用)
useEffect ,顾名思义,用于执行一段副作用。副作用是指一段和当前执行结果无关的代码。比如说要修改函数外部的某个变量,要发起一个请求,等等。useEffect 是每次组件 render 完后判断依赖并执行.useEffect(callback, dependencies)第一个为要执行的函数 callback,第二个是可选的依赖项数组 dependencies。其中依赖项是可选的,如果不指定,那么 callback 就会在每次函数组件执行完后都执行;如果指定了,那么只有依赖项中的值发生变化的时候,它才会执行。
import React, { useState, useEffect } from "react";
function BlogView({ id }) {
// 设置一个本地 state 用于保存 blog 内容
const [blogContent, setBlogContent] = useState(null);
useEffect(() => {
// useEffect 的 callback 要避免直接的 async 函数,需要封装一下
const doAsync = async () => {
// 当 id 发生变化时,将当前内容清楚以保持一致性
setBlogContent(null);
const res = await fetch(`/blog-content/${id}`); // 发起请求获取数据
setBlogContent(await res.text()); // 将获取的数据放入 state
};
doAsync();
}, [id]); // 使用 id 作为依赖项,变化时则执行副作用
const isLoading = !blogContent;
return <div>{isLoading ? "Loading..." : blogContent}</div>;
}
复制代码
2.2.1、useEffect特殊用法解析
// 1.每次 render 后执行:不提供第二个依赖项参数。比如
useEffect(() => {})。
// 2.仅第一次 render 后执行:提供一个空数组作为依赖项。比如
useEffect(() => {}, [])。
// 3.第一次以及依赖项发生变化后执行:提供依赖项数组。比如
useEffect(() => {}, [deps])。
// 4.组件 unmount 后执行:返回一个回调函数。比如
useEffect() => { return () => {} }, [])。
复制代码
2.2.2、 依赖使用的注意事项
Hooks 提供了让你监听某个数据变化的能力。这个变化可能会触发组件的刷新,也可能是去创建一个副作用,又或者是刷新一个缓存。
- 依赖项中定义的变量一定是会在回调函数中用到的,否则声明依赖项其实是没有意义的。
- 依赖项一般是一个常量数组,而不是一个变量。因为一般在创建 callback 的时候,你其实非常清楚其中要用到哪些依赖项了。
- React 会使用浅比较来对比依赖项是否发生了变化,所以要特别注意数组或者对象类型。如果你是每次创建一个新对象,即使和之前的值是等价的,也会被认为是依赖项发生了变化。这是一个刚开始使用 Hooks 时很容易导致 Bug 的地方。例如下面的代码:
function Sample() {
// 这里在每次组件执行时创建了一个新数组
const todos = [{ text: 'Learn hooks.'}];
useEffect(() => {
console.log('Todos changed.');
}, [todos]);
}
复制代码
2.2.3、 依赖闭包问题
以下问题回出现闭包问题
- 使用 setTimeout、setInterval、Promise.then 等
- useEffect 的卸载函数
解决方案
const [count, setCount] = useState(0);
// 通过 ref 来记忆最新的 count
const countRef = useRef(count);
countRef.current = count;
useEffect(() => {
const timer = setTimeout(() => {
console.log(countRef.current)
}, 3000);
return () => {
clearTimeout(timer); }
},[])
复制代码
2.2.4、 使用规则
Hooks 不能在循环、条件判断或者嵌套函数内执行,而必须是在顶层。同时 Hooks 在组件的多次渲染之间,必须按顺序被执行。
2.3、useCallback(缓存回调函数)
下面通过实际代码分析函数的执行和渲染
function Counter() {
const [count, setCount] = useState(0);
// 每次组件状态发生变化的时,函数重新执行一遍
// 在每次执行的时候,实际上都会创建一个新的事件处理函数 handleIncrement
// 每次创建新函数的方式会让接收事件处理函数的组件,需要重新渲染
const handleIncrement = () => setCount(count + 1);
// ...
return <button onClick={handleIncrement}>+</button>
}
复制代码
通过上面例子,我们需要做到的是:只有当 count 发生变化时,我们才需要重新定一个回调函数。而这正是 useCallback 这个 Hook 的作用。它的 API 签名如下:useCallback(fn, deps)这里 fn 是定义的回调函数,deps 是依赖的变量数组。只有当某个依赖变量发生变化时,才会重新声明 fn 这个回调函数。
function Counter() {
const [count, setCount] = useState(0);
// const handleIncrement = () => setCount(count + 1);
const handleIncrement= useCallback(()=>{
setCount(count + 1)
},[count],) // 只有当 count 发生变化时,才会重新创建回调函数
// ...
return <button onClick={handleIncrement}>+</button>
}
复制代码
2.4、useMemo(缓存计算的结果)
下面通过实际代码分析函数的执行和渲染
import React, { useState, useEffect } from "react";
function SearchUserList() {
const [users, setUsers] = useState(null);
const [searchKey, setSearchKey] = useState("");
useEffect(() => {
const doFetch = async () => {
// 组件首次加载时发请求获取用户数据
const res = await fetch("https://reqres.in/api/users/");
setUsers(await res.json());
};
doFetch();
}, []);
let usersToShow = null;
if (users) {
// 无论组件为何刷新,这里一定会对数组做一次过滤的操作
usersToShow = users.data.filter((user) =>
user.first_name.includes(searchKey),
);
}
return (
<div>
<input
type="text"
value={searchKey}
onChange={(evt) => setSearchKey(evt.target.value)}
/>
<ul>
{usersToShow &&usersToShow.length > 0 &&
usersToShow.map((user) => {
return <li key={user.id}>{user.first_name}</li>;
})}
</ul>
</div>
);
}
复制代码
useMemo 的 API 签名如下:useMemo(fn, deps);这里的 fn 是产生所需数据的一个计算函数。通常来说,fn 会使用 deps 中声明的一些变量来生成一个结果,用来渲染出最终的 UI。这个场景应该很容易理解:如果某个数据是通过其它数据计算得到的,那么只有当用到的数据,也就是依赖的数据发生变化的时候,才应该需要重新计算。
import React, { useState, useEffect } from "react";
function SearchUserList() {
const [users, setUsers] = useState(null);
const [searchKey, setSearchKey] = useState("");
useEffect(() => {
const doFetch = async () => {
// 组件首次加载时发请求获取用户数据
const res = await fetch("https://reqres.in/api/users/");
setUsers(await res.json());
};
doFetch();
}, []);
// 使用 userMemo 缓存计算的结果
const usersToShow = useMemo(() => {
if (!users) return null;
return users.data.filter((user) => {
return user.first_name.includes(searchKey));
}
}, [users, searchKey]);
// 避免子组件的重复渲染
return (
<div>
<input
type="text"
value={searchKey}
onChange={(evt) => setSearchKey(evt.target.value)}
/>
<ul>
{usersToShow &&usersToShow.length > 0 &&
usersToShow.map((user) => {
return <li key={user.id}>{user.first_name}</li>;
})}
</ul>
</div>
);
}
复制代码
2.5、useRef(在多次渲染之间共享数据)
在函数组件中,是没有这样一个空间去保存数据的。因此,React 让 useRef 这样一个 Hook 来提供这样的功能。useRef 的 API 签名如下:const myRefContainer = useRef(initialValue);我们可以把 useRef 看作是在函数组件之外创建的一个容器空间。
作用一多次渲染之间共享数据
import React, { useState, useCallback, useRef } from "react";
function Timer() {
// 定义 time state 用于保存计时的累积时间
const [time, setTime] = useState(0);
// 定义 timer 这样一个容器用于在跨组件渲染之间保存一个变量
const timer = useRef(null);
// 开始计时的事件处理函数
const handleStart = useCallback(() => {
// 使用 current 属性设置 ref 的值
timer.current = window.setInterval(() => {
setTime((time) => time + 1);
}, 100);
}, []);
// 暂停计时的事件处理函数
const handlePause = useCallback(() => {
// 使用 clearInterval 来停止计时
window.clearInterval(timer.current);
timer.current = null;
}, []);
return (
<div>
{time / 10} seconds.
<br />
<button onClick={handleStart}>Start</button>
<button onClick={handlePause}>Pause</button>
</div>
);
}
复制代码
这里可以看到,我们使用了 useRef 来创建了一个保存 window.setInterval 返回句柄的空间,从而能够在用户点击暂停按钮时清除定时器,达到暂停计时的目的。 同时你也可以看到,使用 useRef 保存的数据一般是和 UI 的渲染无关的,因此当 ref 的值发生变化时,是不会触发组件的重新渲染的,这也是 useRef 区别于 useState 的地方。
作用二保存某个 DOM 节点的引用
function TextInputWithFocusButton() {
const inputEl = useRef(null);
const onButtonClick = () => {
// current 属性指向了真实的 input 这个 DOM 节点,可以调用 focus 方法
inputEl.current.focus();
};
return (
<>
<input ref={inputEl} type="text" />
<button onClick={onButtonClick}>Focus the input</button>
</>
);
}
复制代码
这段代码是 React 官方文档提供的一个例子,可以看到 ref 这个属性提供了获得 DOM 节点的能力,并利用 useRef 保存了这个节点的应用。这样的话,一旦 input 节点被渲染到界面上,那我们通过 inputEl.current 就能访问到真实的 DOM 节点的实例了。
2.6、useContext:定义全局状态
React 提供了 Context 这样一个机制,能够让所有在某个组件开始的组件树上创建一个 Context。这样这个组件树上的所有组件,就都能访问和修改这个 Context 了。那么在函数组件里,我们就可以使用 useContext 这样一个 Hook 来管理 Context。
useContext 的 API 签名如下:
const value = useContext(MyContext);
复制代码
正如刚才提到的,一个 Context 是从某个组件为根组件的组件树上可用的,所以我们需要有 API 能够创建一个 Context,这就是 React.createContext API,如下:
const MyContext = React.createContext(initialValue);
复制代码
这里的 MyContext 具有一个 Provider 的属性,一般是作为组件树的根组件。这里我仍然以 React 官方文档的例子来讲解,即:一个主题的切换机制。代码如下:
const themes = {
light: {
foreground: "#000000",
background: "#eeeeee"
},
dark: {
foreground: "#ffffff",
background: "#222222"
}
};
// 创建一个 Theme 的 Context
const ThemeContext = React.createContext(themes.light);
function App() {
// 整个应用使用 ThemeContext.Provider 作为根组件
return (
// 使用 themes.dark 作为当前 Context
<ThemeContext.Provider value={themes.dark}>
<Toolbar />
</ThemeContext.Provider>
);
}
// 在 Toolbar 组件中使用一个会使用 Theme 的 Button
function Toolbar(props) {
return (
<div>
<ThemedButton />
</div>
);
}
// 在 Theme Button 中使用 useContext 来获取当前的主题
function ThemedButton() {
const theme = useContext(ThemeContext);
return (
<button style={{
background: theme.background,
color: theme.foreground
}}>
I am styled by theme context!
</button>
);
}
复制代码
比如说常见的切换黑暗或者明亮模式的按钮,用来切换整个页面的主题。事实上,动态 Context 并不需要我们学习任何新的 API,而是利用 React 本身的机制,通过这么一行代码就可以实现:
<ThemeContext.Provider value={themes.dark}>
复制代码
可以看到,themes.dark 是作为一个属性值传给 Provider 这个组件的,如果要让它变得动态,其实只要用一个 state 来保存,通过修改 state,就能实现动态的切换 Context 的值了。而且这么做,所有用到这个 Context 的地方都会自动刷新。比如这样的代码:
// ...
function App() {
// 使用 state 来保存 theme 从而可以动态修改
const [theme, setTheme] = useState("light");
// 切换 theme 的回调函数
const toggleTheme = useCallback(() => {
setTheme((theme) => (theme === "light" ? "dark" : "light"));
}, []);
return (
// 使用 theme state 作为当前 Context
<ThemeContext.Provider value={themes[theme]}>
<button onClick={toggleTheme}>Toggle Theme</button>
<Toolbar />
</ThemeContext.Provider>
);
}
复制代码
在这段代码中,我们使用 state 来保存 theme,从而达到可以动态调整的目的。
3、自定义hooks
首先问自己一个问题:这个功能中的哪些逻辑可以抽出来成为独立的 Hooks?
这么问的目的,是为了让我们尽可能地把业务逻辑拆成独立的 Hooks ,这样有助于实现代码的模块化和解耦,同时也方便后面的维护。如果你基础篇的知识掌握得牢固的话,就会发现,这是因为 Hooks 有两个非常核心的优点:
- 方便进行逻辑复用;
- 帮助关注分离。
3.1、如何自定义Hooks
自定义 Hooks 在形式上其实非常简单,就是声明一个名字以 use 开头的函数,比如 useCounter。这个函数在形式上和普通的 JavaScript 函数没有任何区别,你可以传递任意参数给这个 Hook,也可以返回任何值。但是要注意,Hooks和普通函数在语义上是有区别的,就在于函数中有没有用到其它Hooks。如果你创建了一个 useXXX 的函数,但是内部并没有用任何其它 Hooks,那么这个函数就不是一个 Hook,而只是一个普通的函数。但是如果用了其它 Hooks ,那么它就是一个 Hook。
// 以计数器为例创建一个useCounter hook,用于数值的加减操作
import { useState, useCallback }from 'react';
function useCounter(){
// 定义 count 这个 state 用于保存当前数值
const [count, setCount] = useState(0);
// 实现加 1 的操作
const increment = useCallback(() => setCount(count + 1), [count]);
// 实现减 1 的操作
const decrement = useCallback(() => setCount(count - 1), [count]);
// 重置计数器
const reset = useCallback(() => setCount(0), []);
// 将业务逻辑的操作 export 出去供调用者使用
return { count, increment, decrement, reset };
}
// 使用
import React from 'react';
function App(){
// 调用自定义 Hook
const { count, increment, decrement, reset } = useCounter();
// 渲染 UI ....
return (
<div>
<button onClick={decrement}> - </button>
<p>{count}</p>
<button onClick={increment}> + </button>
<button onClick={reset}> reset </button>
</div>
);
}
复制代码
3.1、监听浏览器状态:useScroll
界面需要根据在窗口大小变化重新布局等。这都需要用到浏览器的 API 来监听这些状态的变化。那么我们就以滚动条位置的场景为例,来看看应该如何用 Hooks 优雅地监听浏览器状态。
import { useState, useEffect } from 'react';
// 获取横向,纵向滚动条位置
const getPosition = () => {
return {
x: document.body.scrollLeft,
y: document.body.scrollTop,
};
};
const useScroll = () => {
// 定一个 position 这个 state 保存滚动条位置
const [position, setPosition] = useState(getPosition());
useEffect(() => {
const handler = () => {
setPosition(getPosition(document));
};
// 监听 scroll 事件,更新滚动条位置
document.addEventListener("scroll", handler);
return () => {
// 组件销毁时,取消事件监听
document.removeEventListener("scroll", handler);
};
}, []);
return position;
};
// 有了这个 Hook,你就可以非常方便地监听当前浏览器窗口的滚动条位置了。
// 比如下面的代码就展示了“返回顶部”这样一个功能的实现:
import React, { useCallback } from 'react';
import useScroll from './useScroll';
function ScrollTop() {
const { y } = useScroll();
const goTop = useCallback(() => {
document.body.scrollTop = 0;
}, []);
const style = {
position: "fixed",
right: "10px",
bottom: "10px",
};
// 当滚动条位置纵向超过 300 时,显示返回顶部按钮
if (y > 300) {
return (
<button onClick={goTop} style={style}>
Back to Top
</button>
);
}
// 否则不 render 任何 UI
return null;
}
复制代码
4、全局状态管理:如何在函数组件中使用 Redux?
4.1、Redux 出现的背景
React 使用的深入,你会发现组件级别的 state,和从上而下传递的 props 这两个状态机制,无法满足复杂功能的需要。例如跨层级之间的组件的数据共享和传递。我们可以从下图的对比去理解:
其中左图是单个 React 组件,它的状态可以用内部的 state 来维护,而且这个 state 在组件外部是无法访问的。而右图则是使用 Redux 的场景,用全局唯一的 Store 维护了整个应用程序的状态。可以说,对于页面的多个组件,都是从这个 Store 来获取状态的,保证组件之间能够共享状态。
Redux Store 的两个特点:
- Redux Store 是全局唯一的。即整个应用程序一般只有一个 Store。
- Redux Store 是树状结构,可以更天然地映射到组件树的结构,虽然不是必须的。
跨组件的状态共享:当某个组件发起一个请求时,将某个 Loading 的数据状态设为 True,另一个全局状态组件则显示 Loading 的状态。
同组件多个实例的状态共享:某个页面组件初次加载时,会发送请求拿回了一个数据,切换到另外一个页面后又返回。这时数据已经存在,无需重新加载。设想如果是本地的组件 state,那么组件销毁后重新创建,state 也会被重置,就还需要重新获取数据。
4.2、理解 Redux 的三个基本概
Redux 引入的概念其实并不多,主要就是三个:State、Action 和 Reducer。
State 即 Store,一般就是一个纯 JavaScript Object。
Action 也是一个 Object,用于描述发生的动作。
而 Reducer 则是一个函数,接收 Action 和 State 并作为参数,通过计算得到新的 Store。
它们三者之间的关系可以用下图来表示:
在 Redux 中,所有对于 Store 的修改都必须通过这样一个公式去完成,即通过 Reducer 完成,而不是直接修改 Store。这样的话,一方面可以保证数据的不可变性(Immutable),同时也能带来两个非常大的好处。
- 可预测性(Predictable):即给定一个初始状态和一系列的 Action,一定能得到一致的结果,同时这也让代码更容易测试。
- 易于调试:可以跟踪 Store 中数据的变化,甚至暂停和回放。因为每次 Action 产生的变化都会产生新的对象,而我们可以缓存这些对象用于调试。Redux 的基于浏览器插件的开发工具就是基于这个机制,非常有利于调试。
import { createStore } from 'redux'
// 定义 Store 的初始值
const initialState = { value: 0 }
// Reducer,处理 Action 返回新的 State
function counterReducer(state = initialState, action) {
switch (action.type) {
case 'counter/incremented':
return { value: state.value + 1 }
case 'counter/decremented':
return { value: state.value - 1 }
default:
return state
}
}
// 利用 Redux API 创建一个 Store,参数就是 Reducer
const store = createStore(counterReducer)
// Store 提供了 subscribe 用于监听数据变化
store.subscribe(() => console.log(store.getState()))
// 计数器加 1,用 Store 的 dispatch 方法分发一个 Action,由 Reducer 处理
const incrementAction = { type: 'counter/incremented' };
store.dispatch(incrementAction);
// 监听函数输出:{value: 1}
// 计数器减 1
const decrementAction = { type: 'counter/decremented' };
store.dispatch(decrementAction)
// 监听函数输出:{value: 0}
复制代码
通过这段代码,我们就用三个步骤完成了一个完整的 Redux 的逻辑:
- 先创建 Store;
- 再利用 Action 和 Reducer 修改 Store;
- 最后利用 subscribe 监听 Store 的变化。
需要注意的是,在 Reducer 中,我们每次都必须返回一个新的对象,确保不可变数据(Immutable)的原则。一般来说,我们可以用延展操作符(Spread Operator)来简单地实现不可变数据的操作,例如:
return {
...state, // 复制原有的数据结构
value: state.value + 1, // 变化 value 值使其 + 1
}
复制代码
通过这个例子,我们看到了纯 Redux 使用的场景,从而更加清楚地看到了 Store、Action 和 Reducer 这三个基本概念,也就能理解 State + Action => New State 这样一个简单却核心的机制。
4.3、如何在 React 中使用 Redux
在实际场景中,Redux Store 中的状态最终一定是会体现在 UI 上的,即通过 React 组件展示给用户。那么如何建立 Redux 和 React 的联系呢?
主要是两点:
- React 组件能够在依赖的 Store 的数据发生变化时,重新 Render;
- 在 React 组件中,能够在某些时机去 dispatch 一个 action,从而触发 Store 的更新。
要实现这两点,我们需要引入 Facebook 提供的 react-redux 这样一个工具库,工具库的作用就是建立一个桥梁,让 React 和 Redux 实现互通。在 react-redux 的实现中,为了确保需要绑定的组件能够访问到全局唯一的 Redux Store,利用了 React 的 Context 机制去存放 Store 的信息。通常我们会将这个 Context 作为整个 React 应用程序的根节点。因此,作为 Redux 的配置的一部分,我们通常需要如下的代码:
import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import store from './store'
import App from './App'
const rootElement = document.getElementById('root')
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
rootElement
)
复制代码
这里使用了 Provider 这样一个组件来作为整个应用程序的根节点,并将 Store 作为属性传给了这个组件,这样所有下层的组件就都能够使用 Redux 了。
完成了这样的配置之后,在函数组件中使用 Redux 就非常简单了:利用 react-redux 提供的 useSelector 和 useDispatch 这两个 Hooks。而 useSelector 则让一个组件能够在 Store 的某些数据发生变化时重新 render。
我在这里仍然以官方给的计数器例子为例,来给你讲解如何在 React 中使用 Redux:
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'
export function Counter() {
// 从 state 中获取当前的计数值
const count = useSelector(state => state.value)
// 获得当前 store 的 dispatch 方法
const dispatch = useDispatch()
// 在按钮的 click 时间中去分发 action 来修改 store
return (
<div>
<button
onClick={() => dispatch({ type: 'counter/incremented' })}
>
+
</button>
<span>{count}</span>
<button
onClick={() => dispatch({ type: 'counter/decremented' })}
>
-
</button>
</div>
)
}
复制代码
此外,通过计数器这个例子,我们还可以看到 React 和 Redux 共同使用时的单向数据流:
需要强调的是,在实际的使用中,我们无需关心 View 是如何绑定到 Store 的某一部分数据的,因为 React-Redux 帮我们做了这件事情。总结来说,通过这样一种简单的机制,Redux 统一了更新数据状态的方式,让整个应用程序更加容易开发、维护、调试和测试。
4.4、使用 Redux 处理异步逻辑
学完了如何在 React 中使用 Redux,接下来我们就进入到 Redux 的进阶场景中。
在 Redux 中,处理异步逻辑也常常被称为异步 Action,它几乎是 React 面试中必问的一道题,可以认为这是 Redux 使用的进阶场景。
在 Redux 的 Store 中,我们不仅维护着业务数据,同时维护着应用程序的状态。比如对于发送请求获取数据这样一个异步的场景,我们来看看涉及到 Store 数据会有哪些变化:
请求发送出去时:设置 state.pending = true,用于 UI 显示加载中的状态;
请求发送成功时:设置 state.pending = false, state.data = result。即取消 UI 的加载状态,同时将获取的数据放到 store 中用于 UI 的显示。
请求发送失败时:设置 state.pending = false, state.error = error。即取消 UI 的加载状态,同时设置错误的状态,用于 UI 显示错误的内容。
前面提到,任何对 Store 的修改都是由 action 完成的。那么对于一个异步请求,上面的三次数据修改显然必须要三个 action 才能完成。那么假设我们在 React 组件中去做这个发起请求的动作,代码逻辑应该类似如下:
function DataList() {
const dispatch = useDispatch();
// 在组件初次加载时发起请求
useEffect(() => {
// 请求发送时
dispatch({ type: 'FETCH_DATA_BEGIN' });
fetch('/some-url').then(res => {
// 请求成功时
dispatch({ type: 'FETCH_DATA_SUCCESS', data: res });
}).catch(err => {
// 请求失败时
dispatch({ type: 'FETCH_DATA_FAILURE', error: err });
})
}, []);
// 绑定到 state 的变化
const data = useSelector(state => state.data);
const pending = useSelector(state => state.pending);
const error = useSelector(state => state.error);
// 根据 state 显示不同的状态
if (error) return 'Error.';
if (pending) return 'Loading...';
return <Table data={data} />;
}
复制代码
从这段代码可以看到,我们使用了三个(同步)Action 完成了这个异步请求的场景。这里我们将 Store 完全作为一个存放数据的地方,至于数据哪里来, Redux 并不关心。尽管这样做是可行的。
但是很显然,发送请求获取数据并进行错误处理这个逻辑是不可重用的。假设我们希望在另外一个组件中也能发送同样的请求,就不得不将这段代码重新实现一遍。因此,Redux 中提供了 middleware 这样一个机制,让我们可以巧妙地实现所谓异步 Action 的概念。
简单来说,middleware 可以让你提供一个拦截器在 reducer 处理 action 之前被调用。在这个拦截器中,你可以自由处理获得的 action。无论是把这个 action 直接传递到 reducer,或者构建新的 action 发送到 reducer,都是可以的。
从下面这张图可以看到,Middleware 正是在 Action 真正到达 Reducer 之前提供的一个额外处理 Action 的机会:
我们刚才也提到了,Redux 中的 Action 不仅仅可以是一个 Object,它可以是任何东西,也可以是一个函数。利用这个机制,Redux 提供了 redux-thunk 这样一个中间件,它如果发现接受到的 action 是一个函数,那么就不会传递给 Reducer,而是执行这个函数,并把 dispatch 作为参数传给这个函数,从而在这个函数中你可以自由决定何时,如何发送 Action。
例如对于上面的场景,假设我们在创建 Redux Store 时指定了 redux-thunk 这个中间件:
import { createStore, applyMiddleware } from 'redux'
import thunkMiddleware from 'redux-thunk'
import rootReducer from './reducer'
const composedEnhancer = applyMiddleware(thunkMiddleware)
const store = createStore(rootReducer, composedEnhancer)
复制代码
那么在我们 dispatch action 时就可以 dispatch 一个函数用于来发送请求,通常,我们会写成如下的结构:
function fetchData() {
return dispatch => {
dispatch({ type: 'FETCH_DATA_BEGIN' });
fetch('/some-url').then(res => {
dispatch({ type: 'FETCH_DATA_SUCCESS', data: res });
}).catch(err => {
dispatch({ type: 'FETCH_DATA_FAILURE', error: err });
})
}
}
复制代码
那么在我们 dispatch action 时就可以 dispatch 一个函数用于来发送请求,通常,我们会写成如下的结构:
import fetchData from './fetchData';
function DataList() {
const dispatch = useDispatch();
// dispatch 了一个函数由 redux-thunk 中间件去执行
dispatch(fetchData());
}
复制代码
可以看到,通过这种方式,我们就实现了异步请求逻辑的重用。那么这一套结合 redux-thunk 中间件的机制,我们就称之为异步 Action。
所以说异步 Action 并不是一个具体的概念,而可以把它看作是 Redux 的一个使用模式。它通过组合使用同步 Action ,在没有引入新概念的同时,用一致的方式提供了处理异步逻辑的方案。