React新特性Hooks使用教学,以及与高阶组件、renderProps模式的对比

目录

一.什么是Hooks?

1.useState的作用

2.useEffect的作用

3.useContext的作用

二、对Hooks的思考

1.高阶组件实现逻辑复用

高阶组件版本的计数器

2.renderProps实现逻辑复用

renderProps版本的计数器

3.Hooks实现逻辑复用

Hooks版本的计数器

4.给计数器增加第二个功能——变换颜色

三、源码github地址:点击这里


Hooks是react在v16.7.0-alpha版本中发布的新特性。尽管推出的时间不长,但业界一致认为,Hooks必然会对react产生极大影响,甚至会永久改变开发者的书写习惯。初次听到这个消息的时候我还半信半疑,但在尝试一番后不得不承认,Hooks的确是一个影响深远的改动,如果有可能,我愿意在项目中立刻用上它。

react-native官方宣布将在0.59版本中支持Hooks,如果你看到该文章时,官方还没有正式更新0.59版本,可以按照以下步骤进行操作。

1.init一个新项目(不要在已有项目上操作)。打开根目录下的package.json,修改react和react-native的版本,如下:

  "dependencies": {
    "react": "^16.7.0-alpha.2",
    "react-native": "npm:@brunolemos/react-native"
  },

2.在根目录下使用控制台执行yarn或者npm install。

这里react-native用了brunolemos这位大佬自己fork下来修改的版本,所以不建议在正式项目中使用。如果你嫌以上的操作麻烦,也可以直接到文章末尾下载我提供的示例项目。

一.什么是Hooks?

Hooks是react 官方在 2018 ReactConf 大会上宣布、将在React v16.7.0-alpha版本中加入的新特性。它是一系列特殊方法的总称,包括三个主要钩子

useState,useEffect,useContext。

和七个附加钩子

useReducer,useCallback,useMemo,useRef,useImperativeMethods,useMutationEffect,useLayoutEffect。

不同钩子的具体作用可到React官方文档中查看。本文的讨论只涉及三个主要钩子。

Hooks的作用很简单:它能够让我们在函数式组件中进行一些以往只有在class组件中才能进行的操作,比如操作state。所以,Hooks只能在函数里而不能在class中使用

1.useState的作用

在Hooks出现之前,state只能在class组件中使用,所以页面的刷新也只能由class组件驱动:

// class组件
class SomeComponent extends React.Component{
  ......
  render(){
    this.setState(someState:'new value') // class组件中可以使用setState对state进行修改
    return <View/>
  }
}


// 函数式组件
const SomeComponent=()=>{
  ......
  // this.setState(someState:'new value') //报错:函数式组件不能对state进行修改,也没有state一说
  return <View/>
}

在官方引入Hook组件后,我们也可以像class组件一样,对state进行声明和修改了。如下:

import { useState } from 'react';

function Example() {
  const [count, setCount] = useState(0);

  return (
    <View>
      <View>You clicked {count} times</View>
      <Button onPress={() => setCount(count + 1)}>
        Click me
      </Button>
    </View>
  );
}

const [count, setCount] = useState(0) 就是useState钩子的使用方法。你没看错,只有一行语句,非常简洁明了。这个魔术般的语法看起来像是从某个方法的返回值中取值,所以不少人误以为useState是一个类似redux一样从某个地方取值的功能。其实不是这样的。

const [count, setCount] = useState(0) 本身就是一次声明+初始化的过程。在这个语句里,我们声明了一个叫做count的变量(等同于class中的state),一个叫做setCount的函数(可以用来修改count的值),并给count传递了一个初始值0(看到useState里面包含的参数了吗?)。count和setCount的名字都是可以随意设定的,你只要记得接受的第一个值是state的引用名,第二个是一个用来改变state的函数就行了。

如果我们把上面的例子改造成class组件,大概就是这个样子:

// class组件
class Example extends React.Component{
    state={count:0}
    setCount=(value)=>this.setState(value)

    render(){
        return (
            <View>
                <View>You clicked {this.state.count} times</View>
                <Button onPress={() => this.setCount(this.state.count+1)}>
                    Click me
                </Button>
            </View>
        )
    }
}

看到这里,相信你应该能领会到useState的使用方法了,同时也对Hooks有了一个初步的认识了吧。

2.useEffect的作用

除了操作state之外,还有什么是以前的函数组件不能做的吗?当然是操纵生命周期了。然而只要有了useEffect,这也并不是什么难事了。

