如何使用 h 函数提升一点 Vue3 开发的幸福感?

6,850 阅读16分钟

参考视频:

  1. 《h函数的使用》 - 远方 OS | Bilibili
  2. 《h函数的使用场景》 - 远方 OS | Bilibili
  3. 《命令式弹框终极版》- 远方 OS | Bilibili

前言

h 函数在 Vue 中是一个非常重要的概念,它对于我们理解 Vue 里的渲染机制很关键。

当我们在编写 Vue 的 template 模板时,实际上并不是在写 HTML,而是在以一种更直观的方式编写对 h函数的调用。h 函数的作用是创建虚拟节点(VNode)。 ——— 类似于 React 中的 createElement

在学习 h 函数后,如果你在某些业务场景厌倦使用 Template 的组件编写方式, 完全可以在 Vue 中像 React 那样,以函数式的编程方式来编写组件(不依赖 JSX 插件)。


h 函数参数

  • h 函数的第一个参数是 DOM 的节点类型,或者是一个组件;
  • 第二个参数是 DOM 的属性 (attrs),或者组件的 props
  • 第三个参数需要区分
    • 如果第一个参数普通 DOM 标签,第三个参数是子节点 (children);
    • 如果第一个参数是一个组件,第三个参数就算一个对象,用于传递插槽 (slots)

Template 如何转换成 h 函数 ?

假设 main.ts代码, 和 App.vue 代码如下:

// main.ts
import { createApp } from "vue";
import App from "./App.vue"

createApp(App).mount("#app")
<!-- App.vue -->
<script setup lang="ts">
import Welcome from "./components/Welcome.vue";
</script>

<template>
  <div class="box">
    <p>Title</p>
    <Welcome msg="Hello" :num="999" />
  </div>
</template>

如果我们使用 h 函来来实现,代码如下:

import { createApp, defineComponent, h } from "vue"
import Welcome from "./components/Welcome.vue"

const App = defineComponent({
  render: () => {
    return h("div", { class: "box" }, [
      h("p", "Title"),
      h(Welcome, { msg: "Hello", num: 999 })
    ])
  }
})

createApp(App).mount("#app")

h 函数用法

使用 h 创建 VNode

h 函数可以创建一个 VNode 实例,可以设置样式和绑定事件,再通过 <component :is="VNode"> 的方式将其动态渲染出来。

<!-- App.vue -->
<script setup lang="ts">
  import { h } from "vue";

  const com = h(
    "div",
    {
      style: { color: "red" },
      onClick: () => {
        console.log(1111)
      },
    },
    "Hello World"
  )
</script>

<template>
  <component :is="com" />
</template>

创建函数式组件

普通函数式组件 (VNode)

刚接触 h 函数时, 我们很容易写出下面的代码:

<!-- App.vue -->
<script setup lang="ts">
import { h, ref } from "vue"
const msg = ref("Hello")

const Com = h("div", { style: { color: "red" } }, msg.value)

setTimeout(() => {
  msg.value = "1111"
}, 3000)
</script>

<template>
  <component :is="Com" />
</template>

上面的代码中吗,我们的期望效果是:在 3 秒后,通过 ref 改变 msg 的值,页面也会更新。但会发现和我们预期的效果并不一致,三秒后页面并没有发生变化。

⚠️注意Com 不能写成下面这种形式:

const Com =  h("div", { style: { color: "red" } }, msg.value)

因为在 setup 中它不是一个函数式组件,而是一个普通的 VNode,直接在模板中使用时不会触发响应式。我们需要把它改成一个函数式组件

函数式组件 (响应式)

在 setup 中编写一个函数式组件,在模板中调用这个函数时,就相当于在模板中使用一个响应式组件(能够建立关联关系, 触发 effect )。

<!-- App.vue -->
<script setup lang="ts">
import { h, ref } from "vue"
const msg = ref("Hello")

const Com = () => h("div", { style: { color: "red" } }, msg.value)

setTimeout(() => {
  msg.value = "1111"
}, 1000)
</script>

<template>
  <component :is="Com" />
</template>
  • 上面定义的 Com 是一个函数组件,而不是一个 VNode
  • 我们在函数调用时访问了 msg.value,而这个函数又是通过 <component :is="Com" /> 动态渲染的,它的调用环境是响应式的。

传递 props

由于 Com 是一个组件,我们还可以给它传递参数:

