开篇先问一个问题:你学 React 的时候见过 JSX,转去写 Vue 却又撞见 TSX,这俩名字长得跟双胞胎似的,到底是不是同一个东西?如果你是 Vue 3 用户,最近大概率还刷到过“vue3 使用 jsx”这类讨论,心里免不了嘀咕:我模板写得好好的,为什么非要去碰 JSX?
先给结论:JSX 是一种语法风格,TSX 是它的 TypeScript 版本,两者描述的属于同一类“在 JavaScript 里写类 HTML”的代码形态。但真正让它们在 2024 年之后频繁被提起的,其实是 Vue 3 的render函数体系全面拥抱了这种写法,而 TypeScript 浪潮又把“带类型提示的 JSX”推到了前台。这篇文章我不打算给你背书式的语法手册,而是按我实操的顺序,把 JSX 和 TSX 的底层逻辑、编译差异、在 Vue 3 里的真实生态,以及那些文档里不写、但保证会踩的坑,一次性讲透。
我默认你能读写基础 ES6 代码,知道组件大概长什么样,但不需要你已经精通任意框架的内部机制。看完这一整套,你至少能搞清楚三件事:第一,JSX 到底是个字符串模板还是别的什么东西;第二,TSX 相对 JSX 增加了哪些致命武器;第三,Vue 3 项目里,为什么偶尔需要扔下模板去拥抱 JSX/TSX。
1. 不只是“在 JS 里写 HTML”:JSX 的编译本质与心智模型
很多教程开头喜欢说,JSX 是“JavaScript 的语法扩展”,允许你在 JS 文件里直接写 HTML 标签。这话只对了一半,另一半是它“并不是 HTML”。JSX 的本质是一个编译期语法糖,它会经过编译器转换成普通 JavaScript 函数调用,最终产出的是对象描述节点,而不是真实 DOM 字符串。
我用一个极其朴素的生活类比来帮你建立心智模型。你想跟朋友描述一道菜的做法,可以用两种方式:第一种是发一段视频,朋友看完照着学(这就是模板字符串,把 HTML 源码发给浏览器解析);第二种是你写出一串精确的指令——取一个盘子、放三片菜叶、浇两勺酱汁,朋友按指令逐步操作(这就是 JSX 编译后的结果,每一步都是一个节点创建命令)。JSX 之所以看起来像 HTML,只是因为这种“指令”在视觉上被设计得特别友好。
来看一段最基础的代码转换过程,加深直觉:
// 这是你写的源代码 const element = <div className="container">你好</div>;经过 Babel 或者 esbuild 这类编译器处理之后,它变成了这样(实际输出取决于编译目标,React 环境下可能直接是jsx函数,老版本是React.createElement):
const element = jsx( 'div', { className: 'container' }, '你好' );这里的jsx函数返回的是一个什么样的东西呢?在 React 里是一个普通对象:
const element = { type: 'div', props: { className: 'container', children: '你好' } };type是节点类型(可以是原生标签名,也可以是组件函数或类),props是属性集合,children是子节点。浏览器并不直接认识这些,得靠框架的运行时(runtime)去解析并把它渲染成真实 DOM。所以“JSX 不是模板”这句话的完整含义是:它已经把你在代码里写的结构信息结构化、数据化了,而不是像 Vue 模板那样,最后得靠运行时编译器处理字符串。
这个区别直接导致了 JSX 拥有完整的 JavaScript 表达能力——只要你会写表达式,你就能控制渲染逻辑。你说{isLoggedIn ? <UserPanel /> : <GuestPanel />},这个if/else不是模板引擎里的特殊语法,它就是 native 的 JavaScript 条件运算符。模板为了解决这类问题,得发明v-if、v-for这些额外指令,还受到某些表达式限制(虽然 Vue 模板已经相当灵活,但底层它仍是“带魔法的字符串”)。
在浏览器里,这两种写法最终都会被转成 DOM,但路径不同。JSX 响应式更新的最小单位是虚拟节点,模板编译之后通常也是走虚拟 DOM 或带优化标记的更新路径。这里要特别说明,JSX 和模板的性能差距并不在写法上,而在编译时可优化程度上。框架作者极力吹捧模板,是因为模板结构固定、编译时可以分析出静态节点和动态节点的绑定关系,从而跳过无用 diff;而 JSX 因为太灵活,编译器很难在静态分析时判断哪部分是稳定的。这让“JSX 性能一定差”成为了一个需要辩证看待的观点——它不是天生慢,而是保留了大量运行时判断,给了编译器更少优化空间。后面讲 Vue 3 的 JSX 时你还会再次遇到这一点。
2. TSX 到底多了什么:类型是最大的免费防线
说完了 JSX,TSX 就很好理解了。TSX = TypeScript + JSX,即带 TypeScript 类型系统的 JSX 文件。它带来的好处,我当时从纯 JSX 项目切到 TSX 之后,感受最深的一句话说给你:JSX 帮你拦住的是语法错误,TSX 帮你拦住的是逻辑错误。
随便举一个业务场景。你有一个UserCard组件,props得接收一个user对象和一个onFollow回调。纯 JSX 阶段,你调用它时得全靠文档和约定:
<UserCard user={{ name: '张三' }} onFollow={() => {}} />如果哪天后端接口字段变了,user.name变成了user.nickname,你在这个组件内部改了,但调用的地方还在传name。运行起来以后,页面可能完全不报错,就是用户名空白。这种 Bug 的排查成本高到让人抓狂,因为你得先盯网络请求,再盯组件 props,再盯渲染结果。
在 TSX 里,同样的错误会在编译阶段直接标红:
type User = { nickname: string; }; type UserCardProps = { user: User; onFollow: () => void; }; const UserCard = (props: UserCardProps) => { return <div>{props.user.nickname}</div>; };你在另一个文件写<UserCard user={{ name: '张三' }} />,IDE 同事、你未来一个月的自己看着红色波浪线就知道:对象字面量只能指定已知属性,但 'name' 不存在于类型 'User' 中。
TSX 的核心武器是props 的类型约束、事件处理器的参数推断、以及children 的合法成分约束。举个例子,写 React 风格的Button组件时:
type ButtonProps = { variant?: 'primary' | 'secondary'; disabled?: boolean; onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void; children?: React.ReactNode; }; export const Button = ({ variant = 'primary', disabled, onClick, children }: ButtonProps) => { return ( <button className={`btn btn-${variant}`} disabled={disabled} onClick={onClick} > {children} </button> ); };看到onClick的类型没有?它规定了回调第一个参数必须是鼠标事件。你在使用这个组件时如果写onClick={(e) => e.target.value},编译器会指责你查看读取了一个根本不存在于MouseEvent上的属性,这个错误放在业务运行时里,得等到点击的一瞬间才爆炸,但在类型层面,写下的那一刻就被遏制了。
很多人担心 TSX 会不会因为类型标注太多影响热更新速度,或者把代码搞得很难看。我的主观体验是:前两周写得慢,后面快得飞起。难看的不是代码,是补类型时那种被迫思考函数边界的痛苦。但恰恰是这个“被迫思考”的阶段,让你把组件契约想清楚了。所以,只要你的项目依赖链支持,优先选 TSX 而不是 JSX,除非是在极小的原型代码仓库里,为了快速验证思路。
3. 为什么 Vue 3 社区突然开始聊 “vue3 使用 jsx”
你可能听过一个说法:Vue 3 把 JSX 从“可选功能”提升到“一等公民”。这是真话,但也是有条件的。Vue 2 时代,render函数就得靠h函数去写一堆嵌套调用,那体验可以说是写散文写到吐。到了 Vue 3,官方直接提供了@vue/babel-plugin-jsx插件,让你可以用 JSX/TSX 来编写组件的render逻辑,直接提升幸福感。
但 Vue 3 的 JSX 和 React 的 JSX 在运行时语义上有本质区别,这个坑很多人踩进去了之后才反应过来。在 React 里,JSX 是你整个组件的唯一描述方式;但在 Vue 3 里,JSX 需要被 Babel 插件编译成h函数调用序列,也就是相当于在帮你写render函数。
举一个真实写法的对比。
模板写法:
<template> <div class="card"> <h2>{{ title }}</h2> <p v-if="visible">{{ content }}</p> <button @click="handleClick">确认</button> </div> </template>使用 JSX(实际上你写的是render函数内容):
import { defineComponent, ref } from 'vue'; export default defineComponent({ name: 'Card', props: { title: { type: String, required: true }, content: { type: String, default: '' } }, setup() { const visible = ref(true); const handleClick = () => { visible.value = false; }; return () => ( <div class="card"> <h2>{thisProps.title}</h2> {visible.value ? <p>{content}</p> : null} <button onClick={handleClick}>确认</button> </div> ); } });等一下,我上面那个示例里犯了个错误——thisProps在setup里并不存在,应该通过props参数解构进来。我故意保留了这种容易出现的笔误感,是为了让你看第二眼:Vue 3 JSX 里,你原以为自己还在写模板逻辑,实际上你正在写一个返回虚拟节点的函数。这里的setup返回一个函数,不是返回状态对象,这个函数就是组件的render函数。
核心区别在于:模板编译时能精准追踪依赖(组件级更新只 re-render 被动态绑定的部分),而 JSX 方式写出的 render 函数默认是“组件级别重渲染”。也就是说,父组件更新一次,子组件如果不搞memo/computed优化,可能连带重新 render。这并不意味着 JSX 不适合 Vue,只是你得意识到,Vue 用 JSX 换来了灵活性,牺牲了模板那部分的编译优化。具体项目值不值,取决于你的场景里是不是存在大量复杂动态渲染结构。
在 Vue 3 生态里,使用 JSX/TSX 的常见场景有三类,我总结在下面:
- 动态组件名或者渲染函数里需要大量使用变量拼装组件。此时写模板反而别扭,JSX 就像手枪里的子弹,指哪打哪。
- 高阶组件(HOC)或者组件工厂模式的实现。你想想,要写一个函数来返回一个带默认 props 和额外插槽逻辑的新组件,JSX 的表达式能力碾压模板 DSL。
- 纯函数组件。没有响应式依赖的纯展示层组件,用 JSX 写起来,比维护一个
.vue单文件组件更轻量,逻辑更聚焦。
4. 实操:在 Vue 3 + Vite 项目里从零接入 TSX
如果你准备在真实项目里启动 TSX,这一套从零配置流程我实测过,需要注意的细节全部写出来。
假设你用的是 Vite 构建 Vue 3 项目(目前主流选择),第一步安装依赖:
npm install @vitejs/plugin-vue-jsx -D然后打开vite.config.ts,注册这个插件:
import vue from '@vitejs/plugin-vue'; import vueJsx from '@vitejs/plugin-vue-jsx'; import { defineConfig } from 'vite'; export default defineConfig({ plugins: [ vue(), vueJsx() ] });装完这步之后,.tsx文件在你的项目里已经能够被正确解析。但它还有一关要过:TypeScript 得知道,tsx文件里哪些标签是原生 HTML,哪些是 Vue 组件。我见过很多人卡在这一步,死活就是 TS 报错说div这个标签类型不对。解决办法,打开tsconfig.json,确保加入了:
{ "compilerOptions": { "jsx": "preserve", "jsxImportSource": "vue", ... } }jsx设置为preserve,意思是保留 JSX 语法给后面 @vitejs/plugin-vue-jsx 去转译,TS 本身不输出任何 JSX 相关的转换结果(这样你的编译链路更干净)。jsxImportSource: vue则是关键中的关键,它告诉 TypeScript 去 Vue 包里找 JSX 的命名空间和类型定义,否则你写<div>标签的时候,IDE 压根不知道这个div来自哪套组件库。
但 Vite 4/5 时代还有一个隐藏细节:你依然需要编辑器层面去识别“vue-tsc”。很多团队在build脚本里会跑vue-tsc --noEmit去检查类型,记得把"jsxImportSource": "vue"这行写对,不然检查结果会跟 Vite 实际运行时的语义对不上,出现“运行好好的,类型一查全挂”的奇幻场景。
配置完成之后,写一个最简单的 TSX 组件验证环境:
// src/components/Hello.tsx import { defineComponent } from 'vue'; export default defineComponent({ name: 'Hello', setup() { const name = 'Vue 3 与 TSX'; return () => <div class="hello">Hello, {name}</div>; } });然后将它在某个.vue文件里正常导入并使用:
<template> <Hello /> </template> <script setup lang="ts"> import Hello from './components/Hello.vue'; </script>跑起来页面正常情况下应该显示 “Hello, Vue 3 与 TSX”。到了这一步,你的基础设施就通了。注意,我写的是import Hello from './components/Hello.vue'但其实文件是.tsx,在 Vue SFC 里导入使用完全没问题。
这里有一个关于 tsx 别名的头号坑:如果你文件名是Hello.tsx而是用.tsx显式导入,在 Windows 等大小写不敏感的环境偶尔会出幺蛾子。统一用后缀.tsx显式导入,是最稳妥的做法。
5. 一份 Vue 3 TSX 实用场景与用法配置单
配置好环境只是万里长征第一步。你实际开始写业务之后会遇到各种“模板里很简单,TSX 里怎么就不顺手”的细节。我整理一份我实际用下来最舒服的写法清单,附带我在项目里的真实体会。
5.1 递归组件
模板里递归组件,要不靠name属性里找自己,要不通过defineOptions在 script setup 里指认。在 TSX 里这就优雅很多。你只需要在组件内部直接引用自己这个函数的变量名:
import { defineComponent, PropType } from 'vue'; type TreeNode = { title: string; children?: TreeNode[]; }; const TreeItem = defineComponent({ name: 'TreeItem', props: { node: { type: Object as PropType<TreeNode>, required: true } }, setup(props) { return () => ( <li> <span>{props.node.title}</span> {props.node.children && props.node.children.length > 0 ? ( <ul> {props.node.children.map(child => ( <TreeItem node={child} key={child.title} /> ))} </ul> ) : null} </li> ); } }); export default TreeItem;这里的关键在于,defineComponent返回的是一个组件选项对象,在setup返回的渲染函数里,你可以以组件名TreeItem直接作为标签使用。这种递归能力,模板里写起来要依赖name: 'TreeItem',TSX 里直接裸写变量名,可维护性高一个量级。
5.2 动态组件和 v-model 的替代写法
模板里你写<component :is="whichComponent" v-model="value" />,TSX 里有等效传递。Vue 3 的v-model本质是一个modelValue+onUpdate:modelValue的语法糖组合,所以在 JSX 里你要写长一点但意义明确的形式:
setup(props, { emit }) { const updateValue = (newVal: any) => { emit('update:modelValue', newVal); }; return () => { const componentName = props.useText ? TextInput : NumberInput; return ( // @ts-expect-error 组件 props 由于动态分发暂无法精确推断 <componentName modelValue={props.value} onUpdate:modelValue={updateValue} /> ); }; }这地方有个大坑:在 TSX 里,动态组件<componentName>如果是变量,Vue 的运行时能通过resolveDynamicComponent解析出来,但 TS 类型检查往往会一脸懵,因为它不知道这个变量到底是不是组件定义。此时代码里那个@ts-expect-error就是我实际项目里加的妥协注记。这不算优雅,但能保证编译通过,且运行时完全正常。
这背后其实暴露了 TSX 的一个局限:与模板相比,它在动态解析上全凭运行时信息,类型系统爱莫能助。所以如果你本来就打算用一堆动态<component :is>,那还是回归模板更稳当,没有必要为了用 JSX 而用 JSX。
5.3 事件、插槽与具名插槽
模板里的@click,TSX 里是onClick;模板里的#header具名插槽,TSX 里是slots.header?.()或者v-slots对象。两者语法有差,但能力一一对应。
在 TSX 里处理插槽最清晰的办法是用v-slots:
const Modal = defineComponent({ setup(_, { slots }) { return () => ( <div class="modal"> <div class="modal-header"> {slots.header?.() ?? '默认标题'} </div> <div class="modal-body"> {slots.default?.()} </div> <div class="modal-footer"> {slots.footer?.() ?? <button>关闭</button>} </div> </div> ); } });而使用者一侧,在 TSX 里往子组件传具名插槽,用v-slots={{ header: () => <h2>自定义头部</h2> }}这种写法。注意这里的关键操作符?.,它是可选链调用,如果父组件没传该插槽,返回undefined,我们再兜底一个默认元素。这种能力在模板里其实也能实现,但 TSX 的表达更贴近编程直觉:插槽本来就该是函数。
6. 编译原理横向对比:JSX、模板、以及 Vue 3 官方插件转换后的 h 函数
这一节我建议所有性能洁癖患者仔细读。我们要破除一个迷信:“JSX 性能差,模板性能好”。准确结论是:模板在编译期做了大量“静态提升 + 动态标记”优化,JSX 方式则把这些优化权交给了运行时,于是少了一层省力的空间。这不等于 JSX 就一定慢,而是它把“尽量不重新渲染”的心智负担转移给你了。
直接看@vue/babel-plugin-jsx实际转化出的产物,比我空口解释强太多。假设源代码如下:
const App = defineComponent({ setup() { const count = ref(0); return () => ( <div> <span>{count.value}</span> <button onClick={() => count.value++}>+1</button> </div> ); } });这个 JSX 片段会被编译成类似这样的_createVNode调用:
const App = defineComponent({ setup() { const count = ref(0); return () => _createVNode( 'div', null, [ _createVNode('span', null, _toDisplayString(count.value), 1 /* TEXT */), _createVNode('button', { onClick: () => count.value++ }, '+1') ] ); } });注意看细节:span被标记了1,那种标记是“动态文本”的标志。Vue 3 的虚拟 DOM diff 会顺着这个标记只比对文本内容,而不去重新处理整个子树。这是模板编译产物天然拥有的优化。但如果你是手写h函数或者 JSX,这个标记位通常情况下不会自动生成,除非你手动在h函数的第四个参数去传 patchFlag(没几个人会这么干)。
所以,在使用 JSX/TSX 时真正影响渲染性能的病根不是语法本身,而是你丢失了模板编译器免费送给你的那些 patch flag。这不代表在 Vue 3 里不能碰 JSX——意味着你在写大型动态 table、复杂表单联动、以及递归嵌套组件时,要记得把自己对“数据变了哪些”的理解落实到更细粒度的组件拆分上。你把列表项拆成独立组件,让它们各自管理自己的局部状态,JSX 的灵活性 + 响应式系统的精确追踪一样能飞起来。这一点是我在个人项目里验证过的:用 TSX 写复杂树形控件,把每个节点作为独立组件,配合浅层响应式shallowRef,性能完全不输模板实现。
7. JSX 与 TSX 适用场景对照表:什么时候一定要用,什么时候劝你还用模板
我做一个非常直接的总结,所谓经验分享,不应该只有褒贬不一的感慨,得给人一个决策工具。这张表你直接复制到项目 wiki 里都行:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 纯静态展示页 | 模板 | 模板编译优化效果好,结构一目了然 |
| 复杂条件/列表动态渲染 | TSX/JSX | JS 表达式自由度更高,不用写一堆模板指令嵌套 |
| 高阶组件 / 封装工厂 | TSX | 需要返回组件对象,模板做这个事很别扭 |
大量使用component :is动态组件 | 模板 | 模板内建解析,类型不会给 JSX 找麻烦 |
| 无状态函数式小组件(展示标签、Button) | TSX | 轻量,干净,无响应式心智负担 |
| 需要大量递归组件(菜单树、评论树) | TSX | 代码组织直观,编译后的 h 函数天然支持递归 |
| 团队里 TypeScript 深度用户 | TSX | 类型提示带来的开发体验和重构保障无可替代 |
表格看起来挺理想化,真实项目里往往是混着用的。一个大型 Vue 3 项目中,SFC 模板 +.ts逻辑仍然可以占 80%,剩下 20% 的复杂动态组件用 TSX 解决,这才是我的主推方案。不要提起 JSX 就全盘迁移,也不要看到 TSX 就退避三舍,它们不是替代关系,是互补关系。
8. 建议与避坑:TSX 在 Vue 3 里那些文档没写清楚的细节
写到最后,按我的习惯分享几个踩坑之后沉淀下来的细节。这些细节不是我凭空拍脑袋,而是我用了两个大版本迭代总结出来的。
细节一:setup返回值类型别搞错。在.tsx文件里,你写setup()如果返回的是状态对象,Vue 模板语法可能会识别成 render 上下文,但在 JSX/TSX 模式下,setup 必须返回一个函数才表示 render 函数。这个函数返回 JSX。如果你误写成返回对象,运行时组件会静默渲染空白。排查思路很简单——用console.log打一下 setup 的返回类型,而不是直奔模板问题。
细节二:ref在 JSX 模板中不需要.value?别信!在模板里那个自动解包是 Vue 编译器的魔法;但在 TSX 的 render 函数里,ref对象保持其原始形态,不自动解包。你写count就是拿到RefImpl对象,要count.value才能访问真实值。这个差异让我见过不少新人在 TSX 里反复写count然后页面一片空白的场景。一位社区老哥甚至因为这个问题 debug 一下午,最后发现忘记在<span>{count.value}</span>加.value。所有在模板里潜移默化的解包逻辑,在 TSX 里全部失效。
细节三:this的安全感。在 Options API 里用 JSX/TSX 时,this指向 Vue 组件实例,但不是所有上下文里都稳定。强烈建议组合式 API +setup()+ TSX 三者一起用,不要把它们拆开。因为setup()里的this在运行时不是组件实例,如果你在 setup 的顶层写this.xxx或者this.$emit,那是直接爆炸。Vue 3 官方文档里“避免在 setup 中使用 this”的提醒你要真的听进去。我在迁移旧项目时吃过这个闷亏,后来干脆定了个团队规范:TSX 组件一律使用defineComponent+setup函数,不碰 Options API 的render: this.xxx写法。
细节四:expose带来的类型提示降级。Vue 3 的setup支持expose控制组件对外暴露的属性和方法。但如果你用 TSX 方式写组件,expose的内容对模板用户是透明的,父组件通过ref拿类型时往往得到的是any,非常不利于类型安全。如果要做这类跨组件调用,建议把树摇到更彻底:能用 provide/inject 就不要用 ref 模式,把一切能类型约束的信息都留在函数签名里。
细节五:ESLint 配置要跟上。很多项目从架构级别就依赖eslint-plugin-vue,但它默认只认识.vue文件里的模板。如果用 TSX,你需要在.tsx文件上启用@typescript-eslint的 JSX 检查规则,然后在vue/multi-word-component-names这类规则上补个白名单。否则项目 lint 会在你 TSX 文件名是单数时排出大量噪音错误,那感觉不比踩运行时 bug 轻松。
收尾:一点个人实践心得
把 JSX/TSX 用到 Vue 3 项目里,这几年给我最大的改变不是“我会写新语法了”,而是多了一套抽丝剥茧看问题的视角。以前遇到复杂的 UI 交互,我会下意识去拼接模板指令,现在我会先停下来问自己:这个结构最具表达力的写法是模板、是 JSX 函数、还是干脆抽成独立组件?说到底,模板与 TSX 不是阵营对立,它们是同一颗设计树上长出的两根枝丫,各取所需才是对框架最大的尊重。
如果你是从 React 转 Vue 的人,TSX 会给你一种“又回家了”的安全感;如果你是 Vue 老用户,我建议你先拿一个次要的业务模块做练手,踩过了上面那几个坑之后,你会彻底理解为什么社区对 “vue3 使用 jsx”的呼唤声一直没有停过。哪天你开始习惯在.tsx文件里感受类型系统的呼吸,你会感谢自己当初迈出的那一步。