React Hook简单使用案例

713 阅读9分钟

这篇文章分享两个使用React Hook以及函数式组件开发的简单示例。

一个简单的组件案例

Button组件应该算是最简单的常用基础组件了吧。我们开发组件的时候期望它的基础样式能有一定程度的变化,这样就可以适用于不同场景了。第二点是我在之前做项目的时候写一个函数组件,但这个函数组件会写的很死板,也就是上面没有办法再绑定基本方法。即我只能写入我已有的方法,或者特性。希望编写Button组件,即使没有写onClick方法,我也希望能够使用那些自带的默认基本方法。

对于第一点,我们针对不同的className,来写不同的css,是比较好实现的。

第二点实现起略微困难。我们不能把Button的默认属性全部写一遍,如果能够把默认属性全部导入就好了。

事实上,React已经帮我们实现了这一点。React.ButtonHTMLAttributes<HTMLElement>里面就包含了默认的Button属性。可是我们又不能直接使用这个接口,因为我们的Button组件可能还有一些自定义的东西。对此,我们可以使用Typescript的交叉类型

type NativeButtonProps = MyButtonProps & React.ButtonHTMLAttributes<HTMLElement>

此外,我们还需要使用resProps来导入其他非自定义的函数或属性。

下面是Button组件具体实现方案:

import React from 'react'
import classNames from 'classnames'

type ButtonSize = 'large' | 'small'
type ButtonType = 'primary' | 'default' | 'danger'

interface BaseButtonProps {
  className?: string;
  disabled?: boolean;
  size?: ButtonSize;
  btnType?: ButtonType;
  children?: React.ReactNode;
}

type NativeButtonProps = BaseButtonProps & React.ButtonHTMLAttributes<HTMLElement>
const Button: React.FC<NativeButtonProps>= (props) => {
  const {
    btnType,
    className,
    disabled,
    size,
    children,
    // resProps用于取出所有剩余属性
    ...resProps
  } = props
  // btn, btn-lg, btn-primary
  const classes = classNames('btn', className, {
    [`btn-${btnType}`]: btnType,
    [`btn-${size}`]: size,
    'disabled': disabled
  })
  return (
    <button
      className={classes}
      disabled={disabled}
      {...resProps}
    >
      {children}
    </button>
  )
}

Button.defaultProps = {
  disabled: false,
  btnType: 'default'
}

export default Button

通过上面的方式,我们就可以在我们自定义的Button组件中使用比如onClick方法了。使用Button组件案例如下:

<Button disabled>Hello</Button>
<Button btnType='primary' size='large' className="haha">Hello</Button>
<Button btnType='danger' size='small' onClick={() => alert('haha')}>Test</Button>

展示效果如下:

image.png

在这个代码中我们引入了一个新的npm package称之为classnames,具体使用方式可以参考GitHub Classnames,使用它就可以很方便实现className的扩展,它的一个简单使用示例如下:

classNames('foo', 'bar'); // => 'foo bar'
classNames('foo', { bar: true }); // => 'foo bar'
classNames({ 'foo-bar': true }); // => 'foo-bar'
classNames({ 'foo-bar': false }); // => ''
classNames({ foo: true }, { bar: true }); // => 'foo bar'
classNames({ foo: true, bar: true }); // => 'foo bar'

// lots of arguments of various types
classNames('foo', { bar: true, duck: false }, 'baz', { quux: true }); // => 'foo bar baz quux'

// other falsy values are just ignored
classNames(null, false, 'bar', undefined, 0, 1, { baz: null }, ''); // => 'bar 1'

通过使用classNames,就可以很方便的在Button中添加个性化的属性。可以看到对于组件的HTML输出结果中有hahaclassName:

<button class="btn haha btn-primary btn-lg">Hello</button>

与此同时,我们上述代码方式也解决了自定义组件没有办法使用默认属性和方法问题。

更复杂的父子组件案例

接下来我们展示一下如何用函数组件完成一个菜单功能。这个菜单添加水平模式和垂直模式两种功能模式。点开某个菜单详情,将这个详情作为子组件。