<!-- App.vue -->
<script setup lang="ts">
import { h } from "vue"
const Coms = (props: { count: number }) => h('div', null, props.count)
</script>

<template>
  <Com :count="1"></Com>
</template>

传递 slots

渲染默认插槽

我们可以将 Com 理解为一个 setup 函数,所以可以从第 2 个参数里获取到 slots

<!-- App.vue -->
<script setup lang="ts">
const Com: FunctionalComponent = (_props, { slots }) =>
  h("div", { style: { color: "red" } }, slots)
</script>

<template>
  <Com>
    <div>Hello World</div>
  </Com>
</template>

渲染具名插槽

<!-- App.vue -->
<script setup lang="ts">
const Com: FunctionalComponent = (_props, { slots }) =>
  h("div", { style: { color: "red" } }, [
    slots?.default?.(),
    "Middle Content",
    slots?.header?.()
  ])
</script>

<template>
  <Com>
    <div>Hello World</div>
    <template #header>
      <div>header</div>
    </template>
  </Com>
</template>

渲染效果如下:

渲染作用域插槽(含插值参数)

<script setup lang="ts">
const Com: FunctionalComponent = (_props, { slots }) => {
  const num = ref(699)
  
  return h("div", { style: { color: "red" } }, [
    slots?.default?.(),
    "Vue Content",
    slots?.header?.(num.value),
  ])
}
</script>

<template>
  <Com>
    <div>Hello World</div>
    <template #header="num">
      <div>header{{ num }}</div>
    </template>
  </Com>
</template>

⚠️注意: 上面的代码中,<template #header="num"> 不要写成 <template #header="{ num }">,因为 slots.header传递的是数字,而不是 object:{num: num.value}

传递属性和事件

<!-- App.vue -->
<script setup lang="ts">
import HelloWorld from "./components/HelloWorld.vue"

const Com = () => {
  return h(HelloWorld, {
    msg: "666",
    onFoo: (text) => {
      console.log(text)
    }
  })
}
</script>

<template>
  <Com></Com>
</template>
<!-- ./components/HelloWorld.vue -->
<script setup lang="ts">
defineProps<{ msg: string }>()
const emits = defineEmits(["foo"])
const change = () => emits("foo", "HelloWorld")
</script>

<template>
  <div @click="change"> {{ msg }} </div>
</template>

h 渲染组件并传递插槽

我们先来看使用 h 渲染组件时,如果不传递任何内容,会是什么效果:

<!-- App.vue -->
<script setup lang="ts">
import HelloWorld from "./components/HelloWorld.vue"

const Com = () => {
  return h(HelloWorld)
}
</script>

<template>
  <Com />
</template>
<template>
  <div>
    <slot>Hello World</slot>
    <br />
    <slot name="footer" foo="cccc">Footer</slot>
  </div>
</template>

最终页面渲染效果如下:

如果我们传入插槽内容,需要注意:此时 h 函数的第三个参数不是数组,而是一个对象

const Com: FunctionalComponent = () => {
  return h(HelloWorld, null, {
    default: () => h("div", "aaaa"),
    footer: ({ foo }: { foo: string }) => h("div", "bbbb " + foo)
  })
}

最终页面渲染效果如下:

当然,我们也可以将 <Com>组件中的插槽内容,传递给 HelloWorld 组件。例如这样写:

<!-- App.vue -->
<script setup lang="ts">
import HelloWorld from "./components/HelloWorld.vue"

const Com: FunctionalComponent = (_props, { slots }) => {
  return h(HelloWorld, null, {
    default: () => slots.default?.(),
    footer: ({ foo }: { foo: string }) => h("div", "bbbb " + foo),
  })
}
</script>

<template>
  <Com>
    <div>Com 组件插槽内容</div>
  </Com>
</template>

最终页面渲染效果如下:

一个小技巧

有时候我们可能只想渲染默认插槽,那么 h 函数的第三个参数我们可以简写为直接返回一个函数,因为当 h 函数第三个参数返回一个函数时,会被转换成默认插槽**。**

简写前:

const Com: FunctionalComponent = (_props, { slots }) => {
  return h(HelloWorld, null, {
    default: () => slots.default?.()
  })
}

简写后:

const Com: FunctionalComponent = (_props, { slots }) => {
  return h(HelloWorld, null, () => slots.default?.())
}

