你好,React 高阶组件(HOC)懂不懂?

3,602 阅读9分钟

新蜂商城开源仓库(内涵 Vue 2.x 和 Vue 3.x 的 H5 商城开源代码,带服务端 API 接口):github.com/newbee-ltd

Vue 3.x + Vant 3.x + Vue-Router 4.x 高仿微信记账本开源地址(带服务端 API 接口):github.com/Nick930826/…

React + Vite 2.0 + Zarm 高仿微信记账本开源地址(带服务端 API 接口)以及学习文档:github.com/Nick930826/…

文章简介

  • 定义:介绍高阶组件的概念。
  • 实例:通过小实例巩固高阶组件的概念。
  • 二次封装 Antd弹窗组件,通过函数方法的形式调用。

正文开始

敲业务敲到一定量的时候,就需要发起一个“质变”。而这个“质变”需要一个契机,那就是要明白如何将新获取的高级知识结合到自己的项目中来。大道理少说几句,大家心里应该都有点逼数的。下面就开始扯一扯React高阶组件这个知识点。

定义

高阶组件英文全称:Higher Order Component。简称:HOC。 用我自己的理解就是:高阶组件是一个函数,它接受组件作为参数,返回值为一个新的组件(函数组件或类组件)。 React组件是将props转换为UI,而高阶组件(HOC)是将组件转换为另一个组件。

dfbaab732edb6729a51a567e347eba60.gif

实例

需求分析

编写两个数据类型相似的列表组件,并且展示在父组件中。 首先,通过yarn create vite新建一个React项目,在项目src目录下新建两个文件,Alist.jsxBlist.jsx,代码如下所示:

// Alist.jsx
import { useEffect, useState } from 'react'
// 笔者手动生成的一个静态JSON数据
const url = 'http://image.chennick.wang/1644916550730-0-a.json'

const Alist = () => {
  const [data, setData] = useState([])
  
  useEffect(() => {
    // 发起请求获取列表数据
    fetch(url).then(res => {
      return res.json()
    }).then(({ data }) => {
      setData(data)
    })
  }, [])
  
  return <div style={{ padding: 20, border: '1px solid #e9e9e9', display: 'inline-block' }}>
    {
      data.map((item, index) => <div key={index}>
        <div>姓名:{item.name};年龄:{item.age}</div>
      </div>)
    }
  </div>
}

export default Alist
// Blist.jsx
import { useEffect, useState } from 'react'
// 笔者手动生成的一个静态JSON数据
const url = 'http://image.chennick.wang/1644917014491-1-b.json'

const Blist = () => {
  const [data, setData] = useState([])
  
  useEffect(() => {
    // 发起请求获取列表数据
    fetch(url).then(res => {
      return res.json()
    }).then(({ data }) => {
      setData(data)
    })
  }, [])
  
  return <div style={{ padding: 20, border: '1px solid #e9e9e9', display: 'inline-block' }}>
    {
      data.map((item, index) => <div key={index}>
        <div>名称:{item.name};价格:{item.price} 元</div>
      </div>)
    }
  </div>
}

export default Blist

最后将两个列表组件展示在页面中,修改App.jsx文件如下:

import { useState } from 'react'
import Alist from './Alist'
import Blist from './Blist'
import './App.css'

function App() {
  return (
    <div className="App">
      <Alist></Alist>
      <Blist></Blist>
    </div>
  )
}

export default App

页面展示效果如下所示:

image.png

实现高阶组件

实现上述代码之后,我们先别忙着激动,冷静思考一番之后,你会发现上述代码中有业务逻辑重复的部分,那就是数据状态datauseEffect钩子函数中的请求逻辑。利用高阶组件将它们的公有部分提取出来单独维护。 ​

高阶组件通常都是以With开头的,比如路由组件的WithRouter、状态组件的WithStore等。 我们也照葫芦画瓢,在src目录下新建一个WithData.jsx文件,添加内容如下。 首先,它是一个函数,并且返回一个新的组件:

// WithData.jsx
const WithData = () => {
	const WithDataComponent = () => {
  	// do something
  }
  return WithDataComponent
}