当然,菜单这个功能根本就不需要父组件传数据到子组件(子组件指的是菜单详情),我们为了学习和演示如何将父组件数据传给子组件,强行给他添加这个功能。有点画蛇添足,大家理解一下就好。

首先介绍父子组件的功能描述。Menu是整体父组件,MenuItem是每一个具体的小菜单,SubMenu里面是可以点开的下拉菜单。

image.png

下图是展开后的样子:

image.png

整体代码结构如下:

<Menu defaultIndex={'0'} onSelect={(index) => {alert(index)}} mode="vertical" defaultOpenSubMenus={['2']}>
  <MenuItem index={'0'}>
    cool link
  </MenuItem>
  <MenuItem index={'1'}>
    cool link 2
  </MenuItem>
  <SubMenu title="dropdown">
    <MenuItem index={'3'}>
      dropdown 1
    </MenuItem>
    <MenuItem index={'4'}>
      dropdown 2
    </MenuItem>
  </SubMenu>
  <MenuItem index={'2'}>
    cool link 3
  </MenuItem>
</Menu>

在这个组件中,我们用到了useState,另外因为涉及父组件传数据到子组件,所以还用到了useContext(父组件数据传递到子组件是指的父组件的index数据传递到子组件)。另外,我们还会演示使用自定义的onSelect来实现onClick功能(万一你引入React泛型不成功,或者不知道该引入哪个React泛型,还可以用自定义的补救一下)。

如何写onSelect

为了防止后面在代码的汪洋大海中难以找到onSelect,这里先简单的抽出来做一个onSelect书写示例。比如我们在Menu组件中使用onSelect,它的使用方式和onClick看起来是一样的:

<Menu onSelect={(index) => {alert(index)}}>

在具体这个Menu组件中具体使用onSelect可以这样写:

type SelectCallback = (selectedIndex: string) => void

interface MenuProps {
  onSelect?: SelectCallback;
}

实现handleClick的方法可以写成这样:

  const handleClick = (index: string) => {
    // onSelect是一个联合类型,可能存在,也可能不存在,对此需要做判断
    if (onSelect) {
      onSelect(index)
    }
  }

到时候要想把这个onSelect传递给子组件时,使用onSelect: handleClick绑定一下就好。(可能你没看太懂,我也不知道该咋写,后面会有整体代码分析,可能联合起来看会比较容易理解)

React.Children

在讲解具体代码之前,还要再说说几个小知识点,其中一个是React.Children。

React.Children 提供了用于处理 this.props.children 不透明数据结构的实用方法。

具体使用可以参考:React.Children.map

为什么我们会需要使用React.Children呢?是因为如果涉及到父组件数据传递到子组件时,可能需要对子组件进行二次遍历或者进一步处理。但是我们不能保证子组件是到底有没有,是一个还是两个或者多个。

this.props.children 的值有三种可能:如果当前组件没有子节点,它就是 undefined ;如果有一个子节点,数据类型是 object ;如果有多个子节点,数据类型就是 array 。所以,处理 this.props.children 的时候要小心[1]

React 提供一个工具方法 React.Children 来处理 this.props.children 。我们可以用 React.Children.map 来遍历子节点,而不用担心 this.props.children 的数据类型是 undefined 还是 object[1]

所以,如果有父子组件的话,如果需要进一步处理子组件的时候,我们可以使用React.Children来遍历,这样不会因为this.props.children类型变化而出错。

React.cloneElement

React.Children出现时往往可能伴随着React.cloneElement一起出现。因此,我们也需要介绍一下React.cloneElement。

在开发复杂组件中,经常会根据需要给子组件添加不同的功能或者显示效果,react 元素本身是不可变的 (immutable) 对象, props.children 事实上并不是 children 本身,它只是 children 的描述符 (descriptor) ,我们不能修改任何它的任何属性,只能读到其中的内容,因此 React.cloneElement 允许我们拷贝它的元素,并且修改或者添加新的 props 从而达到我们的目的[2]

