有了 React Hooks 的加持,妈妈再也不用担心函数组件记不住状态
过去,React 中的函数组件都被称为 无状态函数式组件(stateless functional component),这是因为函数组件没有办法拥有自己的状态,只能根据 Props 来渲染 UI,其性质就相当于是类组件中的 render 函数,虽然结构简单明了,但是作用有限。
但自从 React Hooks 横空出世,函数组件也拥有了保存状态的能力,而且也逐渐能够覆盖到类组件的应用场景,因此可以说 React Hooks 就是未来 React 发展的方向。
React Hooks 解决了什么问题
复杂的组件难以分拆
我们知道组件化的思想就是将一个复杂的页面 / 大组件,按照不同层次,逐渐抽象并拆分成功能更纯粹的小组件,这样一方面可以减少代码耦合,另外一方面也可以更好地复用代码;但实际上,在使用 React 的类组件时,往往难以进一步分拆复杂的组件,这是因为逻辑是有状态的,如果强行分拆,会令代码复杂性急剧上升;如使用 HOC 和 Render Props 等设计模式,这会形成“嵌套地狱”,使我们的代码变得晦涩难懂。
状态逻辑复杂,给单元测试造成障碍
这其实也是上一点的延续:要给一个拥有众多状态逻辑的组件写单元测试,无疑是一件令人崩溃的事情,因为需要编写大量的测试用例来覆盖代码执行路径。
组件生命周期繁复
对于类组件,我们需要在组件提供的生命周期钩子中处理状态的初始化、数据获取、数据更新等操作,处理起来本身逻辑就比较复杂,而且各种“副作用”混在一起也使人头晕目眩,另外还很可能忘记在组件状态变更 / 组件销毁时消除副作用。
React Hooks 就是来解决以上这些问题的
- 针对状态逻辑分拆复用难的问题:其实并不是 React Hooks 解决的,函数这一形式本身就具有逻辑简单、易复用等特性。
- 针对组件生命周期繁复的问题:React Hooks 屏蔽了生命周期这一概念,一切的逻辑都是由状态驱动,或者说由数据驱动的,那么理解、处理起来就简单多了。
利用自定义 Hooks 捆绑封装逻辑与相关 state
我认为 React Hooks 的亮点不在于 React 官方提供的那些 API,那些 API 只是一些基础的能力;其亮点还是在于 自定义 Hooks —— 一种封装复用的设计模式。
例如,一个页面上往往有很多状态,这些状态分别有各自的处理逻辑,如果用类组件的话,这些状态和逻辑都会混在一起,不够直观:
class Com extends React.Component {
state = {
a: 1,
b: 2,
c: 3,
}
componentDidMount() {handleA()
handleB()
handleC()}
}
而使用 React Hooks 后,我们可以把状态和逻辑关联起来,分拆成多个自定义 Hooks,代码结构就会更清晰:
function useA() {const [a, setA] = useState(1)
useEffect(() => {handleA()
}, [])
return a
}
function useB() {const [b, setB] = useState(2)
useEffect(() => {handleB()
}, [])
return b
}
function useC() {const [c, setC] = useState(3)
useEffect(() => {handleC()
}, [])
return c
}
function Com() {const a = useA()
const b = useB()
const c = useC()}
我们除了可以利用自定义 Hooks 来拆分业务逻辑外,还可以拆分成复用价值更高的通用逻辑,比如说目前比较流行的 Hooks 库:react-use;另外,React 生态中原来的很多库,也开始提供 Hooks API,如 react-router。
忘记组件生命周期吧
React 提供了大量的组件生命周期钩子,虽然在日常业务开发中,用到的不多,但光是 componentDidUpdate 和 componentWillUnmount 就让人很头痛了,一不留神就忘记处理 props 更新 和组件销毁需要处理副作用 的场景,这不仅会留下肉眼可见的 bug,还会留下一些内存泄露的隐患。
类 MVVM 框架讲究的是 数据驱动,而生命周期这种设计模式,就明显更偏向于传统的事件驱动模型;当我们引入 React Hooks 后,数据驱动的特性能够变得更纯粹。
处理 props 更新
下面我们以一个非常典型的列表页面来举个例子:
class List extends Component {
state = {data: []
}
fetchData = (id, authorId) => {// 请求接口}
componentDidMount() {this.fetchData(this.props.id, this.props.authorId)
// ... 其它不相关的初始化逻辑
}
componentDidUpdate(prevProps) {
if (
this.props.id !== prevProps.id ||
this.props.authorId !== prevProps.authorId // 别漏了!) {this.fetchData(this.props.id, this.props.authorId)
}
// ... 其它不相关的更新逻辑
}
render() {// ...}
}
上面这段代码有 3 个问题:
- 需要同时在两个生命周期里执行几乎相同的逻辑。
- 在判断是否需要更新数据的时候,容易漏掉依赖的条件。
- 每个生命周期钩子里,会散落大量不相关的逻辑代码,违反了高内聚的原则,影响阅读代码的连贯性。
如果改成用 React Hooks 来实现,问题就能得到很大程度上的解决了:
function List({id, authorId}) {const [data, SetData] = useState([])
const fetchData = (id, authorId) => {}
useEffect(() => {fetchData(id, authorId)
}, [id, authorId])
}
改用 React Hooks 后:
- 我们不需要考虑生命周期,我们只需要把逻辑依赖的状态都丢进依赖列表里,React 会帮我们判断什么时候该执行的。
- React 官方提供了 eslint 的插件来检查依赖项列表是否完整。
- 我们可以使用多个 useEffect,或者多个自定义 Hooks 来区分开多个无关联的逻辑代码段,保障 高内聚 特性。
处理副作用
最常见的副作用莫过于绑定 DOM 事件:
class List extends React.Component {handleFunc = () => {}
componentDidMount() {window.addEventListener('scroll', this.handleFunc)
}
componentWillUnmount() {window.removeEventListener('scroll', this.handleFunc)
}
}
这块也还是会有上述说的,影响高内聚的问题,改成 React Hooks:
function List() {useEffect(() => {window.addEventListener('scroll', this.handleFunc)
}, () => {window.removeEventListener('scroll', this.handleFunc)
})
}
而且比较绝的是,除了在组件销毁的时候会触发外,在依赖项变化的时候,也会执行清除上一轮的副作用。
利用 useMemo 做局部性能优化
在使用类组件的时候,我们需要利用 componentShouldUpdate 这个生命周期钩子来判断当前是否需要重新渲染,而改用 React Hooks 后,我们可以利用 useMemo 来判断是否需要重新渲染,达到局部性能优化的效果:
function List(props) => {useEffect(() => {fetchData(props.id)
}, [props.id])
return useMemo(() => (// ...), [props.id])
}
在上面这段代码中,我们看到最终渲染的内容是依赖于 props.id
,那么只要props.id
不变,即便其它 props 再怎么办,该组件也不会重新渲染。
依靠 useRef 摆脱闭包
在我们刚开始使用 React Hooks 的时候,经常会遇到这样的场景:在某个事件回调中,需要根据当前状态值来决定下一步执行什么操作;但我们发现事件回调中拿到的总是旧的状态值,而不是最新状态值,这是怎么回事呢?
function Counter() {const [count, setCount] = useState(0);
const log = () => {setCount(count + 1);
setTimeout(() => {console.log(count);
}, 3000);
};
return (
<div>
<p>You clicked {count} times</p>
<button onClick={log}>Click me</button>
</div>
);
}
/*
如果我们在三秒内连续点击三次,那么 count 的值最终会变成 3,而随之而来的输出结果是?0
1
2
*/
“这是 feature 不是 bug”,哈哈哈,说是 feature 可能也不太准确,因为这不正是 javascript 闭包的特性吗?当我们每次往 setTimeout
里传入回调函数时,这个回调函数都会引用下当前函数作用域(此时 count 的值还未被更新),所以在执行的时候打印出来的就会是旧的状态值。
类组件是怎么实现的?
那为啥类组件中,每次都能取到最新的状态值呢?这是因为我们在类组件中取状态值都是从 this.state
里取的,这相当于是类组件的一个执行上下文,永远都是保持最新的。
借助 useRef 共享修改
通过
useRef
创建的对象,其值只有一份,而且在所有 Rerender 之间共享。
听上去,这 useRef 其实跟 this.state 很相似嘛,都是一个可以一直维持的值,那我们就可以用它来维护我们的状态了:
function Counter() {const count = useRef(0);
const log = () => {
count.current++;
setTimeout(() => {console.log(count.current);
}, 3000);
};
return (
<div>
<p>You clicked {count.current} times</p>
<button onClick={log}>Click me</button>
</div>
);
}
/*
3
3
3
*/