React Hook的使用示例

這篇文章分享兩個使用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>

展示效果如下:

在這個代碼中我們引入瞭一個新的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裡面是可以點開的下拉菜單。

下圖是展開後的樣子:

整體代碼結構如下:

<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呢?是因為如果涉及到父組件數據傳遞到子組件時,可能需要對子組件進行二次遍歷或者進一步處理。但是我們不能保證子組件是到底有沒有,是一個還是兩個或者多個。

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

參考資料

  • React.Children的用法
  • React.cloneElement 的使用

以上就是React Hook的使用示例的詳細內容,更多關於React Hook的使用的資料請關註WalkonNet其它相關文章!