其他例子:

// 简写前
{ default: () => h(component, { ref: modalInstace, props}) }

// 简写后
() => h(component, { ref: modalInstace, props})

为函数式组件标注 TS 类型

Vue 为我们提供了 FunctionalComponent 来定义函数式组件的 TS 类型:

import type { FunctionalComponent } from 'vue'

const Com: FunctionalComponent = (_props, { slots }) => {
  return h(HelloWorld, null, () => slots.default?.())
}

参考链接🔗: 为函数式组件标注类型 | Vue 官方文档

h 和 createVNode 区别

查看 Vue 的源码可以发现,h 函数本质上是对 createVNode 的封装。它的第二个参数名为 propsOrChildren,意味着这个参数既可以用来传递 props,也可以用来传递 children

因此,h 函数支持多种调用方式,例如:

h("div", h("div", "11111"))
// 等价于
h("div", null, h("div", "11111"))
// 也等价于
h("div", null, [h("div", "11111")])

Vue 源码中的实现如下:

export function h(type: any, propsOrChildren?: any, children?: any): VNode {
  const l = arguments.length
  if (l === 2) {
    if (isObject(propsOrChildren) && !isArray(propsOrChildren)) {
      // single vnode without props
      if (isVNode(propsOrChildren)) {
        return createVNode(type, null, [propsOrChildren])
      }
      // props without children
      return createVNode(type, propsOrChildren)
    } else {
      // omit props
      return createVNode(type, null, propsOrChildren)
    }
  } else {
    if (l > 3) {
      children = Array.prototype.slice.call(arguments, 2)
    } else if (l === 3 && isVNode(children)) {
      children = [children]
    }
    return createVNode(type, propsOrChildren, children)
  }
}

h 函数的运用场景

上面我们讲了 h 函数到底是什么,以及它的基本用法。我们乘热打铁,继续深入探索 h 函数在实际开发中的几种典型运用场景。

表格中动态渲染内容

在使用 ant-design-vue 渲染表格时,基础代码如下:

<script setup lang="ts">
const columns = [
    { title: "Name",dataIndex: "name" },
    { title: "Address",dataIndex: "address" }
]

const data = [
	{ name: "John Brown", address: "New York No. 1 Lake Park" },
	{ name: "Jim Green", address: "London No. 1 Lake Park" },
	{ name: "Joe Black", address: "Sidney No. 1 Lake Park" }
]
</script>

<template>
	<a-table :columns="columns" :data-source="data" bordered></a-table>
</template>

如果我们希望为“名字”加上超链接,可以通过插槽实现:

<script lang="ts" setup>
// columns 和 data 同上
</script>

<template>
    <a-table :columns="columns" :data-source="data" bordered>
        <template #bodyCell="{ column, text }">
            <template v-if="column.dataIndex === 'name'">
                <a href="#">{{ text }}</a>
            </template>
        </template>
    </a-table>
</template>

我们还可以通过 customRender 配合 h 函数实现更灵活的渲染逻辑:

<template>
	<a-table :columns="columns" :data-source="data" bordered></a-table>
</template>

<script setup lang="ts">
import { h } from "vue"

const columns = [
    {
        title: "Name",
        dataIndex: "name",
        customRender: ({ text }: { text: string }) => {
            return h("a", { href: "#" }, text)
        }
    },
    {
        title: "Address",
        dataIndex: "address",
    }
]

// data 同上
</script>

函数式封装组件 (弹窗调用)

在实际开发时,有时我们需要动态创建和挂载组件,例如实现一个弹窗、通知或某些特定的交互组件。以窗组件为例,如果按照传统方式使用弹框组件,我们需在组件内声明 ref、属性及回调函数,若存在多个弹框,会导致代码冗余、命名混乱。

例如:

  • 需为每个弹框声明独立 ref 控制显示状态
  • 点击事件需手动修改 ref
  • 表单提交需通过 ref 调用内部方法

什么是函数调用组件?

与直接在模板中定义组件不同,函数调用组件可以动态创建实例,同时便于灵活传参和事件处理。

