原文链接: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 组件本身支持 confirmButtonText 和 cancelButtonText 属性,我们可以将这两个属性作为参数,传入 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,还能打造出仅靠单文件组件模板无法实现的抽象封装。将模板和渲染函数结合使用,我们就能创造出各种实用又有趣的编码模式。