effect的意思就是副作用。副作用是相对纯函数的概念来说的。一个纯函数的执行过程完全由输出参数决定,如果一个函数的执行受了参数之外的数据的影响(例如使用外界的一个变量加入执行过程中),或者与外界发生了可观察的交互(如发起网络请求、监听用户输入,修改了参数的值,打印log等),我们就称之为发生了副作用。

副作用会带来很多意想不到的结果,所以在函数式编程中要极力避免。但很多时候副作用又是必不可少的,最为典型的就是网络请求。所以为了尽可能地减少副作用带来的影响,最好把他们找个地方统一管理,useEffect就是拿来干这个的。

useEffect的参数是一个函数,组件每次渲染之后,都会调用这个函数参数,这样就达到了 componentDidMount 和 componentDidUpdate 一样的效果。所以当你觉得上面关于纯函数的解释很懵逼的时候,只要记得把以前在componentDidMount 和 componentDidUpdate中做的事,放到useEffect里面就行了。

useEffect(() => {
    fetch(api); //在sueEffect中发起网络请求
});

不过还有一个问题,useEffect每次页面刷新后都会调用,单我只想在页面第一次刷新的时候发起一次网络请求,这该怎么办?其实useEffect还接受第二个参数(数组),第一次运行后,这个参数的值会被保存在缓存中,第二次运行到这里,React会把这个参数和缓存中的值做比较,如果二者相同,就不执行里面的函数(跟一个叫reselect库效果几乎一模一样)。所以只要在里面随意写入几个数字或者字符串就可以让它只运行一次了。

useEffect(() => {
    fetch(api); //在sueEffect中发起网络请求
},[234]);

最后还有一点就是,在useEffect里使用return的话,该return会在组件被卸载的时候调用。利用这一点可以用来移除某些监听效果或者计时器。

useEffect(() => {
  const subscription = props.source.subscribe();
  return () => {
    // Clean up the subscription
    subscription.unsubscribe();
  };
});

useEffect的return其实就相当于class组件的componentWillUnmount。由此可见,useEffect其实是一个集componentDidMount 、componentDidUpdate、componentWillUnmount于一体的函数,真的是非常强大。

3.useContext的作用

useContext是对Context API的简化。context API是为了解决子组件嵌套层级过深,父组件的属性难以传达的问题。要使用Context API,首先要利用Context API创建一个提供者(Provider)和消费者(Consumer)。在提供者所在的地方存入数据,在消费者所在的地方取出数据。如下:

1.创建提供者(Provider)和消费者(Consumer):

import React from 'react';
const countContext = React.createContext();

const countProvider = countContext.Provider;
const countConsumer = countContext.Consumer;

export { countProvider, countConsumer };

2.用Provider把父组件包起来,并在其中存入想传递给子组件的数据。

<CountProvider
  value={{
    count: this.state.count,
    add: this.add.bind(this),
    minus: this.countryCode.bind(this)
  }}
>
  <FatherComponent />
</CountProvider>

注意:为了照顾大家的习惯,这里用了class中的写法,也就是传入了state,并将方法用bind进行了绑定。但是配合Hook函数useState的话,就不用进行绑定了。

3.利用consumer包裹子组件,取出想要的属性:

<CountConsumer>
  {
    ({ count, add, minus }) => 
      <SonComponent 
        count={count} 
        add={add} 
        minus={minus} 
      />
  }
</CountConsumer>

取出consumer的时候,使用了renderProps模式,关于这个模式文章稍后一点的地方还会有详细介绍。但是就从consumer这里看来,取值的过程是比较复杂的,当我们要从多个consumer中取值的时候,还要进行函数嵌套,十分麻烦。useContext则简化成了这个样子:

const { count, add, minus } = useContext(CountContext)

相信这么清晰明了的语法也不用再多介绍了。

二、对Hooks的思考

Hooks可以让我们在函数式组件中,操作一些以往只有在class组件中才能使用的功能。很好,但是有什么意义呢?我用class组件一样能实现这些功能哇?

但喜欢用class的同学需要认识到这一点——React一直都在往函数式编程的方向靠拢(限于篇幅,关于函数式编程的相关知识这里不作探讨),消灭class正是React社区努力的目标之一。大家都这么做是有原因的。

首先,class组件会让整个组件变得极其复杂,想想那多到令人抓狂的生命周期,当初你花了多少工夫去记住它?而且我们都希望一个函数的功能尽量单一,但在生命周期函数中,我们往往会同时执行多项任务,让代码一团杂乱。还有this的指向性问题,或许你已经习惯使用bind、call、apply进行绑定,但我相信大家都承认,绑定对象是一件非常麻烦的事情,this的指向性也会对新手的学习造成很大困扰。而使用函数式的组件搭配Hooks,就能很好地避免这些相关问题(对于Hooks的这些优势,官方文档说得更加详细)。

