【翻译】巧用 Vue 渲染函数打造更优的抽象封装

0 阅读10分钟

原文链接:Building Better Abstractions with Vue Render Functions

作者:Abdelrahman Awad

本文将为你介绍一种实用的 Vue 渲染函数使用模式,教你如何通过它打造更优的代码抽象,简化组件架构设计。

渲染函数是 Vue.js 中一种通过 JavaScript 渲染 HTML 的替代方案。在绝大多数场景下,使用模板仍是更推荐的选择。

其核心原理十分简单:通过调用 JavaScript 函数来编写模板内容。

import { h } from 'vue'

const vnode = h(
  'div', // 标签类型
  { id: 'foo', class: 'bar' }, // 标签属性
  [
    h('h1', 'Hello World'),
    h('p', 'This is a paragraph'),
  ]
)

看起来很简单,对吧?但它其实并不比下面的模板写法更有优势:

<template>
  <div id="foo" class="bar">
    <h1>Hello World</h1>
    <p>This is a paragraph</p>
  </div>
</template>

模板写法更符合直觉,可读性和可维护性也更强。那为什么还要使用渲染函数呢?

Vue 官方文档中提到:

在某些场景下,我们需要用到 JavaScript 的完整编程能力,这时候就可以使用渲染函数。

但文档并未具体说明何时需要使用它。你可能会认为渲染函数能写出更优化的渲染逻辑,但 Vue 编译器的优化能力已经非常出色,很难被超越。

与其凭空猜测适用场景,不如为大家展示我在实际工作中对它的真实用法。其实渲染函数的价值并非体现在「渲染内容」上,更多是为应用打造更优秀的抽象封装。

作为组件工厂的渲染函数

通常,我们可以这样创建一个程序化的组件:

import { h, defineComponent } from 'vue'

const MyComponent = defineComponent({
  setup(props) {
    // 组件初始化逻辑
  },
  render() {
    // 渲染逻辑
    return h('div', 'Hello World')
  }
})

我个人更偏爱一种更简洁的写法 —— 将一个函数传入 defineComponent

import { h, defineComponent } from 'vue'

const MyComponent = defineComponent((props, { slots }) => {
  // setup 作用域

  return () => {
    // 渲染作用域,也可将插槽传递到渲染函数中
    return h('div', 'Hello World', slots)
  }
})

这种写法不仅更简洁,还能让渲染作用域访问到 setup 作用域中的所有内容。因为这本质上是一个「返回函数的函数」,我们可以利用 JavaScript 的闭包特性实现这一点。

单看这一点还不够有说服力,但如果把它放到工厂函数中,效果就会变得很有意思。

import { h, defineComponent } from 'vue'

function useMyComponent(config) {
  // 工厂函数作用域
  const MyComponent = defineComponent((props) => {
    // setup 作用域
    return () => {
      // 渲染作用域
      return h('div', 'Hello World')
    }
  })

  return MyComponent;
}

在这个例子中,我将工厂函数设计成了一个返回组件的组合式函数。这种写法非常实用,因为它能为我们提供一个额外的作用域,让我们可以动态配置组件。

接下来,我们结合实际案例看看具体该如何使用。

确认弹窗为何让我觉得繁琐

在开发表单、CRUD 界面、可编辑数据表格,或是任何需要用户确认操作的 UI 时,确认弹窗的状态管理总是让我感到繁琐。

通常的做法是使用一个传统的弹窗组件,通过 isOpen 属性甚至 v-model 绑定来控制它的显示隐藏,组件可能还会提供 default 插槽用于内容展示,footer 插槽用于放置按钮。

假设我们要为 GitHub 的仓库删除功能开发一个确认弹窗,代码大概会是这样:

<script setup>
import { ref } from 'vue'
const isDeletingRepository = ref(false);
const repository = ref({ name: 'my-repository' });

function onCancel() {
  isDeletingRepository.value = false;
}

function onConfirm() {
  isDeletingRepository.value = false;
  // 执行仓库删除逻辑
}
</script>

