距离去年10 月 25日React团队在首次在React Conf上提出hook这个概念到如今,已经快9个多月的时间,又在今年6月,React发布16.8.x版本,React-hook由此正式成为React的一员。这大半年的时间也有非常多的开发者去探索hooks。如今hooks特性已经稳定,寻找hooks的最佳实践场景也变得十分重要。
从官方的态度可以很容易看出是十分重视hooks这个特性的,并且官方直言我们期望 Hook 能够成为人们编写 React 组件的主要方式
。并且从笔者的实践过程来看hooks不仅仅是一种新玩法,更重要的意义是可以帮助开发者做减法,减少代码量,减少维护成本,甚至减少理解成本。
官网定义hook说它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性,言简意赅。
Hook意为钩子,通常类比与事件机制,例如webpack4中Tapable就由hook替代了以前的事件机制,这应该不仅仅是写法上转变,而是理念的升级。
平时使用的事件机制,往往事件是相互独立,更多的是订阅和发布的关系,也是一种典型的设计模式,设计模式其实本就是特定场景下的一种解法。对于webpack插件机制这样健壮精细的设计来说,单个设计模式过于片面,需要一套更加合理的方法论或者最佳实践才能涵盖得到。
与其从开发者的角度出发,不如着眼于设计本身,这样问题就成了内部系统的运作流程如何向外暴露,而不是如何拓展webpack的能力,从当下来看,问题答案就是Hook(钩子)。
Hook更加关注系统本身的运作。React中实现了组件的状态管理,组件的渲染,组件的嵌套等等一系列围绕组件所实现的特性,而在16.8.x以前,这些特性主要是围绕着Class组件来实现的,既然react有了这样的能力,何不将其也赋予在Function组件上,而将Function组件赋能的设计就是hook,就如钩子一样链接react内部运作的齿轮,使得组件的状态管理和实现形式有了另外一种可能。
“高内聚,低耦合”是非常具有前瞻性的软件开发原则,React中的组件似乎也践行得很不错可以说近乎完美,但是从另一个角度上看,组件内部逻辑的和视图的耦合度却是出奇的高。React 没有提供将可复用性行为“附加”到组件的途径,为了解决组件状态管理复用的问题也有HOC或者renderProps这样的方案,但是采取这样的方案往往需要重新组织组件的内部结构,使得组件难以理解,并且会产生嵌套过深的问题。HOC和renderProps显然不是理想的方案。
你可以使用 Hook 从组件中提取状态逻辑,使得这些逻辑可以单独测试并复用。
举个栗子,表单逻辑是在开发中常常遇到的,如果不使用一些工具库来做,直接手写受控组件、onChange监听、setState调用还有绑定this之类的还是比较麻烦,常用的解决办法也就是使用HOC或者renderProps来减少工作量,比如借用antd的表单:
class App extends React.PureComponent {
...
render() {
return (
<Form onSubmit={this.handleSubmit} className="login-form">
<FormItem>
{getFieldDecorator("userName", {
rules: [{ required: true, message: "Please input your username!" }]
})(
<Input
prefix={<Icon type="user" style={{ color: "rgba(0,0,0,.25)" }} />}
placeholder="Username"
/>
)}
</FormItem>
<FormItem>
<Button type="primary" htmlType="submit" className="login-form-button">
Log in
</Button>
Or <a href="">register now!</a>
</FormItem>
</Form>
);
}
}
现在有了另外一种解决方案,使用hook将获取表单项的值,监听值的改变,再赋值的逻辑封装起来。
const App = () => {
const [formState, formItem] = useFormState();
const { text, password } = formItem;
return (
<form>
<input {...text("username")} required />
<input {...password("password")} required minLength={8} />
</form>
);
}
我们可以通过formState获取到最新表单的值,调用text或者password就会返回对应的表单控件属性,value、onChange包括一些type、name字段也一并返回。hook处理表单的典型方式就是使用useState将表单项的值存储起来,每当触发onChange事件时就更新对应的value。类似于这样的实现:
function useInputValue(initialValue) {
let [value, setValue] = useState(initialValue);
let onChange = useCallback(function(event) {
setValue(event.currentTarget.value);
}, []);
return {
value,
onChange
};
}
当然这随着表单项变多,会状态管理的问题,React官方提供的useReducer
hooks就是为了解决这个问题而生的,useFormState
具体实现就不展开,理解上面的代码就可以大概知晓内部的实现逻辑。
对比可以发现,useFormState
将处理表单基础逻辑封装,真正得以复用,并没有和UI层强耦合在一起。粒度更细,拓展性更强。
通常我们实现一个列表功能的应用时,并不能像想象中的“智能组件”和“木偶组件”那样拆分,随着功能的逐渐增多,列表中的每一项需要承载的功能也就愈多,负责展示的木偶组件也不得不改写为智能组件。这样的场景对于有经验的开发者来说可以在设计组件的时候避免,但是智能组件越写越复杂却是不得不面对的。React开发通常就是这样,最初的组件往往很简单,但是渐渐会被副作用函数和状态管理所困扰。
我们常常在componentDidMount
中获取数据,但是在componentDidMount
中往往不止有获取数据的逻辑,还有很多其他的事处理,比如监听事件,监听过后在componentWillUnmount
中又取消订阅,一个事情被写在了两处,导致增加后期代码对照维护的成本,反而不同逻辑的代码却写在了一处。
无可厚非,组件生命周期函数设计就是这样,在特定的节点运行对应的生命周期函数。那如果将相同的节点任务以任务本身拆分而不是按照节点拆分是不是更好些呢,毕竟需要我们维护的是特定节点处理事情的逻辑,而不需要关心组件的生命周期的实现方式。
例如一个class实现的较为简单的倒计时组件,
export default class countDown extends Component {
constructor(props) {...}
// 开始倒计时
componentDidMount() {
this.countDown();
}
// 组件卸载取消倒计时
componentWillUnmount(){
clearInterval(this.timer);
}
// 接受到新的times时,重新开始
componentWillReceiveProps(nextProps) {
if (nextProps.times !== this.times && !nextProps.restartOnce) {
this.times = nextProps.times;
this.restart();
}
}
// 倒计时
countDown(){...}
// 重新计时
restart(){...}
render() {...}
}
如上代码中,在componentDidMount
设置了一个定时器,又在componentWillUnmount
中取消,一个倒计时的逻辑被拆成很多部分,并且还要考虑到class组件的生命周期,有新的值进来后还得重新开始倒计时。一个逻辑被拆到了至少三处,这还只是在定位明确且简单的倒计时组件中,平时的业务组件逻辑更为复杂,一个函数里揉杂了很多不相关的逻辑。
再来看下Hook的实现
const useTimer = timeStamp => {
let interVal, countDownInterval, initState;
const initState = { tsp: timeStamp };
const [state, dispatch] = useReducer(timeReducer, initState);
const { tsp } = state;
useEffect(() => {
// 开始倒计时
countDownInterval.current = setInterval(()=>{...}, interVal.current);
return () => {
// 清除定时器
clearInterval(countDownInterval.current);
};
}, [tsp]);
return { ...state };
};
可以看到useTimer
中对于倒计时的逻辑全都内敛到了一块,当然,具体倒计时的实现逻辑是可以抽出来的,就像class组件的countDown
一样,更为关键的地方在于这个Hook和视图是没有绑定的,在任何需要倒计时的场景下都可以复用。
const Timer = (timeStamp) => {
const { days, hours } = useTimer(timeStamp);
return (<div>距离结束还有 {days}天 {hours}小时 </div>)
}
React的学习曲线还是挺陡的,采用class来实现组件,得去理解class中this
的工作机制,还不能忘记绑定事件。从官网可以看到class的一下几个问题:
Hook的使用也是有些许学习成本的,特别是针对熟悉class组件开发方式的同学来说,hooks总有一种很迷的感觉。而对于刚接触React同学来说,可能hooks反而更容易接受。因为React中props、state、数据自上而下流动种种理念都是比较好理解的,但是一和class打交道就比较排斥。回想我们最开始学习React的时候,第一个报错可能就和this的指向相关,要不就是在组件生命周期的理解上出现了偏差。反观函数式组件是不是感觉亲切多了。
其实之前说了那么多,归结于一句话就是Hooks可以在现有基础上帮助你提升React的开发体验
熟悉类组件开发的同学刚接触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>
);
}
如上代码实现了一个简单的计数器,点击一次按钮,次数就加一。在onClick的回调函数中调用了useState返回的setCount函数,这个函数可以更新count,到这里都比较好理解,就是this.setState的感觉。疑惑点在于每次更新都会重新调用Example这个函数,useState也就重新调用一次,count状态是如何记住的呢?
在js中实现数据持久化的方式就那么几种
考虑到React的优良设计风格,就可以排除1,2,5这种错误选项,函数组件显然没有类实例属性,剩下的闭包就是答案了。
直接看源码,从这段中可以看到hook的数据结构
export type Hook = {
// 记录上一次hook的状态
memoizedState: any,
// hook的初始状态
baseState: any,
baseUpdate: Update<any> | null,
queue: UpdateQueue<any> | null,
// 指向下一个Hook的指针
next: Hook | null,
};
可以看到在hook中使用了memoizedState
这个字段来存储状态,而在queue中有一个diapatch
字段,它就是用来更新state的。
以下代码是在FunctionalComponent
内部使用Hooks第一次渲染走的流程:
if (reducer === basicStateReducer) {
// Special case for `useState`.
if (typeof initialState === 'function') {
initialState = initialState();
}
} else if (initialAction !== undefined && initialAction !== null) {
initialState = reducer(initialState, initialAction);
}
workInProgressHook.memoizedState = workInProgressHook.baseState = initialState;
queue = workInProgressHook.queue = {
last: null,
dispatch: null,
};
const dispatch: Dispatch<A> = (queue.dispatch = (dispatchAction.bind(
null,
currentlyRenderingFiber,
queue,
): any));
workInProgressHook
就是指向当前上下文中Hook对象,可以看到queue上的dispatch是dispatchAction
绑定了对应的Fiber和queue,而dispatchAction
就是React内部用于创建一次更新的函数。最后返回:
return [workInProgressHook.memoizedState, dispatch];
这是组件单个hook第一次运行的情况,在更新阶段的时候操作基本是一样的,根据reducer和update.action来创建新的state,并赋值给workInProgressHook.memoizedState以及workInProgressHook.baseState。
当我们多次使用Hook时,在React内部,FunctionalComponent
的hooks之间并不是平铺的,而是采用链表的结构,next字段就派上了用场,类似这样的结构:
{
memoizedState: 'a',
next: {
memoizedState: 'b',
next: {
memoizedState: 'c',
next: null
}
}
}
如上可以看到使用了3个hook,只要其中一个hook触发了更新函数,都会按照链表的顺序执行更新,这就对应上了官方的对于使用hooks的建议:不要在循环,条件或嵌套函数中调用 Hook,很明显,如果在条件语句中使用了hook会导致hook对象无法对应上它原本的值。
其实这个大的闭包都是建立在函数组件对应的Fiber
树上的,所有的值都是从上面存取而来,借用网上一张图:
看到这里也就可以大致回答第一个问题了,Hooks的状态持久化是使用闭包的方式,将数据存放在组件对应的Fiber
树上,每次触发更新(Dispatcher)就会在React内部产生一个调度任务(schduleWork),任务结束后会最新的值就会覆盖原来的状态。
在上面那幅图中,memoized state queue
对应了hook的状态的存取实现,右边的passive effects queue
就是hook中的副作用了(生命周期)。其实在React内部,由hook调动的副作用被称为passive effect
也就是被动副作用,在这里可以看到端倪。
hook的副作用触发时机是根据其tag值确定的,不同的Effect有不同的触发时机。
Effect的数据结构如下:
type Effect = {
// 二进制数,控制触发时机。使用与或操作符来实现多状态管理
tag: HookEffectTag,
// mount之后运行的回调函数
create: () => mixed,
// create返回的回调函数
destroy: (() => mixed) | null,
// 调用依赖
inputs: Array<mixed>,
// 下一个effec
next: Effect,
};
// ReactHookEffectTags.js 中对tag值的声明
export const NoEffect = /* */ 0b00000000;
export const UnmountSnapshot = /* */ 0b00000010;
export const UnmountMutation = /* */ 0b00000100;
export const MountMutation = /* */ 0b00001000;
export const UnmountLayout = /* */ 0b00010000;
export const MountLayout = /* */ 0b00100000;
export const MountPassive = /* */ 0b01000000;
export const UnmountPassive = /* */ 0b10000000;
各个副作用对应的tag值可以点击这里查看。hook会在React的commit
阶段触发commitHookEffectList
函数,具体实现就是会根据传入的unmountTag
和mountTag
来判断是否要执行对应的create和destroy方法,感兴趣的同学可以点击这里阅读commitHookEffectList
的源码。
从源码中可以看到一个细节,如果使用useEffect
并且依赖项是随周期变化的,那么它返回的destroy始终会先于create执行,而不是我们理解的只在在组件卸载时执行destroy。
其实有些effectTag
的触发时机和componentDidMount
或者componentDidUpdate
非常相似,所有从代码的角度看Hook也是有生命周期的。它和class组件的生命周期最大的不同就在于其内部的inputs
字段,可以控制effect是否触发,除了触发时机这个条件,还有inputs中的值是否发生了变化这个更重要的条件,也就是说我们可以通过控制effect的依赖项来定义这个副作用触发的时机。
在官方提供的Hooks中,有一部分hooks可以传入一个依赖数组,它会根据上下两次传入的值做浅比较,来决定是不是要销毁重新调用。比如最初计时器的例子,我们增加一个日志的功能,每次点击都记录下来:
import React, { useState, useEffect } from 'react';
function Example() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log(count);
}, [count]);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
在useEffect第二个参数Deps
中传入了依赖项count,每次count有变化的时候都会在合适的时机执行这个副作用函数。如果在这个副作用函数中依赖了另一个变量,假定是B,但是没有在Deps
中出现,即便在count更新时可以拿到最新的变量B,但是在B变化的时候并不会触发这个副作用函数。除非你清楚你在做什么,否则还是将所有的依赖项全部传入Deps
以免引起难以察觉的bug。
我们来看一个更高级的玩法,设想一个场景,用户在每次输入后都向后台发送一次请求查询结果(不考虑节流或者防抖)。用class组件的话肯定是在onChange的回调里做文章,每次触发就发送一次请求。有没有更加聪明的办法,数据变化过后可以自己去服务器请求数据呢:
const useFetch = count => {
return useCallback(() => {
return Promise.resolve("got data :" + count);
}, [count]);
};
function Example() {
const [count, setCount] = useState(0);
const fetchData = useFetch(count);
useEffect(() => {
fetchData().then(res => console.log(res));
}, [fetchData]);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
如上代码就实现了用户输入内容变化后,自动去后台拉数据,而不是通过监听onChange
这种事件实现的。
实现方式也很好理解,就是精准依赖
。先看看都依赖了些哪些变量,在useEffect
中依赖了fetchData这个请求数据的函数,每当fetchData变化时,就会发出请求,fetchData是由useFetch这个自定义hook返回的,在useFetch中使用了useCallback,它会返回一个回调函数,这个回调函数会在依赖项也就是传进来的count变更时返回一个新的回调函数,也就是说count变化过后,fetchData也会发生变化。如此就实现了count和fetchData调用时机的绑定关系。
可以看到,我们可以不用主动去监听count值的变化,而是由useEffect去被动地去监听count的变化,这样是不是有种IOC也就是控制反转的感觉,不用关系依赖项如何变化,只需要在依赖项中写好即可。
当业务较为复杂的时候,依赖项可能会较多,有可能会出现依赖项缺少的情况,React官方也想到了这种情况,推出了eslint-plugin-react-hooks这个工具,他会检查自定义Hook的规则和effect的依赖项。
React官方还是十分推荐大家在新项目中尝试hooks的,并且这大概率上是React以后的主流开发方式。社区也在积极响应官方,推出了很多库的hooks版本,其实最主要还是得益于hooks的设计,使得大多数库出一个hook版本的API还是比较轻松的。hooks很有很多玩法没有介绍到,需要读者一一去探索尝试,这里抛砖引玉说一个点,往往业务开发中需要埋点上报,以往class组件可以借用AOP的思路去做上报,然而在函数式组件中打点上报最佳实践在哪里,还需要继续探索。
总结一下,React-hooks的玩法还是很多的,并且确实可以提升开发体验。尝试一下,不亏。