除此之外,Hooks最大的优势——也是今天我想在这篇文章里详细探讨的——就在于逻辑复用。React为什么设计成组件化的形式?其实最大的原因就是为了方便复用。然而组件的复用虽然方便,逻辑的复用却很麻烦,因为state的存在,逻辑被锁死在组件内部,很难分离出去。

在Hooks出现之前,开发者们得通过花里胡哨的设计模式才能达成高效率的逻辑复用。这里我就拿React中最常用的两种复用逻辑的模式——高阶组件和renderProps模式来和Hooks的对比,看完你就明白Hooks有多好了。

1.高阶组件实现逻辑复用

让我们以一个简单的计数器的例子进行讲解。计数器的界面是这个样子的。

功能我就不多作介绍了,相信你看图就能明白。这个例子的高阶组件版本是这个样子的。

高阶组件版本的计数器

import React from 'react'
import {View, Text,Button} from 'react-native'

function Count({count,add,minus}) {

    return (
        <View style={{flex:1,alignItems:'center',justifyContent:'center'}}>
            <Text>You clicked {count} times</Text>
            <Button onPress={add} title={'add'}/>
            <Button onPress={minus} title={'minus'}/>
            <Button onPress={changeTheme} title={'ChangeTheme'}/>
        </View>
    );
}

const countNumber=(initNumber)=> (WrappedComponent)=>
    class CountNumber extends React.Component {
        state = {count: initNumber};

        add = () => this.setState({count: this.state.count + 1});

        minus = () => this.setState({count: this.state.count - 1});

        render() {
            return <WrappedComponent
                {...this.props}
                count={this.state.count}
                add={this.add.bind(this)}
                minus={this.minus.bind(this)}
            />
        }
    };


export default countNumber(0)(Count);

高阶组件的原理是这样的。我们定义了一个高阶组件countNumber(其实是一个函数),然后把一个不包含state和逻辑的组件Count(一般称作无状态组件)作为countNumber这个函数的参数传进去,然后countNumber函数返回一个class组件。在这个class组件内部,我们实现了计数器的state和逻辑,然后把state和各种函数作为无状态组件的Props传进去,最后在render里面返回被处理过后的无状态组件。

当高阶组件内部的state更新时,由于该state成为了无状态组件的Props,所以就能同时带动内部的无状态组件进行刷新。这样,我们就分离了UI和逻辑,不仅让代码更清晰,而且高阶组件也可以拿给其他的组件反复使用。另外高阶组件还支持链式调用(关于这一点请自行百度,限于篇幅不作深入探讨)。

高阶组件是一个被广泛使用的模式,react-redux的connect函数就是使用了高阶组件模式。然而它并不是毫无缺点的。首先,高阶组件会创造一个新的组件,当程序报错的时候,出现在异常信息里的会是这个新创建的组件而不是原本的无状态组件——想想你在几十个地方都调用了这个高阶组件的时候,该如何知道错误在哪里?

解决这个问题的方法是给高阶组件设置displayName,如下:

const countNumber=(initNumber)=> (WrappedComponent)=> {
    class CountNumber extends React.Component {
        state = {count: initNumber};

        add = () => this.setState({count: this.state.count + 1});

        minus = () => this.setState({count: this.state.count - 1});

        render() {
            return <WrappedComponent
                {...this.props}
                count={this.state.count}
                add={this.add.bind(this)}
                minus={this.minus.bind(this)}
            />
        }
    }
    CountNumber.displayName = `changeTheme(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`;
    return CountNumber;
};

其次,高阶组件的优点虽然是链式调用,但是链式调用过多的话,会生成很长的异常栈,导致错误难以定位。

高阶组件第三个缺点也是最大的缺点,就是属性被写死了。如果子组件需求的属性名写得不一样,高阶组件就无能为力了。比如上面的Count组件,接受了count,add,minus这个三个属性,但如果另一个组件需要的是num,addNum,minusNum这三个属性呢?两个组件明明需要相同的功能,逻辑却没法在这里复用了。

所以在一般情况下,我们会优先考虑renderProps的模式。

 

2.renderProps实现逻辑复用

废话少说,还是先让我们看看renderProps版本的计数器长啥样。

renderProps版本的计数器

import React from 'react'
import {View, Text,Button} from 'react-native'

