VueJS函数式API提案(Composition API RFC)的翻译

2,024 阅读12分钟

Vue3要出来了,所以抓紧时间看看有什么新的东西。其中Composition API涉及到的篇幅很长,所以看过之后翻译出来供大家参考,不对的地方欢迎指正。

概述(Summary)

一套拓展式的,基于函数的API。用于编写灵活可配置的组件逻辑。官方课程API cheat sheet 下载

简单的例子(Basic example)

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

设计目的(Motivation)

更好的逻辑复用与代码结构(Logic Reuse & Code Organization)

我们喜欢Vue是因为它简单易上手,方便开发中小型软件。但是随着使用的人增加,不少人也用它去开发大型应用,这些应用通常有很长的维护周期并且需要多人合作开发。过去几年里,Vue的设计者发现:很多这样的大型应用逐渐被Vue现有的Api束缚住了。问题可以归为两大类:

  1. **随着特性增加,复杂组件的代码变得难以阅读。**特别是交接组件的时候,想要快速了解组件功能是很困难的。造成这种困难的根本原因是:Vue现有的Api迫使你必须按配置(options)来组织代码,你需要把一个功能的实现分布在各个配置里:data,computed,watcher,methods。但是在某些情况下按功能来组织代码更合理。
  2. 缺少干净简便的方法来提取和复用组件的代码,现有方法各有缺点,后面会提到。

这次的新API提供了更灵活的编码方式,让代码能够以函数的形式去按功能编排,而不是在声明各种配置的时候跳来跳去。也使得组件间的代码复用更容易,甚至可以脱离组件的范围去使用。下面会详述

更好的TS支持(Better Type Inference)

以Vue现有的API去接入TS是有困难的,因为Vue依赖于单一的this上下文去导出属性,Vue对组件内的this做了特殊处理(例如:methods下的函数,它里面的this指向的不是你在script标签里写的那个对象,而是组件实例。不明白的可以试试this.data.xxx,看能打出什么)。换言之,Vue现有的API不是为TS设计的,由此使用TS会有很多困难。

大多数用Vue+TS的开发者都在用vue-class-component这个库,它是通过装饰器特性(decorators)来实现组件class语法的,这是一个目前仍未确定的提案,其实现细节有很多不确定的地方。在开发Vue的3.0版本时,官方曾经尝试提供一个内建的Class API(已废弃)来解决类型问题。但是最后发现这个Class API依然需要decorators。

这次的API使用了原生的变量和函数,对TS非常友好。

设计细节(Detailed Design)

API介绍(API Introduction)

这次的API并没有什么新的概念,还是响应式那一套,只是拓展了Vue的核心能力,例如在独立的函数内创建响应式的状态监测。接下来会介绍几个最基本的部分,以2.x版本的配置为例。

响应式的状态与特殊情况(Reactive State and Side Effects)

一个简单的创建响应式状态(reactive state)的例子:

import { reactive } from 'vue'

// 这里返回的state就是我们熟悉的reactive state
const state = reactive({
  count: 0
})

reactive相当于2.x的Vue.observable(),之所以不用observable来命名是为了避免和RxJS的observables冲突。

我们可以在视图渲染的时候使用上面的reactive state。渲染时需要考虑一个特殊情况(side effect):如何在reactive state改变时自动更新视图。这时候可以使用watchAPI:

import { reactive, watch } from 'vue'

const state = reactive({
  count: 0
})

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

watchAPI接受一个包含reactive state的函数,它会立即执行一次函数,并追踪函数执行时用到的reactive state的变化,并在变化时再次执行函数。上面这个例子中,传入watch的函数首次执行后,state.count会被设置为watcher的依赖。当state.count变化时,传入的函数也会再次执行。

这就是Vue响应式系统的本质。2.x版本中,data()返回的对象就是通过reactive()处理的,模板(template)则会被解析成一个使用这些reactive state的函数。

