对于React Hook的思考探索

2,367 阅读11分钟

最近一直在学React相关的东西,React基于组件的编码方式,让写界面省了不少事儿。难怪现在FlutterCompose都开始拥抱这种开发方式。顺便也重拾起了荒废已久的js,js经过这几年的更新已经变得像一门新语言了,还支持了class这个语法,让我们熟悉面向对象开发的人更容易上手。但是恼人多变的this一直都在,一开始用类写组件的时候经常会莫名其妙地遇到对象找不到的问题,最后发现要bind(this)

而且还有个问题是好多复杂的场景为了传递数据只能用高阶组件或者渲染属性来实现,像我这种刚接触前端的人肯定一脸懵逼。比如业务复杂之后我们有好多个Context相关的高阶组件,一层套一层,重重嵌套让我想起了在写Flutter时的恐惧。

像这样:

    <AuthenticationContext.Consumer>    
       {user => (        
          <LanguageContext.Consumer>         
               {language => (                
                    <StatusContext.Consumer>                  
                        {status => (                    
                                ...                  
                        )}                
                    </StatusContext.Consumer>           
               )}       
         </LanguageContext.Consumer>   
       )}
    </AuthenticationContext.Consumer>

所以在React提供的几种编写组件的方式中,我最喜欢函数组件,代码更加简洁,没有什么花里胡哨的新概念,而且可以让我避免跟this打交道。当然了,因此它的能力也十分有限,函数组件没有状态,大部分业务逻辑需要跟生命周期打交道,我还是需要通过类来写组件,管理生命周期跟状态,哪怕它只是个很小的组件。

###转机 然后某天我发现了Hook,打开了新大门!React内置了几个Hook,*100%*向后兼容, 对所有的React 我们熟知的概念提供了直接支持: props, state, context, refs, 以及生命周期。而且, Hook提供了更好的方式去组合这些概念,封装你的逻辑,避免了嵌套地狱或者类似的问题。我们可以在函数组件中使用状态,也可以在渲染后执行一些网络请求。

Hook其实就是普通的函数,是对类组件中一些能力在函数组件的补充,所以我们可以在函数组件中直接使用它,在类组件中,我们是不需要它的。

React提供的Hook不算多,我们最常用的Hook要数useStateuseEffectuseContext了,其他的都是适用更加通用的或者更加边界的场景的HookuseState可以让我们在函数组件中管理状态。

import { useState } from 'react'
const [ state, setState ] = useState(initialState)

之后我们就可以通过state直接访问状态,通过setState来设置状态,组件会自动重新渲染。

useEffect类似于向componentDidMountcomponentDidUpdate添加代码,我们常在这两个方法中设置网络请求或者Timer,现在统一写到一个地方就好了,同时我们也可以返回一个清理函数,它将会在在类似componentWillUnmount的时机被调用,执行一些清理操作。使用useEffect就可以替代这三个方法。

import { useEffect } from 'react'
useEffect(didUpdate)

useContext接受一个Context对象,返回一个Context的值。

import { useContext } from 'react'
const value = useContext(MyContext)

可以用来取代之前的Context Consumer。具体的使用方式我们以后再说,之前的嵌套地狱可以使用useContext来化解:

const user = useContext(AuthenticationContext)    
const language = useContext(LanguageContext)    
const status = useContext(StatusContext)

看到这儿,大家应该对Hook开始感兴趣了。与其写那么多ProviderConsumer,去熟悉一大堆花里胡哨的概念,大家都更喜欢这种直接的方式吧。我将展示给大家看,分别用类的方式跟Hook的方式来实现一个组件,进一步展示Hook带来的便利。

  • 类的方式 采用类去实现组件,我们要在构造器中去定义状态,而且需要修改this去做事件处理,代码如下:
import React from 'react'
class MyName extends React.Component {
	constructor(props) {
		super(props)
		this.state = { name: '' }
		this.handleChange = this.handleChange.bind(this)
	}

	handleChange(evt) {
		this.setState({ name: evt.target.value })
	}