<template>
  <button @click="isDeletingRepository = true">删除仓库</button>

  <ModalDialog :is-open="isDeletingRepository">
    <template #default>
      <p>你确定要删除仓库 {{ repository.name }} 吗?</p>
    </template>

    <template #footer>
      <button @click="onCancel">取消</button>
      <button @click="onConfirm">删除</button>
    </template>
  </ModalDialog>
</template>

这还只是单个资源的弹窗代码。如果需要删除多种资源,就不得不重复编写这样的代码:

<script setup>
import { ref } from 'vue'
const isDeletingRepository = ref(false);
const onCancelRepositoryDeletion = () => {}
const onConfirmRepositoryDeletion = () => {}

const isDeletingUser = ref(false);
const onCancelUserDeletion = () => {}
const onConfirmUserDeletion = () => {}

const isDeletingTeam = ref(false);
const onCancelTeamDeletion = () => {}
const onConfirmTeamDeletion = () => {}
</script>

<template>
  <button @click="isDeletingRepository = true">删除仓库</button>
  <button @click="isDeletingUser = true">删除用户</button>
  <button @click="isDeletingTeam = true">删除团队</button>

  <ModalDialog :is-open="isDeletingRepository">
    <!-- 仓库删除弹窗内容 -->
  </ModalDialog>

  <ModalDialog :is-open="isDeletingUser">
    <!-- 用户删除弹窗内容 -->
  </ModalDialog>

  <ModalDialog :is-open="isDeletingTeam">
    <!-- 团队删除弹窗内容 -->
  </ModalDialog>
</template>

这样的重复代码会越来越多。于是我们第一个想到的解决方案,就是创建一个 ConfirmationDialog 组件来封装这些重复逻辑,就像这样:

<script setup lang="ts">
const props = defineProps<{
  cancelButtonText?: string;
  confirmButtonText?: string;
}>();

const isOpen = defineModel('isOpen');
const emit = defineEmits(['confirm']);
</script>

<template>
  <ModalDialog :is-open="isOpen">
    <template #default>
      <slot />
    </template>

    <template #footer>
      <button @click="isOpen = false">{{ cancelButtonText ?? '取消' }}</button>
      <button @click="emit('confirm')">{{ confirmButtonText ?? '确认' }}</button>
    </template>
  </ModalDialog>
</template>

现在,三个弹窗的示例代码就变成了这样:

<script setup>
import { ref } from 'vue'
const isDeletingRepository = ref(false);
const onConfirmRepositoryDeletion = () => {}

const isDeletingUser = ref(false);
const onConfirmUserDeletion = () => {}

const isDeletingTeam = ref(false);
const onConfirmTeamDeletion = () => {}
</script>

<template>
  <button @click="isDeletingRepository = true">删除仓库</button>
  <button @click="isDeletingUser = true">删除用户</button>
  <button @click="isDeletingTeam = true">删除团队</button>

  <ConfirmationDialog :is-open="isDeletingRepository" @confirm="onConfirmRepositoryDeletion">
    <!-- 仓库删除提示内容 -->
  </ConfirmationDialog>

  <ConfirmationDialog :is-open="isDeletingUser" @confirm="onConfirmUserDeletion">
    <!-- 用户删除提示内容 -->
  </ConfirmationDialog>

  <ConfirmationDialog :is-open="isDeletingTeam" @confirm="onConfirmTeamDeletion">
    <!-- 团队删除提示内容 -->
  </ConfirmationDialog>
</template>

这样写确实简洁了一些,因为取消逻辑已经被封装到组件内部,我们无需再单独处理。但即便如此,每个弹窗仍然需要一个状态变量来控制显隐,以及一个确认后的回调函数。

在我使用 Vue.js 的所有开发经历中,我一直尝试将尽可能多的逻辑封装在独立的代码单元中,无论是组件、组合式函数,还是工具函数。我采用的策略之一,就是减少每个代码单元的依赖项,让它能在不同场景下更轻松地被复用。

对于组件来说,就是减少属性的数量;对于组合式函数和普通函数来说,就是减少参数的数量,依此类推。

而上面这种弹窗组件让我觉得繁琐的原因,就在于它每次被使用时,都需要传入相同数量的依赖项。如果有 100 个这样的弹窗,就需要重复传递 100 次这些依赖。