常见场景:

  • 弹窗(如 DialogModal
  • 通知组件(如 Toast、MessageBox
  • 特定交互(如动态表单、选择器)

核心特点:

  • 动态创建:不需要预定义在模板中;
  • 灵活传参:支持动态传递 props 和事件处理;
  • 销毁机制:组件可以在合适的时机被清理;

Modal 封装设计思路

通过封装函数实现弹框的动态渲染,核心逻辑如下:

使用函数调用方式

openDialog(Component, { message: '登录提示' }, { title: '用户登录' })
  • 第一个参数:弹窗中需要渲染的自定义组件;
  • 第二个参数:表单组件的 props 数据;
  • 第三个参数:弹框自身的属性(如 title );

渲染实现步骤

  • 使用 h 函数创建弹框组件实例;
  • 将自定义组件通过默认插槽插入弹框组件中;
  • 通过 createApp 动态创建应用实例并挂载到 DOM

动态渲染弹框 (初始版)

import { h, createApp } from 'vue'
import { Modal } from 'ant-design-vue'

const openDialog = (component, componentProps, modalProps) => {
  
  // 创建弹框VNode
  const Dialog = () => {
   return h(Modal, {
      ...modalProps,
      open: true,
      },{
        default: () => h(component, componentProps) // 组件插入默认插槽
      })
  }

  // 创建挂载节点
  const div = document.createElement('div')
  document.body.appendChild(div)

  // 动态创建应用并挂载
  const app = createApp(Dialog)
  app.mount(div)
}

示例表单组件

<script lang="ts" setup>
import type { FormInstance } from 'ant-design-vue'
import { reactive } from 'vue'

defineProps<{ msg: string }>()

const formRef = ref<FormInstance>()
const formState = reactive({ username: '', password: '' })

async function submit() {
  await formRef.value?.validate()
  return new Promise(resolve => setTimeout(() => resolve(formState), 2000))
}

defineExpose({ submit })
</script>

<template>
  <div>
    <h3 class="pb-4 pt-3 font-500">{{ msg }}</h3>
    <a-form
      ref="formRef"
      :model="formState"
      name="basic"
      autocomplete="off"
      :label-col="{ span: 4 }"
      :wrapper-col="{ span: 16 }"
      layout="horizontal"
    >
      <a-form-item
        label="用户名"
        name="username"
        :rules="[{ required: true, message: '请输入用户名!' }]"
      >
        <a-input v-model:value="formState.username" />
      </a-form-item>

      <a-form-item
        label="密码"
        name="password"
        :rules="[{ required: true, message: '请输入密码!' }]"
      >
        <a-input-password v-model:value="formState.password" />
      </a-form-item>
    </a-form>
  </div>
</template>

在页面使用

<script lang="ts" setup>
  import { renderDialog } from './dialog'
  import LoginForm from './login-from.vue'

  function onClick() {
    renderDialog(LoginForm, { msg: '晚上好,请登录 👋' }, { title: '登录' })
  }
</script>

<template>
  <a-button @click="onClick">点击打开登录弹窗</a-button>
</template>

关于组件组册问题

如果你在 mian.ts 是使用全局注册的方式使用组件库,那么上面的代码应该无法熏染 ,因为动态创建的 app 实例需重新注册组件, 如果是使用自动导入插件不需要重新注册

import Antd from 'ant-design-vue'

const openDialog = (component, componentProps, modalProps) => {
  // 在动态应用中注册全局组件
  app.use(Antd)
}

处理动画与卸载

直接卸载会导致动画丢失,需通过 afterClose 事件或定时器控制卸载时机:

import { Modal } from 'ant-design-vue'
import { createApp, h, ref } from 'vue'

const openDialog = (component, componentProps, modalProps) => {

  // 声明一个 ref 响应式数据,用于控制弹窗的显示与隐藏 (为了保留 Modal 组件关闭动画)
  const open = ref(true)

  const Dialog = () => {
   return h(Modal, {
      ...modalProps,
      open: open.value // 这里不是模板语法!需要 .value
      onCancel: () => open.value = false,
      // PS: 如果组件库没有 afterClose 钩子,可以使用 setTimeout 处理
      afterClose: () => {
        // 关闭动画结束后,卸载组件
        app.unmount()
        document.body.removeChild(div)
      },
      },{
        default: () => h(component, componentProps)
      })
  }

  const div = document.createElement('div')
  document.body.appendChild(div)

  const app = createApp(Dialog)
  app.mount(div)
}

注意: 上面的代码中,Dialog 返回的必须是一个函数,因为响应式数据只有依赖 effect 才能正常工作, 在 Vue 中函数式组件能监听到 ref 响应式数据的变化,所以这里使用函数式组件 。

注意注意再注意!:使用 h 函数必须写成 函数式组件 才能触发响应式

const dialog = h() ❌
const dialog = () => h() ✅

// ❌ 函数触发不了响应式
const NewModal1 = h(Modal, { 
  open: open.value, 
  onCancel: () => open.value = false 
}, () => h('div', 'Hello World'))

// ✅ 函数式组件可以触发响应式
const NewModal2 = () => h(Modal, {
  open: open.value, 
  onCancel: () => open.value = false 
}, () => h('div', 'Hello World 2'))

响应式触发的必要条件(下面两者缺一不可)!

  1. 数据是响应式的
  2. effect 函数下面执行了 get 方法(建立了关联关系)

对外导出弹窗销毁方法

有时候我们想在某些逻辑执行后手动销毁弹窗,那我们可以在 openDialog 对外导出一个销毁方法:

import { Modal } from 'ant-design-vue'
import { createApp, h, ref } from 'vue'

const openDialog = (component, componentProps, modalProps) => {
  const open = ref(true)

  const Dialog = () => {
   return h(Dialog, {
      ...modalProps,
      open: open.value 
      onCancel: () => unmount(900),
      },{
        default: () => h(component, componentProps)
      })
  }

  const div = document.createElement('div')
  document.body.appendChild(div)

  const app = createApp(Dialog)
  app.mount(div)

  // 导出一个对外关闭弹窗的方法,支持外部调用关闭弹窗
  const unmount = (delay = 900) => {
    if(!open.value) return;
    open.value = false
    setTimeout(() => {
      app.unmount()
      document.body.removeChild(div)
    }, delay)
  }

  return { unmount }
}

处理事件 (表单提交与校验)

添加一个 ref 用于接收 弹窗传入的表单组件 实例,在确认按钮点击时手动触发表单组件对外导出的 submit 方法:

import type { ModalProps } from 'ant-design-vue'
import type { Component } from 'vue'
import { Modal } from 'ant-design-vue'
import { createApp, h, ref } from 'vue'

const openDialog = (
  component: Component, 
  componentProps: Record<string, any>, 
  modalProps: ModalProps
) => {
  const open = ref(true)
  const instace = ref()
  const loading = ref(false)

  const Dialog = () => {
   return h(Modal, {
      ...modalProps,
      open: open.value 
      confirmLoading: loading.value,
      onCancel: () => unmount(900),
      onOk: async () => {
          loading.value = true
          try {
            await instace.value?.submit?.()
          }finally {
            loading.value = false
          }
          unmount()
        },
      },
      // { default: () => h(component, { ref: instace, componentProps}) }
      // 如果传一个函数,会转换成默认插槽,所以上面代码可以简写为
      () => h(component, { ref: instace, componentProps})
    )
  }

  const div = document.createElement('div')
  document.body.appendChild(div)

  const app = createApp(Dialog)
  app.mount(div)

  // 导出一个对外关闭弹窗的方法,支持外部调用关闭弹窗
  const unmount = (delay = 900) => {
    if(!open.value) return
    open.value = false
    setTimeout(() => {
      app.unmount()
      document.body.removeChild(div)
    }, delay)
  }

  // 对外导出卸载方法和表单组件实例
  return { unmount, instace }
}

封装拓展

如果考虑自定义的提交事件怎么办 ?

const {methodKey = 'submit' }  = props

if(instace.value?.[methodKey]){
  try {
    await instace.value?.[methodKey]?.()
  }finally {
    loading.value = false
  }
}
import type { ModalProps } from 'ant-design-vue'
import type { Component } from 'vue'
import Antd, { Modal } from 'ant-design-vue'
import { createApp, defineComponent, h, reactive, ref } from 'vue'

const EMPTY_OBJ = Object.freeze({})

export function renderDialog(
  component: Component,
  props: Record<string, any> = EMPTY_OBJ,
  modalProps: ModalProps = EMPTY_OBJ,
) {
  const { methodKey = 'submit', onSubmit } = props

  const open = ref(true)
  const instance = ref()
  const isLoading = ref(false)

  const _modalProps: ModalProps = {
    ...modalProps,
    async onOk(...args) {
       /** 传了自定义的 onSubmit 方法 */
      if (onSubmit) {
        await onSubmit(instance.value)
      }
      else if (instance.value?.[methodKey]) {
        /** 传了 methodKey */
        isLoading.value = true
        await instance.value[methodKey]()
          .finally(() => isLoading.value = false)
      }
      open.value = false
      modalProps.onOk?.(...args)
    },
    onCancel(...args) {
      open.value = false
      modalProps.onCancel?.(...args)
    },
    afterClose(...args) {
      unmount()
      modalProps.afterClose?.(...args)
    },
  }

  // 这里使用 reactive 是为了特殊需求(比如弹窗显示 2秒后动态更改props数据:如标题)
  const reactiveModalProps = reactive(modalProps)

  const dialog = defineComponent({
    setup: () => {
      return h(
        Modal,
        {
          ...reactiveModalProps,
          ..._modalProps,
          open: open.value,
          confirmLoading: isLoading.value,
        },
        () => h(component, { ...props, ref: instance }),
      )
    },
  })

  const app = createApp(dialog)
  app.use(Antd) // 如果是全局注册需要重新注册,使用自动导入则不需要
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)

  function unmount() {
    document.body.removeChild(div)
    app.unmount()
  }
}

如何共享 Vue app 实例上下文 ?

1. 暴力模式

main.ts

const app = createApp(App)
window._APP_CONTEXT = app._context

app.mount('#app')

使用

const app = createApp(Dialog)
app._context = window._APP_CONTEXT
// app._context.provides = window._APP_CONTEXT.provides
2. 重写 createApp

我们还可以重写一下 createApp 方法,在需要支持共享 App 的实例方法的时候,直接使用我们自己重写的 createApp 方法:

import type { App } from 'vue'

import Antd from 'ant-design-vue'
import { createPinia } from 'pinia'
import { createApp as _createApp } from 'vue'
import router from '~/router'

function loadPlugins(app: App) {
  app.use(Antd)
  app.use(createPinia())
  app.use(router)
}

export const createApp: typeof _createApp = (...args) => {
  const app = _createApp(...args)
  loadPlugins(app)
  return app
}

element plus 版本

import type { Component, ComponentPublicInstance } from 'vue'
import { ElButton, ElDialog } from 'element-plus'
import { createApp, h, ref } from 'vue'

/**
 * 弹窗渲染器
 * @param component 要渲染的组件
 * @param props 组件的 props
 * @param modalProps 弹窗的属性(ElDialog 的 props)
 */
export function renderDialog<
  T extends Record<string, any> = Record<string, any>,
  I extends ComponentPublicInstance<{ submit?: () => Promise<any> }> = ComponentPublicInstance,
>(
  component: Component<T>,
  props?: T,
  modalProps?: Partial<InstanceType<typeof ElDialog>['$props']>,
): void {
  const open = ref(true)
  const loading = ref(false)
  const instance = ref<I>()

  const dialog = () =>
    h(
      ElDialog,
      {
        ...modalProps,
        'modelValue': open.value,
        'onUpdate:modelValue': (val: boolean) => {
          open.value = val
        },
        onClosed() {
          app.unmount()
          document.body.removeChild(div)
        },
      },
      {
        default: () => h(component, { ...(props as T), ref: instance } satisfies T & { ref: typeof instance }),
        footer: () =>
          h('div', { class: 'dialog-footer' }, [
            h(ElButton, { onClick: cancel }, () => '取 消'),
            h(
              ElButton,
              { type: 'primary', onClick: submit, loading: loading.value },
              () => '确 认',
            ),
          ]),
      },
    )

  const app = createApp(dialog)
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)

  async function submit() {
    loading.value = true
    try {
      await instance.value?.submit?.()
      open.value = false
    }
    finally {
      loading.value = false
    }
  }

  function cancel() {
    open.value = false
  }
}

