近期把一个老项目(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 的体验。简单总结下新提案:
- 使用
<script setup>
取代setup()
,里面定义的所有变量,包括函数,都可以直接在模版中使用,不需要手动return
- 可以使用顶级
await
<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)
欢迎吐槽,共同进步