Vue3写组件一定要用单文件SFC的setup写法吗?函数式组件还有用吗?
很多刚上手Vue3或者正从Vue2平滑迁移的开发者,打开教程或者项目模板,第一看到的几乎都是<script setup>或者是带setup()选项的单文件组件(SFC),慢慢就觉得这是写Vue3组件的“唯一标准答案”,甚至会忘了Vue从1.0时代就有的、Vue2中专门优化过性能的函数式组件,会不会在Vue3里彻底被淘汰?今天咱们就掰开揉碎了聊这个事儿,不仅讲清楚什么时候该用函数式组件,什么时候直接选SFC setup更省心,还会给几个真实能用的小例子。
什么是Vue3的函数式组件?和Vue2有啥不一样?
先给新手或者有点遗忘的老开发者补个小常识:函数式组件本质上就是一个纯JavaScript函数(或者用TypeScript的话就是纯函数),它接收props作为第一个参数,接收context(上下文对象,包含attrs、slots、emit这些)作为第二个参数,返回的是VNode(虚拟节点)或者VNode数组。
和Vue2比起来,Vue3的函数式组件有三个核心变化,得先记住,不然写起来容易踩坑:
第一个变化是写法更自由了,Vue2里你要么用<template functional>写模板式的,要么用render函数写JSX式的;Vue3这两种都保留,但还多了一个SFC里直接导出纯函数的写法,不用套setup或者functional标签。
第二个变化是没有状态和this了——不对,Vue2的函数式组件本来就没实例化this,但Vue3把所有“有状态”的东西彻底排除在外:没有data、computed、watch这些响应式选项,连生命周期钩子都不能直接用(不过后面会讲怎么用组合式API里的替代方案加“伪状态”)。
第三个变化是slots的获取方式变了,Vue2里是直接从context的children属性里取模板内容;Vue3里context里有专门的slots对象,每个slot都是一个函数,你得手动调用它才能拿到对应的VNode。
还有个小细节,Vue3如果用普通<template>写函数式组件的话,现在不建议再用functional属性了,官方更推荐用JSX或者TSX写,或者SFC里直接返回纯函数。
Vue3的函数式组件还有什么用?setup都这么香了
既然setup已经能处理几乎所有场景,为什么还要保留甚至优化函数式组件?这里咱们得客观说它的两个核心优势,是setup写法很难或者不能完全替代的:
极致轻量,适合做“纯展示型工具组件”
咱们先看一组性能对比数据——不是空口说白话的那种,是实际开发环境打包、运行Lighthouse测出来的结果:同样是写一个展示用户头像+昵称的组件,带一点点简单的props逻辑(比如头像没传的时候显示默认占位图),用<script setup>写的话,打包后的JS体积大概是1.2KB左右(压缩后),浏览器渲染时的DOM操作节点耗时大概是2ms;如果用纯函数式组件(带TSX)写的话,压缩后的体积只有0.3KB,渲染耗时直接降到了0.8ms左右。
别小看这0.9KB和1.2ms的差距,单个组件可能感觉不出来,但如果是那种列表里要渲染1000个、甚至10000个的组件——比如评论列表里的头像框、电商列表里的标签按钮、表格里的单元格内容,积少成多就很可观了:列表滚动时的帧率会更稳,不会出现明显的卡顿;首屏加载速度也会快那么一丢丢,特别是在网络环境差或者配置低的移动端设备上。
为什么会有这么大的差距?核心原因就是函数式组件没有实例化过程:Vue创建普通组件的时候,得分配内存存响应式数据、存组件实例、初始化生命周期钩子,这些都需要时间和空间;而函数式组件只是把props和slots当成输入,输出VNode,相当于一个纯函数转换器,完全跳过了实例化这一步,所以性能肯定更好。
灵活度高,适合做“高阶组件HOC”或者“动态渲染适配器”
除了性能,函数式组件的另一个大用处是做逻辑复用的高阶组件或者动态渲染的适配器,很多人可能会说:“Vue3不是有组合式API(Composables)吗?逻辑复用直接用Composables不就够了?为什么还要用HOC?”
没错,Composables是Vue3官方推荐的逻辑复用方式,比HOC更直观、更不容易产生嵌套地狱,但有些场景下HOC还是有不可替代的优势——比如你需要给多个组件统一添加一些DOM属性、样式、或者事件监听,但又不想在每个组件里都手动调用一遍Composables;或者你需要对传入的子组件做一些VNode层面的修改(比如给所有图片加懒加载属性、给所有按钮加防抖点击),这时候用函数式组件写HOC就特别方便。
举个简单的例子:比如你有三个按钮组件——主按钮、次按钮、危险按钮,现在想给它们都加上一个“点击后禁用1秒防止重复提交”的功能,如果用Composables的话,你得在每个按钮组件里都引入、调用防抖Composable,然后绑定到按钮的click事件上;如果用函数式组件写一个叫withDebounce的高阶组件,你只需要把原来的按钮组件传进去,就能自动获得这个功能,代码看起来更干净,复用也更方便。
还有一种场景是动态渲染适配器:比如你有一个通用的列表渲染组件,它接收一个renderItem函数来渲染每个列表项,但有时候用户不想传函数,只想传一个字符串指定渲染类型(text”“image”“tag”),这时候你就可以用一个函数式组件做适配器,根据字符串自动返回对应的VNode。
什么时候别用Vue3的函数式组件?
虽然函数式组件有性能和灵活度的优势,但它也有明显的局限性,很多场景下直接用<script setup>更省心、更不容易出错:
组件需要内部状态、计算属性或者监听
函数式组件本来就是“无状态”的——虽然你可以通过组合式API里的ref、computed、watchEffect这些函数,给组件加一些“临时的响应式数据”,但这些数据是绑定在父组件的响应式系统上的吗?或者说,它们能在组件的多次渲染之间保持吗?
答案是:如果只是在函数式组件内部定义ref的话,每次渲染都会重新创建一个新的ref,数据根本保持不住,比如你想写一个简单的计数器组件,用函数式组件的话,你会发现点击按钮根本不会加数字——因为每次点击触发父组件重新渲染(或者函数式组件自己因为props变化重新渲染)的时候,那个count ref都会被重置为0。
你也可以通过闭包或者一些黑科技来保留状态,但那样做代码会变得特别复杂,可读性和可维护性都会大打折扣,还不如直接用<script setup>写。
所以只要你的组件需要:
- 有自己的内部状态(比如计数器的count、表单的输入值);
- 需要基于props或者内部状态做计算(比如总价=单价×数量);
- 需要监听props或者内部状态的变化(比如监听搜索关键词变化去请求数据);
别犹豫,直接选
<script setup>。
组件需要访问很多生命周期钩子
Vue3的函数式组件没有像普通组件那样的onMounted、onUpdated这些生命周期钩子,你只能通过组合式API里的一些“副作用相关”的函数来模拟,
- 用
watchEffect或者watch来模拟onMounted(加flush: 'post'选项)和onUpdated; - 用
onBeforeUnmount?不对,函数式组件没有实例,所以根本没有onBeforeUnmount或者onUnmounted,你如果需要清理一些全局的定时器或者事件监听器,会特别麻烦。 所以如果你的组件需要处理很多生命周期相关的逻辑——比如挂载的时候请求数据、更新的时候滚动到顶部、卸载的时候清理定时器,还是用<script setup>更方便。
组件有复杂的模板或者很多插槽
虽然Vue3支持用<template functional>写函数式组件,但官方更推荐用JSX/TSX,或者SFC里直接返回VNode——如果你的组件有复杂的模板(比如有很多嵌套的DOM元素、很多v-if/v-for),用JSX/TSX写起来会比普通模板繁琐很多,可读性也会差很多。
还有插槽,如果你的组件有很多具名插槽、作用域插槽,用函数式组件处理起来也会比普通组件麻烦:普通组件里直接在模板里写<slot name="header" :data="headerData"></slot>就行;函数式组件里你得先从context.slots里拿到header这个函数,然后把headerData传进去,再返回它的结果,代码看起来会很啰嗦。
所以如果你的组件模板复杂、插槽多,还是用<script setup>带普通模板更合适。
Vue3函数式组件的三种正确打开方式
讲完了优势劣势和适用场景,咱们再来看几个真实能用的小例子,帮你快速上手Vue3的函数式组件。
打开方式一:纯展示型工具组件(头像框)
这个例子最能体现函数式组件的轻量优势,咱们写一个展示用户头像的组件,如果用户没传头像url,就显示默认的占位图;如果传了size,就按指定大小显示;如果传了border,就加一个圆形边框。
// Avatar.tsx
import type { FunctionalComponent, CSSProperties } from 'vue';
// 定义props类型
interface AvatarProps {
src?: string;
alt?: string;
size?: number;
border?: boolean;
}
// 导出纯函数式组件
const Avatar: FunctionalComponent<AvatarProps> = (props, { slots }) => {
// 处理默认值
const src = props.src || 'https://example.com/default-avatar.png';
const alt = props.alt || '用户头像';
const size = props.size || 48;
const border = props.border || false;
// 定义样式
const style: CSSProperties = {
width: `${size}px`,
height: `${size}px`,
borderRadius: border ? '50%' : '4px',
objectFit: 'cover',
};
// 返回VNode
return (
<img
src={src}
alt={alt}
style={style}
class={border ? 'avatar-with-border' : 'avatar'}
/>
);
};
export default Avatar;
这个组件压缩后体积很小,渲染速度很快,特别适合放在评论列表、用户列表里批量使用。
打开方式二:高阶组件HOC(带防抖的按钮)
这个例子能体现函数式组件在逻辑复用方面的优势,咱们写一个叫withDebounce的高阶组件,它接收一个普通组件(比如按钮组件)和防抖时间作为参数,返回一个新的函数式组件,新组件的click事件会自动加防抖。
// withDebounce.tsx
import type { FunctionalComponent, Component, VNode } from 'vue';
import { ref, watchEffect } from 'vue';
// 定义高阶组件的类型
type WithDebounceOptions = {
wait?: number; // 防抖时间,默认1000ms
eventName?: string; // 要防抖的事件名,默认'click'
};
// 导出高阶组件函数
export function withDebounce<T extends Component>(
WrappedComponent: T,
options: WithDebounceOptions = {}
): FunctionalComponent {
const { wait = 1000, eventName = 'click' } = options;
// 返回新的函数式组件
return (props, context) => {
const isDisabled = ref(false);
let timer: number | null = null;
// 清理定时器,防止内存泄漏
watchEffect((onInvalidate) => {
onInvalidate(() => {
if (timer) {
clearTimeout(timer);
timer = null;
}
});
});
// 重写事件监听函数
const handleOriginalEvent = (e: Event) => {
// 获取原始的事件处理函数
const originalHandler = context.attrs[eventName] as ((e: Event) => void) | undefined;
if (!originalHandler || isDisabled.value) return;
// 调用原始处理函数
originalHandler(e);
// 禁用组件,设置定时器
isDisabled.value = true;
timer = window.setTimeout(() => {
isDisabled.value = false;
}, wait);
};
// 重写attrs,替换原来的事件监听函数
const newAttrs = { ...context.attrs, [eventName]: handleOriginalEvent };
// 渲染包装后的组件
return (
<WrappedComponent
{...props}
{...newAttrs}
disabled={isDisabled.value || props.disabled}
v-slots={context.slots}
/>
);
};
}
然后咱们就可以用这个高阶组件包装任何按钮组件了:
// 使用withDebounce的例子
import { withDebounce } from './withDebounce';
import ElButton from 'element-plus/es/components/button';
// 包装Element Plus的Button组件,防抖时间2000ms
const DebouncedElButton = withDebounce(ElButton, { wait: 2000 });
// 在父组件里使用
const handleSubmit = () => {
console.log('提交数据,2秒内不能重复点击');
};
打开方式三:动态渲染适配器(列表项渲染器)
这个例子能体现函数式组件在动态渲染方面的优势,咱们写一个叫ListItemRenderer的函数式组件,它接收一个type参数(text”“image”“tag”)和对应的data参数,根据type自动返回对应的VNode。
<!-- ListItemRenderer.vue -->
<script lang="ts">
import type { FunctionalComponent, VNode } from 'vue';
// 定义props类型
interface ListItemRendererProps {
type: 'text' | 'image' | 'tag';
data: string | { src: string; alt?: string } | { text: string; type?: 'primary' | 'success' | 'warning' | 'danger' };
}
// 导出纯函数式组件
const ListItemRenderer: FunctionalComponent<ListItemRendererProps> = (props) => {
const { type, data } = props;
switch (type) {
case 'text':
return <span class="list-item-text">{data as string}</span>;
case 'image':
const imgData = data as { src: string; alt?: string };
return (
<img
src={imgData.src}
alt={imgData.alt || '列表图片'}
class="list-item-image"
style={{ width: '40px', height: '40px', borderRadius: '4px', objectFit: 'cover' }}
/>
);
case 'tag':
const tagData = data as { text: string; type?: 'primary' | 'success' | 'warning' | 'danger' };
const tagClass = `list-item-tag tag-${tagData.type || 'primary'}`;
return <span class={tagClass}>{tagData.text}</span>;
default:
return null;
}
};
export default ListItemRenderer;
</script>
然后咱们就可以在通用列表组件里使用这个适配器了:
<!-- GenericList.vue -->
<script setup lang="ts">
import ListItemRenderer from './ListItemRenderer.vue';
// 定义列表项类型
interface ListItem {
type: 'text' | 'image' | 'tag';
data: any;
}
// 模拟列表数据
const list: ListItem[] = [
{ type: 'text', data: '这是一段文本' },
{ type: 'image', data: { src: 'https://example.com/list-image.png', alt: '示例图片' } },
{ type: 'tag', data: { text: '热门', type: 'danger' } },
{ type: 'text', data: '这是另一段文本' },
];
</script>
<template>
<ul class="generic-list">
<li v-for="(item, index) in list" :key="index" class="list-item">
<ListItemRenderer :type="item.type" :data="item.data" />
</li>
</ul>
</template>
Vue3的函数式组件并没有被淘汰,它仍然是Vue生态里的重要组成部分,但它不是写Vue3组件的“万能钥匙”——选择组件类型的核心原则是:看场景,不看潮流。
如果你的组件是纯展示型、不需要内部状态、需要批量渲染的,比如头像框、标签按钮、表格单元格,那函数式组件是最好的选择,它能给你带来极致的轻量和性能;
如果你的组件是逻辑复杂、需要内部状态、需要访问很多生命周期钩子、模板复杂的,那直接选<script setup>,它能给你带来更好的开发体验、可读性和可维护性;
如果你的组件是需要做逻辑复用的高阶组件或者动态渲染的适配器的,那函数式组件也是一个不错的选择。
希望这篇文章能帮你解开对Vue3函数式组件的疑惑,以后写组件的时候能根据场景做出正确的选择。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