更复杂的案例参考链接 🔗

使用 h 函数封装 HOC 函数

1. 什么是 HOC ?

高阶组件 HOC 在 React 社区是非常常见的概念,先来回顾一下在 React 里⾼阶组件是什么?

function withExtraProp(Component) {
  return function(props) {
    return <Component extraProp="someValue" {...props} />
  }
}
  • HOC本质上是⼀个函数,它接收⼀个组件作为参数,然后返回⼀个新的组件。
  • 返回的新组件将拥有被包裹的组件的所有 props,并且可以添加额外的 props 或状态。

高阶组件的业务场景:

  • 授权和权限管理:返回的新的组件在渲染之前会检查⽤户是否已经认证。
  • 数据获取: 新的组件在挂载时获取数据,并将数据通过 props 传递给被包裹的组件。
  • 错误处理:在发⽣错误时显示错误信息或其他备⽤内容。

2. 从实际业务场景出发

有一天产品找到你,说要给我们的系统增加会员功能,需要让系统中的几十个功能块增加会员可见功能。如果不是会员这几十个功能块都显示成引导用户开通会员的UI,并且这些功能块涉及到几十个组件,分布在系统的各个页面中。

你肯定很容易想到将会员相关的功能抽取成一个名为 useVip.ts 的组合式函数(hook),代码如下:

