Vue3 + TSX 最佳实践?不存在的
导读
本文是《Vue3 + TS 最佳实践 》的补充篇,笔者之所以“执着于”在 Vue 中使用 JSX,一方面是因为之前使用 React 形成的编码习惯。但更重要的是在使用 React v16.8+ 的过程中,深刻体会到 JSX 与 TS 的结合是最完美的,因为 JSX 本质上只是一种语法糖,并没有超出 JS 的范畴,自然与 TS 配合完美。
所以本文会聚焦于 Vue3 下 JSX + TS,即 TSX 的实现,来探索 TS 在 Vue3 下是否有另一种最佳实践。归根结底,还是为了 TS 的落地。
分析
在笔者的另一个短篇文章《为什么 React 比 Vue 更适合集成 TS》(以下简称“短篇”)里,简单论述了以下内容:
- React 在结合 TS 方面为什么会这么成功
- JSX 在其中充当的角色
- JSX vs. Vue Template(SFC) 按照短篇中的逻辑,如果使用 JSX,就要放弃一些 Vue Template 带来的特性,比如一些自带的性能优化、状态驱动的动态 CSS 等。需要像 React 一样手动去做这些处理,这也就意味着有可能会降低整体工程质量的下限,当然好处就是更加顺滑的 TS 体验。对于这些点的取舍,还要同学们根据实际情况自己判断。毕竟软件开发本质上,是一项寻求平衡的妥协的艺术。
言归正传,先简单整理下 JSX 对 template 的优劣点:
优势
- 除了“标签语法”外,都是 JS 原生的语法,学习成本较小
- 同时意味着更灵活,可控性强,留给开发者全部的发挥空间
- 与 TS 完美结合
- 可以随着 ES、TS 的迭代升级自然进化,能力与开发体验都会不断提升
- 相较于 template 的 props、attrs、emits、slots 等概念,JSX 只有 props 一个概念,心智负担较小
劣势
- 部分人对于在 JS 里混入 HTML 语法表示很抗拒,感觉违背了逻辑与表现分离的原则
- 灵活意味着代码质量与程序员的水平强相关,也就是不好保证代码质量的下限
- 相应的,一些优化点需要程序员自己控制,比如 React 中的 useCallback、useMemo,相信能彻底弄明白的人不是多数
- 没有 template(SFC) 提供的 style 处理能力,如 scoped、module、状态驱动等
正文
其实最关键的点就是如何把 template 中的特殊语法映射到 JSX 上,比如 slots、emits 等。这要靠查看官网 渲染函数 的说明来推测,下面我们用一个实际例子来具体看下怎么做。建议先看下本文的结论,心里先有个底。
场景描述
- 分为 Parent 和 Child 两个组件。分别都会用 Vue3 + TS 最佳实践 和 TSX 的方式各实现一版。
- Parent,蓝色背景,组件内部维护一个
count
响应式变量,当点击 [Count++ 按钮] 时触发handleIncrease
方法自增,并动态展示在 Parent 中 - Child,黄色背景,组件的内部没有任何响应式的声明,只接收 Parent 传入的
props
、slots
、emits
- Child 组件接收的
props
有 :count="count"、style;slots
有 #header 和 #default;emits
有 @childClick="handleIncrease" - Child 会展示
props.count
以及Object.keys(props)
,[Child Count++ 按钮] 的点击会触发emit('childClick')
,即触发 Parent count 的改变
代码实现
为方便对比,这部分的代码用的图片,且做了对齐处理,文末会有文本代码,方便大家复制。
Parent - SFC vs. TSX
关键点如下:
- TSX 要用
defineComponent
包裹,并且只使用setup
(没有 data、methods、computed 等一级声明),返回值要是一个render function
,里面采用 JSX 的写法; - TSX(defineComponent) 中
components
、props
、emits
等的声明是省不了的,props
的声明我们看下文 Child 的实现; @click
在 TSX 中要变为onClick
,自定义emit
也要由@child-click
变为onChildClick
。不过这里要注意,正如“短篇”中提到的,如果用了 TSX,像 onClick 这种可能引起无效重复 render 的问题,就需要使用者自己解决了;
注:如果在 template 中同时传入 @childClick 和 :onChildClick 会发生什么呢?
答案是:不管传入顺序如何,:onChildClick 都会覆盖掉 @childClick,有兴趣的同学可以验证一下。所以在 template 中,事件还是老老实实的用 @ 的好。
- TSX 中的 ref 对象还是需要使用
.value
结尾,有点麻烦,但是编辑器会自动补全; - 如果有多个
slots
,TSX 要像例子中一样,通过一个对象传入子组件。对象的 key 为 slot 的名字,value 为要传入的组件;
综上,需要特别注意的就是 emits
和 slots
的特殊处理。另外,上例当中还存在一个比较大的问题,即 onChildClick
实际上会被编译器提示 TS 校验错误,但代码又是可运行的。要想解决这个问题,只能要求子组件不声明 emits,全部用 props.onXXX 代替,即放弃使用 emits
(感觉不太合适)。这点现在是最难受的,笔者还没有想到一个好的解决方案。
Child - SFC vs. TSX
上图中 TSX 的例子是用 Functional Component 形式实现的,这种组件使用 TSX 可谓是最舒服的,关键点如下:
- 注意两者
emits
声明的不同,SFC 中参考官网的例子(仅限类型的 props/emit 声明),TSX 中 Emit 的声明一定要是type
格式而不能是interface
,这是由FunctionalComponent
内部泛型处理逻辑决定的; - TSX 中可以使用解构赋值,这是一个组件二次封装的场景下很常用的一个语法;
slots
在 TSX 中以函数的形式调用,注意例子中的容错写法,防止没有 slot 传入时的报错; 综上,在 Functional Componet 的场景下,选择 TSX 是个不错的决定。如果用defineComponet
的方式实现 Child 应该是什么样呢?也顺便解答一下上面留下的一个疑问,关于 props 声明的问题。
Child.tsx 的普通写法
import { CSSProperties, defineComponent, PropType } from "vue";
interface Props {
count: number;
style: CSSProperties;
}
export default defineComponent({
props: {
style: {
type: Object as PropType<Props["style"]>,
default: undefined,
},
count: {
type: Number as PropType<Props["count"]>,
default: undefined,
},
},
emits: ["childClick"],
setup(props, ctx) {
const { slots, emit } = ctx;
return () => (
<div style={props.style}>
<h1>This is Child</h1>
{slots?.header && slots.header()}
<button onClick={() => emit("childClick")}>Child Count++</button>
<p>Child count is: {props.count}</p>
{slots?.default && slots.default()}
<p>Props' keys are: {Object.keys(props).join(", ")}</p>
</div>
);
},
});
关键点如下:
- 要想
props
有很好的提示,必须要按照上例的方式,用PropType
来声明各种属性的类型; emits
目前没有找到很好的 TS 声明的方法。即使用验证模式去写,也很不理想,毕竟我们要声明的是 emit 本身,而不是它的验证函数,意义都变了; 只能说太尴尬了,如果 TS 是这么使用的话,实在是太别扭了。也许对defineComponet
进行 TS 加强后,能够一定程度的优化这个问题吧。
结论
本文通过一个典型的父子组件场景,来模拟实践中可能遇到的各种情况,最终的结论如下:
- Functional Component(即只有
props
、emits
、slots
传入的组件),可以完美的使用 TSX; - 没有
props
和emits
传入的组件(如本文的 Parent 组件),使用 TSX 还算可以接受,但是实际上组件内部没有太多的利用上 TS; - 普通组件,尤其是带了
props
和emits
的组件,用 TSX 形式实在是有点强人所难; 所以想要愉快的在 Vue3 中使用 TS,还是首选 Vue3 + TS 最佳实践 的方式吧。如果项目解耦的特别好,有大量的 Functional Component,可以考虑用 TSX。不过 Vue3 官网有这样一句话:
- 在 3.x 中,2.x 带来的函数式组件的性能提升可以忽略不计,因此我们建议只使用有状态的组件
好吧,笔者已经找不出其他理由使用 TSX 了(除了 React 带来的惯性)。所以,Vue3 + TSX 可以说目前还不存在足够“佳”的实践,更不用说是最佳实践了。否定的结论也是结论,最后希望整个问题的思考过程,也能够带给大家一些收获吧。
"I have not failed. I've just found 10,000 ways that won't work." —— Thomas A. Edison
参考文献
代码文本
Parent.vue
<script setup lang="ts">
import { ref } from "vue";
import Child from "./Child1.vue";
const count = ref(0);
const handleIncrease = () => {
count.value++;
};
</script>
<template>
<div :style="{ padding: 10, backgroundColor: '#cef', textAlign: 'center' }">
<h1>This is Parent</h1>
<button @click="handleIncrease">Count++</button>
<p>Parent count is: {{ count }}</p>
<Child
:style="{ backgroundColor: '#ffd' }"
:count="count"
@child-click="handleIncrease"
>
<template #header>
<h3>Slot Header</h3>
</template>
<h3>Slot Default</h3>
</Child>
</div>
</template>
Parent.tsx
import { defineComponent, ref } from "vue";
import Child from "./Child";
export default defineComponent({
components: {
Child,
},
setup() {
const count = ref(0);
const handleIncrease = () => {
count.value++;
};
return () => (
<div
style={{ padding: 10, backgroundColor: "#cef", textAlign: "center" }}
>
<h1>This is Parent</h1>
<button onClick={handleIncrease}>Count++</button>
<p>Parent count is: {count.value}</p>
<Child
style={{ backgroundColor: "#ffd" }}
count={count.value}
onChildClick={handleIncrease}
>
{{
header: () => <h3>Slot Header</h3>,
default: () => <h3>Slot Default</h3>,
}}
</Child>
</div>
);
},
});
Child.vue
<script setup lang="ts">
import { defineProps, defineEmits, CSSProperties } from "vue";
interface Props {
count: number;
style: CSSProperties;
}
interface Emit {
(e: "childClick"): void;
}
defineProps<Props>();
const emit = defineEmits<Emit>();
</script>
<template>
<div :style="style">
<h1>This is Child</h1>
<slot name="header"></slot>
<button @click="() => emit('childClick')">Child Count++</button>
<p>Child count is: {{ count }}</p>
<slot></slot>
<p>Props' keys are: {{ Object.keys($props).join(", ") }}</p>
</div>
</template>
Child.tsx
import { CSSProperties, FunctionalComponent } from "vue";
interface Props {
count: number;
style: CSSProperties;
}
type Emit = {
childClick: () => void;
};
const Child: FunctionalComponent<Props, Emit> = (props, ctx) => {
const { count, ...rest } = props;
const { slots, emit } = ctx;
return (
<div {...rest}>
<h1>This is Child</h1>
{slots?.header && slots.header()}
<button onClick={() => emit("childClick")}>Child Count++</button>
<p>Child count is: {count}</p>
{slots?.default && slots.default()}
<p>Props' keys are: {Object.keys(props).join(", ")}</p>
</div>
);
};
export default Child;
转载自:https://juejin.cn/post/7007731144418394149