Vue3 + TSX 最佳实践?不存在的

25,753 阅读7分钟

导读

本文是《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 的优劣点:

优势

  1. 除了“标签语法”外,都是 JS 原生的语法,学习成本较小
  2. 同时意味着更灵活,可控性强,留给开发者全部的发挥空间
  3. 与 TS 完美结合
  4. 可以随着 ES、TS 的迭代升级自然进化,能力与开发体验都会不断提升
  5. 相较于 template 的 props、attrs、emits、slots 等概念,JSX 只有 props 一个概念,心智负担较小

劣势

  1. 部分人对于在 JS 里混入 HTML 语法表示很抗拒,感觉违背了逻辑与表现分离的原则
  2. 灵活意味着代码质量与程序员的水平强相关,也就是不好保证代码质量的下限
  3. 相应的,一些优化点需要程序员自己控制,比如 React 中的 useCallback、useMemo,相信能彻底弄明白的人不是多数
  4. 没有 template(SFC) 提供的 style 处理能力,如 scoped、module、状态驱动等

正文

其实最关键的点就是如何把 template 中的特殊语法映射到 JSX 上,比如 slots、emits 等。这要靠查看官网 渲染函数 的说明来推测,下面我们用一个实际例子来具体看下怎么做。建议先看下本文的结论,心里先有个底。

场景描述

image.png

  1. 分为 ParentChild 两个组件。分别都会用 Vue3 + TS 最佳实践 和 TSX 的方式各实现一版。
  2. Parent,蓝色背景,组件内部维护一个 count 响应式变量,当点击 [Count++ 按钮] 时触发 handleIncrease 方法自增,并动态展示在 Parent 中
  3. Child,黄色背景,组件的内部没有任何响应式的声明,只接收 Parent 传入的 propsslotsemits
  4. Child 组件接收的 props 有 :count="count"、style;slots 有 #header 和 #default;emits 有 @childClick="handleIncrease"
  5. Child 会展示 props.count 以及 Object.keys(props),[Child Count++ 按钮] 的点击会触发 emit('childClick'),即触发 Parent count 的改变

代码实现

为方便对比,这部分的代码用的图片,且做了对齐处理,文末会有文本代码,方便大家复制。

Parent - SFC vs. TSX

image.png 关键点如下:

  1. TSX 要用 defineComponent 包裹,并且只使用 setup(没有 data、methods、computed 等一级声明),返回值要是一个 render function,里面采用 JSX 的写法;
  2. TSX(defineComponent) 中 componentspropsemits 等的声明是省不了的,props 的声明我们看下文 Child 的实现;
  3. @click 在 TSX 中要变为 onClick,自定义 emit 也要由 @child-click 变为 onChildClick。不过这里要注意,正如“短篇”中提到的,如果用了 TSX,像 onClick 这种可能引起无效重复 render 的问题,就需要使用者自己解决了;

注:如果在 template 中同时传入 @childClick 和 :onChildClick 会发生什么呢?

答案是:不管传入顺序如何,:onChildClick 都会覆盖掉 @childClick,有兴趣的同学可以验证一下。所以在 template 中,事件还是老老实实的用 @ 的好。

  1. TSX 中的 ref 对象还是需要使用 .value 结尾,有点麻烦,但是编辑器会自动补全;
  2. 如果有多个slots,TSX 要像例子中一样,通过一个对象传入子组件。对象的 key 为 slot 的名字,value 为要传入的组件;

综上,需要特别注意的就是 emitsslots 的特殊处理。另外,上例当中还存在一个比较大的问题,即 onChildClick 实际上会被编译器提示 TS 校验错误,但代码又是可运行的。要想解决这个问题,只能要求子组件不声明 emits,全部用 props.onXXX 代替,即放弃使用 emits(感觉不太合适)。这点现在是最难受的,笔者还没有想到一个好的解决方案。

Child - SFC vs. TSX

image.png 上图中 TSX 的例子是用 Functional Component 形式实现的,这种组件使用 TSX 可谓是最舒服的,关键点如下:

  1. 注意两者 emits 声明的不同,SFC 中参考官网的例子(仅限类型的 props/emit 声明),TSX 中 Emit 的声明一定要是 type 格式而不能是 interface,这是由 FunctionalComponent 内部泛型处理逻辑决定的;
  2. TSX 中可以使用解构赋值,这是一个组件二次封装的场景下很常用的一个语法;
  3. 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>
    );
  },
});

关键点如下:

  1. 要想 props 有很好的提示,必须要按照上例的方式,用 PropType 来声明各种属性的类型;
  2. emits 目前没有找到很好的 TS 声明的方法。即使用验证模式去写,也很不理想,毕竟我们要声明的是 emit 本身,而不是它的验证函数,意义都变了; 只能说太尴尬了,如果 TS 是这么使用的话,实在是太别扭了。也许对 defineComponet 进行 TS 加强后,能够一定程度的优化这个问题吧。

结论

本文通过一个典型的父子组件场景,来模拟实践中可能遇到的各种情况,最终的结论如下:

  1. Functional Component(即只有 propsemitsslots 传入的组件),可以完美的使用 TSX;
  2. 没有 propsemits 传入的组件(如本文的 Parent 组件),使用 TSX 还算可以接受,但是实际上组件内部没有太多的利用上 TS;
  3. 普通组件,尤其是带了 propsemits 的组件,用 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

参考文献

  1. 尤雨溪vue 3.0直播你学到了什么?

代码文本

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;