template setup 和 tsx 的混合开发实践

2024-03-23
22768 分钟
...

一般而言,我们在用Vue的时候,都是使用模板进行开发,但其实Vue 中也是支持使用jsx 或 tsx的。 最近我研究了一下如何在项目中混合使用二者,并且探索出了一些模式, 本文就是我在这种开发模式下的一些总结和思考,希望能帮助到大家。

由于tsx 就是ts版本的 jsx,二者基本上可以认作一个东西,所以下文使用tsx的地方,一般而言对于jsx也同样适用。

通常我们都是通过模板来定义我们的ui,这是官方推荐的编写方式,模板开发有很多优点,比如:

  • 简单易上手,接近原生html的编写模式。
  • 内置了自定义指令、事件修饰符等方便的功能。
  • 模板在编译期间会做一些优化例如静态提升等,所以它的性能会更好。
  • 内置 css scoped 方案,简单有效,使用体验优于 css modulecss in js 方案

模板开发提供了指令、事件修饰符等功能,方便我们复用一些逻辑。 特别是 css scoped 方案,个人觉得比 css modulecss in js 方案更直观好用。

然而纯模板开发也有一些缺点,比如:

  • 不够灵活,模板语法由于不是标准的js语法,不够灵活,在实现一些复杂场景的时候,便会显得束手束脚,写出的代码会变得有些臃肿。
  • 难以复用模板片段,基于.vue 文件的单文件方案导致无法在同一个文件中定义多个组件,很多时候我们不得不新建一个个的小文件来创建组件,使得我们组件拆分不那么灵活;

上面我们说到模板不够灵活,而由于这方面恰好是 tsx 的长处,借助一些工具如vite-plugin-jsx 的帮助,我们可以很方便地在 vue3 中使用 tsx 来开发,下面是一个在vue3中使用tsx开发的例子。

import { defineComponent, ref } from "vue";

export default defineComponent({
  name: "TsxDemo",
  props: {
    msg: {
      type: String,
      required: true,
      default: "hello wrold",
    },
  },
  setup(props) {
    const count = ref(0);
    return () => (
      <div>
        <header>{props.msg}</header>
        <main>{count.value}</main>
      </div>
    );
  },
});

vue 中使用 tsx 开发还有一些需要注意的点,但由于本文重点不在于教大家tsx基础,所以我们这里按下不表,这里贴一个链接方便大家学习(Vue3 + TSX 最佳实践?不存在的 - 掘金 (juejin.cn))

tsx的优点:

  • 自由,由于jsx语法本质上就是在写js,所以写jsx基本是随心所欲,想怎么写就怎么写。
  • 便于组件拆分,可以在同一个文件中组织多个组件。

tsx 的缺点

  • 不能使用自定义指令、事件修饰符等功能。
  • 由于tsx直接就是运行时了,无法在编译期做一些优化,导致性能比不过模板;
  • 没法使用 defineProps defineEmits 等只能用在setup语法糖中的预编译宏;
  • 没法使用 css scoped, 不管是 css module 还是 css in js 都不如 css scoped 那么简洁直观;

由于 tsx 开发模式不是vue官方推荐的开发模式,没法使用一些内置的功能,但我觉得最遗憾的是没法使用.vue单文件组件提供的 css scoped 这种css方案,个人觉得该方案相较于css modulescss in js方案更加简单易用。 另外一个比较大的问题还在于,没法使用defineProps defineEmits这些setup script 语法糖,导致在写 ts 类型时,只能使用基于运行时的推导方案,我们看下面个例子。

export default defineComponent({
  props: {
    count: {
      type: Number,
      required: true,
    },
    person: {
      type: Object as PropType<{ name: string }>,
    },
    color: {
      type: String as PropType<"success" | "error" | "primary">,
      required: true,
    },
  },
  setup() {
    return () => <div>demo</div>;
  },
});

在这里,我们写props的定义的时,很多情况下需要依赖 as PropType<xxx> 来帮我们推断出更精确的类型,而在setup script中我们可以使用基于ts的类型方案,这种方式显然会更加地友好。