export function useVip() {
  function getShowVipContent() {
    // 一些业务逻辑判断是否是VIP
    return false;
  }

  return {
    isVip: getShowVipContent()
  }
}

然后再去每个具体的业务模块中去使用 isVip 变量判断,并大量使用 v-if="isVip" 显示原模块,v-else显示引导开通会员 UI。代码如下:

<template>
  <Content v-if="isVip" :userInfo="userInfo" @update="onUpdateProfile" />
  <ActivateVipTip v-else />
</template>

<script setup lang="ts">
  import Content from "./content.vue"
  import ActivateVipTip from "./activate-vip-tip.vue"
  import { useVip } from "@/hooks/useVip"

  const userInfo = ref({ name: 'Yi', age: 24 })
  const { isVip } = useVip()

  const updateProfile = () => {}
</script>
  • 如果页面中有几十个地方需要修改呢,不麻烦吗 ?
  • 你需要修改的地方都是自己写的业务逻辑吗,不会出错吗 ?
  • 如果再加一个 SVIP 功能、或者企业级功能单独显示的 UI 呢 ?

上面的这一场景使用 hooks 去实现,虽然能够完成,但是因为入侵了这几十个模块的业务逻辑。所以容易出错,也改起来比较麻烦,代码也不优雅。

那么有没有一种更好的解决方案,让我们可以不入侵这几十个模块的业务逻辑的实现方式呢 ?