	render() {
		const { name } = this.state
		return (
			<div>
				<h1>My name is: {name}</h1>
				<input type="text" value={name} onChange={this.handleChange} />
			</div>
		)
	}
}

export default MyName
  • 我们现在来看看函数组件的方式:
import React, { useState } from 'react'
function MyName() {
	const [name, setName] = useState('')

	function handleChange(evt) {
		setName(evt.target.value)
	}

	return (
		<div>
			<h1>My name is: {name}</h1>
			<input type="text" value={name} onChange={handleChange} />
		</div>
	)
}
export default MyName

代码量变少了,我们使用了useState,减少了很多模版代码,也不用处理构造器跟修改this了,想要修改状态直接调用setName就好了。整个代码看起来更加简洁易于理解,我们不再关心要怎么维护保存状态,安安心心通过useState函数使用状态就行了。而且函数的形式让编译器更容易去分析优化代码,移除无用的代码块,使生成的文件更小。

###香不香? 我们可以发现,Hook更偏向于我们向React声明我们想要什么,这一点类似于我们的界面描述方式,我们只说我们要什么,而不是告诉框架该怎么做,代码也更加简洁,方便其他人理解跟后期维护,通过函数的方式我们也可以在组件间共享逻辑。

###深入 那么Hook是怎么做到这么神奇的事情的呢,为了深入理解这背后的原理,我们从头开始实现一个我们自己的useState函数来理解这个过程。这个实现不会跟React的实现完全相同,我会尽量简化,将核心原理展示出来。

首先定义一个我们自己的useState函数,方法签名大家都知道了,要传递一个参数作为初始值。

function useState (initialState) {

然后我们定义一个值来保存我们的状态,一开始,它的值会是我们传给函数的initialState

let value = initialState

然后我们要定义一个setState函数,当我们改变状态值时,重新渲染组件。

function setState (nextValue) {        
    value = nextValue        
    ReactDOM.render(<MyName />, document.getElementById('root'))   
 }

这边的ReactDOM是用来重新渲染用的。 最终我们要把这个状态值跟设置方法以数组的形式返回出去:

    return [ value, setState ]
}

一个简单的Hook就实现了,Hook其实就是简单的js函数,用来执行一些有副作用的操作,比如用来设置一个有状态的值。我们的Hook使用了一个闭包来保存状态值,因为setStatevalue在同一个闭包下,所以我们的setState可以访问它,同理不把它传递出去的话在这个闭包外我们是没办法直接访问的。

###来问题了 如果我们现在运行我们的代码,我们会发现组件重新渲染的时候状态重置了,然后我们就不能输入任何文字。这是因为每次重新渲染都调用了useState,然后导致value初始化了那我们得想办法把状态保存在别的地方避免因为重新渲染而受到影响了。

我们先尝试在函数外使用一个全局变量来保存我们的状态,那这样的话我们的状态就不会因为重新渲染而初始化了。

