前言
之前想要做一个小项目,想着换换口味,用一些比较小众的组件库来写。偶然发现了 Vuesax 这个组件库,第一眼就觉得特别出众,样式超级好看,并且动画效果也特别丝滑,但它是用 Vue2 + ts 写的,在 2020 年彻底停止了维护,并没有对 Vue3 的支持。虽然社区内有些人在进行重构,但也都是个人维护的,拉下来之后多多少少会有些问题,而且也没有相应的新文档。
为了让这个精致的组件库重新焕发光彩,我和一个朋友一拍即合,一块开始了对 Vuesax 的重构,我们将原本 Vue sfc 的组件全部使用 TSX 进行重写,并且提供了更加完善的 ts 类型支持。目前已经完成了 80% 的工作,vuesax-ts-docs.vercel.app 大家感兴趣可以看看现在的文档站,支持中文噢。
在重构的过程中,我发现在 Vue 中使用 TSX 写组件与 react 相比大有不同,而且很多注意点是在官方文档中没有提到的,需要自己去各种犄角旮旯查。于是我将使用 JSX/TSX 开发组件中的各个要点整理为文章,如果你正在考虑使用 JSX/TSX 来开发你的 vue 组件,那么这篇文章一定能对你有所帮助~
为什么要使用 JSX/TSX 开发组件?
-
静态类型检查:TSX 在编译阶段就可以检测代码中的类型错误,避免运行时出现类型相关的问题。TSX 可以自动补全、智能提示,让开发者更快地编写代码。
-
更好的 IDE 支持,许多主流的 IDE 都对 TSX 有良好的支持,甚至允许开发者通过代码重构功能来优化代码。
静态树提升是 Vue 3.0 中的一个优化技术,可以减少渲染成本并提高性能。由于 JSX 的灵活性,静态树提升优化变得困难,因为 JSX 语法没有限制,强行提升它会破坏 JS 执行的上下文。这使得在实现对性能要求较高的基础组件库时,模板语法仍然是首选。
项目配置
如果需要在 vue 中使用 jsx 的文件格式需要增加相关的解析插件,以 vite 为例,应使用官方提供的 @vitejs/plugin-vue-jsx 插件,它提供了 Vue 3 特性的支持,包括 HMR,全局组件解析,指令和插槽。
使用的方式如下,首先安装 @vitejs/plugin-vue-jsx
依赖,然后在 vite.config.ts
增加如下代码:
diff
import path from "path";
import vue from "@vitejs/plugin-vue";
+ import vueJsx from "@vitejs/plugin-vue-jsx";
import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
plugins: [
vue(),
+ vueJsx(),
],
});
```
关于 defineComponent
defineComponent
是 Vue 3 中推出的一个新 API,主要用于 TypeScript 代码的类型推导,能够帮助开发者简化编码过程中的类型声明。具体来说,通过 defineComponent
可以省略以下类型声明:
props
和return
的数据类型context
的类型
使用 defineComponent
包裹组件代码,即可获得完善的 TypeScript 类型推导支持。换句话说:
defineComponent
并不提供实际的功能,单纯只是用于在开发环境中提供 TS 类型提示的一个工具函数。
如果我们写的 .vue
格式的单文件,那么我们使用 script setup
语法搭配 TypeScript Vue Plugin 就能直接实现组件,并不需要使用 defineComponent
API。
但如果你需要通过 tsx 来实现组件,那么 defineComponent
肯定是必不可少的,下面的内容会详细为你介绍如何使用这个函数去实现带有完整类型提示的 Vue 组件。
定义、获取 props
在 tsx 中我们无法直接使用 setup 中的 组件的 defineProps
去定义 props 的类型,而是在 defineComponent
传入对象参数的 props 字段上定义:
tsx
type Color = "red" | "blue"
const MyComponent = defineComponent({
props:{
isVisible: {
type: Boolean,
default: true,
required: true,
},
color: {
type: String as PropType<Color>,
},
data: {
type: Object,
default: function () {
return { message: 'hello' }
}
}
})
上面的代码中定义了三个 props:
isVisible
是一个Boolean
类型的prop
,并且默认值为true
。required
代表它是一个必传的 prop 。color
是一个String
类型的 prop,但是它的类型被指定为PropType<Color>
,其中 Color 是一个联合类型。PropType
是 vue 提供的一个工具类型 ,用于在用运行时 props 声明时给一个 prop 标注更复杂的类型定义。data
是一个 Object 类型的 prop,并且设置了默认值{message: 'hello'}
。对象或数组默认值必须从一个工厂函数获取。
这里我们可以注意到 props 类型的定义是需要通过 传入原生构造构造函数 以及类似 rquired 这种字段去声明的,如果我们使用 Typescript 有没有办法直接传入一个类型去定义 props 呢?
以 react 中的使用方式为例:
tsx
type Props = {
isVisible: boolean;
color?: "red" | "blue";
data?: Record<string, any>;
};
const MyComponent = ({
isVisible = true,
color,
data = { message: "hello" },
}: Props) => {};
如果能够这么写,我们不仅能在组件外部定义类型,并且组件的 props 类型也可以更方便导出进行复用了。
但很可惜,我在写这篇文章时还不支持这种写法,只能使用上面提到的这种方式进行定义,这也许是因为 vue 在运行时是需要生成 props 列表的,单纯的 TS 类型无法作为值进行一些判断。
在定义好了 props 后,我们就可以获取 props 进行使用了,获取的方式如下所示:
diff
type Color = "red" | "blue"
const MyComponent = defineComponent({
props:{
isVisible: {
type: Boolean,
default: true,
required: true,
},
color: {
type: String as PropType<Color>,
},
data: {
type: Object,
default: function () {
return { message: 'hello' }
}
},
+ setup(props) {
+ //... use props
+ }
})
在 setup 函数中的第一参数中获取,props 的值一个对象,该对象的属性就是使用组件时的传入的值:
tsx
<MyComponent
isVisible
color="red"
:data="{message:"你好"}">
</MyComponent>
// props = {isVisible: ture, color: "red", data: {message:"你好"}}
如何导出组件的 props 类型
前面提到我们无法直接通过类型去声明我们的 props ,但是我们可以从已经实现的组件中推导出组件的 props 类型:
tsx
const MyComponent = defineComponent({
props: {
isVisible: Boolean,
},
setup(props) {
return () => (
<>
// ...
</>
);
},
});
export type MyComponentProps = InstanceType<typeof MyComponent>["$props"];
InstanceType
是 ts 中内置的一个工具类型,用户获取构造函数实例的类型,而组件实例的 $props
字段就是我们组件的 props 类型。
定义、获取 slots
slots 的类型定义与 props 相同,直接在 defineComponent 传入的对象参数中新增字段 slots
,然后从 setup 函数的第二个参数 中获取并使用:
diff
const MyComponent = defineComponent({
props: {
isVisible: {
type: Boolean,
default: true,
required: true,
},
color: {
type: String as PropType<Color>,
},
data: {
type: Object,
default() {
return { message: "hello" };
},
},
},
+ slots: ["default", "header"],
+ setup(props, { slots }) {
return () => (
<>
+ <header>{slots.header?.()}</header>
+ <main>{slots.default?.()}</main>
</>
);
},
});
这里注意,slots 中的 default 是默认插槽 ,而除了 default 外,其他的都是具名插槽 。在组件中获取到的 slots 的数据类型是一个对象,对象上的每一个属性对应每一个插槽,插槽的值是一个函数,在 setup
函数的返回值中调用就可以渲染出传入的模板内容。
这里之所以使用了可选符号进行函数的调用是为了防止使用组件时没有传入指定的 slot 导致报错。
组件的使用方式如下:
ts
<MyComponent isVisible color="red" :data="{message:"你好"}">
<template #header>
// 这里传入 header 插槽的内容
</template>
// 这里传入 default 插槽的内容
</MyComponent>
定义、获取 emits
emits
的定义和获取基本与 slots
一致,如下所示:
diff
const MyComponent = defineComponent({
props: {
isVisible: {
type: Boolean,
default: true,
required: true,
},
color: {
type: String as PropType<Color>,
},
data: {
type: Object,
default() {
return { message: "hello" };
},
},
},
slots: ["default", "header"],
+ emits: ["update:color"],
+ setup(props, { slots, emit }) {
return () => (
<>
<header>{slots.header?.()}</header>
<main>
+ <button onClick={() => emit("update:color", "blue")}>更新颜色</button>
+ {slots.default?.()}
</main>
</>
);
},
});
这里还有一个注意点,我们时没法在定义组件时判断用户是否传入了 emit 方法的,因此在写组件时要默认所有的 emit 都是可选的。
获取 attrs
也许有些同学还不知道 attr
s 在 vue 中对应着什么。这里先简单介绍下 ,atts
全称是 Attributes ,对应的就是原生标签上的一些属性,例如 id
,class
, onClick
等等。
atts 属性通常我们并不需要自己手动在组件上添加,当一个组件以单个元素为根作渲染时,透传的 attribute 会自动被添加到根元素上。举例来说,假如我们有一个
<MyButton>
组件,它的模板长这样:
tsx<!-- <MyButton> 的模板 --> <button>click me</button>
一个父组件使用了这个组件,并且传入了
class
:
html<MyButton class="large" />
最后渲染出的 DOM 结果是:
html<button class="large">click me</button>
但有时候我们不想让 attrs
透传在根元素上,而是想自己选择一个元素添加 attrs
,那么我们该如何获取到 attrs
呢?其实也和 slot
s, emits
类似,直接从 setup 函数的第二个参数 中获取并使用:
diff
const MyComponent = defineComponent({
props: {
isVisible: {
type: Boolean,
default: true,
required: true,
},
color: {
type: String as PropType<Color>,
},
data: {
type: Object,
default() {
return { message: "hello" };
},
},
},
slots: ["default", "header"],
emits: ["update:color"],
+ setup(props, { slots, emit, attrs }) {
return () => (
<>
<header>{slots.header?.()}</header>
<main>
+ <button {...attrs} onClick={() => emit("update:color", "blue")}></button>
{slots.default?.()}
</main>
</>
);
},
});
上面这段代码就将组件除 props
外的其他使用组件时传入的属性直接透传到了 button 元素上,透传的主要作用是让子组件能够访问到从父组件中传入的非标准化属性或者其他的原生 HTML 属性,同时也可以通过 attrs
对调用组件时传入的事件进行一些自定义处理。
定义 attrs 类型
前面提到了 attrs
如果在 vue 文件中传入组件,是不需要我们自己去定义类型的,但是如果我们使用 tsx 文件定义组件,并且在 tsx 文件中使用组件时,会出现无法传入原生属性的问题。
假设我们先开发了一个图标组件:
tsx
import { defineComponent } from "vue";
import "./style.scss";
const IconArrow = defineComponent({
props: {
less: {
type: Boolean,
default: false,
},
},
setup(props) {
return () => (
<i
ref="icon"
class={[
"vs-icon-arrow",
{
less: props.less,
},
]}
></i>
);
},
});
export default IconArrow;
然后我们在另外一个 tsx 组件中使用图标组件,并传入一个 onClick
事件:
tsx
{/* icon */}
<IconArrow
onClick={() => {
if (isOptionsShow.value) {
isOptionsShow.value = false;
} else {
inputRef.value?.focus();
}
}}
></IconArrow>
这时候我们发现报错了,ts 提示我们的组件的 props 上并没有定义 onClick
这个方法:
遇到这种情况怎么办呢?下面是我自己使用的一种方法,不知道是否官方,但是可以用于拓展 tsx 的类型提示,实现的过程如下:
- 首先定义一个工具类型
CompWithAttr
:
ts
export type CompWithAttr<Comp, Attr extends HTMLAttributes> = Comp &
DefineComponent<Attr>;
Comp
代表一个组件的类型,Attr
代表 HTML 元素的属性类型。通过 DefineComponent
将 Attr
也设置为组件 Props 将 Comp
和 Attr
组合起来,生成一个新的类型,即 CompWithAttr<Comp, Attr>
,它表示一个带有透传属性的新组件类型。
然后我们修改一下刚刚的图标组件:
tsx
import { HTMLAttributes, defineComponent } from "vue";
import "./style.scss";
import { CompWithAttr } from "@/types/utils";
const IconArrow = defineComponent({
props: {
less: {
type: Boolean,
default: false,
},
},
setup(props, { attrs }) {
return () => (
<i
{...attrs}
ref="icon"
class={[
"vs-icon-arrow",
{
less: props.less,
},
]}
></i>
);
},
});
export default IconArrow as CompWithAttr<typeof IconArrow, HTMLAttributes>;
在最后一行代码导出组件的位置,我们新增了一个类型断言 as CompWithAttr<typeof IconArrow, HTMLAttributes>
, 将组件原本的 props
与 HTMLAttributes
进行组合了,这里 Attr 的类型我们可以通过将鼠标悬浮到想要添加 attr 的元素上进行查看:
修改过后,我们再使用组件时就不会报错了,而且还有代码提示:
JSX/TSX 中如何使用变量
在 JSX/TSX 中,可以用花括号 { }
来引用一个变量。比如,如果有一个变量 name
,你可以在 JSX 中使用它进行渲染:
ini
const name = "John";
const element = <h1>Hello, {name}!</h1>;
以上代码中,{name}
将会被替换为变量 name
的值,也就是 "John"。在 JSX 中,花括号内可以放任何 JavaScript 表达式,不仅仅是变量,也包括了函数,元素等等。
如何实现 v-if,v-else
v-if
是 vue 中用于条件性地渲染元素的语法,在 tsx 中 v-if
和 v-else
可以直接使用 js 的三目表达式:
tsx
import { defineComponent } from 'vue'
export default defineComponent({
name: 'MyComponent',
setup() {
const isShow = true
return () => (
<div>
{isShow ? (
// 相当于 v-if
<p>显示内容</p>
) : (
// 相当于 v-else
<p>隐藏内容</p>
)}
</div>
)
}
})
如果只需要 v-if
,那么我们也可以直接通过 &&
且运算符实现:
tsx
import { defineComponent } from 'vue'
export default defineComponent({
name: 'MyComponent',
setup() {
const isShow = true
return () => (
<div>
{isShow && (
// 相当于 v-if
<p>显示内容</p>
)}
</div>
)
}
})
如何实现 v-show
v-show
是 vue 中用于隐藏某个元素且不从 dom 中移除的一个语法,在 JSX/TSX 中可以非常简单的使用原生的 hidden
属性去实现,如下例所示:
tsx
const MyComponent = defineComponent({
props: {
isVisible: Boolean,
},
setup(props) {
return () => (
<>
<button hidden={!props.isVisible}></button>
</>
);
},
});
当然,我们也可以直接使用 v-show
:
tsx
const MyComponent = defineComponent({
props: {
isVisible: Boolean,
},
setup(props) {
return () => (
<>
<button v-show={props.isVisible}></button>
</>
);
},
});
如何实现 v-for
v-for
用于遍历数组并渲染元素,在 tsx 中,我们通常会使用 js 中的 map 去遍历数组并返回元素,如下例:
tsx
import { PropType, defineComponent } from "vue";
const MyComponent = defineComponent({
props: {
list: Array as PropType<{ id: number; name: string }[]>,
},
setup(props) {
return () => <>
{props.list?.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</>
},
});
当然,实现列表渲染的方式还有很多,本质上就是遍历数组返回元素,只要渲染出来的是一个由元素组成的数组就行,例如我们可以将渲染列表抽成一个方法,并改用 forEach
去遍历数组:
tsx
import { PropType, defineComponent } from "vue";
type Item = { id: number; name: string };
const MyComponent = defineComponent({
props: {
list: Array as PropType<Item[]>,
},
setup(props) {
const renderList = () => {
const arr: JSX.Element[] = [];
props.list?.forEach((item) => {
arr.push(<div key={item.id}>{item.name}</div>);
});
return arr;
};
return () => <>{renderList()}</>;
},
});
如何向组件传入 slot
在 JSX/TSX 中如果想要向组件中传递 slot
,是不能使用 Vue 中的 template 语法传递具名插槽的,而是要使用 v-slots
语法,如下例所示:
tsx
import { defineComponent } from "vue";
import { VsAlert } from "vuesax-ts";
const component = defineComponent({
name: "docsWarn",
setup() {
return () => (
<VsAlert
v-slots={{ title: () => "Vuesax-ts",
icon: () => <i class="bx bx-search" /> }}
/>
);
},
});
export default docsWarn;
v-slots
接受一个对象,对象的 键代表了 slot 的名称 , 值对应的是 slot 的内容 ,要注意值必须是一个 返回元素的函数
总结
相信看完这篇文章基本上你就能够使用 TSX 实现大部分的 vue 组件了,更加复杂的场景你也可以参考 ant design vue 或者我正在重构中的 vuesax-ts 中的组件 github.com/oil-oil/vue... ,欢迎 star🌟,更欢迎加入贡献 !
如果文章对你有帮助除了收藏外不妨放我点个赞,respect!