围绕 Vue 3 Composition API 构建一个应用程序,包含一些最佳实践!

200 阅读8分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第11天,点击查看活动详情

围绕 Vue 3 Composition API 构建一个应用程序,包含一些最佳实践!

1. Vue 3 和 Composition API 的状况

Vue 3 已经发布了一年,它的主要新功能是:Composition API。从 2021 年秋季开始,推荐新项目使用 Vue 3 的 script setup 语法,所以希望我们能看到越来越多的生产级应用程序建立在 Vue 3 上。

这篇文章旨在展示一些有趣的方法来利用 Composition API,以及如何围绕它来构造一个应用程序。

2. 可组合函数和代码重用

新的组合 API 释放了许多有趣的方法来重用跨组件的代码。复习一下:以前我们根据组件选项 API 分割组件逻辑:data、methods、created 等。

//  选项 API 风格
data() => ({
    refA1,
    refB2,
  }),
// 在这里,我们经常看到 500 行的代码。
computed: {
  computedA() {
    return this.refA + 10;
  },
  computedB() {
    return this.refA + 10;
  },
},

有了 Composition API,我们就不会受限于这种结构,可以根据功能而不是选项来分离代码。

setup() {
  const refA = ref(1);
  const computedA = computed(() => refA.value + 10);
  /* 
  这里也可能是 500 行的代码。
     但是,这些功能可以保持在彼此附近!
  */
  const computedB = computed(() => refA.value + 10);
  const refB = ref(2);
 
    return {
      refA,
      refB,
      computedA,
      computedB,
    };
  },

Vue 3.2 引入了<script setup>语法,这只是setup()函数的语法糖,使代码更加简洁。从现在开始,我们将使用 script setup 语法,因为它是最新的语法。

<script setup>
import { ref, computed } from 'vue'
 
const refA = ref(1);
const computedA = computed(() => refA.value + 10);
 
const refB = ref(2);
const computedB = computed(() => refA.value + 10);
</script>

在我看来,这是一个比较大想法。我们可以把这些功能分成自己的文件,而不是用通过放置 在 script setup 中的位置来保持它们的分离。下面是同样的逻辑,把文件分割开来的做法。

// Component.vue
<script setup>
import useFeatureA from "./featureA";
import useFeatureB from "./featureB";
 
const { refA, computedA } = useFeatureA();
const { refB, computedB } = useFeatureB();
</script>
 
// featureA.js 
import { ref, computed } from "vue";
 
export default function () {
  const refA = ref(1);
  const computedA = computed(() => refA.value + 10);
  return {
    refA,
    computedA,
  };
}
 
// featureB.js 
import { ref, computed } from "vue";
 
export default function () {
  const refB = ref(2);
  const computedB = computed(() => refB.value + 10);
  return {
    refB,
    computedB,
  };
}

注意,featureA.jsfeatureB.js导出了RefComputedRef类型,因此所有这些数据都是响应式的。

然而,这个特定的片段可能看起来有点矫枉过正。

  • 想象一下,这个组件有 500 多行代码,而不是 10 行。通过将逻辑分离 到 use__.js文件中,代码变得更加可读。
  • 我们可以在多个组件中自由地重复使用 .js文件中的可组合函数 不再有无渲染组件与作用域槽的限制,也不再有混合函数的命名空间冲突。因为可组合函数直接使用了 Vue 的 refcomputed,所以这段代码可以与你项目中的任何 .vue组件一起使用。

陷阱 1:setup 中的生命周期钩子

如果生命周期钩子(onMountedonUpdated等)可以在setup里面使用,这也意味着我们也可以在我们的可组合函数里面使用它们。甚至可以这样写:

// Component.vue

<script setup>

import { useStore } from 'vuex';

const store = useStore();

store.dispatch('myAction');

</script>


// store/actions.js

import { onMounted } from 'vue'

// ...

actions: {
  myAction() {
    onMounted(() => {
       console.log('its crazy, but this onMounted will be registered!')
      })
  }
}

// ...

而且 Vue 甚至会在 vuex 内部注册生命周期钩子! (问题是:你应该🤨🙂)

有了这种灵活性,了解如何以及何时注册这些钩子就很重要了。请看下面的片段。哪些onUpdated钩子将被注册?

<script setup lang="ts">
import { ref, onUpdated } from "vue";
 
// 这个钩子将被注册。我们在 setup 中正常调用它
onUpdated(() => {
  console.log('✅')
});
 
class Foo {
  constructor() {
    this.registerOnMounted();
  }
 
  registerOnMounted() {
     //它也会注册! 它是在一个类方法中,但它是在 
     //在 setup 中同步执行
    onUpdated(() => { 
      console.log('✅')
    });
  }
}
new Foo();
 
