异步 setup

2021/09/21

setup 是 Vue 3 新增的一个组件选项,它作为 Composition API 的入口函数,极大地优化了 Vue 代码的组织方式,让代码逻辑复用变得非常简单和直观。而异步的 setup (async setup) 可以让你在组件创建之前进行异步操作,比如从服务器获取资源和数据,但是有一些注意事项,详情请看这里的讨论

一个例子

先看一个例子:

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

export default {
  async setup() {
    const counter = ref(0)

    watch(counter, () => console.log(counter.value))

    // OK!
    onMounted(() => console.log('Mounted'))

    // 等待异步任务
    await someAsyncFunction()

    // 下面不会正常调用!
    onUnmounted(() => console.log('Unmounted'))

    // 可以正常工作,但是可能造成内存泄漏
    // 因为在组件被销毁后不会自动 dispose
    watch(counter, () => console.log(counter.value * 2))
  }
}

await statement 之后执行以下 composition API,很有可能会出现问题, 因为它们不会自动 dispose:

  • watch / watchEffect
  • computed
  • effect

而下面的这些方法将不会正常工作:

  • onMounted / onUnmounted / onXXX
  • provide / inject
  • getCurrentInstance
  • ...

背后机制

onMounted 为例。如我们所知,onMounted 是一个钩子函数(hook),在当前组件被挂载(mounted)时,执行注册的 listener 函数。请注意,onMounted(以及其它的组合 API)是全局的,这里所说的"全局"是指它可以被导入到任何模块中被调用——即没有本地的上下文与它绑定。

// local: `onMounted` 是组件实例的方法,绑定到组件上下文
component.onMounted(/* ... */)

// global: `onMounted` 没有绑定上下文
onMounted(/* ... */)

那么,onMounted 是如何知道什么组件被挂载的呢? Vue 使用一个内部变量来记录当前组件的实例。当 Vue 挂载一个组件时,它将该组件实例存储在一个全局变量中。当钩子在 setup 函数中被调用时,它将使用全局变量来获取当前组件的实例。下面是简化的代码:

let currentInstance = null

// (pseudo code)
export function mountComponent(component) {
  const instance = createComponent(component)

  // 先保存先前的实例
  const prev = currentInstance

  // 将当前组件实例存入全局变量
  currentInstance = instance

  // setup 内部的 hooks 函数被调用时将以 `currentInstance` 作为上下文
  component.setup()

  // 恢复先前的实例
  currentInstance = prev
}

一个简化的 onMounted 实现可以是这样:

// (pseudo code)
export function onMounted(fn) {
  if (!currentInstance) {
    warn(`"onMounted" can't be called outside of component setup()`)
    return
  }

  // 将 listener 绑定到当前组件实例
  currentInstance.onMounted(fn)
}

这样,只要在组件的 setup 里面调用 onMounted,就能拿到当前组件的实例。

异步 setup 的局限

如果 setup 是同步的,那一切都保持正常,这是基于 JavaScript 是单线程的这一事实。单线程的原子性确保以下语句会紧挨着执行,换句话说,你不可能在同一时间意外地修改 currentInstance:

currentInstance = instance
component.setup()
currentInstance = prev

但当 setup 函数是异步的时候,情况就变了。每当 await 一个 promise 时,你可以认为 JS 引擎暂停了这里的工作,去做另一个任务。而在这个等待的时间段内,原子性会丢失,其它组件的创建将不可预测地会改变全局变量,最终导致混乱:

async function setup() {
  console.log(1)
  await someAsyncFunction()
  console.log(2)
}

console.log(3)
setup()
console.log(4)

// 输出
// 3
// 1
// 4
// (awaiting)
// 2

异步的 setup 函数不会阻塞后面的任务, 但 setup 内的第一个 await statement 之后的代码,将在异步任务完成之后才会被执行, 这时 setup 函数已经 return,这意味着第一个 await statement 之后的代码将拿不到当前组件实例。

解决方案

记住并避免它

当然,这是一个显而易见的解决方案。将所有的 effect 和 hooks 移到第一个 await statement 之前,并且记住在那之后不要再使用它们。

幸运的是,如果你使用 ESLint,可以启用 eslint-plugin-vue 中的 vue/no-watch-after-awaitvue/no-lifecycle-after-await 规则,以便在出现错误时发出警告(默认情况下,插件预设中会启用这些规则)。

显式绑定组件实例

生命周期钩子(lifecycle hooks)实际上接受第二个参数来显式的绑定实例:

export default ({
  async setup() {
    // 在 await 之前先获取组件实例
    const instance = getCurrentInstance()

    await someAsyncFunction()

    onUnmounted(
      () => console.log('Unmounted'),
      instance // <--- 手动给钩子函数绑定实例
    )
  }
})

但是,缺点是此解决方案不适用于 watch/watchEffect/computed/provide/inject,因为它们不接受实例参数。 要使这些正常工作,可以使用 Vue 3.2 中新引入的 effectScope API

import { effectScope } from 'vue'

export default ({
  async setup() {
    // 在 await之前创建一个 scope, 它会将组件实例绑定到这个作用域内
    const scope = effectScope()

    const data = await someAsyncFunction() // <-----------

    scope.run(() => {
      /* Use `computed`, `watch`, etc. ... */
    })
  }
})

<script setup> 编译时处理

在最近的 <script setup> 语法提案中(Vue 3.2 可用),针对也这一问题进行了编译时的处理:

<script setup>
  const post = await fetch(`/api/post/1`).then((r) => r.json())
</script>

await 语句将自动编译为在 await 语句之后保留当前组件实例上下文的格式:

import { withAsyncContext } from 'vue'

export default {
  async setup() {
    let __temp, __restore

    const post =
      (([__temp, __restore] = withAsyncContext(() =>
        fetch(`/api/post/1`).then((r) => r.json())
      )),
      (__temp = await __temp),
      __restore(),
      __temp)

    // current instance context preserved
    // e.g. onMounted() will still work.

    return { post }
  }
}

有了它,异步函数就可以在与<script setup>一起使用时正常工作。唯一的遗憾是它在<script setup>之外无法工作。