let value
function useState (initialState) {

在useState上定义了一个全局变量后,我们的初始化代码也要改一改:

 if (typeof value === 'undefined') value = initialState

这样就没问题了。 但是紧接着,我们又发现,当我们想多调用几次useState来管理多个状态时,它总在往同一个全局变量上写值,所有的useState方法都在操作同一个value!这肯定不是我们想要的结果。

那为了支持多个useState调用,我们要想办法改进一下,把变量替换成一个数组试试?

let values = []
let currentHook = 0

然后赋初始值的地方也要修改:

 if (typeof values[currentHook] === 'undefined') 
      values[currentHook] = initialState

最重要的是我们的setState方法要修改好,这样我们只会更新该更新的状态值。我们需要把当前Hook对应的currentHook保存起来,因为currentHook是一直会变的。

    let hookIndex = currentHook    
    function setState (nextValue) {        
        values[hookIndex] = nextValue        
        ReactDOM.render(<MyName />, document.getElementById('root'))   
     }

最终返回:

return [ values[currentHook++], setState ]

然后我们还要在开始渲染的时候初始化一下currentHook:

function Name () {    
        currentHook = 0

现在我们的Hook可以说是正常工作了

使用一个全局数组保存Hookvalue可以满足多次调用useState的需求,React内部实现也是类似,不过它的实现更加复杂跟优化,它自己处理好了计数器跟全局变量,而且也不需要我们手动去重置计数器,不过大体原理咱算是把它摸清楚了。

###那复杂场景来了 其实也不是什么复杂的场景啦,想象这样一个情况,我们需要把输入的姓名展示出来,姓跟名分开用状态保存,同时我们想把姓做成选填那该怎么办? 我们可以先用一个状态记录姓是不是必需的:

const [ enableFirstName, setEnableFirstName ] = useState(false)

然后我们定义一个处理函数:

function handleEnableChange (evt) {        
    setEnableFirstName(!enableFirstName)    
}

如果checkbox没有勾选上我们就不打算渲染姓了,

<h1>My name is: {enableFirstName ? name : ''} {lastName}</h1>

我们能不能把Hook定义放进一个if条件或者三目运算符中去呢?像这样:

const [ name, setName ] = enableFirstName        
    ? useState('') : [ '', () => {} ]

现在yarn start来运行我们的代码,我们可以发现复选框没有勾选时,名还是可以修改的,姓随你怎么改都没用,这是我们想要的结果。

当我们再次选中复选框时,我们能修改姓了。但是奇怪的事发生了,名的值跑到姓那儿去了。

这是因为Hook的顺序很重要,我们都记得我们实现useState的时候,通过currentHook来确定当前调用的状态所在位置的,现在我们凭空插入了一个Hook调用,导致顺序被打乱了,Hook在重新渲染时会重新确定索引,但是我们的全局数组并不会变,导致姓去取了名的状态。

勾选复选框之前的状态:

  • [false, '客']
  • 依次是:enableFirstName, lastName 勾选之后:
  • [true, '客', ' ']
  • 依次是:enableFirstName, name, lastName

所以调用Hook的顺序很重要! 这个限制在React官方提供的Hook中也存在,而且React也决定坚持现在的设计。我们要避免这种写法,真有这种情况选择的情况,不管用不用,都直接把可能要用的Hook声明好,或者拆分出独立的组件,在组件里使用Hook,把问题转换成要不要渲染某个组件,这也是React团队推荐的做法。

虽然有时候我们会觉得能在条件语句或者循环中这样使用Hook更好,但是React团队为什么这么设计呢?有木有更好的方案呢?

有人提出了 NamedHook:

// 注意: 不是真实的React Hook API
const [ name, setName ] = useState('nameHook', '')

这样做可以避免上面那种数据混乱的情况,每个Hook调用我们都设了一个独特的名字,但是这样做我们就得花时间想出独一无二的名字,解决命名冲突,而且当一个条件变成false的时候我们该怎么做?如果一个元素从循环中删除了我们该怎么做?我们该清理状态吗?如果不清理状态,内存泄漏怎么办?

我们可以看到,这样并没有让事情变得简单,也引入了很多复杂的问题,所以React团队最后坚持了现在的设计,让API尽可能保持简单简单,而我们,在使用时要注意顺序。

看到这儿的同学可能已经跃跃欲试了,可能有同学会问道,既然Hook能大大地简化代码结构,让代码更加可维护,我们是不是该把所有的组件都用Hook来重写呢? 当然不—Hook是可选的。你可以在你的部分组件里面尝试HookReact团队现在还没有打算移除类组件。现在不急着把所有东西都重构成基于Hook。而且Hook并不是银弹,我们可以在觉得用Hook最恰当的地方用Hook来实现,比如, 你有许多组件处理相似的逻辑, 你可以把逻辑抽象成一个Hook,或者一个小组件用Hook实现会比较简单,有些地方状态管理比较复杂那还是用类组件会比较好。所以大部分情况下我们还是会函数组件跟类组件一起混用。

###结语

最后,相信大家对于Hook的作用跟实现原理想必有了个大体的了解,Hook就是一些简单的js函数,大家看一眼文档就知道怎么用啦,现在我们了解了Hook的优点跟限制,可以在日常开发中更好地做出选择,本文的代码看这里:示例代码