分类
vue

Vue3 < script setup > + TypeScript 笔记

近期把一个老项目(Vue 组件库)从 Options API 风格重构成了 <script setup> 风格 + TypeScript,写篇博客记录下坑和心得。

注:这篇博客只记录超出我踩过坑和不熟悉的内容,符合直觉、看了文档写完一次过的部分,我就不记录了。

0. Composition API 与 <script setup>

Vue 3 给我们带来了 Composition API,改变了之前 Options API 使用 mixins 继承和扩展组件的开发模式。这种模式让前端在开发时复用组件、改进组件变得更容易。具体改进我在本文里不再详述,大家可以看下 Vue master 的视频教程:Why the Composition API

之后尤大又提出了 script-setup rfc,进一步改进了使用 Composition API 的体验。简单总结下新提案:

  1. 使用 <script setup> 取代 setup(),里面定义的所有变量,包括函数,都可以直接在模版中使用,不需要手动 return
  2. 可以使用顶级 await
  3. <script setup> 可以与其它 <script> 在同一个 SFC 里共存

实际体验之后,我觉得这个提案的确能节省不少代码,不过也会增加一些复杂度。

1. 导出 name 等非模版属性

<script setup>setup() 的语法糖,主要目的是降低 composition API 的代码复杂度。它直接向模板暴露整个 <script setup> 的上下文,所有定义过的变量自动导出供模版使用,这样我们就不需要手动一个一个 return 了。

所以它就不适合导出其它非模版属性,比如组件名称 name。如有需要,可添加一个普通 <script> 节点,用常见的 export default 导出初始化对象。如下:

<script>
export default {
  name: 'SomeVue3Component',
};
</script>

<script setup>
import {ref} from 'vue';

const foo = ref('bar');
</script>

2. defineProps/defineEmits

要在 <script setup> 里使用 props(传入参数)和 emit(广播事件),需要使用 defineProps()defineEmits() 定义。但要注意,这俩东西其实是编译宏,并非真实函数,不能把它们当作函数来使用。所以也不需要 import,当它们是全局函数直接用就好。同时记得修改 .eslintrc.js 把它们添加到 global 里。

最简单的使用方法如下,以前的 props 定义规则可以沿用。

const props = defineProps({
  foo : {
    type: String,
    default: 'bar',
  },
});
const emit = defineEmits(['change']);

要配合 TypeScript,通常需要使用 withDefaults()props 生成默认值。这也是个宏,不能当函数用,也不能使用当前环境里的变量。改造后的代码如下:

interface Props = {
  foo: string;
};
const props = withDefaults(defineProps<Props>(), {
  foo: 'bar', // 'bar' 不能是变量
});
const {
  foo,
} = toRefs(props);

const emit = defineEmits<{
  (e:'change', value:string),
}>();

这个地方的设计相当不完善,我不知道 Vue 团队会如何改进这里。比如,我们不能使继承 interface,然后再初始化 props,因为继承是常规 ts 语句,而 defineProps 是编译宏,两者的工作环境不一样。而因为无法使用变量,我们也无法将父级组件的 props 混入本地 props。于是复用组件又变得麻烦起来。

耐心等待吧。

3. 使用 undefined 初始化对象

定义 props 问题真不少。有一些参数是可选参数,不一定要定义,也不一定要用到;但是使用 ts 定义时,即使如此,也要初始化他们,可以传值为 undefined,不然 tsc 可能会报告错误:变量可能为空。之所以用 undefined,而不是 null,则是因为 TypeScript 认为 null 是独立类型。

即:

interface Props {
  foo?: string;
}
const props = withDefaults(defineProps<Props>(), {
  foo: undefined,
});
const {
  foo,
} = toRefs(props);

// 接下来才能正常使用
const bar = computed(() => {
  return foo.value || 'bar';
});

4. 使用的变量未初始化错误

有些函数,可以传入为定义变量做参数,但是函数自身的签名没有很好的体现这一点,就会报错。比如 setTimeout,以下这段代码就会报告:

type timer = ReturnType<typeof setTimeout>;
let timeout:timer;
function doAction() {
  clearTimeout(timeout);
}

我确定这段代码没问题,但是 tsc 不给过,只好给 timeout 加上 ! 修饰符,即:let timeout!:timer。这样就可以了。

5. Vue SFC 文件类型定义

为让 tsc 能够正确理解 Vue SFC 格式,需要创建这个描述文件,并且告诉 tsc 加载这个描述文件。

declare module "*.vue" {
  import { defineComponent } from "vue";
  const component: ReturnType<typeof defineComponent>;
  export default component;
}
{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "strict": true,
    "jsx": "preserve",
    "moduleResolution": "node",
    "suppressImplicitAnyIndexErrors": true,
    "allowSyntheticDefaultImports": true,
    "allowJs": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,

    "paths": {
      "@/*": [
        "./src/*"
      ]
    },
    "lib": ["DOM", "ESNext"]
  },
  "include": [
    "src/**/*.ts",
    "src/**/*.vue",
    "test/**/*.ts"
  ],
  "exclude": [
    "node_modules"
  ],
  "files": ["src/types/vue-shims.d.ts"]
}

6. 待解决问题

  • 因为第一次使用 TypeScript,很多不熟悉的地方,tsc 大量报错。但是由于使用了 vue-loader,所以 tsc 报告的错误行号基本都不对,很难查找问题所在,浪费了大量时间。
  • 导入了 moment locale 文件,但是缺少定义,不知道该怎么声明某个对象属于某个接口。可能要通过前面类似定义 Vue SFC 的方式。

7. 项目地址

对项目感兴趣,或者寻求范例的同学可以在 GitHub 上找到这个项目:meathill/muimui-ui: A simple vue 3 ui components suit. (github.com)

meathill

爱编程,爱旅游,爱吐槽。
今年的目标是完成并运营至少一个 Side Project。
《Electron + Vue 实战开发》龟速创作中……

欢迎吐槽,共同进步

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据