在 Hooks 正式推出不久时我写了一篇 Hooks 随谈 ,主要是从概念介绍上对它的简单的一个分析理解,实习时开发的新控制台则完全使用了这个特性,因此想结合目前的实践经验对 Hooks 做一个最佳实践与对比的分析。
Capture Value#
从 Hooks 出现后,函数式组件也具有了状态的特性,因此避免 Stateless Component 而统一叫 Function Component 更为恰当。这里先从 props 的不可变性上对 Function Component 与 Class Component 做一个对比:
Function Component:
function ProfilePage(props) {
setTimeout(() => {
// 父组件 Rerender 了,props 也是初始的
console.log(props)
}, 3000)
}
Class Component:
class ProfilePage extends React.Component {
render() {
setTimeout(() => {
// 父组件 Rerender 了,this.props 也会改变,因为 this 变了
console.log(this.props)
}, 3000)
}
}
如果想在 Class Component 中捕获初始的 props,可以 const props = this.props
,但是这样有点 hack,所以想拿到稳定的 props
推荐使用 Function Component。
Hooks:
function MessageThread() {
const [message, setMessage] = useState("")
const showMessage = () => {
alert("You said: " + message)
}
const handleSendClick = () => {
setTimeout(showMessage, 3000)
}
const handleMessageChange = e => {
setMessage(e.target.value)
}
return (
<>
<input value={message} onChange={handleMessageChange} />
<button onClick={handleSendClick}>Send</button>
</>
)
}
点 send
后修改输入框的值,3 秒后输出还是点击前输入框的值,说明 Hooks 也具有 Capture Value 的特性。
可以认为每次 Render 的内容都会形成一个快照,每个 Render 状态都有自己固定不变的 Props 和 State。
不仅仅是对象,函数在每次渲染时也是独立的,这就是 Capture Value 特性。
实际开发中就被这个特性坑过,避免 capture value 可以利用 useRef
:
function MessageThread() {
const latestMessage = useRef("")
const showMessage = () => {
alert("You said: " + latestMessage.current)
}
const handleSendClick = () => {
setTimeout(showMessage, 3000)
}
const handleMessageChange = e => {
latestMessage.current = e.target.value
}
}
可以认为
ref
在所有 Render 过程中保持着唯一引用,所以ref
的取值或赋值拿到的都是一个最终状态,也可以简洁地认为ref
是 Mutable 而state
的 Immutable 的。
生命周期方法的替代#
- constructor:Function Component 不需要初始构造函数,可以初始化状态通过调用
useState
,如果初始值计算很消耗时间,可以传入函数,这样只会执行一次。 - getDerivedStateFromProps:当渲染时合理调度更新。
- shouldComponentUpdate:见
[React.memo](https://reactjs.org/docs/hooks-faq.html#how-do-i-implement-shouldcomponentupdate)
。 - render:就是 Function Component 本身。
- componentDidMount、componentDidUpdate、componentWillUnmount:它们的集合对应
useEffect
。 - componentDidCatch、getDerivedStateFromError:近期会增加对应的 Hook 方法。
最佳实践#
组件定义#
const App: React.FC<{ title: string }> = ({ title }) => {
return React.useMemo(() => <div>{title}</div>, [title])
}
App.defaultProps = {
title: 'Function Component'
}
- 用
React.FC
申明 Function Component 组件类型与定义 Props 参数类型。 - 用
React.useMemo
优化渲染性能。 - 用
App.defaultProps
定义 Props 默认值。
为什么不用 React.memo?
因为组件通信时存在 React.useContext
的用法,会使所有用到的组件重渲染,只有 React.useMemo
可以按需渲染,同时考虑到未来维护,随时可能通过 useContext
等注入数据,即使没有性能问题的组件也建议使用 useMemo
。
为什么不用解构方式代替 defaultProps?
虽然书写上解构方式更优雅,但是存在一个性能问题:对于对象类型每次 Rerender 时引用都会变化。
局部状态#
按常用程度排列:useState
、useRef
、useReducer
。
useState
const [hide, setHide] = React.useState(false)
const [name, setName] = React.useState("jeremy")
状态和函数名要见名知意,推荐都放在顶部声明,方便查阅。
useRef
const dom = React.useRef(null)
useRef
尽量少用,因为大量 Mutable 的数据会影响代码的可维护性,对于不需要重复初始化的对象推荐使用。
useReducer
局部状态不推荐使用 useReducer
,容易导致内部状态过于复杂,建议在多组件间通信时结合 useContext
使用。
在函数内直接声明普通常量或普通函数合适吗?
因为 Function Component 每次渲染都会重新执行,常量推荐放到函数外层避免性能问题,函数推荐使用 useCallback
声明以保证准确性与性能,useCallback
第二个参数必须填写,eslint-plugin-react-hooks 会自动填写依赖项。
组件通信#
简单的组件通信使用 Props 透传的方式,频繁组件间通信使用 useContext
。
useEffect 指南#
Function Component 没有生命周期,仅描述 UI 状态,然后 React 将其同步到 DOM。每次渲染的状态都会固化下来,包括 state
props
useEffect
和内部的所有函数。
舍弃了生命周期的同步会带来一些性能问题,我们需要告诉 React 如何对比 Effects。
useEffect 的依赖项#
React 在 DOM 渲染时会 diff 内容,只修改改变了的部分,但是做不到对 Effect 的增量修改识别,需要开发者通过 useEffect
的第二个参数告诉 React 用到了哪些外部变量:
useEffect(() => {
document.title = 'Hello, ' + name
}, [name])
直到 name
改变的 Rerender,useEffect
才会再次执行。手动维护比较麻烦,可以利用 eslint 自动提示 fix。
这里需要关注的是依赖项的设置很重要:
由于 useEffect 符合 Capture Value 的特性,必须处理好依赖项才能保证获取值的准确性:
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1)
}, 1000)
return () => clearInterval(id)
}, [count])
如果这里不传入 count
作为依赖项,拿到的 count
值就永远是初始化的 0
,这样之后的 setCount
就没有作用了。
传入了 count
可以获取到最新的 count
,但是导致了两个问题:
- 计时器不准确了,因为每次
count
变化都会销毁重新计时。 - 频繁生成和销毁定时器带来了一定性能负担。
这里设不设依赖都有问题,本质上是因为我们在一个只想执行一次的 Effect 里依赖了外部变量。
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1)
}, 1000)
return () => clearInterval(id)
}, [])
setCount
还有一种函数回调模式,不需要关心当前值是什么,只要对旧的值进行修改即可,这样虽然代码永远运行在第一次 Render 中,但总是可以访问到最新的 state
。
上面的解法并没有彻底解决所有场景的问题,比如同时依赖了两个 state
的情况:
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + step)
}, 1000)
return () => clearInterval(id)
}, [step])
这里就不得不依赖 step
这个变量,那么现在该怎么处理呢?
利用 useReducer
函数将更新与动作解耦:
const [state, dispatch] = useReducer(reducer, initialState)
const { count, step } = state
useEffect(() => {
const id = setInterval(() => {
dispatch({ type: "tick" })
}, 1000)
return () => clearInterval(id)
}, [dispatch])
这样形成了一个局部 Redux,不管更新时需要依赖多少变量,在实际更新时都不需要依赖任何变量,具体更新操作写在 reducer
函数里即可。
Function 与 Effect#
如果函数定义不在 useEffect
中,不仅可能遗漏依赖,而且 eslint 插件也无法帮助自动收集依赖。
只要不依赖 Function Component 内变量的函数都可以直接抽出去,但是依赖了变量的函数怎么办?
useCallback
:
function Parent() {
const [query, setQuery] = useState("react");
// ✅ Preserves identity until query changes
const fetchData = useCallback(() => {
const url = "https://hn.algolia.com/api/v1/search?query=" + query;
// ... Fetch data and return it ...
}, [query]); // ✅ Callback deps are OK
return <Child fetchData={fetchData} />;
}
function Child({ fetchData }) {
let [data, setData] = useState(null);
useEffect(() => {
fetchData().then(setData);
}, [fetchData]); // ✅ Effect deps are OK
// ...
}
因为函数也具有 Capture Value 特性,经过 useCallback
包装的函数可以当成普通变量来作为 useEffect
的依赖。
useCallback
就是在它的依赖变化时返回一个新的函数引用,从而触发 useEffect
的依赖变化并激活它重新执行。
在 Class Component 中,如果希望参数变化就重新取数,我们不能直接对比取数函数的 diff 而是要对比取数参数是否变化,这样的代码不内聚难维护;对比 Function Component 中利用 useCallback
封装的取数函数,useEffect
只需关心这个依赖是否变化,参数的变化在 useCallback
内关系,再配合 eslint 插件扫描就能做到依赖不丢、逻辑内聚易维护。
回收机制#
在组件被销毁时,通过 useEffect
注册的监听需要被销毁,可以通过它的返回值处理:
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange);
};
});
在组件销毁时会执行返回值函数内回调函数,由于 Capture Value 特性,每次注册与回收拿到的都是成对的固定值。
如果没有合理的返回回收,很容易造成内存泄露,如果直接传了一个 async
这样的异步函数 useEffect 也会警告,那如何做到销毁时取消异步函数呢?
如果使用的异步方式支持取消可以直接在清除函数中取消异步请求,更简单的一个方式是借助一个布尔值:
function Article({ id }) {
const [article, setArticle] = useState(null);
useEffect(() => {
let didCancel = false;
async function fetchData() {
const article = await API.fetchArticle(id);
if (!didCancel) {
setArticle(article);
}
}
fetchData();
return () => {
didCancel = true;
};
}, [id]);
// ...
}
这篇文章 讨论了更多关于如何处理错误和加载状态的场景。
其它优势#
useEffect
在渲染结束时执行,也就不会阻塞浏览器渲染进程,符合 React Fiber 的理念,因为 Fiber 是会根据情况暂停或插队执行不同组件的 Render,遵循 Capture Value 特性的代码可以保证值的安全访问,弱化生命周期也能解决中断执行带来的问题。
React Hooks 目前还在完善发展中,官方与社区的实践方案与轮子都有值得参考学习的地方,同时 Vue 3.0 也借鉴了 React Hooks 的思想,产生了 Vue Hooks,两者各有优劣,对这一部分深入了解后会再补充到这里。
参考: