• 开始时间:2019-07-10
  • 目标主要版本:2.x / 3.x
  • 引用 issue:#78
  • 实现的 PR:N/A

    由于这个 RFC 很长,所以在这里以更可读的格式进行了部署。还有一个附带的 API 参考

摘要

介绍一下组合式 API:这是一套附加的、基于函数式的 API,允许组件逻辑上更灵活的组合。

基本范例

  1. <template>
  2. <button @click="increment">
  3. Count is: {{ state.count }}, double is: {{ state.double }}
  4. </button>
  5. </template>
  6. <script>
  7. import { reactive, computed } from 'vue'
  8. export default {
  9. setup() {
  10. const state = reactive({
  11. count: 0,
  12. double: computed(() => state.count * 2)
  13. })
  14. function increment() {
  15. state.count++
  16. }
  17. return {
  18. state,
  19. increment
  20. }
  21. }
  22. }
  23. </script>

动机

逻辑重用和代码组织

我们都喜欢 Vue 非常容易上手,让构建中小型规模的应用程序变得轻而易举。但是今天,随着 Vue 应用的增长,很多用户也开始使用 Vue 来构建大规模的项目 —— 那些由多个开发人员组成的团队在长时间内迭代和维护的项目。多年来,我们目睹了其中一些项目遇到了 Vue 当前 API 所带来的编程模型的限制。这些问题可以归纳为两类:

  1. 随着功能的增加,复杂组件的代码变得更难推理。特别是当开发者在阅读不是他们自己写的代码时,这种情况回发生。根本原因是 Vue 现有的 API 强制按选项组织代码,但在某些情况下,按逻辑关注点组织代码更有意义。
  2. 缺少一个干净的、无成本的机制来提取和重用多个组件之间的逻辑。(更多细节见 Logic Extraction and Reuse)。


本 RFC 提出的 API 在组织组件代码时为用户提供更多的灵活性。与其被迫总是按照选项来组织代码,现在可以将代码组织成处理特定功能的函数。这些 API 也使得在组件之间,甚至在组件之外提取和重用逻辑变得更加直接。我们将在具体设计部分展示这些目标是如何实现的。

更好的类型推导

在大型项目中开发者另一个常见的需求是更好的支持 TypeScript。Vue 目前的 API 在与 TypeScript 的整合时带来了一些挑战,主要是由于 Vue 依赖一个单一的 this 上下文来暴露属性,而在 Vue 组件中的使用比普通 JavaScript 要神奇一些(例如,在 methods 中嵌套的函数里面的 this 指向组件的实例而不是 methods 对象)。换句话说,Vue 现有的 API 在设计时根本没有考虑到类型推导,这在试图让它与 TypeScript 很好的工作时,会产生很多复杂性。