export default function RenderProps() {
    return (
        <View style={{flex:1,alignItems:'center',justifyContent:'center'}}>
            <CountNumber initNumber={0}>
                {
                    ({count,add,minus})=>
                    <>
                        <Text>You clicked {count} times</Text>
                        <Button onPress={add} title={'add'}/>
                        <Button onPress={minus} title={'minus'}/>
                        <Button onPress={changeTheme} title={'ChangeTheme'}/>
                    </>
                }
            </CountNumber>
        </View>
    );
}


class CountNumber extends React.Component{
    state={count:this.props.initNumber};
    add=()=>this.setState({count:this.state.count+1});
    minus=()=>this.setState({count:this.state.count-1});
    render(){
        return this.props.children({
            count: this.state.count,
            add: this.add.bind(this),
            minus:this.minus.bind(this)
        })
    }
}

renderProps第一次见到确实是有些奇怪的,但其实这个模式非常强大。this.props.children是React官方提供的API,通过它可以获得当前组件的所有子组件(包括深层嵌套的那些)。一般来说,子组件当然会是另一个Component,但renderProps模式会假设子组件是一个函数,并把当前组件内部的state和逻辑传入该函数中。而我们真正想要渲染的无状态组件,则可以通过这个函数的参数得到它想要的属性。

由于renderProps内部是一个函数,所以上面所说的高阶组件的第三个缺点——也就是属性名不同导致的逻辑不能复用,就能在这里完美得到解决。如下:

<CountNumber initNumber={0}>
    {
        ({count,add,minus})=> {
            const num=count;
            const addNum=add;
            const minusNum=minus;
            return (
                <>
                    <Text>You clicked {num} times</Text>
                    <Button onPress={addNum} title={'add'}/>
                    <Button onPress={minusNum} title={'minus'}/>
                    <Button onPress={changeTheme} title={'ChangeTheme'}/>
                </>
            )
        }
    }
</CountNumber>

只要在属性传进组件之前,赋值给新的变量就可以了。

render比高阶组件更为强大,但是也有一个小小的缺点,就是难以优化。因为组件内部是一个匿名函数,这就导致即便传入的属性没有任何变化,内部的子组件还是会整个渲染一遍。解决方法就是将该匿名函数再次包装,不过每次都这样做终究还是比较麻烦的。

3.Hooks实现逻辑复用

本来我以为renderProps除了一点点小小的瑕疵,基本上已经很完美了,然而正所谓没有对比就没有伤害,Hooks出来之后,前面的两个看似强大的模式都成了纸老虎。其他不说,首先从代码量上,Hooks就已经完胜了:

Hooks版本的计数器

import React,{useState} from 'react'
import {View, Text,Button} from 'react-native'

export default function HookCount() {
    const [count,addCount,minusCount] = countNumber(0);
    return (
        <View style={{backgroundColor:theme,flex:1,alignItems:'center',justifyContent:'center'}}>
            <Text>You clicked {count} times</Text>
            <Button onPress={addCount} title={'add'}/>
            <Button onPress={minusCount} title={'minus'}/>
            <Button onPress={changeTheme} title={'ChangeTheme'}/>
        </View>
    );
}

function countNumber(initNumber) {
    const [count, setCount] = useState(initNumber);
    const addCount=()=> setCount(count + 1);
    const minusCount=()=>setCount(count -1);
    return [
        count,
        addCount,
        minusCount
    ]
}

在一个函数中定义的state,竟然可以直接拿到另一个函数中使用,然而有了Hooks,这种看似不可能的语法确实行得通。Hooks的优势不仅体现在代码量上,从风格上来说,也显得语义更清晰、结构更优雅(相比之下,高阶组件和renderProps的语法都显得比较诡异)。更重要的是,上述两种模式所拥有的种种缺点,它一个都没有。

当我们复用的逻辑达到多个的时候,这种优势会表现得更加明显。假设现在又有一项新的需求:点击页面上的按钮,让背景随机变换颜色。

变换颜色和计数是两个完全不同的逻辑,所以很适合重新包装成新的组件。

4.给计数器增加第二个功能——变换颜色

首先来一个随机颜色的生成代码:

const randomNum=()=>Math.floor(Math.random()*100);
const getRandomColor=()=>`rgb(${randomNum()},${randomNum()},${randomNum()})`;

export default getRandomColor

然后是高阶组件的实现方式:

import React,{useState} from 'react'
import {View, Text,Button} from 'react-native'
import getRandomColor from '../ColorUtil'

function Count({count,add,minus,theme,changeTheme}) {

    return (
        <View style={{backgroundColor:theme,flex:1,alignItems:'center',justifyContent:'center'}}>
            <Text>You clicked {count} times</Text>
            <Button onPress={add} title={'add'}/>
            <Button onPress={minus} title={'minus'}/>
            <Button onPress={changeTheme} title={'ChangeTheme'}/>
        </View>
    );
}