export default WithData

将两个列表组件的公有部分抽离出来,写入上述返回的组件WithDataComponent中,并且WithDataComponent组件需要将传入的列表组件返回,如下:

import { useEffect, useState } from 'react'
// WithData.jsx
// Component 为传入的组件,url 为每个组件对应的请求地址
const WithData = (Component, url) => {
	const WithDataComponent = () => {
		const [data, setData] = useState([])
    useEffect(() => {
    	fetch(url).then(res => res.json()).then(({ data }) => {
      	setData(data)
      })
    }, [])
    // 将请求返回的 data 属性以 props 的形式返回给传入的 Component 组件
    return <Component data={data} />
  }
  return WithDataComponent
}

export default WithData

上述代码中,提取了请求的逻辑,并且将请求后的data数据,通过组件传入的形式传递给Component。那么此时,便可以在AlistBlist组件中,通过函数参数的形式,获取到相应的data数据。修改Alist.jsxBlist.jsx如下所示:

// Alist.jsx
import WithData from './WithData' // 引入高阶组件
const url = 'http://image.chennick.wang/1644916550730-0-a.json'

const Alist = ({ data }) => {
  return <div style={{ padding: 20, border: '1px solid #e9e9e9', display: 'inline-block' }}>
    {
      data.map((item, index) => <div key={index}>
        <div>姓名:{item.name};年龄:{item.age}</div>
      </div>)
    }
  </div>
}

export default WithData(Alist, url) // 传入 Alist 和 url 作为参数
// Blist.jsx
import WithData from './WithData' // 引入高阶组件
const url = 'http://image.chennick.wang/1644917014491-1-b.json'

const Blist = ({ data }) => {
  return <div style={{ padding: 20, border: '1px solid #e9e9e9', display: 'inline-block' }}>
    {
      data.map((item, index) => <div key={index}>
        <div>名称:{item.name};价格:{item.price} 元</div>
      </div>)
    }
  </div>
}

export default WithData(Blist, url) // 传入 Alist 和 url 作为参数

AlistBlist两个列表组件,经过WithData的包裹之后,抛出一个高阶组件内返回的新组件<Component data={data} />。所以,在App.jsx中,相当于如下:

image.png

所以在Alist.jsxBlist.jsx中,可以在函数的参数中,拿到data参数。最终渲染结果还是没变:

image.png

扩展知识点

此时若是我在父组件App.jsx中,在Alist组件中传入一个参数,如下所示:

import { useState } from 'react'
import Alist from './Alist'
import Blist from './Blist'
import './App.css'

function App() {
  return (
    <div className="App">
      <Alist title='我是父组件传入的title参数'></Alist>
      <Blist></Blist>
    </div>
  )
}

export default App

Alist.jsx中打印这个属性,如下所示:

...
const Alist = ({ data, title }) => {
	console.log('title', title)
  ...
}

结果如下所示:

image.png

原因是,在父组件App.jsx中引入的Alist组件,是经过高阶组件WithData包裹后返回的函数组件,也就是WithDataComponent组件,我们看看代码如下:

import React, { useState, useEffect } from 'react'

const WithData = (Component, url) => {
  const WithDataComponent = () => {
    const [data, setData] = useState([])
    useEffect(() => {
      fetch(url).then(res => res.json()).then(({ data }) => {
        setData(data)
      })
    }, [])
    return <Component data={data} />
  }
  return WithDataComponent
}

export default WithData

WithDataComponent组件中,传入的title并没有带给Component,最终导致在Alist.jsx中,title属性丢失了。 那么,我们需要将父组件传进来的props全部都传给Component,修改如下:

const WithDataComponent = (props) => {
  const [data, setData] = useState([])
  useEffect(() => {
    fetch(url).then(res => res.json()).then(({ data }) => {
      setData(data)
    })
  }, [])
  return <Component {...props} data={data} />
}
return WithDataComponent

然后我们重新运行项目,观察Alist.jsx下的打印信息,结果如下:

image.png

结论