继续上面的例子,加上处理用户点击。得益于Vue的模板系统,我们不需要手动去更新视图或者绑定事件。这里省略掉渲染模板的代码,假设存在一个渲染模板的函数renderTemplate

import { reactive, watch } from 'vue'

const state = reactive({
  count: 0
})

function increment() {
  state.count++
}

const renderContext = {
  state,
  increment
}

watch(() => {
  // 假想的渲染函数
  renderTemplate(
    `<button @click="increment">{{ state.count }}</button>`,
    renderContext
  )
})

计算属性和索引(Computed State and Refs)

计算属性可以帮我们处理一些状态的依赖关系。使用computedAPI去创建计算属性:

import { reactive, computed } from 'vue'

const state = reactive({
  count: 0
})

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

为了更好的说明,这里我们先猜想一下computed的实现:

function computed(getter) {
  let value
  watch(() => {
    value = getter()
  })
  return value
}

第一眼看上去好像没问题,但是我们都知道js里变量的引用分两种:基本数据类型是值引用,对象则是指针引用。如果value是一个基本类型的值,那么它被return之后就和computed内的value无关了。

如此一来这个value就不再具有响应式(reactivity)的特性了。为了解决这个问题,我们得包装一下value,把它放在一个对象中去导出:

function computed(getter) {
  const ref = {
    value: null
  }
  watch(() => {
    ref.value = getter()
  })
  return ref
}

相应的我们也得修改依赖追踪(dependency tracking)和变化响应(change notification)部分的代码来处理这层包装。最后的代价就是我们获取value的时候需要进入一层对象:

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

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

state.count++ // -> 2

我们把这里的double称之为“ref”,一个用来获取其内部值的响应式索引。

通过ref API可以创建一个原始的ref:

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

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

这里的ref与2.x版本里的模板ref是相同的概念,在这有详细说明

Ref去包装(Ref Unwrapping)

Vue内部对ref做了处理,所以在模版里使用ref的时候不用加.value

import { ref, watch } from 'vue'

const count = ref(0)

const renderContext = {
  count
}

watch(() => {
  renderTemplate(
    `<div>{{ count }}</div>`,
    renderContext
  )
})

响应式对象内部的ref也不需要:

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

// no need to use `state.double.value`
console.log(state.double)

写个组件(Usage in Components)

我们的代码差不多可以运行了,但是不太方便复用。想这么做的话,可以用函数来包裹代码:

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

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

  function increment() {
    state.count++
  }

  return {
    state,
    increment
  }
}

const renderContext = setup()

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

上面我们创建了一个setup函数,返回了reactivity state和方法。需要注意的是,这些代码已经不依赖于组件了,这些API都可以在组件之外使用,这给Vue的响应式系统打开了一个更广阔的领域。

我们把“执行setup”,“创建watcher”,“渲染模板”这些工作交给框架去做,这样定义一个组件就更简单了:

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

上面还是我们熟悉的单文件组件格式,template和style部分都没有变化,只是script部分使用了新的API。

生命周期(Lifecycle Hooks)

目前为止我们已经了解了组件的状态部分:reactive state,computed和用户交互。但是除此以外组件也需要实现一些特殊情况(side effects),例如:控制台打印信息,发送ajax请求,或者在windows上添加事件监听。这些特殊情况通常在以下时机触发:

  • 状态变化时;
  • 组件挂载、更新或者卸载(生命周期钩子)。 状态变化可以通过watchAPI去监听。生命周期钩子则可以通过专用的onXXXAPI,例如在mounted内执行代码就使用onMounted:
import { onMounted } from 'vue'

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

PS:其他钩子就是照着葫芦画瓢。

这些生命周期注册函数只能在setup里运行(可以包在别的函数里,但是最终也要在setup下执行。举个例子:你不能在处理用户交互的函数里调用这些生命周期API)。官方文档里还解释了一下设计细节,但是我水平有限没看懂这里的“internal global state”指的是什么:

It automatically figures out the current instance calling the setup hook using internal global state. It is intentionally designed this way to reduce friction when extracting logic into external functions.

代码结构(Code Organization)

使用Composition API可以优化代码结构,但是确实是这样么?通过配置(options)分门别类的去定义组件看着要比把一堆东西塞到函数里更有结构、更清晰不是么?

但这只是第一印象。下面是官方的解释。

什么是结构化的代码?(What is "Organized Code"?)

保持结构化的代码的最终目的是让代码易于阅读和理解。那什么叫易于“理解”呢?不如提这样一个问题:我们可以说知道一个组件包含的所有配置就等于“理解”了这个组件么?你有过阅读大型组件(例如这个)时在各种配置中寻找线索的经历么?

试想一下我们要给其他人描述一个上面那样的大型组件,这种时候介绍组件有哪些功能要比罗列组件配置更清楚。要理解一个组件,我们更关心“这个组件可以做什么?”(或者说代码的目的是什么),而不是“这个组件有哪些配置?”。目前基于配置(options-based)的API写出的代码是在回答后面的问题,对于回答“组件可以做什么?”几乎没有帮助。

功能描述 vs 类型配置(Logical Concerns vs. Option Types)

让我们从功能的角度去定义一个组件。上面提到的阅读困难不会出现在目标单一的小组件里,因为整个组件就是一个功能(logical concern)。但是在高级组件里就会变的非常明显。以这个为例。这个组件有很多功能:

  • 追踪文件夹的状态并展示里面的内容
  • 处理文件夹导航相关操作(打开,关闭,刷新……)
  • 处理新建文件夹的操作
  • 开关标记文件夹的筛选
  • 开关文件夹的显示隐藏
  • 处理当前工作文件夹的变更

你能在阅读代码后马上分辨出这些功能吗?可以说是非常难了。每个功能的相关代码都分布在文件的各个地方。例如,“创建新文件夹”这个功能使用了两个data属性,一个computed和一个method(这个method和data属性之间还隔着上百行代码)。

用色块标记不同的功能代码后,可以直观的感受到这种代码的分散。

这种分散正是导致复杂组件阅读难维护难的原因。这是基于配置去编码导致的不可避免的问题。为了梳理一个功能,你需要频繁的在各种配置之间跳转。

官方:图片里的代码有若干可优化的地方,但是为了重现实际生产的情况,我们没有做

按功能来排布代码就会好很多。这就是Composition API提供给我们的东西。把“新建文件夹”这个功能用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
  }
}

相关的代码都封装在一个函数里,函数命名也能在一定程度上描述自身的功能,我们把这叫做组件式函数(composition function)。推荐使用use开头的名字来命名组件式函数。用这种模式可以把组件的功能分为若干独立的函数:

图中的示例省略了一部分,完整代码在这里

现在每个功能的代码都被收集到一个组件式函数里。阅读大型组件时就不需要在各个配置里跳来跳去了。还有个好处就是在编辑器里可以折叠,方便查看(这也要给你写出来,歪果仁写文档真是事无巨细,折叠代码的示例我就不贴了)。

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

当然,如果用基于配置的API(options API)来写就不需要setup函数了。但是有个好处就是:看一下setup就大概知道这个组件在做什么了。通过观察参数传递可以清楚的知道组件间的关系。看看最后的导出部分就知道模板用到了什么。

相同的功能用两种API编写后表现出的东西全然不同。基于配置的API(Options-based API)强迫我们用配置类型来组织代码,而Composition API让我们可以按功能去排布。

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

Composition API在提取和复用组件功能时非常灵活。它只依赖于传入的参数和全局引入的Vue API,而不是特殊修改过的this。所以只需要导出你想要复用的功能函数。甚至可以导出整个setup函数去实现“类似”继承的效果(把组件A的setup放进组件B的setup里,B就有了A的所有功能)。

看一个复用的例子,这是追踪鼠标移动的功能函数:

// mouse.js
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 }
  }
}

代码复用也可以通过现有的几种模式来实现,例如:mixins,高阶组件(higher-order components)或者作用域slot(scoped slots)。网上有很多相关信息,这里不再赘述。和组件函数(composition functions)相比这些模式都有其各自的缺点:

  • 来源不清晰。例如,阅读一个使用多个mixins的组件时,很难搞清楚一个注入的属性来自哪个mixin。
  • 命名冲突。Mixins可能导致属性或方法名冲突,而高阶组件会在特定的属性名上引起冲突。
  • 性能。高阶组件和作用域slot都需要创建额外的带状态的组件实例,造成了性能的损耗。

相较而言,Composition API 有这些特点:

  • 模板使用的属性都是从组件函数导出的,来源清晰。
  • 组件函数导出的值可以使用任意的命名,所以不存在命名冲突(Mixins也可以随便命名,但是改起来麻烦,改了一个,所有使用的地方都得改)。
  • 只是纯粹的代码复用,不会创建不必要的组件实例。

与现有API混合使用(Usage Alongside Existing API)

Composition API 可以和现有的API混用,需要注意以下几点:

  • Composition API 会比2.x版本的data, computedmethods这些设置更早解析,所有没有办法访问到这些设置里定义的属性。
  • setup()返回的属性可以在this上引用,同样也可以在2.x版本的设置中直接使用。

插件开发

现有插件都向this上注入了属性。例如,Vue Router注入了this.$routethis.$router,Vuex注入了this.$store。所以比较麻烦的是,使用TS的时候就必须手动声明这些注入的属性类型。

使用Composition API的话,就没有这个问题了。插件会隐式的调用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
}

然后这样来用:

// 只在组件内提供store
const App = {
  setup() {
    provideStore(store)
  }
}

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

如果想全局注入store的话,可以用Global API change RFC

缺点(Drawbacks)

Refs的开销(Overhead of Introducing Refs)

Ref技术上的实现是这个提案唯一的“新”的概念(ref这个概念之前就有,这说的是新的实现方法),它的目的是以变量的形式传递响应式的值而不是依赖this。缺点如下:

  1. 使用Composition API时,我们需要区分哪些值/对象是ref,哪些不是,增加了开发者的精力负担。
    要减少这种负担可以使用命名来标记ref(例如,给所有ref添加固定后缀,xxxRef),或者使用TS。另外,代码排布方式的变化使得组件逻辑集中在一个函数里,上下文环境也很简单,所以ref的管理也很容易。
  2. 阅读或者修改ref会有点麻烦,因为你必须通过.value才能实现。
    已经有人提出通过编译时语法糖(类似Svelte 3)来解决这个问题。虽然在技术上是可行的,但是官方还是选择了现有的语法(这里有讨论)。总之如果你不想写.value的话,可以添加一个Babel插件来解决这个问题。

官方讨论过不使用Ref直接操作响应式对象,但是不可行:

  • getter会返回基本的值类型,所以必须要包装一次。
  • 以值类型为参数的或者返回值为值类型的组件函数也需要通过对象包装这些值来实现响应式。如果框架不提供一个标准的方案,开发者很可能会自己建立“Ref”,会使得框架生态变的很零碎(causing ecosystem fragmentation,大概是同样的轮子多了生态很杂乱)。

Ref 还是 Reactive(Ref vs. Reactive)

可以理解的是,开发者会感到疑惑的是:用ref还是reactive。解决这个问题首先要清楚理解这两个概念,不要抱着侥幸心理一股脑的只使用其中一个,那样很可能导致你写出一些奇怪的代码或者造出重复的“轮子”。

两者的区别和代码形式有关:

// style 1: 分开的变量
let x = 0
let y = 0

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

// style 2: 单一对象
const pos = {
  x: 0,
  y: 0
}

function updatePosition(e) {
  pos.x = e.pageX
  pos.y = e.pageY
}
  • ref主要针对style1中单独的变量,把单个值类型的变量变为响应式。
  • reactive则是处理style2中的对象,给对象添加响应式的特性(注意这里不是说reactive会把对象的所有属性变为ref)。