const countNumber=(initNumber)=> (WrappedComponent)=> {
    class CountNumber extends React.Component {
        state = {count: initNumber};
        add = () => this.setState({count: this.state.count + 1});
        minus = () => this.setState({count: this.state.count - 1});
        render() {
            return <WrappedComponent
                {...this.props}
                count={this.state.count}
                add={this.add.bind(this)}
                minus={this.minus.bind(this)}
            />
        }
    }
    CountNumber.displayName = `changeTheme(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`;
    return CountNumber;
};

const changeTheme=(initColor)=>(WrappedComponent)=> {
    class ChangeTheme extends React.Component {
        state = {
            theme: initColor
        };
        changeTheme = () => this.setState({theme: getRandomColor()});
        render() {
            return <WrappedComponent
                {...this.props}
                theme={this.state.theme}
                changeTheme={this.changeTheme.bind(this)}
            />
        }
    }
    ChangeTheme.displayName = `changeTheme(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`;
    return ChangeTheme;
};

export default changeTheme('white')(countNumber(0)(Count));

再看看renderProps的实现方式:

import React from 'react'
import {View, Text,Button} from 'react-native'
import getRandomColor from '../ColorUtil'

export default function RenderProps() {

    return (
        <ChangeTheme initColor={'white'}>
            {
                ({theme,changeTheme})=>
                <View style={{backgroundColor:theme,flex:1,alignItems:'center',justifyContent:'center'}}>
                    <CountNumber initNumber={0}>
                        {
                            ({count,add,minus})=>
                            <>
                                <Text>You clicked {count} times</Text>
                                <Button onPress={add} title={'add'}/>
                                <Button onPress={minus} title={'minus'}/>
                                <Button onPress={changeTheme} title={'ChangeTheme'}/>
                            </>
                        }
                    </CountNumber>
                </View>
            }
        </ChangeTheme>
    );
}


class CountNumber extends React.Component{
    state={count:this.props.initNumber};
    add=()=>this.setState({count:this.state.count+1});
    minus=()=>this.setState({count:this.state.count-1});
    render(){
        return this.props.children({
            count: this.state.count,
            add: this.add.bind(this),
            minus:this.minus.bind(this)
        })
    }
}

class ChangeTheme extends React.Component{
    state={theme:this.props.initColor};
    changeTheme=()=>this.setState({theme:getRandomColor()});
    render(){
        return this.props.children({
            theme:this.state.theme,
            changeTheme:this.changeTheme.bind(this)
        });
    }
}

最后看看让人震撼的,Hooks的实现方式:

import React,{useState} from 'react'
import {View, Text,Button} from 'react-native'
import getRandomColor from '../ColorUtil'

export default function HookCount() {
    const [count,addCount,minusCount] = countNumber(0);
    const [theme,changeBackgroundColor] = changeThemeFunc('white');
    return (
        <View style={{backgroundColor:theme,flex:1,alignItems:'center',justifyContent:'center'}}>
            <Text>You clicked {count} times</Text>
            <Button onPress={addCount} title={'add'}/>
            <Button onPress={minusCount} title={'minus'}/>
            <Button onPress={changeBackgroundColor} title={'ChangeTheme'}/>
        </View>
    );
}

function countNumber(initNumber) {
    const [count, setCount] = useState(initNumber);
    const addCount=()=> setCount(count + 1);
    const minusCount=()=>setCount(count -1);
    return [
        count,
        addCount,
        minusCount
    ]
}

function changeThemeFunc(initColor) {
    const [theme, changeTheme] = useState(initColor);
    const changeBackgroundColor=()=>changeTheme(getRandomColor())
    return [
        theme,
        changeBackgroundColor
    ]
}

高阶组件和renderProps用了50多行代码实现这两个功能,而Hooks仅用了30多行。看看高阶组件内部诡异的实现方式,还有renderProps那丑陋的双重嵌套函数,再看看Hooks,仅仅从视觉上都会给你带来非常强烈的震撼。更不用说完全抛弃class后带来的种种便利与革新了。

如果你是一个不喜欢冒险的开发者,也不用担心Hooks会带来什么隐患。因为官方承诺过,它完全向后兼容,并且不会有任何爆炸性的改变。所以,现在就让我们抛弃复杂的class,从Hooks开始拥抱简洁明了的函数式编程吧!

三、源码github地址:点击这里

展开阅读全文

没有更多推荐了,返回首页