
开篇先问一个问题你学 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 classNamecontainer你好/div;经过 Babel 或者 esbuild 这类编译器处理之后它变成了这样实际输出取决于编译目标React 环境下可能直接是jsx函数老版本是React.createElementconst 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 到底多了什么类型是最大的免费防线说完了 JSXTSX 就很好理解了。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.MouseEventHTMLButtonElement) 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 classcard h2{{ title }}/h2 p v-ifvisible{{ content }}/p button clickhandleClick确认/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 classcard 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 classhelloHello, {name}/div; } });然后将它在某个.vue文件里正常导入并使用template Hello / /template script setup langts 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 PropTypeTreeNode, 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: TreeItemTSX 里直接裸写变量名可维护性高一个量级。5.2 动态组件和 v-model 的替代写法模板里你写component :iswhichComponent v-modelvalue /TSX 里有等效传递。Vue 3 的v-model本质是一个modelValueonUpdate: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 事件、插槽与具名插槽模板里的clickTSX 里是onClick模板里的#header具名插槽TSX 里是slots.header?.()或者v-slots对象。两者语法有差但能力一一对应。在 TSX 里处理插槽最清晰的办法是用v-slotsconst Modal defineComponent({ setup(_, { slots }) { return () ( div classmodal div classmodal-header {slots.header?.() ?? 默认标题} /div div classmodal-body {slots.default?.()} /div div classmodal-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/JSXJS 表达式自由度更高不用写一堆模板指令嵌套高阶组件 / 封装工厂TSX需要返回组件对象模板做这个事很别扭大量使用component :is动态组件模板模板内建解析类型不会给 JSX 找麻烦无状态函数式小组件展示标签、ButtonTSX轻量干净无响应式心智负担需要大量递归组件菜单树、评论树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 组件一律使用defineComponentsetup函数不碰 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文件里感受类型系统的呼吸你会感谢自己当初迈出的那一步。