例如,有的时候我们需要对子元素做进一步处理,但因为React元素本身是不可变的,所以,我们需要对其克隆一份再做进一步处理。在这个Menu组件中,我们希望它的子组件只能是MenuItem或者是SubMenu两种类型,如果是其他类型就会报警告信息。具体来说,可以大致将代码写成这样:

if (displayName === 'MenuItem' || displayName === 'SubMenu') {
  // 以element元素为样本克隆并返回新的React元素,第一个参数是克隆样本
  return React.cloneElement(childElement, {
    index: index.toString()
  })
} else {
  console.error("Warning: Menu has a child which is not a MenuItem component")
}

父组件数据如何传递给子组件

通过使用Context来实现父组件数据传递给子组件。如果对Context不太熟悉的话,可以参考官方文档,Context,在父组件中我们通过createContext来创建Context,在子组件中通过useContext来获取Context。

index数据传递

Menu组件中实现父子组件中数据传递变量主要是index。

最后附上完整代码,首先是Menu父组件:

import React, { useState, createContext } from 'react'
import classNames from 'classnames'
import { MenuItemProps } from './menuItem'

type MenuMode = 'horizontal' | 'vertical'
type SelectCallback = (selectedIndex: string) => void

export interface MenuProps {
  defaultIndex?: string;  // 用于哪个menu子组件是高亮显示
  className?: string;
  mode?: MenuMode;
  style?: React.CSSProperties;
  onSelect?: SelectCallback;  // 点击子菜单时可以触发回调 
  defaultOpenSubMenus?: string[]; 
}

// 确定父组件传给子组件的数据类型
interface IMenuContext {
  index: string;
  onSelect?: SelectCallback;
  mode?: MenuMode;
  defaultOpenSubMenus?: string[];  // 需要将数据传给context
}

// 创建传递给子组件的context
// 泛型约束,因为index是要输入的值,所以这里写一个默认初始值
export const MenuContext = createContext<IMenuContext>({index: '0'})

const Menu: React.FC<MenuProps> = (props) => {
  const { className, mode, style, children, defaultIndex, onSelect, defaultOpenSubMenus} = props
  // MenuItem处于active的状态应该是有且只有一个的,使用useState来控制其状态
  const [ currentActive, setActive ] = useState(defaultIndex)
  const classes = classNames('menu-demo', className, {
    'menu-vertical': mode === 'vertical',
    'menu-horizontal': mode === 'horizontal'
  })

  // 定义handleClick具体实现点击menuItem之后active变化
  const handleClick = (index: string) => {
    setActive(index)
    // onSelect是一个联合类型,可能存在,也可能不存在,对此需要做判断
    if (onSelect) {
      onSelect(index)
    }
  }

  // 点击子组件的时候,触发onSelect函数,更改高亮显示
  const passedContext: IMenuContext = {
    // currentActive是string | undefined类型,index是number类型,所以要做如下判断进一步明确类型
    index: currentActive ? currentActive : '0',
    onSelect: handleClick, // 回调函数,点击子组件时是否触发
    mode: mode,
    defaultOpenSubMenus,
  }

  const renderChildren = () => {
    return React.Children.map(children, (child, index) => {
      // child里面包含一大堆的类型,要想获得我们想要的类型来提供智能提示,需要使用类型断言      
      const childElement = child as React.FunctionComponentElement<MenuItemProps>
      const { displayName } = childElement.type
      if (displayName === 'MenuItem' || displayName === 'SubMenu') {
        // 以element元素为样本克隆并返回新的React元素,第一个参数是克隆样本
        return React.cloneElement(childElement, {
          index: index.toString()
        })
      } else {
        console.error("Warning: Menu has a child which is not a MenuItem component")
      }
    })
  }
  return (
    <ul className={classes} style={style}>
      <MenuContext.Provider value={passedContext}>
        {renderChildren()}
      </MenuContext.Provider>
    </ul>
  )
}

Menu.defaultProps = {
  defaultIndex: '0',
  mode: 'horizontal',
  defaultOpenSubMenus: []
}