// IIFE also works
(function () {
  onUpdated(() => {
    state.value += "✅";
  });
})();
 
 
const onClick = () => {
 /* 
 这不会被注册。这个钩子是在另一个函数里面。
 Vue 不可能在 setup 初始化中达到这个方法。
 最糟糕的是,你甚至不会得到一个警告,除非这个 
 函数被执行! 所以要注意这一点。
 */ 
  onUpdated(() => {
    console.log('❌')
  });
};
 
// 异步 IIFE 也会不行 :(
(async function () {
  await Promise.resolve();
  onUpdated(() => {
    state.value += "❌";
  });
})();
</script>

结论:在声明生命周期方法时,应使其在setup初始化时同步执行。否则,它们在哪里被声明以及在什么情况下被声明并不重要。

陷阱 2:setup 中的异步函数

我们经常需要在我们的逻辑中使用async/await。天真的做法是尝试这样做:

<script setup lang="ts">
import { myAsyncFunction } from './myAsyncFunction.js
const data = await myAsyncFunction();
</script>
 
<template>
  Async data: {{ data }}
</template>

然而,如果我们尝试运行这段代码,组件根本不会被渲染。为什么?因为 Promise 不跟踪状态。我们给 data 变量赋了一个 promise,但是 Vue 不会主动更新它的状态。幸运的是,有一些变通办法:

解决方案 1:使用 .then语法的ref

为了渲染该组件,我们可以使用.then语法。

<script setup>
import { ref } from "vue";
import { myAsyncFunction } from './myAsyncFunction.js
 
const data = ref(null);
myAsyncFunction().then((res) =>
  data.value = fetchedData
);
</script>
 
<template>
  Async data: {{ data }}
</template>
  1. 一开始时,创建一个等于 null 的响应式 ref
  2. 调用了异步函数 script setup 的上下文是同步的,所以该组件会渲染
  3. myAsyncFunction() promise 被解决时,它的结果被赋值给响应性 data ref,结果被渲染

这种方式有自己优缺点:

  • 优点是:可以使用
  • 缺点:语法有点过时,当有多个.then.catch链时,会变得很笨拙。

解决方案 2:IIFE

如果我们把这个逻辑包在一个异步 IIFE 里面,我们就可以使用 async/await的语法。

<script setup>
import { ref } from "vue";
import { myAsyncFunction } from './myAsyncFunction.js'
 
const data = ref(null);
(async function () {
    data.value = await myAsyncFunction()
})();
</script>
 
<template>
  Async data: {{ data }}
</template>

这种方式也有自己优缺点:

  • 优点:async/await 语法
  • 缺点:可以说看起来不那么干净,仍然需要一个额外的引用

解决方案 3:Suspense (实验性的)

如果我们在父组件中用<Suspense>包装这个组件,我们就可以自由在 setup 中自由使用async/await!

// Parent.vue
<script setup lang="ts">
import { Child } from './Child.vue
</script>
 
<template>
  <Suspense>
  <Child />
 </Suspense>
</template>
 
// Child.vue
<script setup lang="ts">
import { myAsyncFunction } from './myAsyncFunction.js
const data = await myAsyncFunction();
</script>
 
<template>
  Async data: {{ data }}
</template>
  • 优点:到目前为止,最简明和直观的语法
  • 缺点:截至 2021 年 12 月,这仍然是一个实验性的功能,它的语法可能会改变。

<Suspense> 组件在子组件 setup 中有更多的可能性,而不仅仅是异步。使用它,我们还可以指定加载和回退状态。我认为这是创建异步组件的前进方向。Nuxt 3 已经使用了这个特性,对我来说,一旦这个特性稳定下来,它可能是首选的方式

解决方案 4:单独的第三方方法,为这些情况量身定做(见下节)。

优点。最灵活

缺点:对 package.json 的依赖

3. VueUse

VueUse 库依靠 Composition API 解锁的新功能,给出了各种辅助函数。就像我们写的useFeatureAuseFeatureB一样,这个库可以让我们导入预制的实用函数,以可组合的风格编写。下面是它的工作原理的一个片段。

<script setup lang="ts">
import {
 useStorage,
 useDark
} from "@vueuse/core";
import { ref } from "vue";
 
/* 
    一个实现 localStorage 的例子。 
 这个函数返回一个 Ref,所以可以立即用`.value`语法来编辑它。
 用.value 语法编辑,而不需要单独的 getItem/setItem 方法。
*/
const localStorageData = useStorage("foo"undefined);
</script>

我无法向你推荐这个库,在我看来,它是任何新的 Vue 3 项目的必备品。

  • 这个库有可能为你节省很多行代码和大量的时间。
  • 不影响包的大小
  • 源代码很简单,容易理解。如果你发现该库的功能不够,你可以扩展该功能。这意味在选择使用这个库时,不会有太大的风险。

下面是这个库如何解决前面提到的异步调用执行问题。

<script setup>
import { useAsyncState } from "@vueuse/core";
import { myAsyncFunction } from './myAsyncFunction.js';
 