遇到这种情况时,我会问自己:这些依赖项的本质是什么?哪些是必要的(核心目标),哪些是辅助性的(实现手段)?

对于确认弹窗来说,我会将 confirm 事件归为必要项,而将 isOpen 属性归为辅助项

我们对确认弹窗的核心需求,其实只是为某个操作加一道「确认关卡」:用户确认,就执行操作;用户取消,就不执行操作。至于弹窗本身是打开还是关闭,其实并不重要,它只是打开和关闭这道关卡的一个实现机制。

想清楚这一点后,我们来看看渲染函数能如何帮我们解决这个问题。

设计理想的 API

在打造这类抽象封装时,我通常会先设想自己想要使用什么样的 API,暂时抛开实现细节,只专注于使用体验

对于确认弹窗这个场景,我想要一个简洁的组合式函数,能通过简单的 API 为某个操作创建一道确认关卡。

所以它最初的形态应该是一个接收回调函数、并返回一个组件的函数:

import { h, defineComponent } from 'vue'

export function useConfirmationDialog(action) {
  return defineComponent((props) => {
    return () => {
      // 我们要渲染什么内容?
    }
  })
}

渲染函数的一个强大之处在于,它可以渲染另一个组件。因此我们可以直接复用刚才写好的 ConfirmationDialog 组件,在渲染函数中调用它。

而因为这个组件接收 onConfirm 回调,我们只需将传入的操作函数传递给它即可:

import { h, defineComponent } from 'vue'
import ConfirmationDialog from './ConfirmationDialog.vue'

export function useConfirmationDialog(action) {
  return defineComponent((props, { slots }) => {
    return () => {
      return h(ConfirmationDialog, {
        onConfirm: action,
      }, slots)
    }
  })
}

目前这段代码还无法正常工作,因为我们还需要实际控制弹窗的显隐状态。所以首先,我们可以创建一个简单的 ref 来管理这个状态:

import { ref, defineComponent, h } from 'vue'
import ConfirmationDialog from './ConfirmationDialog.vue'

export function useConfirmationDialog(action) {
  const isOpen = ref(false)

  return defineComponent((props, { slots }) => {
    return () => {
      return h(ConfirmationDialog, {
        isOpen: isOpen.value,
        onConfirm: action,
      }, slots)
    }
  })
}

最后,我们还需要一个触发弹窗打开的方式。仅仅返回一个组件是不够的,我们需要同时返回一个触发函数和组件本身。

我们可以将它们优雅地封装到一个对象中:

import { ref, defineComponent, h } from 'vue'
import ConfirmationDialog from './ConfirmationDialog.vue'

export function useConfirmationDialog(action) {
  const isOpen = ref(false)

  const Dialog = defineComponent((props, { slots }) => {
    return () => {
      return h(ConfirmationDialog, {
        isOpen: isOpen.value,
        onConfirm: action,
      }, slots)
    }
  })

  return {
    Dialog,
    confirm: () => isOpen.value = true,
  }
}

现在,我们就可以通过这个组合式函数,以非常简洁的 API 创建确认弹窗了:

<script setup>
import { useConfirmationDialog } from './useConfirmationDialog'

const DeleteRepository = useConfirmationDialog(() => {
  console.log('确认删除!')
})
</script>

<template>
  <button @click="DeleteRepository.confirm">删除仓库</button>

  <DeleteRepository.Dialog>
    <p>你确定要删除这个仓库吗?</p>
  </DeleteRepository.Dialog>
</template>

通过这个 API,我们不仅可以根据操作类型为弹窗组件命名,还无需再为弹窗传递任何属性 —— 因为我们真正需要的,只有确认后的回调函数和一个打开弹窗的方法。

我们还可以基于这个思路实现更多有趣的功能。既然 ConfirmationDialog 组件本身支持 confirmButtonTextcancelButtonText 属性,我们可以将这两个属性作为参数,传入 useConfirmationDialog 组合式函数,或是直接传入 confirm 触发函数中,具体选择哪种方式完全由你决定。

下面为大家展示这两种实现方式。