答案是:高阶组件HOC

3. Vue 中的组件即对象

我写了一个组件如下:

<script setup lang="ts">
defineProps({ msg: String })
</script>

<template>
  <div>
    {{ msg }}
    <slot />
  </div>
</template>

我们在父组件引入这个组件,并且打印一下这个子组件:

<script setup lang="ts">
import { ref } from 'vue'
import Comp from './Comp.vue'
console.log(Comp);
</script>

如下图所示, Vue 中的组件经过编译后其实就是一个对象,对象中的 props 属性对应的就是我们写的 defineProps, 而对象中的 setup 方法,对应的就是我们熟知的 <script setup> 语法糖。

还有一点,在 Vue 中,如果在 setup 方法中返回一个函数,那么在 Vue 内部就会认为这个函数就是实际的 render 函数,并且在 setup 方法中我们天然的就可以访问定义的变量。

实战:实现一个简单的HOC

HOC 的一个用途就是对组件进行增强,并且不会入侵原有组件的业务逻辑,在这里就是使用 HOC 判断会员相关的逻辑,如果是会员那么就渲染原本的模块组件,否则就渲染引导开通 VIP 的 UI。

基于在 setup 方法中返回一个函数的方式,我们可以在 Vue3 中实现一个简单的高阶组件,代码如下:

import { h } from "vue"
import ActivateVipTip from "./activate-vip-tip.vue"
import type { Component } from 'vue'

function getShowVipContent() {
  // ... 验证是否为会员的逻辑
  return true
}

export function WithVip(Comp: Component) {
  return {
    setup() {
      const isVip = getShowVipContent()
  
      return () => {
        return isVip ? h(Comp) : h(ActivateVipTip)
      }
    }
  }
}
<script lang="ts" setup>
import Content from './content.vue'
import { WithVip } from './with-vip'
const VipContent = WithVip(Content)
</script>

<template>
  <VipContent />
</template>

这个代码相比前面的 hooks 的方案就简单很多了,只需要使用高阶组件 WithVip 对原来的 Content 组件包一层,然后将原本使用 Content 的地方改为使用 VipContent,对原本的代码基本没有入侵。

HOC 更复杂的例子参考文章: