ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Vue 3项目JSX与模板如何取舍?四大适配场景与工程实践全解

2026/9/19 22:40:07 拓冰建站 浏览量
Vue 3项目JSX与模板如何取舍?四大适配场景与工程实践全解 先说结论Vue 项目里没有“必须用 JSX”一说但确实存在一些场景用模板写会非常别扭而用 JSX 写会顺畅得多。这个话题我最近经常被问到尤其是从 React 转到 Vue 的同学或者正在做中后台复杂表单、动态组件树的团队。这篇文章不吹不黑我就把实际适配的几种场景、完整的接入步骤、以及我在项目里踩过的坑都摊开聊聊希望能帮你少走一趟弯路。JSX 在 Vue 里并不是什么“异端”Vue 3 官方就提供了完善的编译支持。它也不是用来替代 SFC 模板的更多是作为模板的补充在那些模板表达能力不足的地方用 JSX 写出更接近 JavaScript 直觉的代码。这篇内容适合正在用 Vue 3 做中大型项目的前端工程师阅读也适合团队在引入 JSX 之前做技术评估时参考。1. 项目核心思路模板的边界在哪里1.1 模板解决 80% 的问题剩下 20% 开始别扭Vue 的模板语法在设计上就是“结构化”的v-if、v-for、v-model、插槽这些东西写出了绝大多数页面都没什么问题。我自己做项目的时候大概八成的组件都会优先用模板写因为模板的可读性确实好后端出身或者刚入行的同事也能一眼看懂。但你要做嵌套比较深的动态组件、递归组件、高阶组件或者一个组件里的内容大量依赖传入的配置项来决定渲染结构时模板就开始露怯了。举个例子我早期做一个动态表单字段类型有 input、select、date、custom 组件这几种当时模板里全是component :isfield.type还要用v-for套一层再根据不同的字段配置把 props 用v-bindfield.props展开。字段少还好字段一多模板里全是条件分支改一个字段类型要翻半天才知道它渲染成什么。这种时候不是模板写不出来而是写出来的代码维护成本太高。模板擅长的是“结构相对固定”一旦结构本身变成数据的一部分模板的优势就消失了。1.2 方案选型为什么是 JSX 而不是 h 函数或字符串拼接既然模板不够用那有几个替代方案可以想。最原始的是字符串拼接 HTML这个基本不用考虑没有响应式、没有编译检查、XSS 风险还高除非做的是纯静态渲染否则别用。第二个方案是手写渲染函数的h函数。Vue 3 里h函数确实能解决所有动态渲染问题但缺点是可读性差。h(div, { class: box }, [h(span, text)])这种嵌套调用写简单的节点还行写稍微复杂的结构括号层级一多人眼根本分不清层级关系更别说后期维护的人不是你的时候那种代码基本是劝退别人用的。第三个方案就是 JSX。JSX 本质上会被编译成h函数调用所以它跟h函数是同一个能力等级区别在于 JSX 用类 HTML 的语法描述了层级既保留了 JavaScript 的表达能力又让人能看懂结构。对我个人来说选择 JSX 的原因就一句话它能在模板变复杂时把代码的“结构可读性”找回来。1.3 JSX、模板和渲染函数的关系需要先理清楚一个底层关系Vue 3 里模板本身也会被编译成渲染函数最终产出 VNode。JSX 也是同样的命运经过插件编译后变成h函数调用。所以你在模板里能写的东西JSX 理论上都能做你在 JSX 里能做的事模板却不一定能轻松表达因为 JSX 的背后是一整门 JavaScript。Vue 3 官方推荐的用法仍然是“优先模板必要时用渲染函数/JSX”。我这里不劝你把所有组件都用 JSX 重写那是典型的过度设计。我的原则是模板能清晰表达就用模板模板开始靠一堆 hack 才能绕过去的时候换 JSX。2. JSX 在 Vue 中的适配场景全景拆解2.1 场景一递归与动态组件树递归组件是模板最不方便处理的场景之一。在模板里写递归组件你得给组件设name然后在模板里通过这个 name 引用自己还得时刻小心递归终止条件写错了导致栈溢出。我之前在做组织架构树和目录树的时候模板版递归组件的代码大致是这样template ul li v-fornode in treeData :keynode.id span{{ node.label }}/span TreeNode v-ifnode.children node.children.length :tree-datanode.children / /li /ul /template script export default { name: TreeNode, props: { treeData: Array } } /script这个写法本身没问题但如果你需要在递归过程中根据节点类型动态渲染不同组件或者需要根据配置临时插入操作按钮模板里的判断会越来越多。同样的组件用 JSX 写递归就是函数自己调用自己结构非常直接type TreeNodeItem { id: string label: string children?: TreeNodeItem[] } const TreeNode (props: { treeData: TreeNodeItem[] }) { return ( ul {props.treeData.map((node) ( li key{node.id} span{node.label}/span {node.children node.children.length ? ( TreeNode treeData{node.children} / ) : null} /li ))} /ul ) } export default TreeNode注意这里不需要给组件设置 name不需要手动引入自己函数声明之后在调用阶段已经完成初始化递归调用自然成立。而且中间插入逻辑容易后面想要根据节点类型加不同的icon组件直接在map里加个判断就行。2.2 场景二高阶组件与组件工厂高阶组件这个概念从 React 那边来但 Vue 3 的组合式 API 里也完全可以用同样的思路。典型的场景是做权限包装某些按钮和操作只有特定权限的人能看到或者某些组件需要统一注入数据源。用模板做权限包装是比较难受的你多半要写一个很重的容器组件然后用一堆插槽把内容透传进去。用 JSX 的话写一个高阶组件就是一个普通函数接收组件、返回组件逻辑非常清楚import { defineComponent } from vue function withPermission(WrappedComponent: any, permission: string) { return defineComponent({ name: WithPermission, setup() { // 假设这里从某个 store 拿到用户权限列表 const userPermissions [admin:edit, admin:delete] return () { if (!userPermissions.includes(permission)) { return el-empty description暂无操作权限 / } return WrappedComponent / } } }) } const EditButton withPermission(Button, admin:edit)这个模式在项目里非常实用。组件工厂——也就是根据配置动态返回组件——也很适合用 JSX 表达。比如根据字段类型映射组件模板里写component :isgetComponentByType(type) /确实也能做但参数和事件的处理很麻烦JSX 里就是一次switch或者对象映射返回一个组件引用然后以 JSX 标签渲染出来。2.3 场景三配置驱动渲染中后台项目十有八九会遇到表格、表单、步骤条这类配置驱动渲染的需求。模板写法不是不行但配置和实际渲染逻辑之间隔着一层额外的表达式解析调试起来很绕。我写表格列配置的时候最常见的需求是某一列要根据单元格的值渲染不同样式的标签或者把几个字段拼接成某个操作区。模板里一般这样写el-table-column label状态 propstatus template #default{ row } el-tag :typerow.status 1 ? success : danger {{ row.status 1 ? 启用 : 禁用 }} /el-tag /template /el-table-column如果只有一两列这么做没问题。但列一旦到十几个每个列都要写一个template简直是一场灾难。用 JSX 的话可以把列定义提取成一个数组每个列的渲染逻辑直接挂在列配置的render函数上const columns [ { prop: name, label: 姓名, render: (row: any) span classname-cell{row.name}/span }, { prop: status, label: 状态, render: (row: any) ( el-tag type{row.status 1 ? success : danger} {row.status 1 ? 启用 : 禁用} /el-tag ) }, { prop: action, label: 操作, render: (row: any) ( el-button link typeprimary onClick{() handleEdit(row)} 编辑 /el-button el-button link typedanger onClick{() handleDelete(row)} 删除 /el-button / ) } ] // 表格组件内部统一执行 render {columns.map((col) ( el-table-column label{col.label} prop{col.prop} {{ default: ({ row }: any) col.render(row) }} /el-table-column ))}这个方案好在哪列的逻辑内聚在配置项里增删列只需要改数组不用再去模板里找对应的el-table-column块。而且render函数就是普通 JS参数、回调、条件判断都是“程序员的直觉”不需要去记忆模板内置指令的行为。2.4 场景四函数式组件与轻量展示Vue 3 对函数式组件的支持没有 Vue 2 时代那么执着因为现在普通组件性能差距已经很小。但对于纯展示类、没有任何自身状态的组件用函数式组件写也省事。比如一个只负责格式化金额展示的组件模板写要大动干戈template span{{ formattedAmount }}/span /template script setup import { computed } from vue const props defineProps({ value: Number }) const formattedAmount computed(() { return new Intl.NumberFormat(zh-CN, { style: currency, currency: CNY }).format(props.value || 0) }) /script换成 JSX 函数式组件就是一行const AmountCell (props: { value: number }) ( span{new Intl.NumberFormat(zh-CN, { style: currency, currency: CNY }).format(props.value || 0)}/span )函数式组件没有响应式依赖渲染开销更小用在表格单元格这种会被频繁调用的场景效果不错。另外函数式组件做“无脑透传”也很方便接收 props原样渲染到子组件中间不做任何加工。3. 完整实操Vue 3 Vite 项目接入 JSX3.1 环境配置与工程化准备如果你的项目是 Vite Vue 3接入 JSX 非常简单官方提供了vitejs/plugin-vue-jsx插件。第一步安装插件npm install vitejs/plugin-vue-jsx -D第二步在vite.config.ts里配置import { defineConfig } from vite import vue from vitejs/plugin-vue import vueJsx from vitejs/plugin-vue-jsx export default defineConfig({ plugins: [vue(), vueJsx()] })如果你还在用 Vue CLI / Webpack 的 Babel 方案那就装vue/babel-plugin-jsx{ presets: [vue/cli-plugin-babel/preset], plugins: [vue/babel-plugin-jsx] }注意一下vitejs/plugin-vue-jsx只负责编译.tsx/.jsx文件里的 JSX 语法它不修改模板语法。也就是说你可以在同一个项目里既有.vue文件也有.tsx文件两者完全兼容。一个小建议引入 JSX 之后最好在项目里约定一个规范——.vue文件仍然负责常规页面和简单组件.tsx文件负责动态渲染逻辑较强的组件表格列配置、高阶组件、递归树、表单项渲染器。没有这个规范很快项目就会变得“这也能写、那也能写”然后风格就乱了。3.2 从模板语法到 JSX 的映射对照在 Vue 的 JSX 里很多写法跟模板有对应的关系但又不完全一样。我整理了几个最常用的映射直接对照着看就行。属性与事件模板里绑定属性用:propvalueJSX 里直接用一个对象展开或者直接写属性名// 模板 // input :placeholderplaceholder :disableddisabled / // JSX input placeholder{placeholder} disabled{disabled} /事件绑定模板是clickhandlerJSX 里是onClick{handler}button onClick{handleClick}点击/button对象展开仍然是{...someProps}这个在透传配置时分外好用MyComponent {...field.props} /v-model模板里的v-model在 JSX 里有对应语法糖用法几乎一致MyInput v-model{form.name} /如果没有用语法糖底层写法是这样MyInput modelValue{form.name} onUpdate:modelValue{(val) (form.name val)} /原理上v-model就是modelValue加onUpdate:modelValue的缩写。自定义组件如果用defineModel或者显式声明modelValue在 JSX 里这两种写法都可以。v-if / v-for模板里用v-if、v-for的地方JSX 里直接用 JavaScript 的三元表达式、逻辑与和map{isLoading ? Loading / : Content /} {isAdmin AdminPanel /} {list.map((item) Item key{item.id} data{item} /)}注意v-for里的key在 JSX 里是key属性不是数组索引除非你确定数据不会增删排序否则不要用索引当 key。插槽插槽是模板和 JSX 之间差异最大的点。在子组件内部渲染插槽用的是slots对象// 组件内部 setup(props, { slots }) { return () ( div {slots.default?.()} {slots.header?.({ title: 插槽传参 })} /div ) }在父组件里往子组件传插槽用v-slots属性Child v-slots{{ header: ({ title }: any) div{title}/div, default: () span默认内容/span }} /这里有个很容易踩的坑父组件里定义了插槽内容但没接收子组件也没调用对应插槽那内容就不会渲染。模板里如果你写了template #defaultVue 会自动处理插槽传递JSX 里必须显式把slots传给子组件层层透传的时候尤其要注意。指令Vue 3 的 JSX 对指令支持不如模板直观。简单的v-show可以直接用 JSX 表达式替代但自定义指令比如权限控制、弹窗拖拽用法不同得借助resolveDirective和withDirectivesimport { resolveDirective, withDirectives } from vue const vLoading resolveDirective(loading) export default { setup() { return () withDirectives(div classtable-box /, [ [vLoading, isLoading] ]) } }withDirectives接收 VNode 数组每个数组元素由指令引用、绑定值、参数组成。这个写法比模板复杂所以如果在模板里已经有全局注册的指令简单场景可以直接用模板不要为了用 JSX 而把简单问题复杂化。3.3 一个递归树组件的完整改写案例下面给一个稍微完整点的案例让你直观感受从.vue到.tsx的迁移过程。假设我们要做一个带展开/收起功能的目录树组件。模板版通常长这样template ul li v-fornode in treeData :keynode.id div classtree-node clicktoggle(node) span classtree-node-label{{ node.label }}/span span v-ifnode.children node.children.length classtree-node-toggle {{ expandedMap[node.id] ? 收起 : 展开 }} /span /div TreeView v-ifexpandedMap[node.id] node.children :tree-datanode.children :expanded-mapexpandedMap / /li /ul /template script setup name: TreeView props: { treeData: Array, expandedMap: Object } const toggle (node) { expandedMap.value[node.id] !expandedMap.value[node.id] } /script这个组件已经不算简单了因为展开状态要维护在父组件里递归时还要把这个expandedMap往下传。JSX 改写后可以把展开/收起逻辑收拢进组件内部代码看起来更内聚import { defineComponent, reactive } from vue type TreeItem { id: string label: string children?: TreeItem[] } const TreeView defineComponent({ name: TreeView, props: { treeData: { type: Array as () TreeItem[], required: true } }, setup(props) { const expandedMap reactiveRecordstring, boolean({}) const toggle (node: TreeItem) { expandedMap[node.id] !expandedMap[node.id] } const renderNode (node: TreeItem) ( li key{node.id} div classtree-node onClick{() toggle(node)} span classtree-node-label{node.label}/span {node.children?.length ? ( span classtree-node-toggle {expandedMap[node.id] ? 收起 : 展开} /span ) : null} /div {expandedMap[node.id] node.children?.length ? ( TreeView treeData{node.children} / ) : null} /li ) return () ul{props.treeData.map((node) renderNode(node))}/ul } }) export default TreeView这里的改进点是展开状态从外部传入的数据中剥离出来自己用reactive管理这样调用方不用关心组件内部怎么控制展开。这种“把逻辑往内部收”的写法配上 JSX 的层级表达维护起来舒服很多。4. 常见问题与排查技巧实录4.1 响应式状态与闭包陷阱这是第一次从模板跳到 JSX 时最容易翻车的地方。模板里你写{{ count }}Vue 会自动解包ref。JSX 里没有这个自动解包你必须自己写.valueconst count ref(0) // 错误页面上不会更新 return () div{count}/div // 正确显式访问 .value return () div{count.value}/div还有个更隐蔽的闭包问题。如果你在一个map或者事件回调里捕获了循环变量一定要用let声明循环变量或者把变量通过参数传进函数否则你会拿到循环结束后的最终值。这在 JSX 里比模板更常见因为 JSX 本身就是 JavaScript你要自己注意作用域和闭包。4.2 作用域插槽、具名插槽与高阶组件传插槽插槽在 JSX 里是最容易“失联”的一环。模板里template #default你会自然地想到要用slots.default但 JSX 写高阶组件时经常忘了把slots透传下去。举个真实例子我用withPermission包装了一个Table组件结果发现表格的插槽内容全部不渲染。排查了半天发现高阶组件里根本没把slots传给WrappedComponent。正确做法是如果高阶组件要做插槽透传需要显式把slots对象展开到内部组件function withPermission(WrappedComponent: any, permission: string) { return defineComponent({ setup(_, { slots }) { return () ( WrappedComponent {Object.keys(slots).reduce((acc, name) { acc[name] (slotProps: any) slots[name]?.(slotProps) return acc }, {} as Recordstring, any)} /WrappedComponent ) } }) }注意这里不能用v-slots{slots}直接传因为slots里每个函数需要以插槽函数的形式进入子组件直接展开对象有时候会丢失作用域类型声明。具体项目可能有差异但核心原则是JSX 里插槽要显式传递不能指望像模板那样自动帮你完成。4.3 scoped 样式与 TypeScript 的适配问题模板里scoped样式会自动给元素加上>import styles from ./tree-view.module.css return () div class{styles.treeNode}{node.label}/div另一种是使用全局样式给 JSX 组件相关类名加统一前缀避免污染。我的经验是既然用了 JSX 写动态渲染组件样式也尽量用 CSS Modules 或者原子化 CSS 方案不要再指望 scoped。TypeScript 这边也有个坑.tsx文件里如果用了泛型箭头函数TS 会把它当作 JSX 语法解析必须给泛型加尾逗号。比如写高阶组件时// 会报错 const myFunc T,(arg: T) arg // 这样写也不行被识别为 JSX // const myFunc T(arg: T) arg这个问题不只在 Vue JSX 里React TSX 也有但 Vue 项目里写渲染函数经常需要泛型处理 props所以特别容易碰到。要是报错信息像“Cannot use JSX syntax without the --jsx flag”这种先检查是不是泛型箭头函数的锅。4.4 常见问题速查表问题现象原因与解决页面不更新ref 在 JSX 里用{{ count }}习惯写成{count}不显示JSX 需要显式写.value用{count.value}插槽不渲染写了具名插槽但子组件里空白检查是否用slots.xxx?.()调用了插槽scoped 样式不生效定义的样式在 JSX 渲染的 DOM 上没效果Vue 不会给 JSX 生成的 VNode 加 scoped 属性用 CSS Modules 或全局类事件监听不触发点击按钮没有任何响应事件名确认是onClick不是click自定义事件要用onUpdate:modelValue这类格式泛型报错TSX 中泛型箭头函数被解析成 JSX泛型参数后面加逗号T,递归调用栈溢出树形数据无限递归导致卡死检查递归终止条件渲染前确认node.children有长度这个表格里每一项我都实际踩过尤其是 scoped 样式和泛型那两个几乎每个接手 JSX 的同事都要来问我一遍。4.5 避坑指南什么时候不要用 JSX最后这条可能是最重要的因为它不是说“怎么用”而是“考虑不用”。我见过一些项目为了追求“统一”把全部组件都用 JSX 重写结果普通页面的可读性大幅下降连v-if都写成了模板中本来清晰的条件渲染反而变得隐晦。我的经验是结构固定、嵌套不深、状态简单的组件一定用模板只有组件结构高度依赖数据配置、组件需要递归或高阶封装的情况下才考虑 JSX。不要为了炫技而引入说到底 JSX 只是工具适配场景才是关键。把模板当成默认选项把 JSX 当成特定场景的重型武器这个定位比较健康。真让我给一句判断标准我一般问自己这个组件里有多少内容是从配置数据里长出来的如果超过一半它大概率值得用 JSX 写如果结构基本都是固定的模板会舒服得多。我自己现在维护项目会把.tsx文件集中在components/renderer、components/hoc这两个目录下其他地方一律.vue项目经过一段时间磨合风格还是很统一。