React hooks 核心原理和实践
认识 React
使用组件的方式描述 UI
在 React 中,所有的 UI 都是通过组件去描述和组织的。
React 中所有的元素都是组件,具体而言分为两种:
内置组件。内置组件其实就是映射到 HTML 节点的组件,例如
div
、input
、table
等等,作为一种约定,它们都是小写字母。自定义组件。自定义组件其实就是自己创建的组件,使用时必须以大写字母开头,例如
TopicList
、TopicDetail
。
理解 JSX 语法本质
JSX 是模板语言,这里的"模板语言"是加了引号的,因为从本质上来说,JSX 并不是一个新的模板语言,而可以认为是一个语法糖。也就是说,不用 JSX 的写法,其实也是能够写 React 的。
React.createElement(
"div",
null,
React.createElement(
"button",
{ onClick: function onClick() {
return setCount(count + 1);
} },
React.createElement(CountLabel, { count: count })
)
);
React.createElement(componentName, props, childCompnentName)
作用就是创建一个组件的实例,接收一组参数:
- 第一个参数表示组件的类型
- 第二个参数是传给组件的属性,也就是 props
- 第三个以及后续所有的参数则是子组件
理解 Hooks
在 React 中,Hooks 就是把某个目标结果【DOM 结构】钩到某个可能会变化的数据源或者事件源【状态】上,那么当被钩到的数据或事件发生变化时,产生这个目标结果的代码会重新执行,产生更新后的结果。
Hooks 只能在函数组件的顶级作用域使用所谓顶层作用域,
Hooks 不能在循环、条件判断或者嵌套函数内执行,而必须是在顶层。
Hooks 在组件的多次渲染之间,必须按顺序被执行。
useState
- state 里,不要保存通过计算获得的数据
从 props 传递过来的值。有时候 props 传递过来的值无法直接使用,而是要通过一定的计算后再在 UI 上展示,比如说排序。那么我们要做的就是每次用的时候,都重新排序一下,或者利用某些 cache 机制,而不是将结果直接放到 state 里。
从 URL 中读到的值。比如有时需要读取 URL 中的参数,把它作为组件的一部分状态。那么我们可以在每次需要用的时候从 URL 中读取,而不是读出来直接放到 state 里。
从 cookie、localStorage 中读取的值。通常来说,也是每次要用的时候直接去读取,而不是读出来后放到 state 里。
- useState 异步问题导致获取数据不及时问题?
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1); // 重点是在【快照】这个概念,count 其实是 count 的快照
setCount(count + 1);
setCount(count + 1);
};
useEffect(() => {
console.log(count); // count 依然为 0
}, [count]);
但是如果我们采用回调的方式来获取数据
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
// console.log(count) 他还是 0
};
useEffect(() => {
console.log(count); // 这里是最新的值 3
}, [count]);
React 将这三次操作都放在了队列里面,但是由于 count 属于快照,所以一直是 0,也就是他进行了,三次的 setCount(0+1)
的操作;【批处理】
由于 React 识别到三次进行了相同的操作,那么他就会将操作进行合并【合并机制】
理解为什么 setCount(prevCount => prevCount + 1);
这种写法就可以?
prevCount
这个值并不是我传递给 React 的,是他自己内部进行维护的,他会拿到上一个的状态
⭐如果你想在一个函数中多次改变状态,那么你需要使用回调函数
React 状态更新机制
对于任何函数,只要进入了 React 的调度流程,那就是异步的。
只要没有进入 React 的调度流程,那就是同步的。
由 React 引发的事件处理(如 onClick
等)就会进入 React 的调度流程;而诸如 setTimeout
、setInterval
或者直接在 DOM
上绑定原生事件等,这些都不会走 React 的调度流程。
在 React 的 setState
函数实现中,会根据一个变量 isBatchingUpdates
判断是直接更新 state
还是放到队列中回头再说,而 isBatchingUpdates
默认是 false
,也就表示 setState
默认会同步更新 state
。而 batchedUpdates
这个函数会把 isBatchingUpdates
修改为 true
为了合并 setState
,我们需要一个队列来保存每次 setState
的数据,然后在一段时间后,清空这个队列并渲染组件,这个队列就是 dirtyComponents
。当 isBatchingUpdates
为 true
时,将会执行 dirtyComponents.push(component)
,将组件 push
到 dirtyComponents
队列。调用 setState
时,其实已经调用了 batchedUpdates
,此时 isBatchingUpdates
便是 true
。因此展示出异步合并更新
useEffect
在函数组件的当次执行过程中,useEffect
中代码的执行是不影响渲染出来的 UI 的。
useEffect(() => {
// useEffect 的 callback 要避免直接的 async 函数,需要封装一下
const doAsync = async () => {
// 当 id 发生变化时,将当前内容清楚以保持一致性
setBlogContent(null);
// 发起请求获取数据
const res = await fetch(`/blog-content/${id}`);
// 将获取的数据放入 state
setBlogContent(await res.text());
};
doAsync();
}, [id]); // 使用 id 作为依赖项,变化时则执行副作用
useEffect
让我们能够在下面四种时机去执行一个回调函数产生副作用:
- 每次 render 后执行:不提供第二个依赖项参数。比如
useEffect(() => {})
- 仅第一次 render 后执行:提供一个空数组作为依赖项。比如
useEffect(() => {}, [])
- 第一次以及依赖项发生变化后执行:提供依赖项数组。比如
useEffect(() => {}, [deps])
- 组件 unmount 后执行:返回一个回调函数。比如
useEffect() => { return () => {} }, [])
useCallback
useCallback(fn, deps)
定义:这里 fn
是定义的回调函数,deps
是依赖的变量数组。只有当某个依赖变量发生变化时,才会重新声明 fn
这个回调函数。
import React, { useState, useCallback } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const handleIncrement = useCallback(
() => setCount(count + 1),
[count], // 只有当 count 发生变化时,才会重新创建回调函数
);
// ...
return <button onClick={handleIncrement}>+</button>
}
问题:为什么不能直接用 useEffect
代替 useCallback
? 既然他们都存在一种含义:一个值发生改变的时候,去执行某一个函数
useEffect
主要用于处理副作用(比如 fetchData
、订阅
、组装数据
等)。
useCallback
返回一个函数,这个函数可以直接在 JSX 里使用,比如 onClick
事件。
useEffect
不会返回函数,它的作用只是执行某些逻辑,不能直接作为事件处理函数。
✔正确写法
import React, { useState, useCallback } from "react";
export default function App() {
const [count, setCount] = useState(0);
// 只有 count 变化时,才会创建新的 handleClick
const handleClick = useCallback(() => {
console.log("Count:", count);
}, [count]);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increase</button>
<button onClick={handleClick}>Log Count</button>
</div>
);
}
❌错误写法
import React, { useState, useEffect } from "react";
export default function App() {
const [count, setCount] = useState(0);
let handleClick;
useEffect(() => {
handleClick = () => {
console.log("Count:", count);
};
}, [count]);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increase</button>
<button onClick={handleClick}>Log Count</button> {/* ❌ 这里会报错 */}
</div>
);
}
useMemo
useMemo(fn, deps);
如果某个数据是通过其它数据计算得到的,那么只有当用到的数据,也就是依赖的数据发生变化的时候,才应该需要重新计算。=>类似于 Vue 的计算属性
useCallback
的功能其实是可以用 useMemo
来实现的
const myEventHandler = useMemo(() => {
// 返回一个函数作为缓存结果
return () => {
// 在这里进行事件处理
}
}, [dep1, dep2]);
useRef
useRef
还有一个重要的功能,就是保存某个 DOM 节点的引用。- 在多次渲染之间共享数据
- 使用
useRef
保存的数据一般是和 UI 的渲染无关的,因此当ref
的值发生变化时,是不会触发组件的重新渲染的,这也是useRef
区别于useState
的地方。
useContext
React 提供了 Context 【上下文】一个机制,能够让所有在某个组件开始的组件树上创建一个 Context。这样这个组件树上的所有组件,就都能访问和修改这个 Context 了。
// 创建 context
const MyContext = React.createContext(initialValue);
// 使用 context
const value = useContext(MyContext);
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>
);
}
当然如果需要使用状态管理,对于小型的项目,直接使用 useModel
也是一个好办法。
React 生命周期
React 存在了多个版本,React 16 之前,React 16 以后【Class 写法和 Hooks 写法】,无论版本是什么,React 的生命周期主要包含三个部分:挂载,更新,卸载。
React 16 之前(Class 组件)
- 挂载
constructor
render
componentDidMount
- 更新
componentWillReceiveProps
(React 16.3 弃用)shouldComponentUpdate
:控制是否更新,如果返回false
,则不会调用render
、componentWillUpdate
、componentDidUpdate
。componentWillUpdate
(React 16.3 弃用)render
componentDidUpdate
- 卸载
componentWillUnmount
React 16 之后(Class 组件)
- 挂载
constructor
getDerivedStateFromProps
(新增)render
componentDidMount
- 更新
getDerivedStateFromProps
shouldComponentUpdate
render
getSnapshotBeforeUpdate
【获取更新前的 DOM 状态(旧的 DOM)】componentDidUpdate
- 卸载
componentWillUnmount
Class 的生命周期 | 对应 Hooks |
---|---|
挂载【componentDidMount】 | useEffect(()=>{},[]) |
更新【componentDidUpdate】 | useEffect(() => {}, [state]) // 根据状态来进行更新 |
卸载【componentWillUnmount】 | useEffect(() => { return () => {} }, []) |
是否更新【shouldComponentUpdate】 | useMemo() / useCallback() |
getDerivedStateFromProps | useEffect(() => {}, [props]) |
getSnapshotBeforeUpdate | useEffect + useRef |
引申
父子组件更新问题
当父组件更新的时候,子组件无论是否更新;
如果你不想让他进行更新:
- 子组件变为
memo
组件; - 父组件内使用
useCallback
避免函数props
变化
Diff 算法
比较的是什么呢?在哪里比较?
定义:从上而下,同层比较。比较的是 V-DOM 树,比较的 VDOM 节点,【Fiber 节点】
React 采用了 “同层 Diff” 策略,只比较同一层的 Fiber 节点,不跨层级比较:
- 如果类型相同 → 复用节点,更新
props
- 如果类型不同 → 删除旧 Fiber,创建新 Fiber
- 如果子元素发生变化 → 递归比较子 Fiber,使用 Key 进行优化
Fiber 结构是什么?
在本质上,Fiber 是一个 JavaScript 对象,代表 React 的一个工作单元,它包含了与组件相关的信息。
{
type: 'h1', // 组件类型
key: null, // React key
props: { ... }, // 输入的 props
state: { ... }, // 组件的 state (如果是 class 组件或带有 state 的 function 组件)
child: Fiber | null, // 第一个子元素的 Fiber
sibling: Fiber | null, // 下一个兄弟元素的 Fiber
return: Fiber | null, // 父元素的 Fiber
// ...其他属性
}
因为 React 在更新时总是维护了两个 Fiber 树,所以可以随时进行比较、中断或恢复等操作,而且这种机制让 React 能够同时具备拥有优秀的渲染性能和 UI 的稳定性。【这个应该在 getSnapshotBeforeUpdate
这个时候,进行判断】
Fiber 不是单纯的节点结构,而是 React 重新设计的核心架构,优化了渲染性能!
- 使用 Fiber 作为数据结构,更容易管理组件更新状态
- 支持可中断的渲染,React 可以暂停低优先级任务,优先处理用户交互
- 任务优先级调度(Time Slicing),不同任务有不同的执行优先级
- 双缓存机制(双 Fiber 树),避免页面闪烁,提高渲染效率