戳蓝字「合格前端」关注我们哦!
新技术三问
背景是什么?
怎么使用?
怎么实现的?
Hooks 是什么?
Hooks 是 React 16.8新添加东西,它们能让在不写Class的情况下也能使用state和其他的React特性”— React Docs
背景
React Hook的产生主要是为了解决什么问题呢?
官方的文档里写的非常清楚 https://zh-hans.reactjs.org/docs/hooks-intro.html
总结一下要解决的痛点问题就是:
1. 在组件之间复用状态逻辑很难
- 之前的解决方案是:render props 和高阶组件
- 缺点是难理解、存在过多的嵌套形成“嵌套地狱”
2. 复杂组件变得难以理解
- 生命周期函数中充斥着各种状态逻辑和副作用
- 这些副作用难以复用,且很零散
3. 难以理解的Class
- this指针问题
- 组件预编译技术(组件折叠)会在class中遇到优化失效的case
- class不能很好的压缩
- class在热重载时会出现不稳定的情况
class组件 VS fuctional组件
一个简单的例子 https://codesandbox.io/s/new-9ps9e
useState Hook
import React, { useState } from 'react';
function Example() {
// 声明一个叫 "count" 的 state 变量
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
调用 useState 方法的时候做了什么?
它定义一个叫 count的变量, 但是我们可以叫他任何名字,比如 banana。这是一种在函数调用时保存变量的方式 —— useState 是一种新方法,它与 class 里面的 this.setState 提供的功能完全相同。一般来说,在函数退出后变量就就会”消失”,而 state 中的变量会被 React 保留。
useState 需要哪些参数?
useState() 方法里面唯一的参数就是初始 state。
useState 方法的返回值是什么?
返回值为:当前 state 以及更新 state 的函数。
变量存在哪里?
fiberNode
的hook对象以链表的方式存放
useEffect Hook
副作用操作包含哪些?
dom 操作
浏览器事件绑定和取消绑定
发送 HTTP 请求
打印日志
访问系统状态
执行 IO 变更操作
能解决什么问题?
Effect Hook 可以让你在函数组件中执行副作用操作
class组件写法
class Example extends React.Component {
constructor(props) {
super(props);
this.state = {
count: 0
};
this.handleScroll = this.handleScroll.bind(this)
}
handleScroll(){
//do sometings...
}
componentDidMount() {
document.title = `You clicked ${this.state.count} times`;
document.addEventListener('scroll', this.handleScroll)
}
componentDidUpdate(prevProps, prevState) {
if (prevState.count !== this.state.count) {
document.title = `You clicked ${this.state.count} times`;
}
}
componentWillUnMount(){
document.removeEventListener('scroll', this.handleScroll);
}
render() {
return (
<div>
<p>You clicked {this.state.count} times</p>
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
Click me
</button>
</div>
);
}
}
这样写带来的问题
- 生命周期函数中充斥着各种状态逻辑和副作用
- 这些副作用难以复用,且很零散
用useEffect后的写法
import React, { useState, useEffect } from 'react';
function Example() {
const [count, setCount] = useState(0);
handleScroll(){
//do somethings...
}
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]);
useEffect(() => {
document.addEventListener('scroll', handleScroll);
return (){
document.removeEventListener('scroll', handleScroll)
}
},[]);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
这样写带来的好处
-副作用职责明确(dom事件添加跟删除在一个effect里),易于复用
-不用手动bind作用域
-通过useEffect的第二个参数能轻易实现不必要的操作!
如果你熟悉 React class 的生命周期函数,你可以把 useEffect Hook
看做 componentDidMount
,componentDidUpdate
和 componentWillUnmount
这三个函数的组合。
伪实现
useState实现
1.最简单的 useState 用法是这样的:
function Counter() {
var [count, setCount] = useState(0);
return (
<div>
<div>{count}</div>
<Button onClick={() => { setCount(count + 1); }}>
点击
</Button>
</div>
);
}
2.基于 useState 的用法,我们尝试着自己实现一个 useState:
function useState(initialValue) {
var state = initialValue;
function setState(newState) {
state = newState;
render();
}
return [state, setState];
}
3.这时我们发现,点击 Button 的时候,count 并不会变化,为什么呢?我们没有存储 state,每次渲染 Counter 组件的时候,state 都是新重置的。
自然我们就能想到,把 state 提取出来,存在 useState 外面。
var _state; // 把 state 存储在外面
function useState(initialValue) {
_state = _state || initialValue; // 如果没有 _state,说明是第一次执行,把 initialValue 复制给它
function setState(newState) {
_state = newState;
render();
}
return [_state, setState];
}
到目前为止,我们实现了一个可以工作的 useState,至少现在来看没啥问题。
接下来,让我们看看 useEffect 是怎么实现的。
useEffect实现
useEffect 是另外一个基础的 Hook,用来处理副作用,最简单的用法是这样的:
useEffect(() => {
console.log(count);
}, [count]);
我们知道 useEffect 有几个特点:
有两个参数 callback 和 dependencies 数组
如果 dependencies 不存在,那么 callback 每次 render 都会执行
如果 dependencies 存在,只有当它发生了变化, callback 才会执行
我们来实现一个 useEffect
let _deps; // _deps 记录 useEffect 上一次的 依赖
function useEffect(callback, depArray) {
const hasNoDeps = !depArray; // 如果 dependencies 不存在
const hasChangedDeps = _deps
? !depArray.every((el, i) => el === _deps[i]) // 两次的 dependencies 是否完全相等
: true;
/* 如果 dependencies 不存在,或者 dependencies 有变化*/
if (hasNoDeps || hasChangedDeps) {
callback();
_deps = depArray;
}
}
我们好像不小心又实现了一个可以工作的 useEffect。
此时我们应该可以解答一个问题:
Q:为什么第二个参数是空数组,相当于 componentDidMount
?
A:因为依赖一直不变化,callback 不会二次执行。
Not Magic, just Arrays
到现在为止,我们已经实现了可以工作的 useState 和 useEffect。但是有一个很大的问题:它俩都只能使用一次,因为只有一个 _state 和 一个 _deps。比如
const [count, setCount] = useState(0);
const [username, setUsername] = useState('fan');
count 和 username 永远是相等的,因为他们共用了一个 _state,并没有地方能分别存储两个值。我们需要可以存储多个 _state 和 _deps。
可以用数组解决 Hooks 的复用问题。
代码关键在于:
初次渲染的时候,按照 useState,useEffect 的顺序,把 state,deps 等按顺序塞到 memoizedState 数组中。
更新的时候,按照顺序,从 memoizedState 中把上次记录的值拿出来。
let memoizedState = []; // hooks 存放在这个数组
let cursor = 0; // 当前 memoizedState 下标
function useState(initialValue) {
memoizedState[cursor] = memoizedState[cursor] || initialValue;
const currentCursor = cursor;
function setState(newState) {
memoizedState[currentCursor] = newState;
render();
}
return [memoizedState[cursor++], setState]; // 返回当前 state,并把 cursor 加 1
}
function useEffect(callback, depArray) {
const hasNoDeps = !depArray;
const deps = memoizedState[cursor];
const hasChangedDeps = deps
? !depArray.every((el, i) => el === deps[i])
: true;
if (hasNoDeps || hasChangedDeps) {
callback();
memoizedState[cursor] = depArray;
}
cursor++;
}
我们用图来描述 memoizedState 及 cursor 变化的过程。
function APP() {
const [count, setCount] = useState(0);
const [username, setUsername] = useState("fan");
useEffect(() => {
console.log(count);
}, [count]);
useEffect(() => {
console.log(username);
}, [username]);
return (
<div>
<div>{count}</div>
<Button
onClick={() => {
setCount(count + 1);
}}
>
点击
</Button>
<div>{username}</div>
<Button
onClick={() => {
setUsername(username + " hello");
}}
>
点击
</Button>
</div>
);
}
1.初始化
2.初次渲染
3.事件触发
4.重新渲染
到这里,我们实现了一个可以任意复用的 useState 和 useEffect。
同时,也可以解答几个问题:
Q:为什么只能在函数最外层调用 Hook?为什么不要在循环、条件判断或者子函数中调用。
A:memoizedState 数组是按 hook定义的顺序来放置数据的,如果 hook 顺序变化,memoizedState 并不会感知到。
Q:自定义的 Hook 是如何影响使用它的函数组件的?
A:共享同一个 memoizedState,共享同一个顺序。
Q:“Capture Value” 特性是如何产生的?
A:每一次 ReRender 的时候,都是重新去执行函数组件了,对于之前已经执行过的函数组件,并不会做任何操作。
官方实现
Hook对象
Hook对象结构:
export type Hook = {
//... ...
memoizedState: any, //用来记录当前useState应该返回的结果的
queue: UpdateQueue<any, any> | null, //缓存队列,存储多次更新行为
next: Hook | null, //指向下一次useState对应的Hook对象。
};
结合示例代码来看:
import React, { useState } from 'react'
import './App.css'
export default function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState('Star');
// 调用三次setCount便于查看更新队列的情况
const countPlusThree = () => {
setCount(count+1);
setCount(count+2);
setCount(count+3);
}
return (
<div className='App'>
<p>{name} Has Clicked <strong>{count}</strong> Times</p>
<button onClick={countPlusThree}>Click *3</button>
</div>
)
}
第一次点击按钮触发更新时,memoizedState的结构如下
状态变更流程
1.初始化时
主逻辑:创建一个新的hook,初始化state, 并绑定触发器
function mountState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
// 访问Hook链表的下一个节点,获取到新的Hook对象
const hook = mountWorkInProgressHook();
//如果入参是function则会调用,但是不提供参数
if (typeof initialState === 'function') {
initialState = initialState();
}
// 进行state的初始化工作
hook.memoizedState = hook.baseState = initialState;
// 进行queue的初始化工作
const queue = (hook.queue = {
last: null,
dispatch: null,
eagerReducer: basicStateReducer, // useState使用基础reducer
eagerState: (initialState: any),
});
// 返回触发器
const dispatch: Dispatch<BasicStateAction<S>,>
= (queue.dispatch = (dispatchAction.bind(
null,
//绑定当前fiber结点和queue
((currentlyRenderingFiber: any): Fiber),
queue,
));
// 返回初始state和触发器
return [hook.memoizedState, dispatch];
}
重点说一下返回的这个更新函数 dispatchAction
function dispatchAction<S, A>(
fiber: Fiber,
queue: UpdateQueue<S, A>,
action: A,
) {
/** ... ... **/
// 创建新的新的update, action就是我们setCount里面的值(count+1, count+2, count+3…)
const update: Update<S, A> = {
expirationTime,
action,
eagerReducer: null,
eagerState: null,
next: null,
};
// 重点:构建query
// queue.last是最近的一次更新,然后last.next开始是每一次的action
const last = queue.last;
if (last === null) {
// 只有一个update, 自己指自己-形成环
update.next = update;
} else {
const first = last.next;
if (first !== null) {
update.next = first;
}
last.next = update;
}
queue.last = update;
/** ... ... **/
// 创建一个更新任务
scheduleWork(fiber, expirationTime);
}
在dispatchAction
中维护了一份query的数据结构。
query是一个环形链表
,规则:
query.last指向最近一次更新
last.next指向第一次更新
后面就依次类推,最终倒数第二次更新指向last,形成一个环。
所以每次插入新update时,就需要将原来的first指向query.last.next。再将update指向query.next,最后将query.last指向update.
2.更新时
主要逻辑:获取该Hook对象中的 queue,内部存有本次更新的一系列数据,进行更新
let newState = hook.memoizedState;
do {
const action = update.action;
// 此时的reducer是basicStateReducer,直接返回action的值
newState = reducer(newState, action);
update = update.next;
} while (update !== null);
// 对 更新hook.memoized
hook.memoizedState = newState;
// 返回新的 state,及更新 hook 的 dispatch 方法
return [newState, dispatch];
自定义Hook
看一个点击一个按钮加载文章的例子
export default function Article() {
const [isLoading, setIsLoading] = useState(false);
const [content, setContent] = useState('origin content');
function handleClick() {
setIsLoading(true);
loadPaper().then(content=>{
setIsLoading(false);
setContent(content);
})
}
return (
<div>
<button onClick={handleClick} disabled={isLoading} >
{isLoading ? 'loading...' : 'refresh'}
</button>
<article>{content}</article>
</div>
)
}
上面的代码中展示了一个带有 loading 状态,可以避免在加载结束之前反复点击的按钮。这种组件可以有效地给予用户反馈,并且避免用户由于得不到有效反馈带来的不断尝试造成的性能和逻辑问题。
很显然,loadingButton 的逻辑是非常通用且与业务逻辑无关的,因此完全可以将其抽离出来成为一个独立的LoadingButton
组件:
function LoadingButton(props){
const [isLoading, setIsLoading] = useState(false);
function handleClick(){
props.onClick().finally(()=>{
setIsLoading(false);
});
}
return (
<button onClick={handleClick} disabled={isLoading} >
{isLoading ? 'loading...' : 'refresh'}
</button>
)
}
// 使用
function Article(){
const {content, setContent} = useState('');
clickHandler(){
return fetchArticle().then(data=>{
setContent(data);
})
}
return (
<div>
<LoadingButton onClick={this.clickHandler} />
<article>{content}</article>
</div>
)
}
上面这种将某一个通用的 UI 组件单独封装并提取到一个独立的组件中的做法在实际业务开发中非常普遍,这种抽象方式同时将状态逻辑和 UI 组件打包成一个可复用的整体。
很显然,这仍旧是组件复用思维,并不是逻辑复用思维。试想一下另一种场景,在点击了 loadingButton 之后,希望文章的正文也同样展示一个 loading 状态该怎么处理呢?
function LoadingButton(props){
const [isLoading, setIsLoading] = useState(false);
function handleClick(){
props.onClick().finally(()=>{
setIsLoading(false);
});
}
return (
<button onClick={handleClick} disabled={isLoading} >
{isLoading ? 'loading...' : 'refresh'}
</button>
)
}
// 使用
function Article(){
const {content, setContent} = useState('origin content');
const {isLoading, setIsLoading} = useState(false);
clickHandler(){
setIsLoading(true);
return fetchArticle().then(data=>{
setContent(data);
setIsLoading(false);
})
}
return (
<div>
<LoadingButton onClick={this.clickHandler} />
{
isLoading
? <img src={spinner} alt="loading" />
: <article>{content}</article>
}
</div>
)
}
问题并没有因为抽象而变的更简单,父组件 Article 仍然要自定一个 isLoading 状态才可以实现上述需求,这显然不够优雅。那么问题的关键是什么呢?
答案是耦合
。上述的抽象方案将isLoading
状态和button
标签耦合在一个组件里了,这种复用的粒度只能整体复用这个组件,而不能单独复用一个状态。解决方案是:
// 提供 loading 状态的抽象
export function useIsLoading(initialValue, callback) {
const [isLoading, setIsLoading] = useState(initialValue);
function onLoadingChange() {
setIsLoading(true);
callback && callback().finally(() => {
setIsLoading(false);
})
}
return {
value: isLoading,
disabled: isLoading,
onChange: onLoadingChange, // 适配其他组件
onClick: onLoadingChange, // 适配按钮
}
}
export default function Article() {
const loading = useIsLoading(false, fetch);
const [content, setContent] = useState('origin content');
function fetch() {
return loadArticle().then(setContent);
}
return (
<div>
<button {...loading}>
{loading.value ? 'loading...' : 'refresh'}
</button>
{
loading.value ?
<img src={spinner} alt="loading" />
: <article>{content}</article>
}
</div>
)
}
参考资料:
https://zh-hans.reactjs.org/docs/hooks-intro.html
https://github.com/brickspert/blog/issues/26
https://www.infoq.cn/article/fiWNgsIOLaCmt-hphLYC
https://juejin.im/post/5cc809d2f265da036c579620#heading-1