Composition API RFC 中文

1,895 阅读14分钟

Composition API RFC

本文属于译文,原文出处 Vue Composition API 翻译为本人理解。不代表原文任何观点。 如有不正之处,望指出。

  • 开始时间:2019-07-10
  • 主要目标版本:2.x / 3.x
  • 提及索引:#42
  • 实现PR:留空

摘要

Composition API 简介:一组基于函数的允许灵活的组合组件逻辑的附加API。

观看 Vue Mastery 的 Vue3 基础课程。下载 Vue3 备忘单

基础示例

<template>
  <button @click="increment">
    Count is: {{ state.count }}, double is: {{ state.double }}
  </button>
</template>

<script>
import { reactive, computed } from 'vue'

export default {
  setup() {
    const state = reactive({
      count: 0,
      double: computed(() => state.count * 2)
    })

    function increment() {
      state.count++
    }

    return {
      state,
      increment
    }
  }
}
</script>

动机

逻辑复用和代码组织

我们都喜欢Vue非常容易上手,同时使得中小型应用的构建变得轻而易举。但是如今,随着Vue采用率的增长,许多用户也正在使用Vue构建大型项目,这些项目是由多个开发人员组成的团队在长期维护和迭代。多年以来,我们目睹了一些项目被Vue当前API带来的编程模型所限制。这些问题可以概括为两类:

  1. 随着功能的增长,复杂组件的代码变得越来越难以理解。尤其是开发人员在阅读别人编写的代码时。其根本原因时 Vue 现行的 API 强制使用选项来组织代码,但是在某些情况下,通过关注逻辑来组织代码会更有意义。
  2. 缺乏一种可在多个组件之间提取和重用逻辑的干净且无成本的机制 (更多细节)。

该RFC中提出的API在组织组件代码时,为用户提供了更多的灵活性。现在能将代码组织成处理特殊功能的函数,而不是被迫使用选项的方式来组织代码。API同样使得在组件间甚至外部组件之间提取和重用逻辑变得更加简单。我们将在 详细设计 部分说明如何实现这些目标。

更友好的类型推断

构建大型项目的开发者的另一个共同诉求:更友好的 TypeScript 支持。Vue 当前的API在与 TypeScript集成时会遇到一些挑战,这种情况很大程度上是因为Vue依赖于 this 上下文来暴露属性。相比于纯 JavaScript, this 在Vue组件中有更多的魔法(e.g. 嵌套在 methods 下的函数里的 this 指向的是组件实例,而不是 methods 对象)。换句话说,Vue现行的API根本就没有考虑到类型推断,这样在尝试与 TypeScript 完美集成时会带来很多复杂性。

目前,大部分将 Vue 和 TypeScript 一起使用的用户正在使用 vue-class-component ,该库允许将组建编写为 TypeScript 类(在装饰器的帮助下)。在设计3.0时,我们在 先前RFC(已废弃) 中试图提供一个内置的Class API,以更好地解决类型问题。 但是,当我们在设计上进行讨论和迭代时,我们注意到,要使Class API解决类型问题,它必须依赖装饰器 —— 这是一个非常不稳定的 stage 2 提议,在实现细节方面存在很多不确定性。 这以此为底层设计,将会带来相当大的风险。 (有关类API类型问题的更多详细信息,请点击此处)。

相比之下,此RFC中提议的API主要使用天然类型友好的纯变量和函数。使用建议的API编写的代码可以享受完整的类型推断,几乎不需要手动类型提示。 这也意味着用建议的API编写的代码在TypeScript和纯JavaScript中看起来几乎相同,因此,即使非TypeScript用户也可以从中受益,以获得更好的IDE支持。

细节设计

API 介绍

这里提出的API并没有引入新的概念,更多的是将Vue的核心功能作为独立的函数展开(例如 创建和观察响应状态)。这里我们将介绍一些基础API,以及如何使用它们代替2.x的选项来描述组件逻辑。本节内容着重介绍基本概念,故此不会详细介绍每个API。完整的API规范可以在 API 参考 部分中查看。

Reactive State 和 Side Effects

让我们开始一个简单的任务:声明一些可响应状态(Reactive State)。

import { reactive } from 'vue'

// reactive state
const state = reactive({
  count: 0
})

reactive 等价于2.x版本现行API中的 Vue.observable() , 为了避免与 RxJS 的 observables 混淆, 故重命名为 reactive . 如上, 返回的 state 就是Vue用户应该很熟悉的可响应对象.

Vue中,可响应状态的必备场景就是我们可以在整个 render 期间使用它. 由于依赖追踪的机制, 当可响应状态改变时, 视图会自动更新. 在DOM渲染内容时,某些内容被视为 "副作用(Side Effect)": 我们的程序正在修改程序本身(the DOM)外部的状态. 为了根据可响应状态自动重新应用副作用,我们可以使用 watchEffect API:

import { reactive, watchEffect } from 'vue'

const state = reactive({
  count: 0
})

watchEffect(() => {
  document.body.innerHTML = `count is ${state.count}`
})

watchEffect 接受一个函数,这个函数体就是期待被执行的 Side Effect (上述例子中, 就是设置 innerHTML ). 它立即执行该函数, 以可响应状态为依赖, 跟踪其在执行期间的所有属性. 如上, 在初次执行之后, state.count 将作为 侦听器(the watcher) 的依赖被观测追踪. 当 state.count 再次被改变时, 函数内部将会再次执行.

这就是 Vue 可响应系统机制的本质. 当你在组件的 data() 返回一个对象时, 该对象会在内部被 reactive() 构造成可响应的. 该组件模板被编译成使用这些可响应属性的渲染函数(render fucntion, 可以看作更高效的 innerHTML).

watchEffect 与2.x中的 watch 选项相似, 但他不再需要分离被观测的数据源和副作用回调. Composition API 还提供了一个与2.x中选项完全一致的 watch 函数.

继续上面的例子, 下面是如何处理用户输入:

function increment() {
  state.count++
}

document.body.addEventListener('click', increment)

但是在Vue模板体系中, 我们不需要再纠结于 innerHTML 或者 手动添加事件监听器. 为了更加关注可响应性, 我们使用伪代码 renderTemplate 简化这个例子:

import { reactive, watchEffect } from 'vue'

const state = reactive({
  count: 0
})

function increment() {
  state.count++
}

const renderContext = {
  state,
  increment
}

watchEffect(() => {
  // hypothetical internal code, NOT actual API
  // 假设的内部代码, 不是真实的API
  renderTemplate(
    `<button @click="increment">{{ state.count }}</button>`,
    renderContext
  )
})

Computed State 和 Refs

有时候我们需要一些依赖于其他 state 的状态, 在 Vue中可以使用 computed 属性来处理. 为了直接创建一个计算值(computed value), 我们可以使用 computed API:

import { reactive, computed } from 'vue'

const state = reactive({
  count: 0
})

const double = computed(() => state.count * 2)

这里 computed 返回的什么呢? 如果我们猜测一下 computed 的内部实现, 可能会想以下内容:

// simplified pseudo code
// 简单的伪代码
function computed(getter) {
  let value
  watchEffect(() => {
    value = getter()
  })
  return value
}

但是我们都知道这样是不对的: 如果 value 是一个基本类型(Primitive Type), 比如 number , 那么一旦被 return, 它与 computed 内部更新逻辑的链接将立即失效. 这是因为 JavaScript 基本类型是通过值传递, 而不是引用:

https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2020/4/27/171ba743df2be009~tplv-t2oaga2asx-image.image

将值作为属性分配给对象时, 也会发生相同的问题. 如果一个可响应值在分配为属性或者从函数返回时, 不能保持其可响应性, 那么它就不是很有用. 为了确保我们始终可以读取计算的最新值, 我们需要将实际值包装在一个对象中, 然后返回该对象:

// simplified pseudo code
function computed(getter) {
  const ref = {
    value: null
  }
  watchEffect(() => {
    ref.value = getter()
  })
  return ref
}

另外,我们还需要拦截这个对象的 .value 属性的读写操作(简单起见, 此处省略了代码), 用以执行依赖追踪和更改通知. 现在我们可以通过引用传递计算得到的值,而不用担心它失去响应性。这番做法的目的是为了获取最新值,我们现在只需要通过 .value 就可以访问它:

const double = computed(() => state.count * 2)

watchEffect(() => {
  console.log(double.value)
}) // -> 0

state.count++ // -> 2

以上代码中, double 时一个我们称为 'ref' 的对象, 因为它内部的值可以一直提供可响应引用.

你可能会意识到在 Vue 中已经有一个 'refs' 的概念, 但是仅用于组件模板或者DOM元素的引用. 查看 这里 以了解新的引用系统 (refs system) 如何用于逻辑状态和模板引用.

除了计算引用 (computed refs) 外, 我们还可以通过 ref API 直接创建单纯的可变引用:

const count = ref(0)
console.log(count.value) // 0

count.value++
console.log(count.value) // 1

引用展开 (Ref Unwrapping)

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

使用 ref 替代 reactive 改写上面简单的例子:

import { ref, watchEffect } from 'vue'

const count = ref(0)

function increment() {
  count.value++
}

const renderContext = {
  count,
  increment
}

watchEffect(() => {
  // hypothetical internal code, NOT actual API
  // 假设的内部代码, 不是真实的API
  renderTemplate(
    `<button @click="increment">{{ count }}</button>`,
    renderContext
  )
})

另外,当引用作为属性嵌套在反应对象下时,它也将在访问时自动展开:

const state = reactive({
  count: 0,
  double: computed(() => state.count * 2)
})

// no need to use `state.double.value`
// 不需要使用 `state.double.value`
console.log(state.double)

组件中的用法

到目前为止,我们的代码已经提供了能根据用户输入而更新的有效UI - 但是这份代码只能执行一次而且无法复用。如果我们想要复用逻辑,将他们封装成一个函数似乎是可靠的一步:

import { reactive, computed, watchEffect } from 'vue'

function setup() {
  const state = reactive({
    count: 0,
    double: computed(() => state.count * 2)
  })

  function increment() {
    state.count++
  }

  return {
    state,
    increment
  }
}

const renderContext = setup()

watchEffect(() => {
  renderTemplate(
    `<button @click="increment">
      Count is: {{ state.count }}, double is: {{ state.double }}
    </button>`,
    renderContext
  )
})

注意到,上面代码不依赖于组件实例而出现。事实上,到目前为止所介绍的APIs都可以在组件上下文之外使用,从而我们能够在更加广泛的场景中利用Vue的响应机制。

现在我们无需完成 调用 setup() , 创建监听器, 并将模板渲染到框架(watchEffect) 的任务, 就可以定义一个组件. 我们可以仅使用 setup() 函数和模板来定义组件.

<template>
  <button @click="increment">
    Count is: {{ state.count }}, double is: {{ state.double }}
  </button>
</template>

<script>
import { reactive, computed } from 'vue'

export default {
  setup() {
    const state = reactive({
      count: 0,
      double: computed(() => state.count * 2)
    })

    function increment() {
      state.count++
    }

    return {
      state,
      increment
    }
  }
}
</script>

Lifecycle Hooks — 生命周期钩子

到目前为止, 我们已经介绍了组件的纯状态内容: 用户输入上的 可响应的状态( Reactive State ), 计算状态( Computed State ) 和 变异状态 ( Mutating State ). 但是一个组件也会需要执行副作用. 比如, 打印日志, 发送 ajax 请求, 或者在 window 上监听事件. 这些副作用通常在以下时间节点执行:

  1. 当某些状态改变;
  2. 当组件被装载完( mounted ), 更新完( update ), 或者卸载( unmounted )(生命周期的钩子);

我们知道可以使用 watchEffectwatch APIs 来实现状态改变的副作用执行. 至于在不同的生命周期中执行副作用, 我们可以使用 onXXX APIs 来实现( 直接反映现有生命周期的选项):

import { onMounted } from 'vue'

export default {
  setup() {
    onMounted(() => {
      console.log('component is mounted!')
    })
  }
}

这些生命周期方法只能在 setup() 钩子调用期间注册调用. 它会使用钩子内部全局状态, 自动找出当前调用 setup 钩子的实例. 有意设计这种方式, 是减少我们提取逻辑到外部函数时产生的问题.

更多关于这些 APIs 的细节可以再 API 索引 中找到. 但是, 我们推荐在阅读下面的章节之后再去深入了解.

Code Organization — 代码组织

至此,我们已经通过导入函数( inported functions ) 复制了组件 API, 但是为了什么呢?

用选项定义组件, 看起来比将所有东西都混合在一个大函数中要有组织的多.

这是可以理解的第一印象. 但是, 正如动机部分所述, 我们认为 Composition API 实际上可以更好的组织代码, 尤其是在复杂组件中. 接下来, 我们将尝试解释原因.

什么是组织代码?

让我们重头思考, 当我们谈论 "组织代码"的时候, 我们想要表达的是什么. 保持代码井井有条的最终目的是使代码更易于阅读和理解. 那么什么是 "理解" 代码呢? 我们可以仅仅因为知道组件含有哪些选项就声称自己"理解"组件吗? 你是否发现深入一个由其他开发者编写的超大组件 (例如这个) 时, 很难将其掌握.

试想一下, 上述连接中的超大组件, 我们将如何引导一个开发者掌握它. 您很有可能从 "这个组件正在处理 X, Y 和 Z" 而不是 "这个组件具有这些数据属性, 计算属性和这些方法" . 在理解组件的时候, 我们更关注 "这个组件正在试图做什么" (即 代码背后的意图) ,而不是 "组件中使用了哪些选项". 虽然使用基于选项的 API 自然可以回答后者, 但是在表达前者的方面做得很差.

Logical Concerns vs. Option Types — 逻辑问题 vs. 选项类型

我们将组件要处理的 "X, Y 和 Z" 定义为逻辑问题. 小型单一用途的组件通常不存在可读性问题, 因为整个组件只处理一个逻辑问题. 但是, 在高级场景中, 这个问题更加突出. 以 Vue CLI UI File Exploer 为例, 该组件必须处理不同的逻辑问题:

  • 跟踪当前文件夹状态并显示其内容
  • 处理文件夹导航 ( 打开, 关闭, 刷新...)
  • 处理新文件夹的创建
  • 切换显示文件夹
  • 切换显示隐藏的文件夹
  • 处理当前工作目录的更改

你是否可以通过阅读这些基于选项的代码, 立即理解并区分这些逻辑问题? 这肯定是困难的. 你会注意到, 与特定逻辑逻辑相关的问题往往分散在各处. 例如, "创建文件夹" 功能使用了2个数据属性, 1个计算属性1个方法. 文件中这个方法和定义数据属性的距离超过了100行.

如果我们用不同颜色对这些逻辑问题中的对应代码着色, 就会注意到, 使用组件选项表示他们有多分散:

user-images.githubusercontent.com/499550/6278…

正是这种碎片化使得复杂组件难以理解和维护. 选项的强制分离使得基本逻辑问题变得模糊. 另外, 当我们关注单个逻辑点时, 我们不得不在多个选项块中频繁地 "跳动" , 只为了查看和这个问题相关的部分.

注意:源码可能会在几个地方改进,但是在撰写本文时展示的是最新提交的版本,无需任何修改就可以提供一个我们自己可能会在真实生产环境写的案例。

如果我们能将同一个逻辑问题相关的代码放在一起, 那就再好不过了. 这正是 Composition API 赋予我们的能力. 这个 "创建文件夹" 的功能可以通过这种方式写:

function useCreateFolder (openFolder) {
  // originally data properties
  const showNewFolder = ref(false)
  const newFolderName = ref('')

  // originally computed property
  const newFolderValid = computed(() => isValidMultiName(newFolderName.value))

  // originally a method
  async function createFolder () {
    if (!newFolderValid.value) return
    const result = await mutate({
      mutation: FOLDER_CREATE,
      variables: {
        name: newFolderName.value
      }
    })
    openFolder(result.data.folderCreate.path)
    newFolderName.value = ''
    showNewFolder.value = false
  }

  return {
    showNewFolder,
    newFolderName,
    newFolderValid,
    createFolder
  }
}

请注意, 我们如何将 "创建文件夹" 功能相关的所有逻辑并列并封装在同一个函数中. 由于其语义化的名称, 这个函数在某种程度上是自带文档 (self-documenting) 的. 这就是我们所说的复合函数 (Composition Function) . 建议约定以 use 开头作为函数名, 来表明它是一个复合函数. 这种模式可以应用于组件中其它的逻辑问题, 从而产生了很多很好解耦的函数:

c8095d6.png

上图的对比排除了 import 语句和 setup() 函数. 这个组件使用 Composition API 的重新实现可以在这里查看.

现在, 每个逻辑关注点的代码在组合函数中并置在一起. 当在大型组件工作时, 这大大减少了频繁 "跳跃" 的需求. 组合函数也可以在编辑器中折叠, 这使组件更便于扫描:

export default {
  setup() { // ...
  }
}

function useCurrentFolderData(networkState) { // ...
}

function useFolderNavigation({ networkState, currentFolderData }) { // ...
}

function useFavoriteFolder(currentFolderData) { // ...
}

function useHiddenFolders() { // ...
}

function useCreateFolder(openFolder) { // ...
}

现在, setup() 主要作为所有组合函数被调用的入口:

export default {
  setup () {
    // Network
    const { networkState } = useNetworkState()

    // Folder
    const { folders, currentFolderData } = useCurrentFolderData(networkState)
    const folderNavigation = useFolderNavigation({ networkState, currentFolderData })
    const { favoriteFolders, toggleFavorite } = useFavoriteFolders(currentFolderData)
    const { showHiddenFolders } = useHiddenFolders()
    const createFolder = useCreateFolder(folderNavigation.openFolder)

    // Current working directory
    resetCwdOnLeave()
    const { updateOnCwdChanged } = useCwdUtils()

    // Utils
    const { slicePath } = usePathUtils()

    return {
      networkState,
      folders,
      currentFolderData,
      folderNavigation,
      favoriteFolders,
      toggleFavorite,
      showHiddenFolders,
      createFolder,
      updateOnCwdChanged,
      slicePath
    }
  }
}

当然, 当我们使用 options API 无需编写这些代码. 但是请注意, setup 这个函数读起来就好像是在口头描述, 这个组件将要执行的操作. — 这是基于选项的版本完全缺少的信息. 您还可以根据传递的参数清楚地看到组合函数之间的依赖关系流. 最后, return 语句是向模板暴露内容的唯一出口.

逻辑提取和复用(Logic Extraction and Reuse)

当涉及到跨组件的 提取\复用逻辑时, Composition API 非常灵活. 组合函数不再依赖于 魔法的 this 上下文, 而仅依赖其参数和全局导入的 Vue APIs. 你可以复用组件中的任何逻辑, 只需要简单地将其导出为函数. 您甚至可以导出组件的整个 setup 函数来实现同 extends 等效的功能.

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

import { ref, onMounted, onUnmounted } from 'vue'

export function useMousePosition() {
  const x = ref(0)
  const y = ref(0)

  function update(e) {
    x.value = e.pageX
    y.value = e.pageY
  }

  onMounted(() => {
    window.addEventListener('mousemove', update)
  })

  onUnmounted(() => {
    window.removeEventListener('mousemove', update)
  })

  return { x, y }
}

如何在其他组件中使用这个函数:

import { useMousePosition } from './mouse'

export default {
  setup() {
    const { x, y } = useMousePosition()
    // other logic...
    return { x, y }
  }
}

在上述文件浏览实力的 Composition API 版本中, 我们已经将一些实用的代码 (例如 usePathUtilsuseCwdUtils ) 提取到外部文件中, 因为我们发现它们对其他组件很有用.

使用现有的模式 , 例如 mixin , 高阶组件 或 无渲染组件 (通过 scoped slots ), 也可以实现类似的逻辑复用. 网上有很大量的信息解释这些模式. 此处不再多言. 高水平的看法时, 相比于组合函数, 这些模式中的每一个都存在各自的缺点:

  • 渲染上下文中暴露的属性来源不明. 例如, 当使用多个mixin读取组件的模板时, 可能很难确定从哪个 mixin 中注入了特定的属性.
  • 命名空间冲突. Mixins 可能会与 属性名和方法名冲突. 而HOC可能会和预期的prop名称冲突.
  • 性能上, HOCs和无渲染组件需要额外的有状态组件实例, 这回降低性能.

较之与组合API而言:

  • 暴露给模板的属性具有明确的来源, 因为他们是从组合函数返回的值.
  • 组合函数返回的值可以任意命名, 因此不会发生命名空间冲突.
  • 没有仅为了逻辑复用, 创建不必要的组件实例.

和现有的API组合使用(Usage Alongside Existing API)

Composition API可以和现有基于选项的API结合使用.

  • Composition API已经在 2.x选项 (data , computedmethods ) 之前完成, 而且没有访问由这些选项定义的属性的权限.
  • setup() 返回的属性会被暴露到 this 上, 并且可以在2.x的选项中访问.

插件开发

如今, 很多Vue插件都将属性注入到 this 上. 例如, Vue Router 注入 this.$routerthis.$route , 还有 Vuex 注入 this.$store . 由于每个插件都要求用户为注入的属性增加Vue类型, 这使得类型推断变得很棘手.

当使用 Composition API 时, 没有 this . 取而代之的, 插件将利用内置的 provideinject 抛出一个组合函数. 以下是插件的伪代码:

const StoreSymbol = Symbol()

export function provideStore(store) {
  provide(StoreSymbol, store)
}

export function useStore() {
  const store = inject(StoreSymbol)
  if (!store) {
    // throw error, no store provided
  }
  return store
}

在消费者组件中:

// provide store at component root
//
const App = {
  setup() {
    provideStore(store)
  }
}

const Child = {
  setup() {
    const store = useStore()
    // use the store
  }
}

请注意, store 是可以可以通过应用程序级别来提供, 如同 Global API change RFC 中建议的一样. 但 useStore 这种 API 在消费者组件就是一样的.

不足之处

Refs引入的额外开销

从技术上讲, Ref是本次提案中引入的唯一 "新" 概念. 引入他是以变量的形式传递响应式的值而不是依赖 this. 缺点是:

  1. 使用Composition API 时, 我们需要区分 refs 和纯值\对象. 从而增加了使用API时的精神负担. 通过使用命名约定 (例如, 将所有的ref变量后缀为 xxxRef ), 或者使用类型机制, 大大减少心理负担. 另一方面, 由于提高了组织代码的灵活性, 组件逻辑将更好的被隔离成一些小的函数, 这些小函数的局部上下文很简单, 引用的开销很容易被管理.
  2. 由于需要 .value ,因此读取和修改 ref 逼使用纯值更冗长. 有些人建议使用编译时的语法糖 (类似 Svelte 3) 来解决此问题. 尽管这在技术上可行, 但是我们不认为可以将其作为 Vue的默认值 (正如在 与Svelte的比较 中所讨论的那样) . 就是说, 在用户领域作为Babel插件在技术上可行.

我们已经讨论了是否有可能完全避免使用 Ref 概念而仅仅使用可响应对象. 但是:

  • 计算的访问器 (Computed getters) 可以返回简单类型, 因此不可避免的要使用类似 Ref 的容器.
  • 仅从可响应性出发, 期望或者返回简单类型的组合函数也需要将值包装在对象中. 如果框架的实现中没有提供标准的实现, 那么用户很可能造出自己的类似Ref的模式 (并最终导致生态系统碎片化) .

Ref vs. Reactive

很显然, 用户可能会在 refreactive 之间纠结使用哪个. 首先我们得明白, 你需要理解两者才能有效地使用 Composition API. 单独使用某一个极有可能导致神奇的问题或者重复造轮子.

refreactive 的区别主要在于如何设计你的 JavaScript 代码。

 // style 1: separate variables
// 第一种写法: 独立的变量,使用 Ref
let x = 0
let y = 0

function updatePosition(e) {
  x = e.pageX
  y = e.pageY
}

// --- compared to ---

// style 2: single object
// 第二种写法:一个对象,使用 Reactive
const pos = {
  x: 0,
  y: 0
}

function updatePosition(e) {
  pos.x = e.pageX
  pos.y = e.pageY
}
  • 如果使用 ref , 我们主要使用 ref 将第一种写法转化成更为详细的等价形式. (以此使得简单类型具有可响应性)
  • 如果使用 reactive , 我们的代码就会和第二种相同 (使用单个对象) , 只需要使用 reactive 创建对象即可.

但是, 只使用 reactive 的问题在于, 组合函数的消费者必须始终保持对返回对象的引用, 以此保持可响应性. 这个对象不可以被解构或者展开:

// composition function
// 组合函数
function useMousePosition() {
  const pos = reactive({
    x: 0,
    y: 0
  })

  // ...
  return pos
}

// consuming component
// 消费者
export default {
  setup() {
    // reactivity lost!
    const { x, y } = useMousePosition()
    return {
      x,
      y
    }

    // reactivity lost!
    // 失去可响应性
    return {
      ...useMousePosition()
    }

    // this is the only way to retain reactivity.
    // 保持可响应性
    // you must return `pos` as-is and reference x and y as `pos.x` and `pos.y`
    // in the template.
    // 你必须返回 `pos`, 然后在模板中使用 `pos.x` 和 `pos.y` 来引用 x 和 y
    return {
      pos: useMousePosition()
    }
  }
}

提供了 [toRef API](composition-api.vuejs.org/api.html#to…) 来处理这种约束. 它可以将 reactive 对象上的每一个属性转换成响应的 ref:

function useMousePosition() {
  const pos = reactive({
    x: 0,
    y: 0
  })

  // ...
  return toRefs(pos)
}

// x & y are now refs!
const { x, y } = useMousePosition()

综上, 有两种可行的写法:

  1. 就像在普通JavaScript中声明基本类型变量和对象变量一样, 同时使用 refreactive . 这种写法时, 建议使用具有IDE支持的类型系统.
  2. 尽可能使用 reactive , 记住从组合函数返回可响应对象时需要使用 toRefs . 这就减少了花在 ref 上的精力, 但并不意味着你不需要去熟悉这个概念.

在现阶段, 我们认为强制 ref vs. reactive 作为最佳实践还为时过早. 我们建议您从上面的两个选项中, 选择符合心理预期的写法. 我们会收集所有使用者的反馈, 并最终为这个话题提供更加明确的指导.

Return 语句的冗长 (Verbosity of the Return Statement)

有些用户已经提出了关于 setup() 中return语句过于冗长, 并且像样板一样的担忧.

我们认为明确的 return 语句有利于可维护性. 它使我们能显示的控制暴露给模板的内容, 同时可以跟踪属性在模板中定义的入口.

有些建议认为可以自动暴露声明在 setup() 中的变量, 而将 reture 语句作为可选项. 同上, 我们不认为这应该是默认设置. 因为它违背了标准 JavaScript 的直觉. 但是有一些方法可以减少用户领域的琐事:

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

越灵活, 就越要规范 (More Flexibility Requires More Discipline)

许多用户指出, 尽管 Composition API在组织代码方面非常灵活, 但需要开发人员有更多的 "纪律" 才能 "正确执行" . 有些人担心该 API 会导致经验不足的工程师写出 "面条式代码" (代码的流向就像一盘面一样扭曲纠结). 也就是说, Composition API 提高了代码质量的上限, 同时也降低了代码质量的下限.

我们一定程度上同意上述观点. 但是, 我们认为:

  1. 上限的收益远大于下限.
  2. 通过适当的文档和社区指引, 可以有效地解决代码组织的问题.

有些用户以 Angular 1 中的controller作为例子, 来证明这样设计将会导致编写不良代码. 但是 Composition API 和 Angular 1 的controller最大的区别在于, 它不依赖于共享方位的上下文. 这使得逻辑分离成单独的函数变得非常容易, 这就是 JavaScript代码组织的核心机制.

任何JavaScript程序都会有入口文件(就像 setup() ). 根据逻辑关注点, 我们将它们分为功能和模块来组织代码, 而 Composition API 让我们能够对 Vue 组件进行相同的操作(The Composition API enables us to do the same for Vue component code). 换句话说, 当使用 Composition API 时, 优雅地书写JavaScript代码的能力就等同于优雅地书写Vue代码的能力.

采用策略

Composition API纯粹是新增的, 不会影响/弃用任何2.x的API. 它已经通过 @vue/composition 库作为 2.x 插件提供. 这个库的主要目的是提供API实验和收集反馈. 当前的实现是此提案的最新版, 但是由于作为插件的技术限制, 可能会包含一些不一致性. 随着该提案的更新, 它可能还会出现破坏性的变更, 所以不建议当前阶段在生成环境中使用它.

我们打算将其内置在3.0中, 它将与现有的2.x选项一起使用.

对于只打算在App中使用Composition API 的用户, 可以提供编译时的标志, 用来弃用仅用于 2.x选项的代码并减少库的大小. 但是, 这完全是可选的.

该API将被定位为高级功能. 因为它旨在解决的问题主要出现在大规模应用里. 我们不打算修改文档将其作为默认, 但是会有专门的章节介绍它.

附录

Class API的类型问题

引入 Class API 的主要目的是提供一种更好的支持 TypeScript 的替代API方案. 但事实上Vue的组件需要将多个源对象的属性合并挂载到单一的 this 上下文中, 即使是使用 Class 的API也会带来很多挑战.

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

以下是使用了泛型参数的例子:

interface Props {
  message: string
}

class App extends Component<Props> {
  static props = {
    message: String
  }
}

尽管传递给泛型参数的接口仅处于类型区域, 用户仍然需要为 this 上的 props 代理行为提供一个运行时的 props 声明. 这种双重声明很多余而且笨拙.

我们已经考虑过使用装饰器替代它:

class App extends Component<Props> {
  @prop message: string
}

使用装饰器会产生对 stage-2 规范的依赖, 存在很多不确定性. 尤其是当 TypeScript 的当前实现与 TC39提案完全不同步. 此外, 无法将用装饰器实现的 props 提供给 this.$props , 这回破坏 TSX 的支持. 用户可能也会猜想, 他们可以为 props 声明一个默认值, 例如 @props message: string = 'foo' , 但是技术上讲, 这个不会生效.

除此之外, 目前尚无办法为类的方法参数使用上下文类型, 这就意味着传递给 Class 的 render 函数的参数, 不能基于 Class 的其他属性来进行类型推断.

与React Hooks 的比较

这种基于函数的API提供了与React Hooks相同级别的逻辑组合能力, 但也有一些重要的区别. 与React钩子不同, setup 函数仅被调用一次. 这意味着使用 Vue 的 Composition API:

  • 一般情况下, 更符合常用的 JavaScript代码直觉
  • 对于调用顺寻不敏感, 可以有条件的执行
  • 不会再每次渲染时都重复调用, 并减少GC压力
  • 不必考虑总是要使用 useCallback 的问题, 以防止内联处理程序导致子组件过渡重渲染的问题;
  • 如果用户忘记传递正确的依赖项数组, 则 uesEffectuseMemo 可能会捕捉旧的变量的问题不复存在, Vue的自动依赖跟踪功能会确保 watchcomputed 的值始终是最新的.

我们认可 React Hooks 的创造价值, 这也是本提案的主要灵感来源. 但是上面提到的问题, 真实存在于它的设计之中, 我们可以注意到Vue的可响应模型正好解决了这些问题.

与Svelte 的对比

尽管采用的路线截然不同, 但是Composition API 和 Svelte 3 的基于编译器的方法实际上是有很多共通之处. 这是一个对比例子:

<script>
import { ref, watchEffect, onMounted } from 'vue'

export default {
  setup() {
    const count = ref(0)

    function increment() {
      count.value++
    }

    watchEffect(() => console.log(count.value))

    onMounted(() => console.log('mounted!'))

    return {
      count,
      increment
    }
  }
}
</script>

<script>
import { onMount } from 'svelte'

let count = 0

function increment() {
  count++
}

$: console.log(count)

onMount(() => console.log('mounted!'))
</script>

Svelte代码看起来更简洁,因为它在编译时执行以下操作:

  • 将整个<script>块(import语句除外)隐式包装到为每个组件实例调用的函数中(而不是仅执行一次)
  • 隐式的为可变的变量注册了可响应性
  • 隐式的将所有局部作用域内的变量暴露到渲染上下文中
  • $ 语句编译为重新执行的代码

从技术上讲,我们可以在Vue中做同样的事情(可以通过Babel插件来完成). 我们不这样做的主要原因是与标准JavaScript保持一致。 如果您从Vue文件的<script>块中提取代码,我们希望它的工作原理与标准ES模块完全相同。 另一方面,Svelte <script>块中的代码在技术上不再是标准JavaScript。 这种基于编译器的方法存在很多问题:

  1. 代码有无编译过程,工作方式截然不同。作为一个渐进式框架,许多Vue用户可能期望、需要、必须无构建流程的情况下使用它,所以我们不能将 “构建” 作为默认项。另一方面,Svelte将自身定义为编译器,并且只能和构建步骤一起使用。这是两个框架在有意识地做出取舍。
  2. 代码在内部/外部组件的工作方式不同。当尝试将逻辑从Svelte组件中提取到标准JavaScript文件中时,我们将失去神奇的简明语法,而不得不使用冗长的低级API
  3. Svelte的响应性编译仅适用于顶层变量 — 他不涉及在函数内部声明的变量。因此,我们无法在组件内部声明的函数中封装响应性变量。这对使用 function 组织的代码, 施加了不小的限制 — 正如我们在RFC中展示的那样, 这对于保持大型组件的可维护性非常重要.
  4. 不标准的语义导致与TypeScript集成时会有问题.

这绝不是说Svelte 3是一个坏想法 — 实际上,这是一种非常创新的方法,我们非常尊重Rich的工作。 但是基于Vue的设计约束和目标,我们必须做出不同的权衡。