至此,一个简单的高阶组件就写完了。当然,复杂业务情况下,高阶组件远不止这么简单,需要对业务的高度理解,以及项目结构的统筹规划,将很多相似、类似的结构一一提取出来,封装成高阶组件。

二次封装 Ant Design 弹窗组件

利用高阶组件的特性,我们来封装一个Ant Design的弹窗以方法调用的形式。

需求分析

我们在业务开发中,经常会使用到Ant Design为我们提供的Modal弹窗组件。正常情况下使用它,需要通过visible属性来控制显示或隐藏,如果在一个页面中存在多个弹窗需求,则会遇到下面这样的情况:

const [visible1, setVisible1] = useState(false)
const [visible2, setVisible2] = useState(false)
const [visible3, setVisible3] = useState(false)
const [visible4, setVisible4] = useState(false)
const [visible5, setVisible5] = useState(false)
...

N 多个visible状态在同一个页面中进行管理,眼花缭乱。 于是,为了解决这个困境,利用高阶组件优化一波,将弹窗的visible状态封装到高阶组件中控制。

实现逻辑

首先,在上述项目的基础上,通过指令安装Ant Design

yarn add antd

修改main.jsx全局引入antd样式如下所示:

...
import 'antd/dist/antd.css'

正常使用visible控制弹窗,在src目录下添加组件DialogRename.jsx组件,如下所示:

import { Input, Modal } from 'antd'
const DialogRename = ({ visible, onCancel }) => {
  return <Modal
    title="我是弹窗"
    visible={visible}
    onOk={onCancel}
    onCancel={onCancel}
  >
    <>
      <label>名称:</label>
      <Input style={{ width: 400 }} onChange={(e) => {
        setValue(e.target.value)
      }} placeholder='请输入名称' />
    </>
  </Modal>
}

export default DialogRename

上述代码中,从父组件接受visible状态,和控制弹窗隐藏的方法onCancel。然后在App.jsx入口页引入弹窗组件,代码如下:

import { useState } from 'react'
import { Button } from 'antd'
import DialogRename from './DialogRename'
import './App.css'

function App() {
  const [visible, setVisible] = useState(false) // 控制 DialogRename 组件显示或隐藏
  return (
    <div className="App">
      <Button onClick={() => setVisible(true)}>打开它</Button>
      <DialogRename visible={visible} onCancel={() => setVisible(false)} />
    </div>
  )
}

export default App

效果如下所示:

接下来,我们来完成一个高阶组件,新建WithDialog.jsx,代码如下所示:

// WithDialog.jsx
import React from 'react'
import { render, unmountComponentAtNode } from 'react-dom'

export default class WithDialog {
  constructor(Component) {
    this._ele = null
    this._dom = <Component onCancel={this.close} />
    this.show()
  }
  
  show = () => {
    this._ele = document.createElement('div')
    render(this._dom, document.body.appendChild(this._ele))
  }

  close = () => {
    unmountComponentAtNode(this._ele)
    this._ele.remove()
  }
}

声明一个WithDialog类组件,构造函数constructor接受Component参数为需要包装的组件,比如此次我们需要包装的DialogRename组件。声明一个全局属性this._ele用于后续创建一个弹窗需要挂载的目标div标签。this._dom用于挂载传入的弹窗组件。最后默认执行this.show方法,挂载弹窗。

show方法,创建一个div标签,通过react-dom包提供的render方法,将弹窗组件this._dom挂载到页面中。

close方法,通过react-dom方法提供的unmountComponentAtNode卸载组件方法,将this._ele挂载弹窗组件的节点卸载,最后再remove移除。

改造DialogRename.jsx组件如下:

import { useState } from 'react'
import { Input, Modal } from 'antd'
import WithDialog from './WithDialog'

const DialogRename = ({ ...props }) => {
  const [value, setValue] = useState('') // 输入框的值
  const handleOk = () => {
    props.onCancel()
  }
  return <Modal
    title="我是弹窗"
    visible
    onCancel={props.onCancel}
    onOk={handleOk}
  >
    <>
      <label>名称:</label>
      <Input style={{ width: 400 }} onChange={(e) => {
        setValue(e.target.value)
      }} placeholder='请输入名称' />
    </>
  </Modal>
}

