【Vue3】组合式API(<script setup>)究竟应该如何组织代码?

354 阅读8分钟

省流不看:跳转官网示例

问题背景

2020年09月18日,Vue官方团队发布了Vue.js V3.0.0版本,代号One Piece,距今已经过去了4年又9个月,看起来过去了很久,但是我相信很多朋友接触Vue3的时间并没有如此之久,因为Vue3的自我证明之路并非一帆风顺,甚至在诞生之初备受诟病。

image.png

Vue3引入了Composition API,官方称Composition API支持类似React hooks的逻辑组合和复用,更灵活的代码组织模式以及比Vue2更可靠的类型推断,在当下的时间开发Vue3项目,Composition API是一种自然而然的选择,但是在Vue3诞生之初,它并不完全像Vue官方团队介绍的那样,至少在那时易用和灵活是要打一个问号的,同时v3.0.0 One Piece版本提出了一项实验性功能<script setup>

image.png

随着Vue3的持续更新(性能优化、官方ide插件、社区生态、向后移植、官方文档),Vue3被越来越多的开发者接受,Vue3的影响范围也逐渐扩散开来。

我正式使用Vue3的第一个版本是2022年02月07日发布的V3.2.30,在这个版本中,Composition API<script setup>写法不仅从实验性功能成为了正式语法,更是已经占据了主流,作为一个Vue2的使用者,刚切换到Vue3时是非常喜欢这种代码组织形式的,它几乎和书写原生js代码一样,让开发者可以非常自由灵活地组织代码,但当我在团队中引入它时,问题开始浮现。

问题

每个人都会面临取舍,开发者也在时刻遵循平衡的艺术。

<script setup>vue开发者带来了自由和灵活,同时也意味着约束边界被放大了,每个团队成员都可以按照自己的方式去书写代码,这意味着可能每个SFC文件都有着不同的模样,有的朋友可能觉得这并没有什么问题,只要自己保持一种组织形式即可,毕竟在团队协作开发中多数时候每个成员都是负责一个相对单独的模块,但谁也不能避免自己在将来的某个时候需要调整或者是重构其他团队成员的代码,即使没有这样的时候,哪怕是自己修改自己曾经负责的代码,我相信没有一个好的组织方式是绝无法像Vue2那样快速锁定目标修改内容的,特别是当组件内容十分庞大时。

探索

当问题开始浮现时,我开始思考如何解决(后来发现要是阅读官方文档再仔细一些,理解再深入一些,也许就少了这些探索过程),于是我组织团队成员协商并提出:将同一个逻辑关注点的代码写在一起,同时每一部分使用jsDoc在逻辑代码块上方以固定格式写明该关注点所实现内容,大体示意如下:

<script setup lang="ts">
import ...

/** @功能 功能描述 */
.......(功能逻辑实现)
<--功能块的变量-->
<--功能块涉及的计算属性-->
<--功能块涉及的监听-->
<--功能块涉及的生命周期钩子-->
<--功能块涉及的方法-->
<--...-->
</script>

初步应用后发现其实这么做不能完全地实现关注点分离,因为在实际的项目开发过程中一定会遇到某些变量被多个功能块使用,假设变量A同时被功能1功能2所应用,那么在此情况下,变量A应该定义在功能1对应的功能块中还是功能2对应的功能块中?也许在实际情况下,会根据逻辑侧重将变量A定义在功能1对应的功能块下,但是当你某一天修改功能1时,你无法第一时间确定变量A只被功能1所应用,还是会被多个功能块应用,这使得你在修改功能1的逻辑时还可能需要密切关注其他的功能块,这和我们分离逻辑关注点的初衷背离了。

因此,在此基础上我加以改进,提出了如下的优化:

<script setup lang="ts">
import ...

/** @公共 功能1,功能2...的公共内容 */
<--功能块12均需要使用的变量-->
<--功能块12均需要使用的计算属性-->
<--...-->

/** @功能 功能描述1 */
<--功能块1的变量-->
<--功能块1涉及的计算属性-->
<--功能块1涉及的监听-->
<--功能块1涉及的生命周期钩子-->
<--功能块1涉及的方法-->
<--...-->

/** @功能 功能描述2 */
<--功能块2的变量-->
<--功能块2涉及的计算属性-->
<--功能块2涉及的监听-->
<--功能块2涉及的生命周期钩子-->
<--功能块2涉及的方法-->
<--...-->

...

</script>

很简单,就是将可能存在的公共内容进行提升,但是在实际情况下,它的应用并不好,因为可能存在这样的情况,公共内容变量A功能1功能2应用,公共内容变量B功能1功能3应用,公共内容变量C功能2功能N应用,相信你也看出来了,提升的公共功能模块并不“公共”。

那么只能考虑将其进一步细化了,比如功能1功能2存在公共内容,那就提升一个公共功能块,功能2功能N存在公共内容,就再提升一个公共功能块,但我们并没有考虑这么做,很明显,这将会大大提升工作量,如果真这么做,为了实现逻辑关注点的分离,我们增加了太多的不必要工作。

但在那时,团队成员都是初次接触Vue3,关于如何组织代码并没有一锤定音的方案,并且在我看来,Vue3真正地称得上好用的版本要从v3.3.0 "Rurouni Kenshin"开始算起,所以那时候网络上几乎找不到关于这些内容的讨论,团队也就没有可参考的信息。

于是,很长一段时间,在项目推进的实际要求下,我们都用着上面的这种并不太方便的代码组织方式,正如前面所说,开发者时刻都在遵循平衡的艺术,毕竟比起完全自由、一盘散沙,这种组织方式再怎么也要好过无组织的。

但是,你知道的,像这种约定规范,是很难遵守的,它不像commit校验或者eslint、Prettier校验,大多数时候对于开发者来说是无感知的(保存时自动校验和格式化),并且这种约定规范给开发者带来了额外工作,因此实践效果并不好。

推荐方案

后来因为一直忙着赶项目,也没有时间再去思考这些项目之外的事情,直到后来随着逐渐的应用,慢慢对Vue3的了解更加深入,深刻地体会到Composition API + 响应式有着怎样的可能性,也才真的体会到Vue3官网文档 为什么要有组合式 API?这一小节所举的 案例 中所呈现的组织方式的益处。

应该说,使用这种方式来组织SFC代码,解决了我们曾经所使用的组织方式的所有缺陷,那时,Vue3也更新了一些新的基础API,于是,团队逐渐开始形成了下面的代码组织方式。

<script setup lang="ts">
import ...

// options(必需)
defineOptions({
  name: 'Example',
  ...
})

// props(可能存在)
const props = defineProps<{
  example: string
}>()

// model(可能存在)
const model = defineModel()

// inject(可能存在)
const injectExample = inject('example')

// emits(可能存在)
const emits = defineEmits(['change-example'])

// 导入的外部公共hooks(可能存在)
const { example } = useCommonExample()
...

// 功能1 简单描述
const { variableA, updateVariableA, ... } = useFunctionOne()

// 功能2 简单描述
const { variableB, ... } = useFunctionTwo()

// 功能N 简单描述
...

// provide(可能存在)
provide('example', example)

// expose(可能存在)
defineExpose({ variableA, ... })

// 功能1实现
function useFunctionOne() {
  const variableA = ref(0)
  
  const updateVariableA = (val: number) => {
    variableA.value = val
  }
  
  ...
  
  return {
    variableA,
    updateVariableA,
    ...
  }
}

// 功能2实现
function useFunctionTwo() {
  const variableB = computed(() => {
    return variableA.value * 2
  })
  
  ...
  
  return {
    variableB,
    ...
  }
}

// 功能N实现
...

</script>

需要说明的是,在上面的代码组织方式中,并不是每一项都一定存在于每个SFC文件。

下面我们简单梳理一下上面的代码组织方式:

  • Vue公共项清晰,一眼可得
  • 打开文件时第一时间只需要关注从optionsexpose的内容即可构建对此SFC的整体认知(绝大多数时候都是如此,除非你的SFC十分复杂,功能函数数十项,超过了一屏所展示的内容行数),且此认知是十分清晰的
  • 逻辑关注点以功能函数分离,通过返回值暴露的信息一定会被模板或者其他功能所使用,否则信息只存在于当前逻辑关注点
  • 当需要修改具体的某一项功能时,点击函数名即可在IDE中跳转直达,此时才需要关注具体的这一功能实现,其他时候这些功能函数实现应当在IDE中被折叠,不增加视野无效信息

以上。