如果只使用reactive,那么在处理函数导出对象的时候必须保证被导出对象的完整性,不可以解构或展开:

// 使用reactive的组件函数
function useMousePosition() {
  const pos = reactive({
    x: 0,
    y: 0
  })

  return pos
}

// 组件
export default {
  setup() {
    // 响应式失效
    const { x, y } = useMousePosition()
    return {
      x,
      y
    }

    // 响应式失效
    return {
      ...useMousePosition()
    }

    // 这是保证响应式的唯一办法,必须整个导出,然后在模板中通过pos.x pos.y去使用
    return {
      pos: useMousePosition()
    }
  }
}

可以用toRefs处理这种情况,它会把响应式对象转换为对应的ref:

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

  return toRefs(pos)
}

// x & y 已经是ref了
const { x, y } = useMousePosition()

所以,有两种可行的方式:

  1. 分别使用refreactive去声明值类型的变量和对象。推荐配合支持TS的IDE使用。
  2. 尽量使用reactive,记得使用toRefs处理导出的值。这样你的心智负担(mental overhead)会小一点,但是依然要熟悉这两个概念。

这里不存在一个使用ref还是reactive的最佳实践。推荐你从上面两者里选一个自己最舒服的方式去使用。官方会持续收集开发者的反馈,来确定最好的方式是什么。

setup()返回值的粒度(Verbosity of the Return Statement)

一些开发者提出,setup函数返回的声明太冗长并且感觉像是一个样板(boilerplate,没明白啥意思,太死板?)。

但是官方的观点是精确的返回声明可读性更好,可以精确控制模板使用的部分,并且可以参照它去追踪模板参数的来源。

有人建议说可以自动导出setup()方法中声明的变量,让return部分可写可不写。同样因为这不是标准的js行为,官方没有把这种行为放到框架内。但是你可以自己鼓捣:

  • 写一个IDE插件自动通过setup()内声明的变量生成return部分
  • 写一个Babel插件隐式的生成并插入return部分

代码自由度变高对规范的要求也更高(More Flexibility Requires More Discipline)

许多开发者提到Composition API提高代码结构自由度的同时,也会需要更多规范来限制开发者去正确的使用它。一些人担心新的API在新手开发者手里会制造出糟糕的代码。换句话说,Composition API提高了代码质量的上限,也降低了代码质量的下限。

某些方面确实是这样。但是我们也相信:

  1. 上限提高带来的益处大于下限降带来的损失。
  2. 通过正确的文档和社群规范可以降低代码结构的问题。

一些人用Angular1的controllers为例来试图说明这个设计会让代码写的更糟糕。但是controllers和Composition API有一个显著的区别,那就是Composition API不依赖于区域上下文(没用过Angular,翻译的不对请指正)。这使得通过函数去分离代码逻辑非常简单,这也是js代码结构的核心机制(意思是函数就是用来分离功能的)。

通常我们会把程序代码按功能分成若干函数和模块。Composition API让我们可以去这样分割组件。可以说,用Composition API写出结构良好的Vue代码的能力就是组织基本js的能力(尤大:你要是不会用我也没办法)。

使用策略(Adoption strategy)

Composition API是完全独立的不会影响现有的2.x版本API。想要在2.x版本里使用它的话可以安装@vue/composition,这个库只用于体验和收集反馈。现有的实现与提案是同步的,但是由于插件的技术限制可能会有一些差别。提案更新后也会迎来破坏性的大版本更新(braking changes),所以不要在生产环境使用。

将来的3.0版本会内置这个API,但是你还是可以使用现有的API去写代码,不影响。

对于那些想在app里全部使用Composition API的开发者,可能会提供一个可选的打包配置去移除那些只用于实现2.x设置的代码以此来缩小打包体积。

这个API的定位是一个解决大型应用可读性问题的高级特性。不会作为默认设置使用。

