[译]Composition API RFC (三)

818 阅读10分钟

原文地址:vue-composition-api-rfc.netlify.app/#summary

缺点

引入Refs的负担

Ref是这次提议中唯一的技术性的“新”概念。引入refs是为了将响应式数据作为变量传递,而不需要依赖this。缺点如下:

  • 在使用Composition API时,我们需要不断分辨refs是来自于原始数据类型还是对象,增加了使用API的心智负担。

使用良好的命名约定(例如,所有的ref变量都增加xxxRef,这样的后缀),或者使用类型系统,可以大大的减少这种心智负担。从另一方面来说,由于增加了代码组织的灵活性,使得组件逻辑将经常被封装于本地上下文环境简单的小函数中,因此使用refs带来的负担也很容易控制。

  • 阅读和修改原始数据生成的refs过于麻烦,因为需要通过.value的方式读/改。

有一些编译型的语法糖(类似于Svelte 3)可以解决上述问题。当然,这个只是技术上的实现,其实我们觉得这种方式和Vue的默认方式一样,并没有使refs更清晰。总而言之,这是一种和Babel插件一样的,开发者侧的技术上的实现方式。

那么我们能不能完全避免使用ref,而仅仅使用响应式对象呢?然而并不能,原因如下:

  • computed接收的回调函数可能返回原始数据类型的数据,因此类Ref的容器是无法避免的。
  • 组合函数的接收参数或者只返回原始数据类型数据,都需要使用一个对象包裹其值,才可以保证其的响应式。如果框架本身不提供ref的实现,很大可能开发者们最终也会发明他们自己的类Ref实现(可能还会引起生态系统的分裂,不同的Ref实现之间争一个你死我活)。

Ref vs. Reactive

现在肯定很多开发者都在纠结该使用ref还是reactive。首先,这2个API你都必须理解,才能更高效的使用它们。只使用其中一个,大概率会导致复杂的实现方法或者重复造轮子。

使用ref和reactive之间的区别,类似于你编写标准js逻辑的不同风格,请看如下代码:

// style 1: separate variables
let x = 0
let y = 0

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

// --- compared to ---

// style 2: single object
const pos = {
  x: 0,
  y: 0
}

function updatePosition(e) {
  pos.x = e.pageX
  pos.y = e.pageY
}
  • 假设使用ref,refs的实现和风格1的实现方式相同,代码更加冗余。(为了保证原始数据类型数据的响应式性)。
  • 使用reactive和风格2的实现是一致的。我们只需要使用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.
    return {
      pos: useMousePosition()
    }
  }
}

可以使用toRefs这个API解除上述限制,它可以将返回对象的每一个属性都转变为相同的ref:

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

  // ...
  return toRefs(pos)
}

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

综上所述,有2种可行的使用风格:

  1. 使用ref和reactive,就像你在普通JavaScript中如何声明变量一样,使用原始数据类型还是使用对象。使用这种风格时,推荐使用带有类型推断系统的IDE。
  2. 只使用reactive,但是需要记住在组合函数返回响应式对象时使用toRefs API使之可解构。这种方式可以减少refs带来的心智负担,但是你还是需要学习熟悉这个新概念。

我们相信在这个部分去定义ref vs. reactive的最佳实践还言之尚早。我们推荐你使用上述2种风格中你理解更好的那个。后续我们会收集开发者使用的真实反馈,最终会提供关于这个问题更明确的引导。

冗长的返回语句

许多开发者都很关心setup函数中的返回语句过于冗长,而且看起来有点像样板文件:

我们相信明确的返回语句是有助于维护的。它可以让我们明确的知道暴露给模板的有哪些属性,同时是组件中追踪模版中使用属性定位位置的起点。

有建议说让setup函数自动暴露变量声明,让return语句变为可选的。再次说明,我们不认为这种做法是可取的,因为这与标准JavaScript的印象是背道而驰的。不管怎么说,在日常开发中,开发者有很多方法可以优化这种情况:

  • IDE支持,编辑器自动根据setup函数中的变量声明生成返回语句。
  • Babel插件,在编译时自动插入return语句。

更多的灵活性伴随着更多的规则

许多开发者指出,使用Composition API在组织到代码方面提供更多的灵活性,同时也伴随着很多规定,开发者们必须“做正确的事”才可以。有一些担心是对于新手而言,容易写出意大利面条式的代码(spaghetti code)。从另一方面看,Composition API拥有很高的代码质量上限,同时也有很低的下限。

在一定程度上我们承认这种担心。但是,我们相信:

  1. 利远大于弊。
  2. 通过完善的文档和社区引导,我们可以很快的定位代码组织中出现的问题并解决。

一些开发者以 Angular 1中的控制器为例来说明如何设计可以减少书写的代码量。 Composition API与Angular 1中控制器的最大不同是,前者不依赖共享的上下文环境。这个特性使分离逻辑到单独的函数更加容易,这也是 JavaScript代码组织的核心机制。

任何JavaScript项目都是从一个单文件开始的(可以认为这个单文件就是这个项目的setup函数)。我们组织项目的方式是以逻辑关注点为基础将它们分离为不同的函数和模块。Composition API允许我们按照相同的方式组织 Vue组件代码。换句话说,编写组织良好的JavaScript代码的技能,可以直接转换是使用Composition API编写组织良好的Vue代码的技能。

使用策略

Composition API是非常纯洁的新增特性,没有影响或废弃任何2.x版本中现存的APIs。通过@vue/composition这个库,可以在2.x版本中以插件的方式使用Composition APIs。这个库的主要目标是提供一种方式实践这些API并收集反馈。当前的实现是最新的,但是由于作为插件本身的技术限制导致可能存在一些小问题。随着文档的更新,具体实现可能会有很大变化,我们不建议这个阶段在生产环境中使用。

我们打算在3.0版本中作为内建功能提供。同时兼容2.0版本的options。

对于想在app开发中只使用Composition API的开发者来说,可能需要在编译的时候提供一个标志,用来删除兼容2.0版本的功能的代码,以减小库代码的体积。不管怎样,这都是完全可选的。

这些API被定位为增强特性,因为这些特性解决的问题主要是出现在大型应用项目开发中。我们不打算全面修改文档,将这些特性作为默认API,而是在当前文档中有一个专门的模块介绍。

附录

Class API的类型问题

引入Class API的主要目标是提供一个替代API,可以获得更好的TypeScript推断支持。然而事实上,即使使用基于Class的API,由于Vue组件需要将多个来源的属性声明合并到单一的this上下文中,依然给类型问题的带来一些挑战。

以props的类型为例。为了将props合并至this,我们需要提供一个泛型参数给组件类或者使用装饰器。

下面是一个使用泛型参数的例子:

interface Props {
  message: string
}

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

由于作为泛型参数的interface仅作为类型判断的依据,因此开发者依然需要提供一份运行时使用的props声明,这样props才可以代理到this上。这种2遍声明的代码编写是多余的、尴尬的。

我们也可以考虑使用装饰器作为替代方案:

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

使用基于stage-2测试阶段的装饰器特性创建依赖存在很多不确定性,特别是TypeScript当前的实现完全没有和TC39建议同步。另外,使用装饰器不可以暴露this.$props中的props类型声明,在TSX中不支持。有些开发者想通过@prop message: string = ‘foo’这种方式为props指定一个默认值,但是技术上是无法如期实现的。

另外,现在还没有办法对传递给类方法的参数使用上下文类型推断,也就意味着传递给类的render方法的参数,不能基于类的其他属性进行类型推断。

对比React Hooks

基于API的函数提供了和React Hooks一样水平的逻辑组合能力,但是有着某些重要区别。不像 React Hooks,setup函数仅调用一次。使用Vue的Composition APIs编写的代码具有如下特性:

  • 一般更符合对惯用JavaScript代码的印象。
  • 对调用顺序没有要求,而且可以在判断条件中使用。
  • 不会在每次渲染的时候重复调用,减少GC(garbage collection,垃圾回收)压力。
  • 不需要使用useCallback来阻止行内handlers造成的子组件过度重新渲染的问题。
  • 在react hooks中,如果开发者忘记传入正确的依赖数组,useEffect和useMemo可能会捕获旧数据,而Vue hooks中没有这样的问题。Vue的自动依赖追踪特性可以保证watchers和computed总是在正确的时间失效。

我们承认React Hooks的创新性,而且它的确是Composition API灵感的主要来源。不管怎样,上述的几个问题的确存在于它的设计中,Vue的响应式模型可以避免这些问题。

与Svelte相比

虽然用法不同,但是实际Composition API和Svelte 3的基于编译的方法在某些相同的概念上是相同的。这里我们一一对比:

Vue

<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>

Svelte

<script>
import { onMount } from 'svelte'

let count = 0

function increment() {
  count++
}

$: console.log(count)

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

Svelte的写法看上去更加简洁,因为它在编译时做了以下几件事:

  • 把整个<script>包裹的代码(除了import语句)编译为一个函数。这个函数可以被任一组件实例调用(而不是只被执行一次)。
  • 自动监听变量的变化,以实现其响应式。
  • 在渲染上下文中,暴露全部作用域范围内的变量。
  • 把$关键字编译为重复执行的代码。

在技术上,我们可以在Vue中实现相同的功能(或者也可以通过Babel插件实现)。但是我们并没有这么做的原因是与标准JavaScrip对齐。如果你单独把Vue文件中的script部分拿出来,我们希望它的执行与标准ES模块的执行相同。也就是说,Svelte中script中的部分在技术上来说,并不是标准的JavaScript。基于编译的方法有以下几个问题:

  1. 编译/未编译的代码工作方式不同。在网页的开发过程中,许多Vue开发者可能希望/需要/必须使用没有经过build步骤的代码,因此编译后的版本不应该设置为默认。从另一方面来说,Svelte定位它自己为一个编译器,而且只可以使用经过build步骤后的代码。这是一个框架开发者经过考虑之后做的决定。
  2. 组件内/组件外的代码工作方式不同。当我们尝试把Svelte组件中的逻辑部分抽出作为一个标准的JavaScript文件执行,我们将会丢失上面例子中简洁的语法,并且需要回退到更冗长的低级API
  3. Svelte的响应式编译只适用于顶级变量,并没有触及在函数中声明的变量,因此我们不能在组件内部定义的函数中封装响应式状态。这个特性对于使用函数组织代码的方式有极大的限制,正如这个RFC所说的,这种使用函数组织代码的方式会使大组件更容易维护。
  4. 非标准的语法导致它不能很好的兼容TypeScript使用

并不是说Svelte 3是不好的,实际上,它非常具有创新性,而且我们高度尊重Rich的工作。但是基于Vue的设计限制和目标,我们必须做出不同的决定。