什么样的组件值得改成 Composition API:一个判断标准
要说清楚"什么样的组件适合用 Composition API 重写",光靠看文档里的计数器 demo 没什么用——那种例子太小,看不出组织方式的差别。真正需要判断的场景是:手里有一个逻辑复杂的老组件,要不要花时间把它改一遍。这个问题这两个月一直在组里断断续续讨论,一直没有一个清楚的标准,直到最近重写了一个后台列表页组件,才算把判断依据理清楚。
我们团队目前的生产主力还是 Vue 2.6.x,Vue 3 只在内部工具项目里小范围试用。日常写 Composition API,靠的是 @vue/composition-api 这个官方兼容插件——它今年二月就发布了,不用等 Vue 3 正式版就能在 Vue 2 项目里提前用上这套写法,我们组从三月份起就陆续在新写的组件上用它练手。生态还在追,Element UI、Vuex、Vue Router 这些主流库大多没跟上 Vue 3,所以判断"值不值得重写"这件事,眼下更多发生在 Vue 2 项目里,而不是等着整体搬迁。
判断标准:逻辑有没有跨选项打散
Options API 在小组件里没有问题,data、methods、computed 各自归位,读起来很直接。它开始难受的临界点,是一个功能的状态和行为被拆到了好几个选项里,你得来回翻页才能拼出这个功能的全貌。
我们组里有个后台列表页组件,属于典型的"值得重写"样本。它同时承担搜索、分页、列表请求、批量操作弹窗、按钮权限判断、表单校验,之前是用三四个 mixin 拼起来的:searchMixin、paginationMixin、permissionMixin,外加组件自己写的表单校验逻辑。mixin 混入之后,data 里混着分页的 pageSize、搜索的 keyword、表单的 formErrors,methods 里也是同样的乱炖,谁是哪个功能的一部分,得靠命名猜。这类命名冲突和来源不透明的问题,之前记过一次笔记,这里不重复。
这个组件符合几个信号,值得考虑重写:一个业务功能的状态和方法分散在多个选项里;同一套逻辑(搜索、分页)在好几个页面组件里重复出现;组件本身状态很多,但能按功能域清楚拆开。反过来,团队里那些纯展示的卡片组件、只有几个 prop 和一个 computed 的组件,我们没有去动它们——十几行的组件改成 setup,只是换了个写法,没有实际收益,团队里新人接手时反而要多学一层概念。
重写前:Options API + mixin 拼出来的原貌
为了让判断标准落到实处,先把这个列表页组件原来的样子摊开看看。省略掉模板部分,只看 script:
1// searchMixin.js 2export default { 3 data() { 4 return { 5 keyword: '', 6 searchLoading: false, 7 searchError: '', 8 }; 9 }, 10 methods: { 11 async doSearch() { 12 this.searchLoading = true; 13 this.searchError = ''; 14 try { 15 const res = await this.fetchList(this.keyword); 16 this.list = res.data; 17 } catch (e) { 18 this.searchError = '搜索失败,请重试'; 19 } finally { 20 this.searchLoading = false; 21 } 22 }, 23 }, 24};
1// paginationMixin.js 2export default { 3 data() { 4 return { 5 pageSize: 20, 6 currentPage: 1, 7 total: 0, 8 }; 9 }, 10 computed: { 11 totalPage() { 12 return Math.ceil(this.total / this.pageSize); 13 }, 14 }, 15 methods: { 16 onPageChange(page) { 17 this.currentPage = page; 18 this.doSearch(); 19 }, 20 }, 21};
1// permissionMixin.js 2export default { 3 computed: { 4 canBatchDelete() { 5 return this.userPermissions.includes('batch_delete'); 6 }, 7 canExport() { 8 return this.userPermissions.includes('export'); 9 }, 10 }, 11};
1<script> 2import searchMixin from './searchMixin'; 3import paginationMixin from './paginationMixin'; 4import permissionMixin from './permissionMixin'; 5 6export default { 7 mixins: [searchMixin, paginationMixin, permissionMixin], 8 data() { 9 return { 10 list: [], 11 userPermissions: [], 12 formErrors: {}, 13 showBatchDialog: false, 14 }; 15 }, 16 computed: { 17 hasError() { 18 return !!this.searchError; 19 }, 20 }, 21 methods: { 22 fetchList(keyword) { 23 return fetch(`/api/users?keyword=${keyword}&page=${this.currentPage}&size=${this.pageSize}`) 24 .then((r) => r.json()); 25 }, 26 validateForm(form) { 27 const errors = {}; 28 if (!form.username) errors.username = '用户名不能为空'; 29 if (!form.role) errors.role = '请选择角色'; 30 this.formErrors = errors; 31 return Object.keys(errors).length === 0; 32 }, 33 openBatchDialog() { 34 this.showBatchDialog = true; 35 }, 36 }, 37 created() { 38 this.doSearch(); 39 }, 40}; 41</script>
这段代码单独看每一块都不复杂,问题出在组合起来之后。fetchList 定义在组件自己的 methods 里,却被 searchMixin 里的 doSearch 调用——两个文件互相依赖,但这层依赖关系没有写在任何一处,只能靠读两份代码去拼。currentPage、pageSize 属于分页 mixin,doSearch 却要读它们来拼请求参数,同样是隐式耦合。想确认"改 pageSize 的默认值会不会影响别的地方",得把三个 mixin 文件和组件本身都打开对照着看。
重写:把搜索逻辑收成一个组合函数
重写的第一步不是把整个组件搬进一个大的 setup,而是先把"搜索"这一块单独抽出来,让它有自己的状态、动作和错误处理:
1import { ref } from '@vue/composition-api'; 2 3export function useSearch(requestList) { 4 const keyword = ref(''); 5 const loading = ref(false); 6 const errorMessage = ref(''); 7 const list = ref([]); 8 9 async function search() { 10 loading.value = true; 11 errorMessage.value = ''; 12 13 const result = await requestList(keyword.value); 14 15 loading.value = false; 16 17 if (!result.ok) { 18 errorMessage.value = result.message; 19 return; 20 } 21 22 list.value = result.data; 23 } 24 25 return { 26 keyword, 27 loading, 28 errorMessage, 29 list, 30 search, 31 }; 32}
组件里用的时候:
1setup() { 2 const { keyword, loading, errorMessage, list, search } = useSearch(fetchUsers); 3 4 return { keyword, loading, errorMessage, list, search }; 5}
跟原来那个 searchMixin 比,最大的差别不是代码量,是来源清楚了。useSearch 返回的每一个字段都写在一个函数里,点进去就能看到全部逻辑;mixin 混入组件后,这些字段散在组件自身的选项之间,你甚至看不出哪些属性是 mixin 带来的。这也是社区讨论 Composition API 时反复提到的一点——mixin 的隐式合并会把状态来源搞模糊,而组合函数至少保证了"从哪来"是显式的。
分页、表单校验也是照着同样的思路各自抽成一个组合函数。原来三个 mixin 变成三个 useXxx 函数,setup 里做的事情,只是把它们分别调用一遍,再决定要不要把某几个返回值再组合一层(比如分页要用到搜索返回的 list.length 来算总页数,这种依赖关系在 setup 里用普通的函数调用顺序就能表达,不需要靠 mixin 的合并顺序去猜)。
分页那个组合函数写出来是这样:
1import { ref, computed } from '@vue/composition-api'; 2 3export function usePagination(initialPageSize = 20) { 4 const currentPage = ref(1); 5 const pageSize = ref(initialPageSize); 6 const total = ref(0); 7 8 const totalPage = computed(() => Math.ceil(total.value / pageSize.value)); 9 10 function onPageChange(page) { 11 currentPage.value = page; 12 } 13 14 function setTotal(count) { 15 total.value = count; 16 } 17 18 return { 19 currentPage, 20 pageSize, 21 total, 22 totalPage, 23 onPageChange, 24 setTotal, 25 }; 26}
权限判断那部分更简单,是对已有权限数组的纯计算,用 computed 就够了:
1import { computed } from '@vue/composition-api'; 2 3export function usePermission(permissions) { 4 const canBatchDelete = computed(() => permissions.value.includes('batch_delete')); 5 const canExport = computed(() => permissions.value.includes('export')); 6 7 return { canBatchDelete, canExport }; 8}
表单校验那部分因为字段之间要一起读写,用 reactive 包一层会更顺手,这个选择标准放在后面单独说。
重写后:同一个组件用 Composition API 的样子
把这几个组合函数准备好之后,组件的 setup 变成了这样:
1import { ref, computed, onMounted } from '@vue/composition-api'; 2import { useSearch } from './useSearch'; 3import { usePagination } from './usePagination'; 4import { usePermission } from './usePermission'; 5import { useFormValidate } from './useFormValidate'; 6 7export default { 8 setup() { 9 const permissions = ref([]); 10 const { canBatchDelete, canExport } = usePermission(permissions); 11 12 const { currentPage, pageSize, total, totalPage, onPageChange } = usePagination(); 13 14 const { 15 keyword, 16 loading: searchLoading, 17 errorMessage: searchError, 18 list, 19 search, 20 } = useSearch((kw) => fetchUsers(kw, currentPage.value, pageSize.value)); 21 22 const hasError = computed(() => !!searchError.value); 23 24 const { formErrors, validateForm } = useFormValidate(); 25 26 const showBatchDialog = ref(false); 27 function openBatchDialog() { 28 showBatchDialog.value = true; 29 } 30 31 // 分页变化之后要重新搜索,这层依赖在这里显式写出来, 32 // 不再需要靠 mixin 的挂载顺序去猜谁先执行 33 function changePage(page) { 34 onPageChange(page); 35 search(); 36 } 37 38 onMounted(() => { 39 search(); 40 }); 41 42 return { 43 keyword, 44 searchLoading, 45 searchError, 46 hasError, 47 list, 48 search, 49 currentPage, 50 pageSize, 51 total, 52 totalPage, 53 changePage, 54 canBatchDelete, 55 canExport, 56 formErrors, 57 validateForm, 58 showBatchDialog, 59 openBatchDialog, 60 }; 61 }, 62};
跟原来的版本对照着看,最直观的差别是:fetchUsers 需要用到的 currentPage、pageSize 是通过闭包传进 useSearch 的回调里的,依赖关系写在调用处,不再是靠组件实例上的同名属性去偷偷共享。usePermission 需要的 permissions 也是显式传参,而不是指望组件的 data 里刚好有一个叫 userPermissions 的字段。整个 setup 函数读下来,就是"声明几个功能域、把它们需要的输入传进去、把输出交给模板",不需要再靠命名约定去维持几个选项之间的隐式契约。
请求错误要放进组合逻辑里,而不是丢给组件
真实业务的组合函数不能只写成功分支。请求函数这边,我们让它统一返回一个结构,而不是直接抛出网络异常:
1export async function fetchUsers(keyword) { 2 try { 3 const response = await fetch(`/api/users?keyword=${encodeURIComponent(keyword)}`); 4 5 if (!response.ok) { 6 return { ok: false, message: '用户列表加载失败,请稍后重试' }; 7 } 8 9 return { ok: true, data: await response.json() }; 10 } catch (error) { 11 return { ok: false, message: '网络异常,请检查连接' }; 12 } 13}
useSearch 只负责处理页面状态,接口函数只负责网络细节,两边职责分开,组件本身不需要写任何 try/catch,也不会变成到处堆判断的地方。这个边界跟用不用 Composition API 没有必然关系,但组合函数这种写法,会自然地把"状态管理"和"数据获取"分成两个可以单独测试的单元——之前维护那几个 mixin 的时候,测试基本没法做,因为 mixin 混入之后的状态依赖组件实例,脱离组件测不了;组合函数是纯粹的普通函数,可以直接单测。
computed 和 watch:写法变了,但要盯的坑没变
除了状态怎么组织,Options API 迁移过来最常打交道的还有 computed 和 watch。这两个 API 在 Composition API 里的写法跟原来差别不算大,但有几个细节容易在迁移时翻车。
Options API 里的 computed 是个选项对象,取值和设值分开写:
1export default { 2 computed: { 3 fullName: { 4 get() { 5 return `${this.firstName} ${this.lastName}`; 6 }, 7 set(value) { 8 const parts = value.split(' '); 9 this.firstName = parts[0]; 10 this.lastName = parts[1]; 11 }, 12 }, 13 }, 14};
Composition API 里对应的写法是把 computed 当函数调用,只读的情况直接传一个 getter 函数,要读写两用就传一个 { get, set } 对象:
1import { ref, computed } from '@vue/composition-api'; 2 3const firstName = ref('张'); 4const lastName = ref('三'); 5 6const fullName = computed({ 7 get: () => `${firstName.value} ${lastName.value}`, 8 set: (value) => { 9 const parts = value.split(' '); 10 firstName.value = parts[0]; 11 lastName.value = parts[1]; 12 }, 13});
watch 的差别更值得留意。Options API 的 watch 选项,键名就是要监听的属性名,写法很直观:
1export default { 2 watch: { 3 keyword(newVal, oldVal) { 4 this.search(); 5 }, 6 'formErrors.username'(newVal) { 7 // 监听嵌套路径要写成字符串 8 }, 9 }, 10};
Composition API 里 watch 是个函数,第一个参数要传一个"来源",不能直接传一个普通变量,得传 ref 本身或者一个返回值的函数:
1import { ref, watch } from '@vue/composition-api'; 2 3const keyword = ref(''); 4 5watch(keyword, (newVal, oldVal) => { 6 search(); 7}); 8 9// 监听 reactive 对象里的某个字段,要用 getter 函数的形式, 10// 不能像 Options API 那样直接写字符串路径 11watch( 12 () => formErrors.username, 13 (newVal) => { 14 // ... 15 } 16);
这里踩过一次坑:把 watch(keyword.value, callback) 写成了传值而不是传 ref 本身,运行时不会报错,但回调永远不会触发——因为 keyword.value 取出来的是一个普通字符串,watch 拿到的是一份快照,根本追踪不到后续的变化。这个坑和前面 reactive 解构丢响应性是同一类问题:Composition API 的响应式追踪依赖的是"引用",一旦不小心把引用拆成了普通值,编译器和运行时都不会报错,只会安静地失效,只能靠平时写的时候多留意。
ref 和 reactive 怎么选,我们内部的约定
组里对这两个 API 的用法吵过几轮,最后定下来一个简单的约定:单个值(字符串、数字、布尔值、单个对象引用)用 ref,一组关系紧密、总是一起读写的字段用 reactive 包一层。比如表单校验那块,各个字段的错误信息放在一个 reactive 对象里,模板里 formErrors.username、formErrors.password 读起来跟 Options API 时代的 data 差别不大,减少了新人上手的心理负担。响应式的实现原理(Proxy 怎么拦截、ref 为什么要套一层 .value)团队里另外整理过一次,这里不重复展开。
表单校验那个组合函数最初是按这个约定直接用 reactive 包一层的,但这里踩过一个小坑:一开始图省事,直接在组件的 setup 里把 formErrors 解构出来用,写成 const { username, role } = formErrors,结果发现模板里字段更新之后视图不刷新。原因是普通解构拿到的是值的拷贝,不再是指向 reactive 对象属性的引用,响应性就断在了这一步。Vue 提供的解法是 toRefs,把 reactive 对象的每个属性各自包成一个 ref 再解构出来,这样解构出来的每个变量还是响应式的:
1import { reactive, toRefs } from '@vue/composition-api'; 2 3export function useFormValidate() { 4 const state = reactive({ 5 formErrors: { username: '', role: '' }, 6 submitting: false, 7 }); 8 9 function validateForm(form) { 10 state.formErrors.username = form.username ? '' : '用户名不能为空'; 11 state.formErrors.role = form.role ? '' : '请选择角色'; 12 return !state.formErrors.username && !state.formErrors.role; 13 } 14 15 return { 16 ...toRefs(state), 17 validateForm, 18 }; 19}
setup 里再用这个组合函数时,直接 const { formErrors, submitting, validateForm } = useFormValidate() 解构出来的 formErrors、submitting 都还是 ref,改它们的值模板照样能感知到。这条踩坑经历值得记一笔:只要打算把一个 reactive 对象的字段解构出去传给别的函数或者从组合函数里返回,就得先过一遍 toRefs,不然响应性会在解构这一步悄悄丢掉,而且丢了之后不会报错,只是视图不更新,排查起来容易摸不着头脑。
新增的几点收获:TypeScript、<script setup> 缺位、迁移路径
重写这个列表页组件的过程里,还有几个值得记一笔的点。
第一是 TypeScript 类型推导的差异。Options API 下,this 的类型要靠 Vue.extend 或者装饰器语法去凑,data、computed、methods 混在一起的时候,类型经常推导不准。改成组合函数之后,useSearch 的返回值就是一个普通对象,TypeScript 能直接推导出每个字段的类型,keyword 是 Ref<string>,list 是 Ref<UserItem[]>,写业务代码时编辑器的提示明显更准。团队里那几个还没上 TypeScript 的老项目,这一点感受不到,但新起的项目基本都在往 TS 上靠。
第二是现在还没有 <script setup> 这种语法糖,setup 函数里返回一大堆变量、组件里再手动 return { ... } 这一步没法省,写多了会觉得啰嗦。社区里已经有人在讨论编译时的简化方案,但目前用不上,只能先接受这层样板代码。
第三是团队实际走的迁移路径,不是把整个项目推倒重写,而是"新组件优先用、老组件按需重写"。判断要不要重写一个老组件,就用前面说的那个标准:逻辑是不是跨了好几个选项、mixin 之间有没有命名冲突或者依赖不透明的问题、同一套逻辑是不是在多处重复。不满足这几条的组件,继续留着 Options API,没必要为了统一风格而改。
逻辑复用函数的命名约定
现在还没有"组合式函数"这种正式叫法,社区里和我们组内部都是叫"逻辑复用函数",或者干脆直接叫 useXxx 函数——这个命名前缀是从 React Hooks 那边借鉴过来的说法,两边虽然实现原理完全不同,但"一眼看出这是个可复用状态逻辑"的意图是一致的。
组里对这类函数定了几条不成文的约定:
文件名和导出的函数名保持一致,useSearch 就放在 useSearch.js 里,不要在一个文件里塞好几个不相关的 useXxx,方便按名字找文件。参数只传这个函数真正需要的东西,不要图省事直接把整个组件实例传进去——useSearch(requestList) 只接收一个请求函数,而不是接收整个 this,这样这个函数才能脱离具体组件被复用,也才能单独写测试。返回值统一返回一个普通对象,字段名尽量和组件里用到的名字保持一致,不做二次改名,减少调用方的心智负担。副作用型的操作(发请求、绑事件)尽量收在函数内部的具体方法里,不要在函数体顶层直接执行,除非确实需要在创建时就跑一次初始化。
命名上还有一个小分歧:要不要用 create 前缀代替 use,比如 createPagination 而不是 usePagination。组里最后统一成 use 前缀,一是跟社区大部分讨论对齐,二是这个前缀本身能提醒调用者"这里面用到了响应式 API,得在 setup 里调用,不能拿到 setup 外面随便执行"。
Composition API 和 Options API 混用同一个组件时要注意什么
真实项目里很少有组件是一步到位全部改完的,混用是常态——尤其是这次重写,setup 里接管了搜索、分页、权限、表单,但 created 钩子里原来还有一段埋点上报的逻辑,暂时没挪,还留在 Options 那一侧。
两边混用的时候,setup 函数执行的时机比 beforeCreate 还早,这时候 this 还没有初始化完,data、computed、methods 都还拿不到,所以 setup 里不能访问 this,Vue 也不会把 this 绑定到 setup 内部。但反过来,setup 返回的属性会被合并到组件实例上,Options 那一侧的 data、computed、methods、生命周期钩子里,可以像访问普通实例属性一样访问 setup 暴露出来的字段,比如 created 钩子里的埋点逻辑照样能读到 this.keyword:
1export default { 2 setup() { 3 // ... 前面那一大段 4 return { keyword, list, search /* ... */ }; 5 }, 6 created() { 7 // 这里能正常读到 setup 返回的 keyword,因为它已经合并到实例上了 8 trackPageView({ keyword: this.keyword }); 9 }, 10};
这里要注意的是合并方向只能单向:setup 拿不到 Options 那侧定义的 data 和 methods,因为 setup 执行的时候实例还没有这些东西;而且如果 setup 返回的字段和 Options 侧的 data/methods 重名,setup 的会覆盖 Options 侧的,这一点跟 mixin 的合并策略不是一回事,容易搞混。我们组内部的做法是,只要一个组件里两边都在用,就在 code review 里额外检查一遍有没有重名,暂时没有更好的自动化手段。
@vue/composition-api 插件的几个不兼容点
@vue/composition-api 这个插件把 Vue 3 的 setup 语法搬到了 Vue 2 项目里,日常写业务代码的体验和真正的 Vue 3 差别不大,但底层毕竟是在 Vue 2 的响应式系统(基于 Object.defineProperty)上模拟出来的,有几个跟正式版不一致的地方,用的时候要留意。
一是 reactive 包裹一个数组时,直接用下标赋值(list[0] = xxx)在插件里可能追踪不到变化,这是 Vue 2 响应式系统本身对数组下标赋值的老限制,插件没有能力绕开,得用 splice 或者整体替换数组。二是插件不支持 Vue 3 里 Fragment、Teleport、Suspense 这些新的内建组件,因为这些是模板编译和渲染器层面的改动,跟 setup 语法本身没关系,插件只补了组合式 API 这一层。三是响应式对象的一些边界场景(比如给一个 reactive 对象新增插件初始化时不存在的属性)在 Vue 2 上依然拿不到响应性,这跟 Vue 2 响应式原理本身的限制是一回事,跟组合式 API 的写法没关系,团队里踩过一次之后,现在约定 reactive 初始化的时候把所有可能用到的字段都提前列出来,哪怕先赋成空值。
这几个不兼容点不影响这次列表页组件的重写,因为组件本身没有用到这些边界写法,但团队里已经有人在别的组件上踩过第一条数组下标的坑,这里顺带记一笔,免得下次再踩一次。
组件规模小、逻辑单一的时候,Options API 依然是更省心的选择;一旦一个组件要靠好几个 mixin 才能撑住,或者同一块逻辑要在多个页面复用,Composition API 提供的组织方式就能把这些混在一起的状态和行为重新捋清楚。这是我们目前给团队定的标准,不是所有组件都要按这套写法过一遍。