export default Menu

然后是MenuItem子组件:

import React from 'react'
import { useContext } from 'react'
import classNames from 'classnames'
import { MenuContext } from './menu'

export interface MenuItemProps {
  index: string;
  disabled?: boolean;
  className?: string;
  style?: React.CSSProperties;
}

const MenuItem: React.FC<MenuItemProps> = (props) => {
  const { index, disabled, className, style, children } = props
  const context = useContext(MenuContext)
  const classes = classNames('menu-item', className, {
    'is-disabled': disabled,
    // 实现高亮的具体逻辑
    'is-active': context.index === index
  })
  const handleClick = () => {
    // disabled之后就不能使用onSelect,index因为是可选的,所以可能不存在,需要用typeof来做一个判断
    if (context.onSelect && !disabled && (typeof index === 'string')) {
      context.onSelect(index)
    }
  }
  return (
    <li className={classes} style={style} onClick={handleClick}>
      {children}
    </li>
  )
}

MenuItem.displayName = 'MenuItem'
export default MenuItem

最后是SubMenu子组件:

import React, { useContext, FunctionComponentElement, useState } from 'react'
import classNames from 'classnames'
import { MenuContext } from './menu'
import { MenuItemProps } from './menuItem'

export interface SubMenuProps {
  index?: string;
  title: string;
  className?: string
}

const SubMenu: React.FC<SubMenuProps> = ({ index, title, children, className }) => {
  const context = useContext(MenuContext)
  // 接下来会使用string数组的一些方法,所以先进行类型断言,将其断言为string数组类型
  const openedSubMenus = context.defaultOpenSubMenus as Array<string>
  // 使用include判断有没有index
  const isOpened = (index && context.mode === 'vertical') ? openedSubMenus.includes(index) : false
  const [ menuOpen, setOpen ] = useState(isOpened)  // isOpened返回的会是true或者false,这样就是一个动态值
  const classes = classNames('menu-item submenu-item', className, {
    'is-active': context.index === index
  })
  // 用于实现显示或隐藏下拉菜单
  const handleClick = (e: React.MouseEvent) => {
    e.preventDefault()
    setOpen(!menuOpen)
  }
  let timer: any
  // toggle用于判断是打开还是关闭
  const handleMouse = (e: React.MouseEvent, toggle: boolean) => {
    clearTimeout(timer)
    e.preventDefault()
    timer = setTimeout(()=> {
      setOpen(toggle)
    }, 300)
  }
  // 三元表达式,纵向
  const clickEvents = context.mode === 'vertical' ? {
    onClick: handleClick
  } : {}
  const hoverEvents = context.mode === 'horizontal' ? {
    onMouseEnter: (e: React.MouseEvent) => { handleMouse(e, true) },
    onMouseLeave: (e: React.MouseEvent) => { handleMouse(e, false) },
  } : {}

  // 用于渲染下拉菜单中的内容
  // 返回两个值,第一个是child,第二个是index,用i表示
  const renderChildren = () => {
    const subMenuClasses = classNames('menu-submenu', {
      'menu-opened': menuOpen
    })
    // 下面功能用于实现在subMenu里只能有MenuItem
    const childrenComponent = React.Children.map(children, (child, i) => {
      const childElement = child as FunctionComponentElement<MenuItemProps>
      if (childElement.type.displayName === 'MenuItem') {
        return React.cloneElement(childElement, {
          index: `${index}-${i}`
        })
      } else {
        console.error("Warning: SubMenu has a child which is not a MenuItem component")
      }
    })
    return (
      <ul className={subMenuClasses}>
        {childrenComponent}
      </ul>
    )
  }
  return (
    // 展开运算符,向里面添加功能,hover放在外面
    <li key={index} className={classes} {...hoverEvents}>
      <div className="submenu-title" {...clickEvents}>
        {title}
      </div>
      {renderChildren()}
    </li>
  )
}

SubMenu.displayName = 'SubMenu'
export default SubMenu
参考资料
  1. React.Children的用法
  2. React.cloneElement 的使用