请记住react 的公式: UI = f(state) ,这很重要。
useState
🌰示例1:useState 拆分过细
const [firstName, setFirstName] = useState();
const [lastName, setLastName] = useState();
const [school, setSchool] = useState();
const [age, setAge] = useState();
const [address, setAddress] = useState();
const [weather, setWeather] = useState();
const [room, setRoom] = useState();
较好解决方式✌: 适当的合并 state
学会归类这些状态。
firstName, lastName 均是用户的信息,可以放在一个 useState 进行管理。
const [userInfo, setUserInfo] = useState({
firstName,
lastName,
school,
age,
address
});
const [weather, setWeather] = useState();
const [room, setRoom] = useState();
注意🎈:useState 的 set 动作永远是 替换 值。(React class 中的 this.setState 是会进行 合并 的)
在进行变更用户的某个信息例如 年龄 的时候记得带上之前的值。
setUserInfo((prevInfo) => ({
...prevInfo,
age: newAge
}))
🌰示例2:多个状态实则只是一个状态的变形
doneSource 、doingSource 是 source 转变的。
const SomeComponent = (props) => {
const [source, setSource] = useState([
{type: 'done', value: 1},
{type: 'doing', value: 2},
])
const [doneSource, setDoneSource] = useState([])
const [doingSource, setDoingSource] = useState([])
useEffect(() => {
setDoingSource(source.filter(item => item.type === 'doing'))
setDoneSource(source.filter(item => item.type === 'done'))
}, [source])
return (
<div>
.....
</div>
)
}
较好解决方式✌: 当一个状态可以被另外一个状态计算出来的话就不要去声明
const SomeComponent = (props) => {
const [source, setSource] = useState([
{type: 'done', value: 1},
{type: 'doing', value: 2},
])
// 这里的 useMemo 视实际情况添加,通常不是需要大量的计算 react 是不建议使用 useMemo
const doneSource = useMemo(()=> source.filter(item => item.type === 'done'), [source]);
const doingSource = useMemo(()=> source.filter(item => item.type === 'doing'), [source]);
return (
<div>
.....
</div>
)
}
useRef
🌰示例1:多余的依赖
期望: 只有当 visible 变化时,弹出 Message 。
其中 text 、color 分别控制 弹窗的文案、背景颜色
function Demo(props) {
const [visible, setVisible] = useState(false);
const [text, setText] = useState('');
const [color, setColor] = useState('red');
useEffect(() => {
Message(visible, text, color);
}, [visible]);
return (
<div>
<button
onClick={() => setCount(visible =>!visible)}
>
click
</button>
<input value={text} onChange={e => setText(e.target.value)} />
<input value={color} onChange={e => setColor(e.target.value)} />
</div>
)
}
如果你下载了 eslint-plugin-react-hooks
插件的话,你会发现这行代码出现警告。
于是你在 effect deps 增加了 text
、color
依赖。
于是出现了一个问题,当 text
、 color
发生变化的也会上传数据, 这并不符合我们的目标。
较好解决方式✌:善用 useRef
当text
、 color
改变的时候不需要重新更新视图的时候,尝试使用 useRef
去替代。
使用 useRef
去替换useState
:
function Demo(props) {
const [visible, setVisible] = useState(false);
const textRef = useRef('');
const colorRef = useRef('red');
useEffect(() => {
// 注意这里的 Message 内部接收的时候也要做处理
// 不能直接传 textRef.current, 这样可能会导致 Message 无法接收到最新的值
Message(visible, textRef, colorRef);
}, [visible]);
return (
<div>
<button
onClick={() => setVisible(preVisible => !preVisible)}
>
click
</button>
<input value={text} onChange={e => { textRef.current = e.target.value }} />
<input value={color} onChange={e => { colorRef.current = e.target.value } />
</div>
)
}
思考💡: 如果 text 、color 值需要在视图上进行渲染,如何进行设计? - useReducer。
🌰示例2:缺少依赖导致的闭包问题
永远不要欺骗 hook
期望: 当进入页面 3s 后,输出当前最新的 count
function Demo() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setTimeout(() => {
console.log(count)
}, 3000);
return () => {
clearTimeout(timer);
}
}, [])
return (
<button
onClick={() => setCount(c => c + 1)}
>
click
</button>
)
}
同样,当我们拥有 eslint-plugin-react-hooks
插件的时候还是会报缺少 count 的错误, 且该代码在 3s 内多次点击按钮, 还是会输出 0。
此时我们想到将 count 加入到依赖项。
useEffect(() => {
const timer = setTimeout(() => {
console.log(count)
}, 3000);
return () => {
clearTimeout(timer);
}
}, [count])
但是这样又会陷入一个怪圈,当我们在点击的时候会重新调用 useEffect 中的方法。 这样并没有达到我们的需求。
较好解决方式✌:
解法一:在有延迟调用场景时,可以通过 ref 来解决闭包问题
function Demo() {
const countRef = useRef(0);
useEffect(() => {
const timer = setTimeout(() => {
console.log(countRef.current)
}, 3000);
return () => {
clearTimeout(timer);
}
}, [])
return (
<button
onClick={() => { countRef.current++ }}
>
click
</button>
)
}
解法二: 可能我们需要一个自定义的 hook useEvent。
假设 count 值需要在进行视图展示,换句话说就是当 count 改变的时候会改变视图。
可能我们需要一个自定义的 hook useEvent。
具体的内容,你可以查看 Dan 在社区发布的一篇文章 useEvent 🔗
useEvent 实现方式 :
import React, { useRef, useLayoutEffect, useCallback } from 'react';
function useEvent(handler) {
const handlerRef = useRef();
// 在 render 之前执行
useLayoutEffect(() => {
handlerRef.current = handler;
});
return useCallback((...args) => {
const fn = handlerRef.current;
return fn(...args);
}, []); // 因为 ref 的地址不会发生变化,可以在依赖项中进行忽略(同理 setState 也是)
}
export default useEvent;
最终代码:
import React, { useState, useEffect } from 'react';
import useEvent from '@/hooks/useEvent';
function Demo() {
const [count, setCount] = useState(0)
const consoleCount = useEvent(() => {
console.log(count)
})
useEffect(() => {
const timer = setTimeout(() => {
consoleCount();
}, 3000);
return () => {
clearTimeout(timer);
}
}, [])
return (
<>
<div>当前数量:{ count }</div>
<button
onClick={() => { setCount(pre => pre+1) }}
>
click
</button>
</>
)
}
export default Demo;
被该 useEvent 包裹的函数,拿到外部的 props 或者 state 永远是最新的值。
useEffect
请特别注意以下两点📢:
useEffect
在开发调试阶段会运行两次。 React 18 最大的特性之一就是可以支持稳定的并发渲染,在实际开发中我们可以加上strickMode
来开启严格模式来支持稳定的并发渲染,该模式下为了能够暴露出一些特定情况的 bug, react 会在开发模式下调用两次useEffect
。
- 请不要将
useEffect
当做watcher
监听的方式来使用。
如果想跟上 react 的技术更新这些真的很重要,提前去注意总是好的,也是为了以后更好的迭代项目做准备。
哪怕是现在的项目并没有开启该模式。
🌰示例1:props 改变的时候 重置 state
期望: 当 userId
变化的时候,将 ProfilePage
组件的评论状态(即组件全部状态)清空
export default function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
useEffect(() => {
setComment('');
}, [userId]);
return <div>{comment}</div>
}
当前组件如果需要展现正确的值的时候,中间会更新一次 dom
然后去运行 useEffect
, 由于改变状态并再次进行渲染及其子组件,无疑增加了额外的一次渲染。
较好解决方式 ✌:
export default function App({ userId }) {
return (
<div>
<ProfilePage key={userId} userId={userId} />
</div>
)
};
function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
return <div>{comment}</div>
}
将 userId
为ProfilePage
组件 key 的标识,当 userId
发生变化的时候,由于组件ProfilePage
的key
不同 react 则会重新render
该组件的, 状态不在复用而是重建, 你不用担心这样会新建 dom
, react fiber会进行比较,是否选择复用缓存。
🌰示例2:state 依赖于 props
期望: 当 items
变换的 只重置selection
的状态
function List({ items }) {
const [selection, setSelection] = useState(null);
const [otherState, setOtherState] = useState(xxx);
useEffect(() => {
setSelection(null);
}, [items]);
// ...
}
咋一看没有什么问题,让我们仔细看一下这段代码是如何运行的:
- 第一次
render
:当items
变化的时候: 整个组件重新运行,此时selection
的值为旧值, 当组件更新dom
之后, 运行useEffect
的函数,由于运行了set
函数,组件需要重新render
. - 第二次
render
, 此时的selection
内部的值是最新的值。
❓ 可是我们只是变化了一次 items
, list
组件居然渲染了两次页面, 显然跟我们想的不一样。
较好解决方式 1✌:
function List({ items }) {
const [selection, setSelection] = useState(null);
const [otherState, setOtherState] = useState(xxx);
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
// ...
}
在渲染期间就改变selection
prevItems
总是记录上一次的值, 判断是否发生了变化, 如果发生变化则重新更新 selection
,并储存当前items
, 保证在下次渲染的时候使用的是上一次的值。 由于在第一次 render
,更新到真实dom
树上的时候, selection
值已经是最新的了, 整个组件则渲染完毕。
当然在这里你可能会想到使用 React.memo
。 可以自己去尝试下。
较好解决方式 2✌:
当然,在例子中我们也发现,其实 selection
更多是取决于items
,他严格来说是依赖于 props
的一个变量,大可不必作为一个新的状态存储在 List
组件中。
function List({ items }) {
const [otherState, setOtherState] = useState(xxx);
const selection = items.find(item => item.id === selectedId) ?? null;
// ...
}
React 更推荐这种方式去处理:
不管你怎么做,根据 props 或其他状态调整状态会使你的数据流更难理解和调试。当你检查代码的时候应考虑是否可以 重置所有状态 或者 在渲染期间计算所有内容 。
---- react 新文档
🌰示例3:正确的请求方式
期望: 初始化页面的时候
function App() {
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
// ...
}
但是可能在开发模式下会渲染两次 ,尝试做一次判断。
question: 为什么开发模式下会渲染两次?
简单的来说,大部分人在开发的过程中并不会注意到去清理 Effect
,提前在开发阶段暴露问题给开发者。详见开发中初次渲染调用两次 useEffect 内函数。
🎈例如我在 useEffect
去注册一个事件,但是我并没有 return 一个 清理该监听的事件的函数; 或者是 使用一些弹窗,而这个组件可能会为了防止开发人员多次调用而创建多个 portals
,所以只允许实例化一次。
但这些可能会造成:
- 调用弹窗关闭后未清除弹窗组件导致二次调用报错。
2.当该绑定事件到达一定量,页面由于绑定的事件过多会对用户浏览流畅性产生一定影响。
较好解决方式 ✌:
function App() {
useEffect(() => {
if(!isInit) {
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
// ...
}
🌰示例4:解决竞态问题
function User({ query }) {
const [data, setData] = useState(null);
useEffect(() => {
const res = await fetch(`https://xxx?${query}/`);
setData(res.json());
}, [query]);
if (data) {
return <div>{data.name}</div>;
}
return null;
}
这里可能会有一个问题,当 query
变化足够快的时候,可能会同时发出两个请求,而这两个请求可能返回时间的先后顺序与你预想不一致。举个例子:
假设我们希望查询 https://xxxx?id=123
, 即 query
我们希望输入的是 123
。但是由于 input
的 onChange
函数在 input
每次改变的时候都会改变 query
值, 输入间隔短倒一定时间的时候,输入12
的时候与输入 123
的请求同时发送。但是可能 123
请求结果先返回, 后面12
的请求结果后返回,则可能会造成 请求12
的结果覆盖了我们希望得到的123
的请求结果。
question: 什么是 竞态问题?
当 query
变化足够快, 就会发起不同的请求,而展示哪个最终取决于最终返回的那个结果,这可能并不符合你的预期显示内容。
较好解决方式 ✌
加一个锁
function User({ query }) {
const [page, setPage] = useState(1);
const data = useData(`/api/search?${query}`);
// ...
}
function useData(url) {
const [data, setData] = useState(null);
useEffect(() => {
let ignore = false;
fetch(url)
.then(res => {
if (!ignore) {
setData(res?.data || {});
}
});
return () => {
ignore = true;
};
}, [url]);
return result;
}
我们简单的梳理一下,还是query
id 为 123
的例子。手速过快同时发送12
, 123
的请求,在每一次请求中,总是会执行上一次副作用的 cleanup
函数, 当发起123
的请求的时候,请求 12
的函数由于运行了cleanup
函数, ignore
变量已经被赋值为 true
, 无论如何都没办法进入 setData
的操作, 这就解决了竞态竞争的问题。
或许你可以尝试使用一些市面上比较优秀的库: ahooks
、react-use
、react-Query
、 useSWR
。内部已经做了类似的判断。
useMemo
在遇到一些计算量大的时候我们总是会想到使用 useMemo
的 hook,对计算内容进行缓存。
在进行函数优化的时候,也会想到使用 React.memo 中添加第二个函数来进行比较是否需要优化该函数。
但是在使用这些 hook 之前,可能你需要先考虑一下是否可以使用以下两种解决方式:
示例1 :向下移动 state:
// 子组件
// 模拟组件重新渲染需要大量的计算
function ExpensiveTree() {
const nowTime = new Date()
while (new Date() - nowTime < 500 ) {
}
return <div> slower comp</div>
}
// 父组件
export default function Demo() {
const [color, setColor] = useState('#eee');
const handleChange = (e) => {
setColor(e.target.value)
}
return (
<div>
背景颜色: <input value={color} onChange={handleChange}/>
<ExpensiveTree />
</div>
);
}
可以看到,每当我在 input
框输入的时候都会重新渲染 ExpensiveTree
,可能我们第一眼的直觉是不是只要将 ExpensiveTree
组件包一层 useMemo
不就好了吗,但是或许可以用另外一种的方式.
较好解决方式✌:
function ExpensiveTree() {
const nowTime = new Date()
while (new Date() - nowTime < 500 ) {
}
return <div> slower comp</div>
}
// 看这里👉: 将 input 中所需内容下移,变成一个组件
function Form() {
const [color, setColor] = useState('#eee');
const handleChange = (e) => {
setColor(e.target.value)
}
return (<div>
背景颜色: <input value={color} onChange={handleChange}/>
</div>)
}
export default function Demo() {
return (
<div>
<Form />
<ExpensiveTree />
</div>
);
}
将原先会改变状态一部分内容给提取到一个组件中,与 ExpensiveTree
形成并列关系。也就是将 state 状态下移了。 此时更新 state
的时候,只会重新render
该子组件内部的内容。
示例2 :提升组件
🤔 但是如果父组件也依赖 input
值呢, 即 state 上升到上层组件?
我们改一下示例:
// 子组件
// 模拟组件重新渲染需要大量的计算
function ExpensiveTree() {
const nowTime = new Date()
while (new Date() - nowTime < 500 ) {
}
return <div> slower comp</div>
}
// 父组件
export default function Demo() {
const [color, setColor] = useState('#eee');
const handleChange = (e) => {
setColor(e.target.value)
}
return (
👉 <div style={{ backgroundColor: color }} >
背景颜色: <input value={color} onChange={handleChange}/>
<ExpensiveTree />
</div>
);
}
此时上层 dom div
的背景颜色需要 input
的值来控制此时没办法使用刚才的方法了。但是真的只能用 useMemo
了吗?
较好解决方式✌:
function ColorPicker({ children }) {
let [color, setColor] = useState("red");
return (
<div style={{ color }}>
背景颜色:<input value={color} onChange={(e) => setColor(e.target.value)} />
{children}
</div>
);
}
export default function Demo() {
return (
<ColorPicker>
<ExpensiveTree />
</ColorPicker>
);
}
将 demo
组件根据是否关心 color
状态来分为两个组件树 ColorPicker
和 ExpensiveTree
。 而 ExpensiveTree
通过children
属性传递到 ColorPicker
。
这样的话从组件结构来看 ExpensiveTree
是否改变不取决于 ColorPicker
组件是否改变, 在改变 color
状态的时候,ColorPicker
重新render
的时候并不会导致内部 children
重新 render
,而是从缓存中复用(请注意,这里的缓存是 react 的双缓存树)。
useReducer(待续。。)
参考文档:
zhuanlan.zhihu.com/p/450513902 (在阅读来源文章的时候请保持 UI = f(state) 的观念,文章内容并不一定是正确的)
beta.reactjs.org/learn/you-m… 《或许你不需要useEffect》
mp.weixin.qq.com/s/0RfqiObXz…《在React18中请求数据的正确姿势》
beta.reactjs.org/learn/synch…《如何在开发中处理两次 effect 调用》
github.com/reactjs/rfc… 《useEvent》
overreacted.io/zh-hans/bef…《在你使用 memo 之前 》