本文最后更新于:3 个月前
了解官方 Hooks 的适用场景和用法,了解自定义 Hook 需要注意的问题
React入门
一、官方Hooks
1. useState
什么场景下需要useState?
useState这个Hook提供了两个功能:
- (1)在组件的多次渲染之间,保存数据
- (2)在数据发生变化时,触发组件重新熏染
当存在这两种需求时,就应该使用useState()
useState如何使用
1 2
| import { useState } from 'react'; const [index, setIndex] = useState(0);
|
惯例是将这对返回值命名为 const [thing, setThing]
。你也可以将其命名为任何你喜欢的名称,但遵照约定俗成能使跨项目合作更易理解。
thing
被称为state
;setThing
被称为state setter
state setter传入 函数/值 的差异
state setter
可以接受:
- 更新函数,eg:
n=>n+1
。更新函数会被添加到队列中,依次执行
- 任何其它的值,eg:数字
6
,其实也相当于更新函数n=>6
,只是参数n
并没有使用
通过示例了解一下这两者的差异
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| export default function Counter() { const [number, setNumber] = useState(0);
return ( <> <h1>{number}</h1> <button onClick={() => { setNumber(number + 1); setNumber(number + 1); setNumber(number + 1); }}>增加数字</button> </> ) }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| export default function Counter() { const [number, setNumber] = useState(0);
return ( <> <h1>{number}</h1> <button onClick={() => { setNumber(n => n + 1); setNumber(n => n + 1); setNumber(n => n + 1); }}>增加数字</button> </> ) }
|
(更深入的介绍参考把一系列 state 更新加入队列 – React 中文文档 (docschina.org))
当state保存的是对象时
当要修改state中保存的对象时,不应该直接修改存在在state中的对象(或数组)!而需要创建一个对象副本再进行修改!
state的更新需要排队
调用state setter
之后,state的更新并不会立即生效,而是将setter()
加入到队列中,等待所有的同步代码执行完之后,再执行队列中setter()
。
2. useReducer
什么是reducer函数
先看一个例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| function tasksReducer(tasks, action) { switch (action.type) { case 'added': { return [ ...tasks, { id: action.id, text: action.text, done: false, }, ]; } case 'changed': { return tasks.map((t) => { if (t.id === action.task.id) { return action.task; } else { return t; } }); } case 'deleted': { return tasks.filter((t) => t.id !== action.id); } default: { throw Error('未知 action: ' + action.type); } } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import { useReducer } from 'react';
let nextId = 3; const initialTasks = [ {id: 0, text: '参观卡夫卡博物馆', done: true}, {id: 1, text: '看木偶戏', done: false}, {id: 2, text: '打卡列侬墙', done: false} ];
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
function handleAddTask(text) { dispatch({ type: 'added', id: nextId++, text: text, }); }
|
dispatch()
函数的参数被视为action
对象,通常,用type
来表示用户要发生的动作,并用其它字段来传递额外的信息
reducer函数设计的理念是:它告诉React,发生了什么类型的事件(即调用dispatch()
函数);而对应事件的处理逻辑定义在另外一个专门的地方(即reducer()
函数)。通过这样来实现:视图与逻辑的分离
回顾对比 state
,它是直接告诉React应该做什么,这样做的一个坏处可能是,在视图中夹带了大量的逻辑代码而变得不易阅读和维护,就像下面这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| function handleAddTask(text) { setTasks([ ...tasks, { id: nextId++, text: text, done: false, }, ]); }
function handleChangeTask(task) { setTasks( tasks.map((t) => { if (t.id === task.id) { return task; } else { return t; } }) ); }
function handleDeleteTask(taskId) { setTasks(tasks.filter((t) => t.id !== taskId)); }
|
使用reducers需要注意
reducers 必须是纯粹的。 它们不应该包含异步请求、定时器或者任何副作用(对组件外部有影响的操作)。它们应该以不可变值的方式去更新 对象 和 数组。
每个 action 都描述了一个单一的用户交互,即使它会引发数据的多个变化。 举个例子,如果用户在一个由 reducer
管理的表单(包含五个表单项)中点击了 重置按钮
,那么 dispatch 一个 reset_form
的 action 比 dispatch 五个单独的 set_field
的 action 更加合理。
3. useContext
context的用途
先上结论,Context 用来允许父组件向其下层无论多深的任何组件提供信息,而无需通过 props 显式传递。
下面继续看Context如何使用
context使用方式
使用分为3个步骤,分别是创建Context、提供Context、使用Context
第一步,创建Context
1 2 3 4 5
| import { createContext } from 'react';
export const LevelContext = createContext(1);
|
第二步,提供Context。使用 context provider 包裹,并用value提供context的值
1 2 3 4 5 6 7 8 9 10 11
| import { LevelContext } from './LevelContext.js';
export default function Section({ level, children }) { return ( <section className="section"> <LevelContext.Provider value={level}> {children} </LevelContext.Provider> </section> ); }
|
第三步,使用context
1 2 3 4 5 6 7
| import { useContext } from 'react'; import { LevelContext } from './LevelContext.js';
export default function Heading({ children }) { const level = useContext(LevelContext); }
|
4. useRef
什么时候使用ref
当组件中的某个变量,存在被修改的需求,但是并不需要被同步渲染时,使用ref。
作为对比,可以看一下什么时候应该使用state:数据需要被修改并且修改能够在重新渲染后得到保留而不是被刷新,数据的修改需要引起组件的刷新
如何使用ref
1 2 3 4 5 6 7
| import { useRef } from 'react';
export default function Stopwatch(){ const countRef = useRef(null); countRef.current = 1; }
|
使用ref操作DOM
ref的另外一大用途,是用来在React中获取对DOM节点的引用,以实现一些特殊的操作,如:让输入框获得焦点、让DOM节点滚动到可视范围内。下面通过一个示例,演示如何利用ref实现这样的操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| import { useRef } from 'react';
export default function Form(){ const inputRef = useRef(null); function handleClick(){ inputRef.current.focus(); } return ( <> <input ref={inputRef} /> <button onClick={handleClick}> 聚焦输入框 </button> </> ) }
|
但是,该方法只对原生的html元素生效,对自定义组件不生效!如果需要在自定义组件上使用ref,需要结合forwardRef
使用,详细请参考:访问另一个组件的DOM节点 – React 中文文档 (docschina.org)
5. useEffect
useEffect的特点
Effect指定由渲染这个行为本身去触发一些行为,而不是由特定事件(如:用户点击)来触发一些行为。
默认情况下,Effect会在每次渲染之后都执行;但如果指定了Effect依赖,它将变成按需执行(减少执行次数)。
1 2 3 4 5 6
| function MyComponent() { useEffect(() => { }); return <div />; }
|
每当你的组件渲染时,React 将更新屏幕,然后运行 useEffect
中的代码。换句话说,useEffect 会把这段代码放到屏幕更新渲染之后执行!即,useEffect会将副作用从渲染过程中分离出去。这也是useEffect
存在的作用
避免陷入死循环!
需要特别注意一点:不要在useEffect
包裹的副作用中触发组件重新渲染,这会使代码陷入死循环!
1 2 3 4 5 6 7
| function ErrorExample(){ const [count, setCount] = useState(0); useEffect(() => { setCount(count + 1); }); }
|
不同依赖项的区别
1 2 3 4 5 6 7 8 9 10 11
| useEffect(() => { });
useEffect(() => { }, []);
useEffect(() => { }, [a, b]);
|
cleanup清理函数
如何指定 cleanup 函数?如下,在useEffect包裹的函数中,使用return
返回一个函数,该函数即为cleanup函数
1 2 3 4 5 6 7
| useEffect(() => { const connection = createConnection(); connection.connect(); return () => { connection.disconnect(); }; }, []);
|
cleanup函数的执行时机?(1)重新执行Effect之前被调用;(2)组件被卸载时被调用
Effect被执行两次?
在开发环境下,useEffect包裹的副作用会被执行两次,这是React”故意“的。下面是官方解释:
想象 ChatRoom 组件是一个大规模的 App 中许多界面中的一部分。用户切换到含有 ChatRoom 组件的页面上时,该组件被挂载,并调用 connection.connect() 方法连接服务器。然后想象用户此时突然导航到另一个页面,比如切换到“设置”页面。这时,ChatRoom 组件就被卸载了。接下来,用户在“设置”页面忙完后,单击“返回”,回到上一个页面,并再次挂载 ChatRoom。这将建立第二次连接,但是,第一次时创建的连接从未被销毁!当用户在应用程序中不断切换界面再返回时,与服务器的连接会不断堆积。
如果不进行大量的手动测试,这样的错误很容易被遗漏。为了帮助你快速发现它们,在开发环境中,React 会在初始挂载组件后,立即再挂载一次。
Effect Event(实验性API)
React 稳定版中 还没有发布的实验性 API
Effect Event用于从useEffect
中提取出非响应式的逻辑。下面通过一个示例来了解它的作用:
1 2 3 4 5 6 7 8 9 10 11
|
function ChatRoom({ roomId, theme }) { useEffect(() => { const connection = createConnection('https://localhost:1234', roomId); connection.on('connected', () => { showNotification('Connected!', theme); }); connection.connect(); return () => connection.disconnect(); }, [roomId, theme]);
|
之所以会出现预期之外的行为,是因为上述代码不得不将theme
列为依赖项。下面展示如何破解
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| function ChatRoom({ roomId, theme }) { const onConnected = useEffectEvent(() => { showNotification('Connected!', theme); }); useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.on('connected', () => { onConnected(); }); connection.connect(); return () => connection.disconnect(); }, [roomId]); }
|
这个方法解决了问题。注意你必须从 Effect 依赖项中 移除 onConnected
。Effect Event 是非响应式的并且必须从依赖项中删除。
6. useCallback
用法 & 作用
1 2 3
| const cachedFn = useCallback(fn, dependencies)
|
在初次渲染时,useCallback
返回你已经传入的 fn
函数;
在之后的渲染中, 如果依赖没有改变,useCallback 返回上一次渲染中缓存的 fn 函数;否则返回这一次渲染传入的 fn
。
什么时候需要用useCallback
除非是有特殊需要,否则不必将函数包裹在 useCallback 中!
- 场景一:将被缓存的函数作为 props 传递给包装在 [memo] 中的组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| import { useCallback } from 'react';
function ProductPage({ productId, referrer, theme }) { const handleSubmit = useCallback((orderDetails) => { post('/product/' + productId + '/buy', { referrer, orderDetails, }); }, [productId, referrer]); return ( <div className={theme}> <ShippingForm onSubmit={handleSubmit} /> </div> ); };
|
默认情况下,当一个组件重新渲染时, React 将递归渲染它的所有子组件
如上,handleSubmit
被作为参数传入子组件,如果不将handleSubmit
用useCallback
包裹,那么每次父组件ProductPage
重新渲染,都会生成一个新的handleSubmit
函数对象(即使它们看起来一模一样),这就会进一步导致子组件也跟着重新渲染;但实际上子组件的重新渲染时是不必要的!!
通过useCallback
包裹,如果依赖项不发生变化,handleSubmit
也不会发生变化,因此不会引起子组件的重新渲染(前提:子组件使用memo
方法包裹。memo的作用是:如果props不发生变化,那么其内部包裹的组件就不会重新渲染)
- 场景二:被缓存的函数可能被作为某些 Hook 的依赖项
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| function ChatRoom({ roomId }) { const [message, setMessage] = useState('');
const createOptions = useCallback(() => { return { serverUrl: 'https://localhost:1234', roomId: roomId }; }, [roomId]);
useEffect(() => { const options = createOptions(); const connection = createConnection(); connection.connect(); return () => connection.disconnect(); }, [createOptions]);
|
如果不将createOptions
包裹,ChatRoom
组件每次重新渲染都会重新连接聊天室。但其实除了使用useCallback
,针对上面这种问题,还有另外一种处理方法——将createOptions
函数移入 useEffect
内部!
- 场景三:如果是自定义Hook,建议将所有返回的函数都用 useCallback 包裹(但这是为什么呢??)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| function useRouter() { const { dispatch } = useContext(RouterStateContext);
const navigate = useCallback((url) => { dispatch({ type: 'navigate', url }); }, [dispatch]);
const goBack = useCallback(() => { dispatch({ type: 'back' }); }, [dispatch]);
return { navigate, goBack, }; }
|
7. useMemo
用法 & 作用
1 2 3
| const cachedResult = useMemo(calculateValue, dependencies)
|
在初次渲染时,你从 useMemo
得到的 值 将会是你的 calculation 函数执行的结果。
在随后的每一次渲染中,React 将会比较前后两次渲染中的 所有依赖项 是否相同。如果通过 Object.is
比较所有依赖项都没有发生变化,那么 useMemo
将会返回之前已经计算过的那个值。否则,React 将会重新执行 calculation 函数并且返回一个新的值。
适用场景
使用 useMemo
进行优化仅在少数情况下有价值:
- 你在
useMemo
中进行的计算明显很慢,而且它的依赖关系很少改变。
- 将计算结果作为 props 传递给包裹在
memo
中的组件。当计算结果没有改变时,你会想跳过重新渲染。记忆化让组件仅在依赖项不同时才重新渲染。
- 你传递的值稍后用作某些 Hook 的依赖项。例如,也许另一个
useMemo
计算值依赖它,或者 useEffect
依赖这个值。
二、Hooks
1. 什么是Hook?
Hooks ——以 use
开头的函数——只能在组件或自定义 Hook 的最顶层调用。 你不能在条件语句、循环语句或其他嵌套函数内调用 Hook
我的理解:hooks就是抽离出公共的函数逻辑(以达到复用的目的),component组件就是抽离出公共的函数逻辑 + UI(以达到复用的目的),hooks就是组件少了UI那部分。
下面这个是React Hooks 入门教程 - 阮一峰的网络日志 (ruanyifeng.com)中的例子,可以很好地帮助理解:如何自定义hooks,什么是hooks
首先是不封装hooks的版本:(这是一个展示个人信息的组件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| const Person = ({ personId }) => { const [loading, setLoading] = useState(true); const [person, setPerson] = useState({});
useEffect(() => { setLoading(true); fetch(`https://swapi.co/api/people/${personId}/`) .then(response => response.json()) .then(data => { setPerson(data); setLoading(false); }); }, [personId])
if (loading === true) { return <p>Loading ...</p> }
return <div> <p>You're viewing: {person.name}</p> <p>Height: {person.height}</p> <p>Mass: {person.mass}</p> </div> }
|
下面我们将「纯函数」之外的部分(即:外部功能、副作用等等)提取出来,封装成单独的逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| const usePerson = {personId} => { const [loading, setLoading] = useState(true) const [person, setPerson] = useState() useEffect(()=>{ setLoading(true) fetch(`https://swapi.co/api/people/${personId}/`) .then(response => response.json()) .then(data => { setPerson(data); setLoading(false); }); },[personId]) return [loading, person] }
|
然后,看看应该如何使用自定义hooks
1 2 3 4 5 6 7 8 9 10 11 12 13
| const Person = ({personId} => { const [loading, person] = usePerson(personId) if (loading === true) { return <p>Loading ...</p> }
return <div> <p>You're viewing: {person.name}</p> <p>Height: {person.height}</p> <p>Mass: {person.mass}</p> </div> })
|
2. Hook公约
- 命名方式:Hook 的名称必须以
use
开头,然后紧跟一个大写字母,就像内置的 useState
或者本文早前的自定义 useOnlineStatus
一样。
- 返回值:Hook 可以返回任意值
- 调用:Hook只能在组件顶层调用;而且不能再循环或条件语句中调用
三、其它API
1. flushSync同步更新DOM
React 执行完封装在 flushSync
中的代码后,会立即同步更新 DOM!
1 2 3 4
| flushSync(() => { setTodos([ ...todos, newTodo]); }); listRef.current.lastChild.scrollIntoView();
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| import { useState, useRef } from 'react'; import { flushSync } from 'react-dom';
export default function TodoList() { const listRef = useRef(null); const [text, setText] = useState(''); const [todos, setTodos] = useState( initialTodos );
function handleAdd() { const newTodo = { id: nextId++, text: text }; flushSync(() => { setText(''); setTodos([ ...todos, newTodo]); }); listRef.current.lastChild.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }
return ( <> <button onClick={handleAdd}> 添加 </button> <input value={text} onChange={e => setText(e.target.value)} /> <ul ref={listRef}> {todos.map(todo => ( <li key={todo.id}>{todo.text}</li> ))} </ul> </> ); }
let nextId = 0; let initialTodos = []; for (let i = 0; i < 20; i++) { initialTodos.push({ id: nextId++, text: '待办 #' + (i + 1) }); }
|
更多细节介绍见:用flushSync同步更新state – React 中文文档 (docschina.org)
2. memo缓存组件
如果将组件用memo
包裹之后,当组件的props
与上一次渲染相同,组件将跳过重新渲染。
1 2 3 4 5
| import { memo } from 'react' const YourComponent = memo((props)=>{ return (...) })
|
四、生命周期
每个 React 组件都经历相同的生命周期:
- 当组件被添加到屏幕上时,它会进行组件的 挂载。
- 当组件接收到新的 props 或 state 时,通常是作为对交互的响应,它会进行组件的 更新。
- 当组件从屏幕上移除时,它会进行组件的 卸载。
只有两件事可以触发React组件的重新渲染:(1)组件的props发生变化;(2)组件中的state变量发生变化。
重新渲染时,组件中的所有代码将重新执行一遍(当然也有例外,如指定了依赖项的useEffect
、useCallback
、useMemo
等语句块)
QA
- Q:在什么场景下必须/需要使用 useState、useRef、useEffect、useCallback?
A:在上面各自的介绍中进行了回答
- Q:React组件的生命周期是怎样的?React组件在哪些情况下会发生重新渲染?
A:
链接
轻松学会 React 钩子:以 useEffect() 为例 - 阮一峰的网络日志 (ruanyifeng.com)
React Hooks 入门教程 - 阮一峰的网络日志 (ruanyifeng.com)
React 中文文档 (docschina.org)