今天,大多数使用 Vue 与 TypeScript 的用户都在使用 vue-class-component,这个库允许组件被编译成 TypeScript 类(在装饰器的帮助下)。在设计 3.0 时,我们曾试图提供一个内置的类 API,来更好的解决之前(放弃)的 RFC 中类型问题。然而,当我们讨论和迭代设计时,我们注意到,为了让 Class API 解决类型问题,它必须依赖装饰器 —— 这是一个非常不稳定的 stage 2 的提案,其实现细节还有很多不稳定性。这使得它成为一个相当有风险的基础来建立。(更多 Class API 类型问题的细节,请点击这里

相比之下,本 RFC 中提议的 API 主要利用普通变量和函数,这自然是类型友好的。使用提议的 API 编写的代码可以享受完全的类型推导,几乎不需要手动类型提示。这也意味着使用提议的 API 编写的代码在 TypeScript 和 JavaScript 中看起来几乎是一样的,所以即使是非 JavaScript 用户也可以从类型中受益,以获得更好的 IED 支持。

具体设计

API 介绍

这里提出来的 API 不是带来新的概念,而是将 Vue 的核心能力 —— 如创建和观察响应式状态 —— 作为独立的函数公开。这里我们将介绍一些最基本的 API,以及如何用它们来替代 2.x 的选项来表达组件内逻辑。请注意,本节的重点是介绍基本的想法,所以并不设计每个 API 的全部细节。完整的 API 标准可以在 API Reference 部分找到。

响应式状态和副作用

让我们从一个简单的任务开始:声明一些响应式状态

  1. import { reactive } from 'vue'
  2. // reactive state
  3. const state = reactive({
  4. count: 0
  5. })

reactive 相当于当前 2.x 中的 Vue.observable() API,重命名是为了避免与 RxJS 中的 observables 向混淆。在这里,返回的 state 是一个所有 Vue 用户都应该熟悉的 reactive 对象。

Vue 中响应式状态的基本用例是,我们可以在渲染过程中使用它。由于依赖跟踪,当响应式状态发生变化时,视图会自动更新。渲染 DOM 的东西通常被认为是一种 “副作用”:我们的程序正在修改程序本身(DOM)之外的状态。为了应用和自动重新应用一个基于响应式状态的副作用,我们可以使用 watchEffect API:

  1. import { reactive, watchEffect } from 'vue'
  2. const state = reactive({
  3. count: 0
  4. })
  5. watchEffect(() => {
  6. document.body.innerHTML = `count is ${state.count}`
  7. })

watchEffect 期望一个应用所需副作用的函数(在本例中,是设置 innerHTML)。它立即执行该函数,并跟踪它在执行过程中使用的所有响应式状态属性作为依赖关系。在这里,state.count 将在初始之行之后作为这观察者的依赖关系被跟踪。当 state.count 在未来某个时间被改变时,内部函数将会再次被执行。

这就是 Vue 响应式系统的本质,当你在一个组件中从 data() 返回一个对象时,它在内部被 reactive() 做成了响应式。模版编译成一个渲染函数(可以认为是一个更有效的 innerHTML),它利用了这些响应式属性。

watchEffect 类似于 2.x 中的 watch 选项,但它不需要把被监视的数据源和副作用分开调用。组合式 API 还提供了一个与 2.x 选项行为完全相同的 watch 函数。

继续上面的例子,这就是我们处理用户输入的方法:

  1. function increment() {
  2. state.count++
  3. }
  4. document.body.addEventListener('click', increment)

但有了 Vue 的模版系统,我们就不需要和 innerHTML 打交道或者手动添加事件监听器了。让我们用一个假设的 renderTemplate 方法来简化这个例子,这样我们就可以将注意力放在响应式方面:

  1. import { reactive, watchEffect } from 'vue'
  2. const state = reactive({
  3. count: 0
  4. })
  5. function increment() {
  6. state.count++
  7. }
  8. const renderContext = {
  9. state,
  10. increment
  11. }
  12. watchEffect(() => {
  13. // hypothetical internal code, NOT actual API
  14. renderTemplate(
  15. `<button @click="increment">{{ state.count }}</button>`,
  16. renderContext
  17. )
  18. })

计算状态和 Refs

有时候我们的状态需要依赖其他状态 —— 在 Vue 中是通过计算属性来处理的。要直接创建一个计算值,我们可以使用 computed API:

  1. import { reactive, computed } from 'vue'
  2. const state = reactive({
  3. count: 0
  4. })
  5. const double = computed(() => state.count * 2)

computed 在这里返回什么?如果我们猜测一下 computed 在内部时如何实现的,我们可能会得出这样的结果:

  1. // simplified pseudo code
  2. function computed(getter) {
  3. let value
  4. watchEffect(() => {
  5. value = getter()
  6. })
  7. return value
  8. }

但是我们知道这是不可能工作的:如果 value 是一个像 number 这样的原始类型,一旦它被返回,它与计算的更新逻辑就会被丢失。这是因为 JavaScript 中的原始类型都是通过值传递的,而不是通过引用:
68747470733a2f2f7777772e6d61746877617265686f7573652e636f6d2f70726f6772616d6d696e672f696d616765732f706173732d62792d7265666572656e63652d76732d706173732d62792d76616c75652d616e696d6174696f6e2e676966.gif
当一个值被作为属性赋值给一个对象时,也会出现同样的问题。如果一个响应式的值在作为属性赋值或者从函数返回时不能保持其响应式特性,那么它不会很有用。为了确保我们总是能够读取一个计算的最新值,我们需要将实际值包装在一个对象中并返回该对象:

  1. // simplified pseudo code
  2. function computed(getter) {
  3. const ref = {
  4. value: null
  5. }
  6. watchEffect(() => {
  7. ref.value = getter()
  8. })
  9. return ref
  10. }

此外,我们还需要拦截对象的 .value 属性的读/写操作,以执行依赖的跟踪和变化之后通知(为简单起见,此处省略代码)。现在我们可以通过引用来传递计算值,不同担心失去响应式特性。权衡之下,为了获取新的值,我们需要通过 .value 访问它:

  1. const double = computed(() => state.count * 2)
  2. watchEffect(() => {
  3. console.log(double.value)
  4. }) // -> 0
  5. state.count++ // -> 2

这里的 double 我们称之为一个 “ref” 的对象,因为它作为它所持有的内部值的一个响应式引用。

你可能知道,Vue 已经又了 “refs” 的概念,但只是用于引用 DOM 元素或者模版中的实例(模版 refs)。看看这里,看看新的 refs 系统如何用于逻辑状态和模版 refs。

除了计算的 refs,我们还可以直接通过 ref API 创建普通的可变 refs:

  1. const count = ref(0)
  2. console.log(count.value) // 0
  3. count.value++
  4. console.log(count.value) // 1

Ref Unwrapping

我们可以将 ref 作为渲染上下文的一个属性公开。在内部,Vue 会对引用进行特殊处理,因此当在前渲染上下文中遇到 refs 时,上下文会直接暴露其内部 value。这意味着在模板中,我们可以直接写 {{ count }},而不是 { count.value }

下面是同一个计数器例子的另一个版本,使用 ref 而不是 reactive

  1. import { ref, watch } from 'vue'
  2. const count = ref(0)
  3. function increment() {
  4. count.value++
  5. }
  6. const renderContext = {
  7. count,
  8. increment
  9. }
  10. watchEffect(() => {
  11. renderTemplate(
  12. `<button @click="increment">{{ count }}</button>`,
  13. renderContext
  14. )
  15. })

另外,当 ref 被嵌套为一个 reactive 对象的一个属性时,它也会在访问时被自动解包:

  1. const state = reactive({
  2. count: 0,
  3. double: computed(() => state.count * 2)
  4. })
  5. // no need to use `state.double.value`
  6. console.log(state.double)

在组件中使用

到目前为止,我们的代码已经提供了一个可以根据用户输入进行更新的工作界面 —— 但代码只能运行一次,不能重复使用。如果我们想要重复使用这些逻辑,合理的下一步似乎是将其重构为一个函数。

  1. import { reactive, computed, watchEffect } from 'vue'
  2. function setup() {
  3. const state = reactive({
  4. count: 0,
  5. double: computed(() => state.count * 2)
  6. })
  7. function increment() {
  8. state.count++
  9. }
  10. return {
  11. state,
  12. increment
  13. }
  14. }
  15. const renderContext = setup()
  16. watchEffect(() => {
  17. renderTemplate(
  18. `<button @click="increment">
  19. Count is: {{ state.count }}, double is: {{ state.double }}
  20. </button>`,
  21. renderContext
  22. )
  23. })

请注意,上面的代码并不依赖于组件实例的存在。事实上,到目前为止介绍的 APIs 都可以在组件上下文之外使用,让我们可以在更广泛的场景中利用 Vue 的响应式系统。

现在,如果我们把调用 setup()、创建观察者和渲染模版的任务都留给框架,我们可以只用 setup() 函数和模版来定义一个组件。

  1. <template>
  2. <button @click="increment">
  3. Count is: {{ state.count }}, double is: {{ state.double }}
  4. </button>
  5. </template>
  6. <script>
  7. import { reactive, computed } from 'vue'
  8. export default {
  9. setup() {
  10. const state = reactive({
  11. count: 0,
  12. double: computed(() => state.count * 2)
  13. })
  14. function increment() {
  15. state.count++
  16. }
  17. return {
  18. state,
  19. increment
  20. }
  21. }
  22. }
  23. </script>

这是我们熟悉的单文件组件格式,只有逻辑部分(<script>)用不同的格式表达。模版语法保持完全相同。<style> 被省略了,但是也会有完全一样的效果。

生命周期钩子

到目前为止,我们已经涵盖了一个组件的纯状态方面:响应式状态、计算状态和在用户输入时改变状态。但一个组件也可能需要执行副作用 —— 例如,向控制台记录,发送 AJAX 请求,或在 window 上设置一个时间监听。这些副作用通常在一下时间段执行:

  • 当某些状态改变时;
  • 当组件 mounted、updated 和 unmounted(生命周期钩子)时。

我们知道,我们可以使用 watchEffect 和 watch APIs 来执行基于状态改变的副作用。至于在生命周期钩子中执行副作用,我们可以使用专门的 onXXX APIs(它直接反映了现有的生命周期选项):

  1. import { onMounted } from 'vue'
  2. export default {
  3. setup() {
  4. onMounted(() => {
  5. console.log('component is mounted!')
  6. })
  7. }
  8. }

这些生命周期注册函数执行在调用钩子的过程中使用。它使用全局状态自动找出调用 setup 钩子的当前实例。这是有意这样设计的,以减少在将逻辑提取到外部函数中的摩擦。

关于这些 API 的更多细节可以在 API Reference 中找到。然而,我们建议在深入研究细节之前完成以下部分。

代码组织

在这一点上,我们已经用导入的函数复制了组件的 API,但这么做是为什么?用选项来定义组件似乎比把所有东西都混在一个大函数中更有条理!

这是一个可以理解的第一印象。但是正如在动机一节提到的,我们相信组合式 API 实际上会导致更好的代码组织,特别是在复杂的组件中。这里我们将尝试解释一下原因。

什么是 “有组织的代码”?

让我们退一步考虑,当我们谈论“有组织的代码”时,我们真正的意思是什么?保持代码有组织的最终目标应该是使代码更容易阅读和理解。那么我们所说的“理解”代码是什么意思呢?我们真的可以声称我们“理解”一个组件,只是因为我们知道它包含哪些选项吗?你是否曾经遇到过一个由其他开发者编写的大组件(例如这个),而你却很难理解它?

想一想,我们会如何引导其他开发者完成一个像上面链接的大组件。你可能会从 “这个组件在处理 X、Y 和 Z” 开始,而不是 “这个组件有 data 属性、这些 computed 属性和这些 methods”。当涉及到理解一个组件,我们更关心的是 “这个组件尝试做什么”(即代码背后的意图),而不是 “这个组件碰巧使用什么选项”。虽然用基于选项的 API 编写的代码自然的回答了后者,但它在表达前者方面做些相当差。

逻辑问题与选项类型

让我们把组件处理的“X、Y 和 Z”定义为逻辑问题。可读性问题通常不存在于小型的、单一目的的组件中,因为整个组件只处理一个逻辑问题。然而,这个问题在高级用例中变得更加突出。以 Vue CLI 文件管理器为例。该组件必须处理很多不同的逻辑问题:

  • 跟踪当前文件状态并显示其内容
  • 处理文件夹导航(打开、关闭、刷行。。。)
  • 处理新文件夹的创建
  • 切换只显示喜爱的文件夹
  • 切换显示隐藏的文件夹
  • 处理当前工作目录的变化

通过阅读基于选项的代码,你能立即识别并区分这些逻辑上的关注吗?这肯定是困难的。你会注意到,与一个特定的逻辑关注点相关的代码往往是零散的,散落在各个地方。例如,“创建新文件夹”功能使用了两个 data 属性一个 computed 属性一个方法 —— 其中方法被定义在距离 data 属性 100 多行的地方。

如果我们给这些逻辑上的关注点逐一进行颜色编码,我们会注意到当使用组件选项来表达时,它们是多么的零散:
image.png
这样的分割正是让人难以理解和维护一个复杂组件的原因。通过组件的强制分离掩盖了底层的逻辑问题。此外,当我们在一个单一的逻辑关注点上工作时,我们必须不断的在选项中 “跳跃”,以找到与该关注点相关的部分。

注意:原始代码可能有一些地方可以改进,但是我们所展示的最新提交的代码(截止本文写作时),没有修改,以提供一个我们自己编写的实际生产代码的例子。

如果我们能将与同一逻辑关注点相关的代码放在一起,那就更好了。而这正是 Composition API 使我们能做到的。“创建新文件夹”的功能可以这样写:

  1. function useCreateFolder (openFolder) {
  2. // originally data properties
  3. const showNewFolder = ref(false)
  4. const newFolderName = ref('')
  5. // originally computed property
  6. const newFolderValid = computed(() => isValidMultiName(newFolderName.value))
  7. // originally a method
  8. async function createFolder () {
  9. if (!newFolderValid.value) return
  10. const result = await mutate({
  11. mutation: FOLDER_CREATE,
  12. variables: {
  13. name: newFolderName.value
  14. }
  15. })
  16. openFolder(result.data.folderCreate.path)
  17. newFolderName.value = ''
  18. showNewFolder.value = false
  19. }
  20. return {
  21. showNewFolder,
  22. newFolderName,
  23. newFolderValid,
  24. createFolder
  25. }
  26. }

请注意,所有与创建新文件夹功能相关的逻辑现在都被集中起来,封装在一个函数中。由于其描述性的名字,该函数也在一定程度上是自我注释的。这就是我们所说的一个组合式函数。推荐的惯例是,在函数的名字中以 use 开头,以表明它是一个组合式函数。这种模式可以应用于组件中所有其他的逻辑关注点,从而产生一些很好的解耦函数:
image.png

这种比较不包括 import 语句和 setup() 函数。使用组合式 API 重新实现的完整组件可以在这里找到。

现在,每个逻辑关注点的代码都在拼凑在一个组合式函数中。这大大减少了在一个大型组件上工作时不断“跳跃”的需要。组合式函数也可以在编辑器中折叠,使得组件更容易扫描:

  1. export default {
  2. setup() { // ...
  3. }
  4. }
  5. function useCurrentFolderData(networkState) { // ...
  6. }
  7. function useFolderNavigation({ networkState, currentFolderData }) { // ...
  8. }
  9. function useFavoriteFolder(currentFolderData) { // ...
  10. }
  11. function useHiddenFolders() { // ...
  12. }
  13. function useCreateFolder(openFolder) { // ...
  14. }

setup() 函数现在主要是作为一个调用所有组合式函数的入口点:

  1. export default {
  2. setup () {
  3. // Network
  4. const { networkState } = useNetworkState()
  5. // Folder
  6. const { folders, currentFolderData } = useCurrentFolderData(networkState)
  7. const folderNavigation = useFolderNavigation({ networkState, currentFolderData })
  8. const { favoriteFolders, toggleFavorite } = useFavoriteFolders(currentFolderData)
  9. const { showHiddenFolders } = useHiddenFolders()
  10. const createFolder = useCreateFolder(folderNavigation.openFolder)
  11. // Current working directory
  12. resetCwdOnLeave()
  13. const { updateOnCwdChanged } = useCwdUtils()
  14. // Utils
  15. const { slicePath } = usePathUtils()
  16. return {
  17. networkState,
  18. folders,
  19. currentFolderData,
  20. folderNavigation,
  21. favoriteFolders,
  22. toggleFavorite,
  23. showHiddenFolders,
  24. createFolder,
  25. updateOnCwdChanged,
  26. slicePath
  27. }
  28. }
  29. }

当然,这是我们在使用选项 API 时不需要写的代码。但请注意,setup 函数几乎是对组件试图做什么的口头描述 —— 这是基于选项的版本中完全缺少的信息。你还可以清楚地看到,根据传来的参数,组成函数之间的依赖关系。最后,返回语句是检查暴露在模版中的内容的唯一地方。

考虑到相同的功能,通过选项定义的组件和通过组合式函数定义的组件,体现了表达相同底层逻辑的两种不同方式。基于选项的 API 迫使我们根据选项的类型组织代码,而组合式 API 使我们能够根据逻辑关注点来组织代码。

逻辑提取和复用

当涉及到跨组件提取和复用逻辑时,组合式 API 时非常灵活的。组合式函数不依赖于神奇的 this 上下文,而只依赖于它的参数和全局导入的 Vue APIs。你可以通过简单的将你的组件逻辑导出为一个函数来重用它的任何部分。你甚至可以通过导出整个 setup 函数来实现等同于 extends

让我们来看一个例子:跟踪鼠标的位置。

  1. import { ref, onMounted, onUnmounted } from 'vue'
  2. export function useMousePosition() {
  3. const x = ref(0)
  4. const y = ref(0)
  5. function update(e) {
  6. x.value = e.pageX
  7. y.value = e.pageY
  8. }
  9. onMounted(() => {
  10. window.addEventListener('mousemove', update)
  11. })
  12. onUnmounted(() => {
  13. window.removeEventListener('mousemove', update)
  14. })
  15. return { x, y }
  16. }

下面是一个组件如何使用该函数:

  1. import { useMousePosition } from './mouse'
  2. export default {
  3. setup() {
  4. const { x, y } = useMousePosition()
  5. // other logic...
  6. return { x, y }
  7. }
  8. }

在文件资源管理器的组合式 API 版本中,我们将一些公共的代码(例如 usePathUtilsuseCwdUtils)提取到外部文件中,因为我们发现它们对其他组件也有用。

类似的逻辑复用也可以通过现有的模式来实现,比如 mixins、高阶组件或者无渲染组件(通过作用域插槽)。在互联网上有很多解释这些模式的信息,所以我们在这里就不重复它们的全部细节。高层次想法是,与组合式 API 相比,这些模式都有各自的缺点:

  • 渲染上下文中暴露的属性来源不明确。例如,当读取一个使用了多个 mixins 的组件的模版时,可能很难分辨一个特定的属性是从哪个 mixin 中注入的。
  • 命名空间冲突。Mixins 有可能在属性和方法的名称上发生冲突,而 HOC(高阶组件)组可能在期望的 prop 名称上发生冲突。
  • 性能。HOC 和无渲染组件需要额外的有状态组件实例,这需要付出性能上的代价。

相比之下,使用组合式 API:

  • 暴露在模版中的属性有明确的来源,因为它们是从组合式函数返回的值。
  • 从组合式函数返回的值可以任意命名,所以没有命名空间的冲突。
  • 没有只是为了逻辑重用而创建的不必要的组件实例。

与现有的 API 一起使用

组合式 API 可以于现有的基于选项的 API 一起使用。

  • 组合式 API 在 2.x 选项(datacomputedmethods)之前就已经解析了,并且将无法使用这些选项中定义的属性。
  • setup() 返回的属性将在 this 中暴露,并且可以在 2.x 的选项中访问。

插件开发

今天,很多 Vue 插件都会将属性注入到 this。例如,Vue Router 注入了 this.$routethis.$router,而 Vuex 注入了 this.$store。这将让类型推导变得很棘手,因为每一个插件都需要为用户注入的属性增加 Vue 的类型。

当使用组合式 API,没有 this。相反,插件将利用内部 provide 和 inject,并且暴露一个组合式函数。下面是一个插件的假设性代码:

  1. const StoreSymbol = Symbol()
  2. export function provideStore(store) {
  3. provide(StoreSymbol, store)
  4. }
  5. export function useStore() {
  6. const store = inject(StoreSymbol)
  7. if (!store) {
  8. // throw error, no store provided
  9. }
  10. return store
  11. }

使用代码:

  1. // provide store at component root
  2. //
  3. const App = {
  4. setup() {
  5. provideStore(store)
  6. }
  7. }
  8. const Child = {
  9. setup() {
  10. const store = useStore()
  11. // use the store
  12. }
  13. }

请注意,store 也可以通过 Global API change RFC 中建议的 app-level 来提供 provide,但是消费的组件中的 useStore 风格的 API 将会是相同的。

缺点

引入 refs 的开销

从技术上来讲,Ref 是本提案中唯一的“新”概念。它的引入是为了将 reactive 的值作为变量传递,而不依赖于对 this 的访问。其中的缺点是:

  1. 当使用组合式 API 时,我们需要不断的将 refs 和普通的值和对象区分开来,这增加了使用 API 时的心理负担。

通过使用命名规则(例如,将所有 ref 变量后缀写为 xxxRef),或者使用类型系统,可以大大减少心理负担。另一方面,由于代码组织的灵活性提高了,组件逻辑将更多的被隔离到小函数中,局部上下文是简单的,而且 refs 的开销也很容易管理。

  1. 由于需要 .value,读取和修改 refs 比处理普通的值更加冗长。

有些人建议用编译时的语法糖(类似于 Svelte3)来解决这个问题。虽然在技术上是可行的,但我们认为将其作为 Vue 的默认配置是不合理的(正如 Comparison with Svelte 中讨论的)。也就是说,作为一个 Babel 插件,这在技术上在用户区是可行的。

然而,我们已经讨论了是否有可能完全避免 Ref 概念而只使用 reactive 对象:

  • 计算的 getters 可以返回原始类型,所以类似 Ref 的容器是不可避免的。
  • 期待或者只返回原始类型的组合式函数,也需要将 value 包裹在一个对象中,这只是为了响应式的考虑。如果没有框架提供的标准实现,用户很可能最终会发明他们自己的类似 Ref 的模式(并造成生态系统的分裂)。

对比 Ref 和 Reactive

可以理解的是,用户可能会在 refreactive 之间使用哪个感到困惑。首先要知道的是,你需要了解这两种方法来有效的使用组合式 API。只使用其中一个很可能会导致深奥的变通方法或者重新造轮子。

使用 refreactive 的区别可以和你写标准的 JavaScript 逻辑的方式做一些比较:

  1. // style 1: separate variables
  2. let x = 0
  3. let y = 0
  4. function updatePosition(e) {
  5. x = e.pageX
  6. y = e.pageY
  7. }
  8. // --- compared to ---
  9. // style 2: single object
  10. const pos = {
  11. x: 0,
  12. y: 0
  13. }
  14. function updatePosition(e) {
  15. pos.x = e.pageX
  16. pos.y = e.pageY
  17. }
  • 如果使用 ref,我们主要是将 style 1 转换为使用 refs 的更粗略的等价物(为了使原始值有响应性)。
  • 使用 reactive 几乎于 style 2 相同。我们只需要用 reactive 创建对象就可以了。

然而,只使用 reactive 的问题是,为了保持响应特性,组合式函数的消费者必须一直保持对返回对象的引用。该对象不能被解除结构化或者传播:

  1. // composition function
  2. function useMousePosition() {
  3. const pos = reactive({
  4. x: 0,
  5. y: 0
  6. })
  7. // ...
  8. return pos
  9. }
  10. // consuming component
  11. export default {
  12. setup() {
  13. // reactivity lost!
  14. const { x, y } = useMousePosition()
  15. return {
  16. x,
  17. y
  18. }
  19. // reactivity lost!
  20. return {
  21. ...useMousePosition()
  22. }
  23. // this is the only way to retain reactivity.
  24. // you must return `pos` as-is and reference x and y as `pos.x` and `pos.y`
  25. // in the template.
  26. return {
  27. pos: useMousePosition()
  28. }
  29. }
  30. }

[toRefs](https://github.com/vuejs/rfcs/blob/master/active-rfcs/api.html#torefs) API 提供来处理这个约束 —— 它将 reactive 对象上的每个属性转换为相应的 ref:

  1. function useMousePosition() {
  2. const pos = reactive({
  3. x: 0,
  4. y: 0
  5. })
  6. // ...
  7. return toRefs(pos)
  8. }
  9. // x & y are now refs!
  10. const { x, y } = useMousePosition()

总而言之,有两种可行的风格:

  1. 使用 refreactive 就像你在正常使用 JavaScript 中声明的原始类型变量和对象变量。在使用这种风格时,建议使用 IDE 支持的类型系统。
  2. 尽可能的使用 reactive,并记住在组合式函数中返回 reactive 对象时使用 toRefs。这减少了使用 refs 的心理开销,但并没有消除熟悉 ref 概念的需要。

在这个阶段,我们认为强制规定一个关于 refreactive 之间的最佳实践还为时尚早。我们建议你在上述两个选项中选择与你的心理模型更一致的风格。我们将收集现实世界用户的反馈,并最终就这一主题提供更明确的指导。

return 语句的冗长

一些用户担心 setup() 中的 return 语句过于冗长并且感觉像模版。

我们相信,明确的返回语句有利于可维护性。它让我们有能力明确的控制哪些东西被暴露在模版中,并且作为追踪模版的属性在组件中定义的起点。

有人建议自动公开 setup() 中声明的变量,时 return 语句成为可选项。同样,我们认为这有悖于标准 JavaScript 的直觉。然而,有一些方法可以使它在用户区不那么麻烦:

  • IDE 扩展,根据 setup() 中声明变量自动生成 return 语句。
  • Babel 插件,隐式的生成并插入 return 语句。

更多的灵活性需要更多的纪律

很多用户指出,虽然组合式 API 在代码组织方面提供了更多的灵活性,但它也要求开发者有更多的纪律来“做对”它。有些人担心,在没有经验的人手中,API 会导致意大利面条式的代码。换句话说,虽然组合式 API 提供了代码质量的上限,但它也降低了下限。

我们在某种程度上同意这一点。然而,我们认为:

  • 上限的收益远远超过了下限的损失。
  • 我们可以通过适当的文档和社区指导有效的解决代码组织问题。

一些用户以 Angular 1 为例,说明这种设计会导致代码写得很差。组合式 API 和 Angular 1 的 controller 最大的区别是它不依赖于共享的范围上下文。这使得将逻辑分割成独立的函数变得非常容易,这也是 JavaScript 代码组织的核心机制。

任何 JavaScript 程序是从一个入口文件开始的(可以把它看作是程序的 setup())。我们根据逻辑上的关注点,将程序分割函数和模块来组织。组合式 API 使我们能够为 Vue 组件代码做同样的事情。换句话说,在使用组合式 API 时,编写组织良好的 JavaScript 代码的技能可以很好的转化为编写良好的组件代码。

备选方案

N/A

采纳策略

组合式 API 是纯粹的附加功能,不影响/废除任何现有的 2.x API。它已经通过 @vue/composition 库作为一个 2.x 插件提供。这个库主要目标是提供一种实验 API 的方法,并收集反馈。目前的实现是与本提案同步的,但由于作为一个插件的技术限制,可能包含一些小的不一致。随着本提案的更新,它也可能收到破坏性的变化,所以我们不建议在现阶段在生产中使用它。

我们打算在 3.0 中把这个 API 作为内置的。它将与现有的 2.x 选项一起使用。

对于那些选择在 app 中完全使用组合式 API 的用户,可以提供一个编译时标志,以放弃只用于 2.x 选项的代码,并减少库的大小。然而,这完全是可选的。

该 API 将被定位为一个高级功能,因为它所要解决的问题主要出现在大规模的应用中。我们不打算彻底修改文档,将其作为默认功能。相反,它将在文档中拥有自己专门的部分。

没有解决的问题

N/A

附录

Class API 的类型问题

引入 Class API 的主要目的是提供一个替代的 API,它带有更好的 TypeScript 推导支持。然而,事实上 Vue 组件需要从多个源中声明的属性合并到一个单一的上下文中,这给基于 Class API 带来了一些挑战。

一个例子是 props 的类型化。为了将 props 合并到 this,我们必须对组件类使用一个范型参数,或者使用装饰器。

这是一个使用范型参数的例子:

  1. interface Props {
  2. message: string
  3. }
  4. class App extends Component<Props> {
  5. static props = {
  6. message: String
  7. }
  8. }

由于传递给范型参数的接口只在 type-land,用户仍然需要提供一个运行时的 props 声明,以便在这上面进行 props 代理行为。这重双重声明是多余的,而且很笨拙。

我们考虑使用装饰器作为替代:

  1. class App extends Component<Props> {
  2. @prop message: string
  3. }

使用装饰器会造成很多不确定性的 stage-2 规范的依赖,特别是当 TypeScript 当前的实现与 TC39 甜完全不同步的时候。此外,没有办法在 this.$props 上暴露装饰器声明的 props 类型,这就破坏了 TSX 支持。用户也可能认为他们可以用 @prop message: string = 'foo' 来声明 prop 的默认值,而从技术上讲,这根本无法达到预期的效果。

此外,目前没有办法为类方法的参数利用上下文类型 —— 这意味着传递给类的渲染函数的参数不能根据类的其他属性类型。

与 React Hooks 的比较

基于函数的 API 提供了与 React Hooks 相同水平的逻辑组合能力,但是也有一些重要的区别。与 React hooks 不同,setup() 函数只被调用一次。这意味着使用 Vue 的组合式 API 是:

  • 一般来说,更符合管用 JavaScript 代码的直觉;
  • 对于调用顺序不敏感,可以是有条件的;
  • 每次渲染时不重复调用,产生的 GC 压力较小;
  • 为了防止内联处理程序导致子组件的国度渲染,几乎总是需要用到 useCallback,这一点不会受影响;
  • 如果用户忘记传递正确的依赖数组,则不会出现 useEffectuseMemo 可能捕获过时的变量问题。Vue 的自动依赖性跟踪确保观察者和计算值总是正确无效。

我们承认 React Hooks 的创造性,它是本提案的祖尧灵感来源。然而,上面提到的问题确实存在于它的设计中,我们注意到响应式模型刚好提供了一个绕过它们的办法。

与 Svelte 的比较

虽然采取了非常不同的路线,但 Composition API 和 Svelte3 的基于编译器的方法在概念上其实有很多共同点。下面是一个并列的例子:

Vue

  1. <script>
  2. import { ref, watchEffect, onMounted } from 'vue'
  3. export default {
  4. setup() {
  5. const count = ref(0)
  6. function increment() {
  7. count.value++
  8. }
  9. watchEffect(() => console.log(count.value))
  10. onMounted(() => console.log('mounted!'))
  11. return {
  12. count,
  13. increment
  14. }
  15. }
  16. }
  17. </script>

Svelte

  1. <script>
  2. import { onMount } from 'svelte'
  3. let count = 0
  4. function increment() {
  5. count++
  6. }
  7. $: console.log(count)
  8. onMount(() => console.log('mounted!'))
  9. </script>

Svelte 的代码看起来更简洁,因为它在编译时做了以下工作:

  • 隐式地将整个 <script> 快(出了 import语句)包装成一个函数,为每个组件实例调用(而不是只执行一次)
  • 隐式地注册了变量突变的响应性
  • 隐式地将所有作用域内的变量暴露给渲染上下文
  • $ 语句编译成宠幸执行的代码

从技术上讲,我们可以在 Vue 中做同样的事情(通过用户的 Babel 插件也可以做到)。我们不这样做的主要原因是与标准的 JavaScript 对齐。如果你从 Vue 文件的 <script> 块中提取代码,我们希望它与标准 ES 模块一样。另一方面,Svelte 的 <script> 块内的代码在技术上已经不是标准的 JavaScript 了。我们看到这种基于编译器的方法有很多问题:

  1. 代码在编译和不编译时的工作方式不同。作为一个渐进式框架,许多 Vue 有可能希望 / 需要 / 必须在没有编译的情况下使用它,所以编译版不能成为默认版本。另一方面,Svelte 将自己定位成编译器,只能通过构建步骤使用。这是两个框架都在有意识的进行权衡。
  2. 代码在组件的内部/外部的工作方式不同。当我们试图将逻辑从 Svelte 组件中提取到标准 JavaScript 文件中时,我们将失去神奇的简明语法,而不得不回退到更粗略的低级 API
  3. Svelte 的响应式编译只适用于鼎城变量 —— 它不涉及在函数中声明的变量,所以我们不能将响应式状态封装在一个组件中声明的函数中。这给函数的组织代码带来了非同小可的限制 —— 正如我们在本 RFC 中所展示的,这对于保持大型组件的可维护性非常重要。
  4. 非标准的语义使得它与 TypeScript 的整合存在问题

这绝不是说 Svelte 3 是一个坏主意 —— 事实上,它是一个非常创新的方法,我们高度尊重 Rich 的工作。但基于 Vue 的设计限制和目标,我们必须做出不同的权衡。