方式一:创建时配置

import { ref, defineComponent, h } from 'vue'
import ConfirmationDialog from './ConfirmationDialog.vue'

export function useConfirmationDialog(action, confirmText, cancelText) {
  const isOpen = ref(false)

  const Dialog = defineComponent((props, { slots }) => {
    return () => {
      return h(ConfirmationDialog, {
        isOpen: isOpen.value,
        onConfirm: action,
        confirmButtonText: confirmText,
        cancelButtonText: cancelText,
      }, slots)
    }
  })

  return {
    Dialog,
    confirm: () => isOpen.value = true,
  }
}

// 使用方式
const DeleteRepository = useConfirmationDialog(() => {
  console.log('确认删除!')
}, '删除仓库', '取消')

方式二:调用时配置

import { ref, defineComponent, h } from 'vue'
import ConfirmationDialog from './ConfirmationDialog.vue'

export function useConfirmationDialog(action) {
  const isOpen = ref(false)
  const confirmTextState = ref('')
  const cancelTextState = ref('')

  return {
    Dialog: defineComponent((props, { slots }) => {
      return () => {
        return h(ConfirmationDialog, {
          isOpen: isOpen.value,
          onConfirm: action,
          confirmButtonText: confirmTextState.value,
          cancelButtonText: cancelTextState.value,
        }, slots)
      }
    }),
    confirm: (confirmText, cancelText) => {
      isOpen.value = true
      confirmTextState.value = confirmText ?? '确认'
      cancelTextState.value = cancelText ?? '取消'
    },
  }
}

// 使用方式
const DeleteRepository = useConfirmationDialog(() => {
  console.log('确认删除!')
})

// 在按钮的点击事件中调用
DeleteRepository.confirm('删除', '取消')

我个人更偏爱第二种方式,因为它让我可以在打开弹窗的任意时刻,灵活修改确认和取消按钮的文字。

现在我们回到最初的三个弹窗示例,看看这个新的 API 适配得有多好:

<script setup>
import { useConfirmationDialog } from './useConfirmationDialog'

const DeleteRepository = useConfirmationDialog(() => {
  console.log('仓库已删除!')
})

const DeleteUser = useConfirmationDialog(() => {
  console.log('用户已删除!')
})

const DeleteTeam = useConfirmationDialog(() => {
  console.log('团队已删除!')
})
</script>

<template>
  <button @click="DeleteRepository.confirm()">删除仓库</button>
  <button @click="DeleteUser.confirm()">删除用户</button>
  <button @click="DeleteTeam.confirm()">删除团队</button>

  <DeleteRepository.Dialog>
    <p>你确定要删除这个仓库吗?</p>
  </DeleteRepository.Dialog>

  <DeleteUser.Dialog>
    <p>你确定要删除这个用户吗?</p>
  </DeleteUser.Dialog>

  <DeleteTeam.Dialog>
    <p>你确定要删除这个团队吗?</p>
  </DeleteTeam.Dialog>
</template>

没有多余的事件监听,没有零散的状态管理,没有繁琐的属性传递,甚至不需要使用 provide/inject。仅仅依靠闭包和渲染函数,我们就打造出了这样一个简洁的 API。

点击查看该示例的在线演示

这种模式还能应用在很多场景中,只要你需要将逻辑和 UI 封装在一起,形成一个独立的代码单元。再为大家举几个例子:

  • 带分页的表格:一个组合式函数,负责请求数据并将数据关联到 Table 组件和 Pagination 组件,无需全局状态管理就能创建带分页的表格。
  • 通知提示框:一个组合式函数,返回一个 Notification 组件和一个 trigger 触发函数,无需全局状态管理就能生成提示框。
  • 带 UI 的表单状态:一个 useForm 组合式函数,不仅返回表单状态,还会返回一个包装组件,自动处理加载遮罩和错误提示。

总结

渲染函数是一个强大的工具,不仅能让我们以极高的灵活性和控制力渲染 UI,还能打造出仅靠单文件组件模板无法实现的抽象封装。将模板和渲染函数结合使用,我们就能创造出各种实用又有趣的编码模式。