附录

Class API的类型问题

介绍Class API的目的是为当下提供一种支持TS类型的选择。然而实际上由于Vue中this的使用方法(组件会把各个地方引入的属性都合并到this上),使用Class API也有一些麻烦。

一个关于Props类型的例子。为了把props合并到this上,我们需要给组件class传递一个范型参数,或者使用decorators)。

使用范型参数:

interface Props {
  message: string
}

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

因为范型参数仅用作类型推断,开发者还需要提供一个props的行为描述。这种双重声明笨重又冗长。所以我们考虑使用decorators作为替代方案:

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

使用decorators的坏处是:这是一个stage-2阶段的提案,有非常多的不确定性。TS当前的实现甚至与TC39规范完全不同(意思大概是decorators会不会被弃用不知道,即使不被弃用会不会一直被TS支持也不知道)。此外,使用decorators声明的props类型无法添加到this.$props上,这一点破坏了对TSX的支持。开发者方面会以为可以这样给props设置默认值:

@prop message: string = 'foo'

实际上在技术层面是实现不了的。

还有一点,目前没有办法在类方法的参数上利用上下文类型——意味着传递给render函数内无法基于Class上的其他属性进行类型推导。

相比React Hooks(Comparison with React Hooks)

Composition API提供了与React Hooks同级别的代码组件化能力,但是有一些重要的区别。不像React Hooks,setup()函数只调用一次。这意味着用Vue的Composition API写出来的代码:

  • 一般来说更贴近传统js;
  • 对执行顺序不敏感且可以是有条件的(can be conditional,没明白什么意思);
  • 不会在每次渲染时重复执行,计算量更少;
  • 不需要设置useCallback来防止子组件过度重渲染(React的问题);
  • 不会存在这种问题:当用户忘记传入正确的依赖数组时,useEffectuseMemo可能会获取到过期的变量(还是React的问题)。Vue的自动依赖追踪会确保watchers和computed总是获取到正确的值。

React Hooks是Composition API的主要灵感来源。但它确实存在上面的问题,并且Vue的响应式模式正好可以避免这些问题。(尤大:你们懂我意思吧)

相比Svelte(Comparison with Svelte)

尽管采用了非常不同的路线,Composiiton API和Svelte 3有很多共同点。 Vue:

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

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

    function increment() {
      count.value++
    }

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

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

    return {
      count,
      increment
    }
  }
}

Svelte:

import { onMount } from 'svelte'

let count = 0

function increment() {
  count++
}

$: console.log(count)

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

Svelte的代码看起来更整洁,因为它在编译时做了这些工作:

  • 隐式的将script标签内的代码(import部分除外)包装在一个函数内,每次组件实例化时执行一次(注意不是只执行一次)。
  • 隐式的创建响应式变量。
  • 隐式的将区域内的变量导出到渲染上下文。
  • $标记的语句编译成watcher。

技术上Vue也可以做到这些(可以通过Babel插件)。之所以不做主要还是因为它不是标准的JS。官方希望Vue文件script标签内的代码就是标准的ES模块。Svelte里<script>标签内的代码已经不是普通的JS了。这种依赖编译时的实现有几个问题:

  1. 过于依赖编译器。作为一个渐进式的框架,要保留开发者不使用构建工具就可以使用Vue的能力,所以不能将需要编译器才能实现的语法设置为默认。
  2. 需要修改代码才能和普通代码同时使用。如果想要将Svelte组件内的代码提取到普通js文件里,你就必须使用更底层的API
  3. Svelte的响应式编译只对最外层的变量起作用——它不会处理函数内的变量,所以不能在函数内封装reactive state。这就导致我们很难用函数去组织代码,也就不能保证复杂组件的代码可读性。
  4. 非标准的语法会会在集成TS的时候产生问题。

并不是说Svelte 3是个不好的想法——事实上这种方法是非常创新的。但是基于Vue的设计理念,官方没有使用它。