export default () => new WithDialog(DialogRename)

首先明确一点,在父组件App.jsx中,是需要通过调用方法的形式,发起弹窗组件的。所以我们在DialogRename.jsx中,抛出一个函数,export default () => new WithDialog(DialogRename)。经过WithDialog包裹之后,DialogRename组件可以接收到在WithDialog.jsx中传入的onCancel方法,这里通过解构的形式获取,赋值给ModalonCancel属性。 ​

之后,我们在App.jsx中通过方法的形式调用组件,如下所示:

import { Button } from 'antd'
import DialogRename from './DialogRename'
import './App.css'

function App() {
  const handleOpen = () => {
    DialogRename()
  }
  return (
    <div className="App">
      <Button onClick={handleOpen}>打开它</Button>
    </div>
  )
}

export default App

重启项目,效果如下所示:

Kapture 2022-02-16 at 17.49.24.gif

当点击弹窗的确认组件时,需要执行一些方法,所以我们可以在DialogRename方法中传入onOk方法,如下所示:

import { Button } from 'antd'
import DialogRename from './DialogRename'
import './App.css'

function App() {
  const handleOpen = () => {
    DialogRename({
    	onOk: (val) => {
				console.log('onOk:', val)
      }
    })
  }
  return (
    <div className="App">
      <Button onClick={handleOpen}>打开它</Button>
    </div>
  )
}

export default App

然后在DialogRename.jsx中接受onOk赋值给Modal组件的onOk,修改DialogRename.jsx如下所示:

import { useState } from 'react'
import { Input, Modal } from 'antd'
import WithDialog from './WithDialog'

const DialogRename = ({ onOk, ...props }) => {
  const [value, setValue] = useState('')
  const handleOk = () => {
    onOk(value)
    props.onCancel()
  }
  return <Modal
    title="我是弹窗"
    visible
    onOk={handleOk}
    {...props}
  >
    <>
      <label>名称:</label>
      <Input style={{ width: 400 }} onChange={(e) => {
        setValue(e.target.value)
      }} placeholder='请输入名称' />
    </>
  </Modal>
}

export default () => new WithDialog(DialogRename)

执行onOk(value)方法,将输入框的值回调给App.jsx中的onOk方法,如下所示:

image.png

点击OK按钮之后,出现如上图所示的报错。原因很简单,当DialogRenameWithDialog组件包裹的时候,传给它的onOk方法丢失了,此时需要在构造方法中传入父组件传递给DialogRenameprops,如下所示:

// DialogRename.jsx
...
export default (props) => new WithDialog(DialogRename, props)

然后前往WithDialog.jsx,将props透传给Component,如下所示:

// WithDialog.jsx
...
constructor(Component, props) {
  this._ele = null
  this._dom = <Component onCancel={this.close} {...props} />
  this.show()
}

此时你才能真正的拿到传递进来的onOk方法,如下所示:

image.png

你可以尝试在App.jsx中再传入一个属性,覆盖掉Modal组件的title属性,如下所示:

// App.jsx
// ...
const handleOpen = () => {
  DialogRename({
    onOk: (val) => {
      console.log('onOk:', val)
    },
    title: '我是App传入的title'
  })
}

弹窗的标题就会被覆盖掉,效果如下:

image.png

最后,英文看起来怪怪的,我们引入全局中文包,修改WithDialog.jsx如下:

// WithDialog.jsx
...
import zhCN from 'antd/lib/locale/zh_CN'
import { ConfigProvider } from 'antd'

...
constructor(Component, props) {
  this._ele = null
  this._dom = <ConfigProvider locale={zhCN}>
      <Component onCancel={this.close} {...props} />
    </ConfigProvider>
  this.show()
}

效果如下所示:

image.png

总结

掌握好高阶组件,对业务开发会有更深刻的理解。这是初中级前端进阶为高级前端的必经之路,专注业务的优化,也是衡量一个前端开发者技术好坏的评判标准。