const { state, isReady } = useAsyncState(
 // the async function we want to execute
  myAsyncFunction,
 
  // Default state:
  "Loading...",
 
  // UseAsyncState options:
  {
    onError(e) => {
      console.error("Error!", e);
      state.value = "fallback";
    },
  }
);
</script>
 
<template>
  useAsyncState: {{ state }}
  Is the data ready: {{ isReady }}
</template>

这种方法可以让你在setup里面执行异步函数,并给你回退选项和加载状态。现在,这是我处理异步的首选方法。

4. 如果你的项目使用 Typescript

新的definePropsdefineEmits语法

script setup 带来了一种在 Vue 组件中输入 props 和 emits 的更快方式。

<script setup lang="ts">
import { PropType } from "vue";
 
interface CustomPropType {
  bar: string;
  baz: number;
}
//  defineProps 的重载。
// 1. 类似于选项 API 的语法
defineProps({
  foo: {
    type: Object as PropType<CustomPropType>,
    requiredfalse,
    default: () => ({
      bar: "",
      baz: 0,
    }),
  },
});
 
// 2. 通过一个泛型。注意,不需要 PropType!
defineProps<{ foo: CustomPropType }>();
 
// 3.默认状态可以这样做。
withDefaults(
  defineProps<{
    foo: CustomPropType;
  }>(),
  {
    foo: () => ({
      bar: "",
      baz: 0,
    }),
  }
);
 
// // Emits 也可以用 defineEmits 进行简单的类型化
defineEmits<{ (foo: "foo"): string }>();
</script>

就个人而言,我会选择通用风格,因为它为我们节省了一个额外的导入,并且对 null 和 undefined 的类型更加明确,而不是 Vue 2 风格语法中的{ required: false }

💡 注意,不需要手动导入 definePropsdefineEmits。这是因为这些是 Vue 使用的特殊宏。这些在编译时被处理成 "正常 的选项 API 语法。我们可能会在未来的Vue 版本中看到越来越多的宏的实现。

可组合函数的类型化

因为 typescript 要求默认输入模块的返回值,所以一开始我主要是用这种方式写 TS 组合物。

import { ref, RefSetupContext, watch } from "vue";
 
export default function ({
  emit,
}: SetupContext<("change-component" | "close")[]>): 
// 下面的代码真的有必要吗?
 {
  onCloseStructureDetails() => void;
  showTimeSlotsRef<boolean>;
  showStructureDetailsRef<boolean>;
  onSelectSlot(arg1: onSelectSlotArgs) => void;
  onBackButtonClick() => void;
  showMobileStepsLayoutRef<boolean>;
  authStepsComponentRef<string>;
  isMobileRef<boolean>;
  selectedTimeSlotRef<null | TimeSlot>;
  showQuestionarireLinkRef<boolean>;
} {
  const isMobile = useBreakpoints().smaller("md");
  const store = useStore();
 // and so on, and so on
 // ... 
}

这种方式,我认为这是个错误。其实没有必要对函数返回进行类型化,因为在编写可组合的时候可以很容易地对它进行隐式类型化。它可以为我们节省大量的时间和代码行。

import { ref, RefSetupContext, watch } from "vue";
 
export default function ({
  emit,
}: SetupContext<("change-component" | "close")[]>) {
  const isMobile = useBreakpoints().smaller("md");
  const store = useStore();
 // The return can be typed implicitly in composables
}

如果 EsLint 将此标记为错误,将'@typescript-eslint/explicit-module-boundary-types': 'error',放入 EsLint 配置(.eslintrc)。

Volar extension

Volar 是作为 VsCode 和 WebStorm 的 Vue 扩展来取代 Vetur 的。现在它被正式推荐给 Vue 3 使用。对我来说,它的主要特点是:typing props and emits out of the box。这很好用,特别是使用 Typescript 的话。

现在,我总是会选择 Vue 3 项目中使用 Volar。对于 Vue 2, Volar 仍然适用,因为它需要更少的配置 。

5. 围绕组合 API 的应用架构

将逻辑从.vue组件文件中移出

以前,有一些例子,所有的逻辑都是在 script setup 中完成的。还有一些例子是使用从.vue文件导入的可组合函数的组件。

大代码设计问题是:我们应该把所有的逻辑写在.vue文件之外吗?有利有弊。

所有的逻辑都放在 setup 中 移到专用的.js/.ts 文件 不需要写一个可组合的,方便直接修改 可扩展更强 重用代码时需要重构 不需要重构

更多模板

我是这样选择的:

  • 在小型/中型项目中使用混合方法。一般来说,把逻辑写在 setup 里面。当组件太大时,或者当很清楚这些代码会被重复使用时,就把它放在单独的js/ts文件中
  • 对于大型项目,只需将所有内容编写为可组合的。只使用 setup 来处理模板名称空间。

谢谢支持

如果有什么不同的想法,可以在评论区留言,大家一起学习呀~ 最后如果这篇文章对您有帮助的话,请一件三连给作者一点点支持,谢谢啦!

以上便是本次分享的全部内容,希望对你有所帮助^_^