
1. 项目概述为什么要在Vue3中重新审视JSX最近在几个Vue3项目中我尝试性地在一些特定场景下用JSX替换了传统的模板语法结果发现开发体验和灵活性提升了不少。这让我意识到很多Vue开发者尤其是从React转过来或者对JSX有天然好感的同学可能低估了JSX在Vue3生态中的成熟度和实用性。大家一提到Vue第一反应就是.vue单文件组件和模板语法这确实是Vue的特色和优势。但Vue3从设计之初就加强了对JSX在Vue语境下常称为TSX当与TypeScript结合时的一等公民支持这绝不是为了兼容而兼容而是为了解决模板在某些复杂场景下的局限性。简单来说JSX在Vue3中是一个完全受支持的、强大的备选方案。它特别适合那些逻辑高度复杂、需要大量动态渲染逻辑、或者组件结构需要根据运行时数据灵活变化的场景。想象一下你需要渲染一个根据后端返回的复杂JSON动态生成的表单每个字段的类型、校验规则、联动关系都不同或者你要实现一个类似可视化拖拽编辑器的渲染引擎节点的类型和结构完全由数据驱动。在这些场景下用模板写v-if、v-for的嵌套可能会让你陷入“模板地狱”而JSX的JavaScript表达能力就能大显身手。它允许你将渲染逻辑像写普通JavaScript函数一样组织享受完整的语言特性如解构、三元表达式、数组方法等带来的便利。接下来我会结合具体实践拆解如何在Vue3项目中巧妙、高效地运用JSX让它成为你工具箱里一把趁手的“手术刀”而不是一把笨重的“锤子”。2. 环境搭建与基础配置让JSX在Vue3中跑起来要在Vue3中使用JSX第一步就是正确配置开发环境。Vue3推荐使用Vite作为构建工具它的开箱体验和热更新速度都非常优秀。2.1 创建项目与安装核心插件首先使用Vite创建一个Vue3项目。如果你已经有一个现有的Vue3项目可以跳过创建步骤直接安装插件。# 使用 npm 创建 npm create vitelatest my-vue-jsx-app -- --template vue-ts # 进入项目目录 cd my-vue-jsx-app # 安装 Vue3 的 JSX 官方支持插件 npm install vitejs/plugin-vue-jsx -D这里的关键是vitejs/plugin-vue-jsx插件。它是Vite官方维护的插件专门用于在Vue单文件组件或独立的.jsx/.tsx文件中编译JSX语法将其转换为Vue3能识别的h()函数调用。注意要安装为开发依赖-D。2.2 配置 Vite.config.ts安装完插件后需要在vite.config.ts中引入并配置它。// vite.config.ts import { defineConfig } from vite import vue from vitejs/plugin-vue import vueJsx from vitejs/plugin-vue-jsx // 引入插件 // https://vitejs.dev/config/ export default defineConfig({ plugins: [ vue(), vueJsx(), // 添加到插件数组 ], })这个配置同时支持了.vue文件通过vue()插件和.jsx/.tsx文件通过vueJsx()插件。现在你就可以在项目中创建.tsx文件了。2.3 创建你的第一个TSX组件在src/components目录下创建一个HelloJsx.tsx文件。// HelloJsx.tsx import { defineComponent, ref } from vue; export default defineComponent({ name: HelloJsx, setup() { const count ref(0); const increment () { count.value; }; // 在setup中返回一个渲染函数 return () ( div classhello-jsx h1Hello, JSX in Vue3!/h1 pCurrent count is: {count.value}/p button onClick{increment}Click me/button /div ); }, });这里有几个关键点使用defineComponent这是定义Vue组件的标准方式提供了更好的TypeScript类型推断。在setup函数中编写逻辑setup是Vue3的组合式API入口。所有的响应式数据ref,reactive、计算属性computed、生命周期钩子onMounted等都在这里定义。返回一个渲染函数setup函数最后返回一个函数这个函数返回的就是JSX描述的虚拟DOM。这是JSX组件最核心的写法。JSX语法使用大括号{}来嵌入JavaScript表达式如{count.value}。事件监听使用onClick小驼峰而不是模板中的click。2.4 在Vue组件中使用TSX组件在App.vue中你可以像使用普通Vue组件一样引入和使用它。!-- App.vue -- script setup langts import HelloJsx from ./components/HelloJsx; /script template div HelloJsx / /div /template至此一个最基本的Vue3 JSX开发环境就搭建完成了。你可以看到JSX组件和SFC组件可以无缝混合使用。注意如果你在.vue文件的script setup中直接写JSX目前需要一些额外的配置或实验性标志。更常见的做法是将复杂的JSX渲染部分抽离到独立的.tsx文件中或者在setup()函数内返回渲染函数。保持关注Vue和Vite官方文档这方面的支持正在不断完善。3. JSX核心语法与Vue指令的映射习惯了Vue模板的开发者切换到JSX最大的障碍可能就是指令的写法不同。其实它们之间有着清晰的对应关系理解后就能顺畅转换。3.1 数据绑定与表达式在模板中我们使用双大括号{{ }}进行文本插值。在JSX中任何JavaScript表达式都可以放在单大括号{}中。模板语法span{{ message }}/spanJSX语法span{message}/span对于元素属性Attribute的绑定模板使用:前缀或v-bind而JSX同样使用大括号。模板语法div :iddynamicId :class{active: isActive}/divJSX语法div id{dynamicId} class{{active: isActive}}/div注意class绑定在JSX中通常直接写class而不是className这是与React的区别之一vitejs/plugin-vue-jsx默认处理了。对于动态对象直接传入对象即可。3.2 条件渲染v-if 与 v-show模板中的v-if/v-else-if/v-else和v-show在JSX中需要转换为JavaScript的条件语句。v-if链!-- 模板 -- div v-iftype AType A/div div v-else-iftype BType B/div div v-elseType Other/div// JSX - 使用三元表达式或逻辑与 return () ( div {type A ? divType A/div : type B ? divType B/div : divType Other/div} /div ); // 或者更清晰的方式使用 if-else 语句块 const renderContent () { if (type A) return divType A/div; if (type B) return divType B/div; return divType Other/div; }; return () div{renderContent()}/div;v-showv-show本质是控制CSS的display属性。在JSX中你可以直接操作样式。return () div style{{ display: isVisible ? block : none }}内容/div;3.3 列表渲染v-forv-for指令在JSX中被数组的map方法所取代。模板语法li v-for(item, index) in items :keyitem.id{{ item.name }}/liJSX语法return () ( ul {items.map((item, index) ( li key{item.id}{item.name}/li ))} /ul );关键点key属性必须显式提供和React中的要求一致。这对于Vue的虚拟DOM diff算法高效更新至关重要。3.4 事件处理v-on模板中的click或v-on:click在JSX中变为onClick小驼峰命名。模板语法button clickhandleClickClick/buttonJSX语法button onClick{handleClick}Click/button事件修饰符如.stop,.prevent在JSX中没有直接的语法糖。你需要手动调用事件对象的方法。模板语法form submit.preventonSubmit.../formJSX语法const onSubmit (event: Event) { event.preventDefault(); // 手动阻止默认行为 // ... 提交逻辑 }; return () form onSubmit{onSubmit}.../form;对于.stopevent.stopPropagation()等其他修饰符处理方式类似。3.5 双向绑定v-modelv-model是Vue的一个语法糖在JSX中需要拆解为value绑定和事件监听。Vue3的v-model支持多个绑定和自定义修饰符在JSX中对应为modelValue和onUpdate:modelValue。单个v-model默认!-- 模板 -- input v-modeltext /// JSX import { defineComponent, ref } from vue; export default defineComponent({ setup() { const text ref(); return () ( input value{text.value} onInput{(e) { text.value (e.target as HTMLInputElement).value; }} / ); }, });更简洁的写法是使用Vue提供的vModel指令需要从vue/runtime-dom导入但通常插件已处理但直接拆解写更能理解其本质。多个v-model如v-model:title!-- 模板 -- MyComponent v-model:titlepageTitle v-model:contentpageContent /// 在父组件中使用JSX return () ( MyComponent title{pageTitle.value} onUpdate:title{(val) { pageTitle.value val; }} content{pageContent.value} onUpdate:content{(val) { pageContent.value val; }} / );3.6 插槽slot插槽在JSX中的写法与React的children和props类似但更贴近Vue的思维。默认插槽在JSX中子元素直接作为组件的子节点传递它们就是默认插槽的内容。// 父组件 return () ( ChildComponent div这是默认插槽的内容/div /ChildComponent ); // 子组件 ChildComponent.tsx import { defineComponent } from vue; export default defineComponent({ setup(props, { slots }) { // 通过 setup 上下文解构出 slots return () ( div classchild headerChild Header/header {/* 渲染默认插槽 */} main{slots.default?.()}/main footerChild Footer/footer /div ); }, });具名插槽通过v-slots在JSX中是一个对象属性来传递。// 父组件 return () ( ChildComponent v-slots{{ header: () h1自定义标题/h1, footer: (slotProps: { text: string }) p页脚: {slotProps.text}/p, }} div默认内容/div /ChildComponent ); // 子组件 ChildComponent.tsx export default defineComponent({ setup(props, { slots }) { return () ( div {/* 渲染具名插槽 header */} div{slots.header?.()}/div div{slots.default?.()}/div {/* 向具名插槽 footer 传递参数 */} div{slots.footer?.({ text: 来自子组件的数据 })}/div /div ); }, });v-slots是Vue JSX插件提供的一个特殊属性用于定义插槽内容。在子组件内部通过setup的第二个参数解构出slots对象来访问和渲染它们。3.7 属性透传与继承在Vue中未被组件props或emits声明的属性会作为$attrs自动透传到组件的根元素上。在JSX函数式组件或使用setup的组件中你需要手动处理。import { defineComponent, useAttrs } from vue; export default defineComponent({ setup(props, { attrs }) { // 从上下文解构 attrs // 或者使用组合式APIconst attrs useAttrs(); return () ( // 使用展开运算符将 attrs 应用到元素上 div {...attrs} 组件内容 /div ); }, });使用{...attrs}可以方便地将所有未声明的属性如class、style、>// DynamicFormRenderer.tsx import { defineComponent, computed } from vue; import TextInput from ./TextInput; import SelectInput from ./SelectInput; import CheckboxGroup from ./CheckboxGroup; // 组件映射表 const componentMap { text: TextInput, select: SelectInput, checkbox: CheckboxGroup, } as const; type ComponentType keyof typeof componentMap; interface FieldConfig { type: ComponentType; label: string; name: string; options?: Array{label: string; value: any}; // ... 其他配置 } export default defineComponent({ props: { fields: { type: Array as PropTypeFieldConfig[], required: true, }, formData: { type: Object as PropTypeRecordstring, any, required: true, }, }, setup(props) { // 动态渲染每个字段 const renderField (field: FieldConfig) { const Component componentMap[field.type]; // 根据类型获取组件 if (!Component) { return divUnsupported component type: {field.type}/div; } // 准备传递给动态组件的props const componentProps { modelValue: props.formData[field.name], onUpdate:modelValue: (val: any) { // 更新表单数据这里需要触发一个事件或使用Vuex/Pinia // 例如context.emit(update:formData, { ...props.formData, [field.name]: val }); }, label: field.label, options: field.options, // ... 其他props }; return Component key{field.name} {...componentProps} /; }; return () ( form {props.fields.map(renderField)} /form ); }, });在这个例子中componentMap建立了类型到组件的映射。renderField函数利用JavaScript的Map查找和JSX的动态组件渲染能力清晰地表达了“根据类型选择组件并渲染”的逻辑。如果用模板写可能需要一堆component :is...配合复杂的v-if链可读性和可维护性都不如JSX。4.2 高阶组件与渲染劫持高阶组件HOC是接收一个组件并返回一个新组件的函数。在JSX中实现HOC非常自然因为它本身就是JavaScript函数。场景创建一个withLoadingHOC在数据加载时显示加载指示器。// withLoading.tsx import { defineComponent, DefineComponent } from vue; export function withLoadingP extends Recordstring, any( WrappedComponent: DefineComponentP ) { return defineComponent({ props: { loading: { type: Boolean, default: false, }, // 透传原组件的所有props ...(WrappedComponent.props || {}), }, setup(props, { attrs, slots }) { return () { if (props.loading) { return div classloading-spinner加载中.../div; } // 渲染被包裹的组件并传递所有属性和插槽 return WrappedComponent {...props} {...attrs} v-slots{slots} /; }; }, }); } // 使用示例 import UserProfile from ./UserProfile.vue; const UserProfileWithLoading withLoading(UserProfile); // 在另一个组件中使用 export default defineComponent({ setup() { const user ref(null); const loading ref(true); onMounted(async () { loading.value true; user.value await fetchUser(); loading.value false; }); return () ( UserProfileWithLoading loading{loading.value} user{user.value} / ); }, });withLoading函数接收一个组件返回一个新的组件。新组件增加了loadingprop并根据其值决定渲染加载态还是原组件。这种逻辑复用模式在JSX中写起来行云流水。注意这里使用了v-slots{slots}来透传所有插槽内容。4.3 复杂逻辑内联与代码复用当渲染逻辑包含大量条件判断、循环或计算时JSX允许你将逻辑直接内联在渲染函数中或者轻松地提取为纯JavaScript函数这比在模板中使用复杂的计算属性或方法更清晰。场景渲染一个树形结构的数据。// TreeNode.tsx import { defineComponent, PropType } from vue; interface TreeNode { id: string; label: string; children?: TreeNode[]; } export default defineComponent({ props: { node: { type: Object as PropTypeTreeNode, required: true, }, level: { type: Number, default: 0, }, }, setup(props) { // 一个纯函数用于判断节点是否有特殊样式 const getNodeClass (node: TreeNode) { const classes [tree-node]; if (!node.children || node.children.length 0) { classes.push(is-leaf); } if (node.label.includes(重要)) { classes.push(is-important); } return classes.join( ); }; // 渲染函数本身 return () { const { node, level } props; const paddingLeft ${level * 20}px; return ( div class{getNodeClass(node)} style{{ paddingLeft }} span{node.label}/span {node.children node.children.length 0 ( div classchildren {node.children.map(child ( TreeNode key{child.id} node{child} level{level 1} / ))} /div )} /div ); }; }, });这里getNodeClass是一个纯函数负责计算CSS类名。渲染函数内直接使用了三元表达式和map来递归渲染子节点。整个组件的逻辑是自包含且声明式的。如果使用模板可能需要将getNodeClass定义为计算属性递归部分可能还需要引入额外的组件或复杂的v-for结构上不如JSX紧凑和直观。4.4 与 TypeScript 的完美结合JSX尤其是TSX与TypeScript的集成是天衣无缝的。你可以获得完整的类型检查、自动补全和重构支持。组件Props类型定义使用PropType或基于泛型的defineComponent来获得严格的props类型检查。import { defineComponent, PropType } from vue; interface MyProps { title: string; count?: number; items: Array{id: number; name: string}; onCustomEvent: (value: string) void; } export default defineComponent({ props: { title: { type: String, required: true }, count: Number, // 可选 items: Array as PropTypeMyProps[items], // 复杂类型 onCustomEvent: Function as PropTypeMyProps[onCustomEvent], }, setup(props) { // props.title 是 string 类型有自动补全 // props.items 是 {id: number; name: string}[] 类型 props.onCustomEvent?.(hello); // 类型安全的事件调用 // ... }, });渲染函数返回值类型TypeScript能自动推断渲染函数的返回值类型。事件处理函数事件处理函数的参数如MouseEvent和自定义事件payload都能获得完整的类型提示。这大大减少了运行时错误提升了大型项目的可维护性。在模板中虽然Vue 3 script setup langts也能提供不错的类型支持但在处理动态组件、高阶函数等复杂场景时TSX的类型推导往往更加直接和强大。5. 性能考量与最佳实践虽然JSX带来了灵活性但在Vue3中也需要考虑一些性能和习惯上的最佳实践。5.1 避免不必要的渲染函数重建在setup中返回的渲染函数如果其依赖的响应式数据没有变化Vue不会重新执行它。但是如果你在渲染函数内部创建了内联函数比如事件处理器每次渲染都会创建一个新的函数实例这可能导致子组件不必要的更新。// 欠佳内联箭头函数 return () ( button onClick{() doSomething(item.id)}Click/button ); // 更佳使用 useMemo 或 稳定的引用 import { useMemo } from vue; setup() { const handleClick useMemo(() (id: string) { doSomething(id); }, []); // 依赖项为空函数只创建一次 return () ( button onClick{() handleClick(item.id)}Click/button // 或者如果 item.id 是循环中的变量可能需要更复杂的处理 ); }对于列表渲染中的事件处理器可以考虑将item作为参数传递给一个稳定的函数引用。5.2 合理使用 Fragment 和 TeleportJSX支持.../Fragment语法来包裹多个相邻元素而无需额外的DOM节点。return () ( {/* Fragment */} header标题/header main内容/main footer页脚/footer / );对于Teleport在JSX中可以直接使用。import { Teleport } from vue; return () ( div button打开模态框/button Teleport tobody div classmodal模态框内容/div /Teleport /div );5.3 与 Vue 生态工具的结合状态管理Pinia在JSX组件中使用Pinia与在SFC中无异。在setup中调用useStore()即可。import { useUserStore } from /stores/user; setup() { const userStore useUserStore(); return () divHello, {userStore.name}/div; }路由Vue Router使用useRouter和useRoute组合式API。import { useRouter, useRoute } from vue-router; setup() { const router useRouter(); const route useRoute(); const goHome () router.push(/); return () ( div p当前路径: {route.path}/p button onClick{goHome}首页/button /div ); }UI 库如 Element Plus, Ant Design Vue大多数现代UI库都提供了良好的TypeScript支持和组合式API示例在JSX中使用它们与在模板中类似注意属性名改为小驼峰即可。查阅库文档中关于“在JSX中使用”的部分。5.4 何时用JSX何时用模板这是一个经验问题没有绝对答案。我的个人准则是优先使用模板对于表现型组件Presentational Components即主要职责是渲染UI、结构相对固定、逻辑简单的组件。模板更声明式、更简洁视觉结构一目了然对于设计师和不太熟悉JavaScript的开发者更友好。.vue文件的template、script、style分离也符合关注点分离的习惯。考虑使用JSX/TSX逻辑复杂型组件渲染逻辑极度复杂包含大量条件分支、循环嵌套或动态计算。高阶组件/渲染函数组件需要实现组件抽象、逻辑复用如withLoading、withTooltip。动态组件渲染需要根据运行时数据动态决定组件类型或结构如动态表单、可视化编辑器、JSON Schema渲染器。对TypeScript有极高要求项目非常庞大需要极致的类型安全和重构能力JSX与TypeScript的集成在某些边缘场景下可能更顺畅。团队技术栈偏好如果团队成员主要来自React背景或者非常熟悉JSX那么统一使用JSX可以降低上下文切换成本。一个项目中可以同时存在.vue和.tsx文件。可以将复杂的、适合JSX的组件抽离成.tsx文件而在业务页面或展示型组件中继续使用.vue模板。这种混合模式非常灵活。6. 常见问题与排查技巧实录在实际开发中从模板切换到JSX可能会遇到一些特有的问题。这里记录了几个我踩过的坑和解决方案。6.1 JSX 元素类型报错问题在TypeScript项目中写div可能报错JSX element type div is not a constructor function for JSX elements.原因TypeScript无法正确识别Vue的JSX类型。这通常是因为tsconfig.json中缺少或配置了错误的JSX相关设置。解决确保tsconfig.json中compilerOptions包含以下配置{ compilerOptions: { jsx: preserve, // 对于 Vue JSX通常使用 preserve jsxImportSource: vue, // 告诉 TypeScript JSX 工厂函数来自 vue // 或者如果你使用的是更老的配置可能是 // jsx: preserve, // jsxFactory: h, // jsxFragmentFactory: Fragment } }具体配置可能因vitejs/plugin-vue-jsx版本和Vue版本略有不同。最可靠的方法是参考你当前项目所用插件和Vue的官方文档。通常使用Vite创建的项目已经配置好了。6.2 事件处理器中访问响应式数据问题在JSX的事件处理函数中直接访问ref的.value有时发现值不是最新的。现象与原因setup() { const count ref(0); const handleClick () { // 如果handleClick是在渲染函数外定义的且被用作回调如传递给子组件 // 它捕获的是定义时的 count 值闭包。 console.log(count.value); }; return () button onClick{handleClick}Click/button; }如果handleClick在setup中只定义了一次它形成了一个闭包捕获了初始的count引用。这通常没问题因为count是一个ref其.value属性是可变的。问题常出现在你将handleClick作为prop传递给子组件而子组件可能缓存了这个函数导致其上下文不是最新的。但在大多数直接内联在JSX中的场景每次渲染都会创建新函数反而不容易出问题。更常见的陷阱是解构setup() { const state reactive({ count: 0 }); // 错误解构会失去响应性 const { count } state; const handleClick () { console.log(count); // 永远输出 0 state.count; // 修改 state.count但 count 变量不会更新 }; return () button onClick{handleClick}Click/button; }解决始终通过ref.value或reactive对象本身来访问响应式数据。如果需要在事件处理器中使用最新的值且该处理器会被缓存考虑使用computed或者确保在需要最新值时重新读取.value。对于reactive对象避免在setup顶层直接解构其属性用于事件处理函数。如果需要解构并保持响应性使用toRefs。import { reactive, toRefs } from vue; setup() { const state reactive({ count: 0 }); const { count } toRefs(state); // count 现在是一个 ref const handleClick () { console.log(count.value); // 正确 }; return () button onClick{handleClick}Click/button; }6.3 样式和 Class 绑定问题在JSX中绑定class和style写法与HTML/模板有差异。解决class绑定静态类div classcontainer动态类对象语法div class{{ active: isActive, text-danger: hasError }}动态类数组语法div class{[base-class, isActive ? active : ]}混合div class{[static-class, { active: isActive }]}注意Vue的JSX插件通常期望使用class而不是React中的className。但为了兼容两者可能都支持最好统一使用class。style绑定静态对象div style{{ color: red, fontSize: 14px }}注意属性名是小驼峰fontSize动态绑定div style{{ color: activeColor, fontSize: size px }}数组语法合并多个样式对象Vue JSX也支持但不如对象语法常用。6.4 获取 DOM 引用在模板中我们使用ref属性配合ref响应式变量。在JSX中用法几乎一样。import { defineComponent, ref, onMounted } from vue; export default defineComponent({ setup() { const inputRef refHTMLInputElement | null(null); onMounted(() { if (inputRef.value) { inputRef.value.focus(); } }); return () input ref{inputRef} typetext /; }, });关键点在JSX中ref属性直接绑定到ref变量即可。类型标注为HTMLInputElement | null可以获得更好的TypeScript支持。6.5 调试与开发工具Vue DevtoolsVue Devtools 完全支持JSX组件。你可以在组件树中看到它们检查它们的props、state和事件。确保你使用的是最新版的Vue Devtools。浏览器控制台如果JSX编译或运行时出错错误信息通常会指向你的.tsx文件行号。结合source map可以方便地定位问题。类型检查充分利用TypeScript的编译时检查。在VSCode等编辑器中类型错误会直接标红能在运行前发现许多潜在问题。从模板转向JSX初期最大的挑战是思维模式的转换。一旦你习惯了用JavaScript的函数式思维来描述UI并且享受到了类型安全和灵活性的红利在很多复杂场景下你会发现自己再也回不去了。我的建议是不要试图全盘替换而是在那些模板写起来开始感到“别扭”或“冗长”的组件中尝试引入JSX。你会发现Vue3提供的这种灵活性让你能够为不同的任务选择最合适的工具。