<script setup lang="tsx">

const props = defineProps<{
  count: number;
  person?: {
    name: string;
  };
  color?: "success" | "error" | "primary";
}>();
</script>

既然模板开发和tsx开发都有各有各的优缺点,那么有没有什么办法可以将他们的优点组合一下呢,答案即是我们今天要讨论的setup script + tsx 混合编程模式。

那么如何开启 setup script + tsx 混合编程模式呢?

很简单将lang改为tsx 就可以

<script lang="tsx" setup>

</script>

首先我们按最常规的方法,定义一个子组件,并且渲染到父组件中:

<template>
  <div>
    <Demo msg="hello world" />
  </div>
</template>

<script lang="tsx" setup>
import { defineComponent } from "vue";

const Demo = defineComponent({
  props: {
    msg: String,
  },
  setup(props) {
    return () => (
      <div>
        <div>msg is {props.msg}</div>
      </div>
    );
  },
});
</script>

这就是最初始的状态,我们将在.tsx 中写组件的方式搬到了 setup script 中, Demo组件接受一个类型为string的属性msg,这段代码在浏览器中最终会渲染成

现在我们它加上样式,由于我们是在.vue文件中编写 Demo 组件,所以我们可以运用单文件内置的css方案。 .vue 文件中支持 css modulecss scoped 方案,这两种方式都可以用,我们先看 css module方案

<template>
  <div>
    <Demo msg="hello world" />
  </div>
</template>

<script lang="tsx" setup>
import { defineComponent, useCssModule } from "vue";

const styles = useCssModule();

const Demo = defineComponent({
  props: {
    msg: String,
  },
  setup(props) {
    return () => (
      <div class={styles.wrapper}>
        <div class={styles.inner}>msg is {props.msg}</div>
      </div>
    );
  },
});
</script>

<style lang="less" module>
.wrapper {
  .inner {
    color: red;
  }
}
</style>

可以看到,完美生效,我们再来看一下 css scoped方案:

<template>
  <div>
    <Demo msg="hello world" />
  </div>
</template>

<script lang="tsx" setup>
import { defineComponent } from "vue";

const Demo = defineComponent({
  props: {
    msg: String,
  },
  setup(props) {
    return () => (
      <div class="wrapper">
        <div class="inner">msg is {props.msg}</div>
      </div>
    );
  },
});
</script>

<style lang="less" scoped>
.wrapper {
  .inner {
    color: red;
  }
}
</style>

可以看到,并没有生效,这是因为Demo是一个子组件,而scoped方案不会透传到子组件中dom中,所以这里我们得用:deep处理下:

<style lang="less" scoped>
:deep(.wrapper) {
  .inner {
    color: red;
  }
}
</style>

再刷新下浏览器就可以看到css 生效了。

到这一步,通过用:deep做一下特殊处理,我们可以实现在 vue 中使用css scoped方案了。 那,能不能连 :deep都不写呢? 我们继续研究下。

这里之所以需要:deep 特殊处理的原因在于Demo 是一个组件,而css scoped默认不会透传到子组件中,那么如何去规避这个问题呢?

其实,Demo组件本质上是要实现一个生成一棵vnode 树,而这可以通过函数去生成:

const renderDemo = (props:{msg:string}) => {
    <div class="wrapper">
        <div class="inner">msg is {props.msg}</div>
    </div>
}

现在我们需要将这个函数生成的 vnode 插入到模板中。

在tsx 中,我们经常可以看到这样的写法

  setup() {
    const header = <header>this is header</header>;
    const renderMain = () => <main>this is main</main>;
    const renderFooter = () => <footer>this is footer</footer>;

    return () => (
      <div>
        {header}
        {renderMain()}
        {Math.random() > 0.5 ? renderFooter() : null}
      </div>
    );
  },

我们将组件的各个部分通过拆分成了一个个子单元,它可以是一个单独的vnode,也可以是一个渲染函数,然后通过 {} 表达式嵌入主单元。那么,在混编模式下我们能不能这么做呢?

模板中有{{}}表达式,也是接受动态的内容,顺着这个思路,我们可以写出这样的代码:

<template>
  <div>
    {{ header }}
    {{ renderMain() }}
    {{ Math.random() > 0.5 ? renderFooter() : null }}
  </div>
</template>

<script lang="tsx" setup>
const header = <header>this is header</header>;
const renderMain = () => <main>this is main</main>;
const renderFooter = () => <footer>this is footer</footer>;
</script>

然而,渲染出来的结果却是会报错.

原因是{{}}{} 不同,其实是用来渲染动态字符串的,这里传vnode肯定是不行的,那么这里该怎么做呢?

答案是<component :is='xxx'>

<component :is='xxx'> 这里传的is既支持传组件,也支持传vnode,所以我们可以这样写。

<template>
  <div>
    <component :is="header"></component>
    <component :is="renderMain()"></component>
    <component :is="Math.random() > 0.5 ? renderFooter() : null"></component>
  </div>
</template>

<script lang="tsx" setup>
const header = <header>this is header</header>;
const renderMain = () => <main>this is main</main>;
const renderFooter = () => <footer>this is footer</footer>;
</script>

渲染也是正常的

所以,之前的Demo也可以这样写

<template>
  <div>
    <component :is="renderDemo('hello world')"></component>
  </div>
</template>

<script lang="tsx" setup>
const renderDemo = (msg: string) => (
  <div>
    <div> msg is {msg}</div>
  </div>
);
</script>

之前我们的写法是通过一个将这个片段拆分为一个组件,导致我们在用css scoped 方案的时候,必须要用 :deep() 去透传一下,而这种写法支持渲染一个vnode,所以没有这个限制,也就是说我们写css 可以按照正常的写法去写,即:

<template>
  <div>
    <component :is="renderDemo('hello world')"></component>
  </div>
</template>

<script lang="tsx" setup>
const renderDemo = (msg: string) => (
  <div class="wrapper">
    <div class="inner"> msg is {msg}</div>
  </div>
);
</script>

<style lang="less" scoped>
.wrapper {
  .inner {
    color: red;
  }
}
</style>

而渲染出的组件也是正常的

到此为止我们已经能将 tsxcss scoped 完美结合起来了,那还能更优化吗?

我们来看下面这个计数器 demo:



<template>
  <component :is="render()"></component>
</template>

<script setup lang="tsx">
const props = defineProps<{
  count: number;
}>();

const emits = defineEmits(["update:count"]);

const render = () => {
  return (
    <div>
      <div class="content">count is {props.count}</div>
      <div>
        <button
          onClick={() => {
            emits("update:count", props.count + 1);
          }}
        >
          add
        </button>
      </div>
    </div>
  );
};
</script>

<style lang="less" scoped>
.content {
  color: red;
}
</style>




<template>
  <Counter v-model:count="count" />
</template>

<script setup lang="ts">
import { ref } from "vue";
import Counter from "./views/Counter.vue";

const count = ref(0);
</script>

我们直接将所有的渲染逻辑抽象为一个 render 函数,然后在模板部分,只用

<template> 
   <component :is="render()"></component> 
</template>

这样写, 这就相当于我们完全实现了在template setup 模式中,完全使用tsx 去开发, 使得tsx 可以使用 defineProps defineEmits 等宏语法的支持;从渲染结果可以看出,点击逻辑可以生效,在我们设置的样式也不需要 :deep 加持就能作用到dom中;

这就是终极黑魔法!😃😃😃

上面啰啰嗦嗦说了一大堆,其实总结起来就是

  • <script setup lang="tsx"> 开启tsx 混编;
  • 直接在 创建vnode 或者render 函数;
  • 借用<component :is="render()" />插入到模板中;

这是我们常用的混合使用setup templatetsx 的方式,既能实现 vnode 的复用,又可以完美地和 css scoped结合,推荐大家在一些需要灵活的场景下使用这种模。

以上就是本文的全部内容了,如果觉得有用,可以点赞👍👍👍收藏哟!

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
vue2025-10-22
shallowRef 与 ref 的区别、场景与坑位
结论先行: 会 深度 地把你放进去的对象转成响应式(递归代理),因此"对象内部的改动"也会触发视图更新。 只在 顶层 做依赖追踪: 更换 才触发更新 ;若你只是"在原对象上改属性",不会触发,除非手动 。 1\. 快速对比 维度 追踪粒度 深度(递归) 仅顶层 对象内部属性变更 会触发更新 不会 触发(除非 或重新赋值) 性能 有递归 & 依赖跟踪开销 更轻量,适合大对象/第三方实例 适用对象 普通标量、普通对象、数组 第三方实例(图表...
学习面试
vue2025-10-20
Vue 3 中的 setup执行时机与异步逻辑详解
Vue 3 中的 执行时机与异步逻辑详解 本文详细介绍 Vue 3 组合式 API 的核心函数 :它在组件生命周期中的执行时机、异步行为( 的影响)、常见用法和最佳实践。 一、 的执行时机 是 Vue 3 组件实例创建后、渲染前执行的函数。 它相当于 Vue 2 中的 和 的合体。 执行流程: 也就是说: 所有响应式数据( 、 、 )都在这里定义; 所有生命周期钩子( 、 等)都在这里注册; 模板渲染会等待 执行完成(或异步返回的 Pr...
学习面试
vue2025-10-08
vue中ref与reactive
Vue 3 的 与 :区别、实现,以及为什么更推荐 1\. 一句话结论 :适合 原始类型 或 独立、细粒度 的状态单元;类型推断明确、解构安全、追踪粒度更小, 默认首选 。 :适合管理 对象/集合 这类结构化状态;但 解构会丢失响应性 , 默认 非深度 ,需要正确的使用姿势配合。 2\. 用法与语义差异 2.1 基本示例 2.2 模板中的解包(unwrapping) 在 模板 里, 会 自动解包 ,可直接写 ,不必 。 在响应式对象中...
学习面试
vue2025-09-23
Vue3 中computed属性依赖总结
Vue3 属性依赖总结 1. 依赖收集机制 的 getter 内部 读取 到哪些响应式变量 ( / ),就会把这些变量收集为依赖。 当这些依赖发生变化时, 会重新计算。 2. 如果只返回常量 始终是 。 修改 或 不会触发 重新计算,因为 getter 内没有访问它们。 3. 如果访问了响应式变量但仍返回常量 初始化时会打印一次。 当 或 改变时,getter 会重新执行(再次打印),因为它们被收集为依赖。 但 依旧始终是 。 4. 总...
学习面试
vue2025-09-17
template标签为什么不可以使用v-show
前言 写 Vue 时,最容易踩的小坑之一,就是给 套一个 。编辑器不报错,页面却“消失”了。很多人以为这是 bug,其实它只是 Vue 的底层设计在默默提醒: 不是真节点,它只是个“草稿纸”。 一、为什么 拒绝 v show 的本质是切换元素的 CSS: 。浏览器要找到真实 DOM 节点,才能给它加样式。而 在运行时会被完全剥离,不会留下任何标签,Vue 找不到节点,也就无处下刀。 举个反面教材: 打开控制台,你会看到 还是生成了,但...
学习面试
vue2025-09-17
Vue 3中watchEffect自动追踪详解
Vue 3 自动追踪详解 一、是什么 是 Vue 3 Composition API 提供的一个响应式副作用(side effect)函数。 它会 立即执行一次回调函数 ,并且在回调函数中访问到的所有响应式数据(ref、reactive、computed 等)都会被 自动追踪 。 当这些依赖的响应式数据发生变化时,回调会重新执行。 简单理解: 能“自动发现”它用到的响应式变量,不需要手动指定依赖。 二、基本用法 特点: 第一次立即执行...
学习面试

评论

请登录后发表评论

去登录
加载评论中...