大家好,我是蔓越莓曲奇,今天我想给大家分享的是我最近开源的中后台管理系统模板,Vue TSX Admin 。 正如项目名称所表述的,该项目是完全通过 Vue3 + TSX 开发的。
在讲为什么使用 JSX 前,我想先说些在中后台业务开发中,使用 template 开发的痛点。
将 list 数据进行表格形状的展示在中后台管理系统是最为通用的需求,然而渲染如下图这样一个表格
如果直接使用 element 的组件库,我们需要这样构建模板
<template>
<el-table :data="tableData">
<el-table-column prop="name" label="Name" width="120" />
<el-table-column prop="state" label="Salary" width="120" />
<el-table-column prop="city" label="Address" width="320" />
<el-table-column prop="address" label="Email" width="600" />
</el-table>
</template>
<script>
export default {
setup() {
const tableData = [];
return {
columns,
data
}
},
}
</script>
这样做的缺点是需要开发者需要重复地表达结构相似的 table-column 元素
于是我们进行优化,假定每列渲染的结构相同,那么开发者只需传入每列的所渲染的数据的 key 值,就可以省略掉重复的 column 。
<template>
<a-table
:columns="columns"
:data="data"
/>
</template>
<script>
import { reactive } from 'vue';
export default {
setup() {
const columns = [
{
title: 'Name',
dataIndex: 'name',
},
{
title: 'Salary',
dataIndex: 'salary',
},
{
title: 'Address',
dataIndex: 'address',
},
{
title: 'Email',
dataIndex: 'email',
},
];
const data = [];
return {
columns,
data
}
},
}
</script>
但是这样仍不解决问题,实际业务中,表格列的渲染形态并不是固定死的,并不能简单的根据传入 data 所对应的 key 跟 value 进行渲染,自定义列的并不能简单默认渲染为 value 值,可能是按钮,可能是 Tag ,还可能是各种权限杂糅下的渲染资源,因而需要自定义化,交给开发者决定某些列该如何渲染,我们再次优化,进行插槽拓展,具体思路为传入的 columns 中,需要自定义化的配置 slotName ,不需要的走默认字段渲染逻辑。
<template>
<a-table
:columns="(cloneColumns as TableColumnData[])"
:data="data"
>
<template #name="{ record }">
<span v-if="record.status === 'offline'" class="circle"></span>
<span v-else class="circle pass"></span>
{{ $t(`searchTable.form.status.${record.status}`) }}
{{record.name}}
</template>
</a-table>
</template>
<script>
import { ref } from 'vue';
export default {
setup() {
const show = ref(true)
const columns = [{
title: 'Name',
dataIndex: 'name',
slotName: 'operate'
}, {
title: 'Salary',
dataIndex: 'salary',
}, {
title: 'Address',
dataIndex: 'address',
}, {
title: 'Email',
dataIndex: 'email',
}];
const data = [];
return {
columns,
data,
}
},
}
</script>
这样似乎已经优化到极致了,但开发体验仍旧不好。
在动辄 200 行的 SFC 中,template 的内容一旦增多,我就需要这样开发
但是在 JSX 中,只需要这样表达
export default defineComponent({
name: ViewNames.searchTable,
setup() {
// table columns render logic
const colList = ref([
{
getTitle: () => t('searchTable.columns.number'),
dataIndex: 'number',
checked: true
},
{
getTitle: () => t('searchTable.columns.name'),
dataIndex: 'name',
checked: true
},
{
getTitle: () => t('searchTable.columns.contentType'),
dataIndex: 'contentType',
render: ({ record }: { record: PolicyRecord }) => {
const map: Record<PolicyRecord['contentType'], string> = {
img: '//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/581b17753093199839f2e327e726b157.svg~tplv-49unhts6dw-image.image',
horizontalVideo:
'//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/77721e365eb2ab786c889682cbc721c1.svg~tplv-49unhts6dw-image.image',
verticalVideo:
'//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/ea8b09190046da0ea7e070d83c5d1731.svg~tplv-49unhts6dw-image.image'
}
return (
<>
<Space>
<Avatar size={16} shape="square">
<img alt="avatar" src={map[record.contentType]} />
</Avatar>
{t(`searchTable.form.contentType.${record.contentType}`)}
</Space>
</>
)
},
checked: true
},
{
getTitle: () => t('searchTable.columns.filterType'),
dataIndex: 'filterType',
render: ({ record }: { record: PolicyRecord }) => (
<>{t(`searchTable.form.filterType.${record.filterType}`)}</>
),
checked: true
},
{
getTitle: () => t('searchTable.columns.count'),
dataIndex: 'count',
checked: true
},
{
getTitle: () => t('searchTable.columns.createdTime'),
dataIndex: 'createdTime',
checked: true
},
{
getTitle: () => t('searchTable.columns.status'),
dataIndex: 'status',
render: ({ record }: { record: PolicyRecord }) => {
return (
<Space>
<Badge status={record.status === 'offline' ? 'danger' : 'success'}></Badge>
{t(`searchTable.form.status.${record.status}`)}
</Space>
)
},
checked: true
},
{
getTitle: () => t('searchTable.columns.operations'),
dataIndex: 'operations',
render: () =>
checkButtonPermission(['admin']) && (
<Link>{t('searchTable.columns.operations.view')}</Link>
),
checked: true
}
])
return () => (
<Table
data={renderData.value}
columns={colList.value}
></Table>
)
}
})
这样做的好处是可以获取到上下文的信息,对自定义列进行开发时,可以灵活的向下拓展,不必再同时关注模板跟 script 。
使用声明式弹窗的好处不再赘述,但是当使用模板进行开发时,我们很难获得使用声明式弹窗的完美体验。
声明式弹窗对于 SFC 的难点是怎么在函数调用时,把虚拟 DOM 传递进去,Vue 中无非就三种可能,字符串、h 函数 跟 JSX ,字符串需要引入框架编译时代码,因此不考虑。 大部分组件库都是用的 h 函数这种方案
<template>
<el-button text @click="open">Click to open Message Box</el-button>
</template>
<script lang="ts" setup>
import { h } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const open = () => {
ElMessageBox({
title: 'Message',
message: h('p', null, [
h('span', null, 'Message can be '),
h('i', { style: 'color: teal' }, 'VNode'),
]),
showCancelButton: true,
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
beforeClose: (action, instance, done) => {
if (action === 'confirm') {
instance.confirmButtonLoading = true
instance.confirmButtonText = 'Loading...'
setTimeout(() => {
done()
setTimeout(() => {
instance.confirmButtonLoading = false
}, 300)
}, 3000)
} else {
done()
}
},
}).then((action) => {
ElMessage({
type: 'info',
message: `action: ${action}`,
})
})
}
</script>
但是这种方案很难用,阅读体验跟维护成本都很高。
还有一些组件库的封装思路是不传递虚拟节点了,只传参,通过参数控制弹窗的结构跟行为,但这种方式并不是一种很好的解决方案,因为如果想保持 modal 的灵活性,弹窗内部的大量状态跟行为都需要向外暴露为参数,这就导致了开发者使用需要查看文档,维护者拓展需要继续加参数的局面。
以上各种解决方案都是命令式弹窗在 SFC 开发限制下的妥协产物。 但在 JSX 中,只需要这样,就可以调用一个弹窗。
const handleError = () => {
Modal.error({
title: () => <div>error</div>,
content: () => (
<p>
<span>Message can be error</span>
<IconErro />
</p>
)
})
}
SFC 的特点是什么,关注点分离,关注点分离有什么好处呢?
但在有些场景下,我们并不希望这样的分离。 业务开发中,经常会出现一些小组件,会让我陷入矛盾:需不需要为这些组件单独创建一个新的 Vue 文件进行维护?分割必然会导致组件状态维护成本与通信成本的提高,不封装的后果则是组件经过业务多轮迭代以后,分离这些代码就会成为一件极为痛苦的事情,因为我既需要分离 template ,又需要从混乱的业务中提取维护这些 template 所需要的状态。
但在 JSX 中,可以在 setup 中随时随地的通过函数创建组件,等到分割的时候,只关注这部分维护函数正常运行所需要的状态就可以。
function getGreeting(user) {
if (user) {
return <h1>Hello, {formatName(user)}!</h1>;
}
return <h1>Hello, Stranger.</h1>;
}
以上林林总总的痛点都可以归咎于一个问题: 在 SFC 的开发方式中,没有找到一种对开发者友好的方式在 script 中表达虚拟 DOM 。 但在 JSX 中,可以通过 JavaScript 创建 JSX 进而表达虚拟 DOM ,解决了这个问题。
大部分开发者反驳 Vue + JSX 开发者的第一个问题就是,你都选择了 JSX 为什么还用 Vue 呢?
首先我们明确的一点是 JSX 跟 Vue 并不是对立的两种存在,同时 React 也不等同于 JSX ,所谓创建模板跟使用 JSX 本质上都是在以对开发者更加友好的方式创建虚拟 DOM ,经过渲染框架编译后的产物才是能被浏览器所执行的运行时代码,既然两者编译后的产物如出一辙,同时模板在有些场景不够灵活,为什么不去选择 JSX ?
第二点是我想说的是存在即合理,既然 Vue3 支持通过 JSX 表达虚拟 DOM ,为什么不选择这种方式进行开发?开发者需要明白 Vue 并不是凭空产生的,框架 feature 的出现与各种提案的进行必然伴随着开发者的需求,技术需要依附于业务才能存活,开源项目也是如此。同时 typescript 本身都对 JSX 开了后门,类型推导可以直接通过 typescript 进行配置,而使用 SFC ,为了获取良好的开发体验,还需要借助 IDE 的插件 volar ,与之伴随的就是启动 IDE 还需要关注 volar 的正常运行,而 volar 的运行又需要依赖 typescript ,所以为什么不直接使用 JSX 呢?
大部分开发者纠结于 JSX 开发的无非以下几点
入门成本与迁移成本
如果是几年前的我,还可能会颇有微词地认为 JSX 并不适合前端开发初学者,但是在大环境越来越卷的今天,各种 mini vue 跟 Vue 原理的文章层出不穷,JSX 的入门成本基本为 0 ,如果你能流畅的进行 SFC 的开发,JSX 的开发也基本不在话下,同时,使用 JSX 还会让你更加深刻的理解 Vue 这个框架。
关于语法迁移,babel-plugin-jsx 已经完成了大量的语法转换,同时业界已经涌现了许多文章进行详细说明,我就不过多介绍,只说常用的几点
v-show
、v-model
目前可以在 JSX 中使用
事件修饰符可以通过 withModifiers
进行替换
但是 v-pre
、v-cloak
和v-memo
目前还没有特别完美的替代方案,有条件的同学可以去提 PR 。
插槽写法变的更加容易理解 -> 本质上就是函数传参
const A = (props, { slots }) => (
<>
<h1>{slots.default ? slots.default() : 'foo'}</h1>
<h2>{slots.bar?.()}</h2>
</>
);
const App = {
setup() {
const slots = {
bar: () => <span>B</span>,
};
return () => (
<A v-slots={slots}>
<div>A</div>
</A>
);
},
};
// or
const App = {
setup() {
const slots = {
default: () => <div>A</div>,
bar: () => <span>B</span>,
};
return () => <A v-slots={slots} />;
},
};
// or you can use object slots when `enableObjectSlots` is not false.
const App = {
setup() {
return () => (
<>
<A>
{{
default: () => <div>A</div>,
bar: () => <span>B</span>,
}}
</A>
<B>{() => 'foo'}</B>
</>
);
},
};
事件绑定需要注意的一点就是如果要传递自定义的参数,就需要使用箭头函数或者通过 bind 绑 this ,否则就会造成回调函数自动触发。
JSX 性能是比不过模板的,这点无可否认,但是模板的性能优化究竟占据了多大一个部分? Vue 模板比 JSX 更高效的原因在于,Vue 的编译过程可以在编译阶段对模板进行静态分析,并生成更精确的渲染函数。我们可以将其理解为在编译过程中,Vue 在以一种 treeshaking 的思路进行优化,通过删除无用的逻辑分支,以此生成最优代码。听起来很高大上是不是,但是按照计算机科学的角度来讲,这一部分进行的优化的效果是极为有限的,这一点我也向官方求证了,维护者对模板跟 JSX 的性能差异是这么形容的:
但是前端好歹是一门工科,a bit less 如何用数字衡量呢?
为此,我找到了 js-framework-benchmark ,一个基准测试框架性能的工具,也就是我们俗称的跑分,这个工具的原理是让各种渲染框架都去实现一个业务场景,然后使用 puppeteer 模拟各种浏览器行为进行测试获取性能指标。
js-framework-benchmark 目前是没有 Vue JSX 的跑分结果的,为此我 clone 了项目进行了本地测试。
通过表格可以看出,Vue + JSX 的性能是差,但也是只略差,并不能成为抵触 Vue + JSX 开发的理由,换一方面来说,中后台开发中能触碰到到 Vue 性能瓶颈的场景真的多么? 这个问题打个比方,就好像我在玩 LOL ,你在跟我说玩 LOL ,用 4090 跟 4070 存在性能差距、4090 开启超频后体验会更好,这不是跟我扯犊子么,我玩个 LOL 还需要特别在意用 4090 还是 4070, 4090 显卡是否超频么? 中后台业务中虚拟化数据渲染跟增量更新基本已经满足大部分性能场景,如果说一个业务方案的性能瓶颈都需要考虑到 DSL 方面的性能,那么这个业务本身的设计方案也需要重新审视跟考量了。
大家都在讨论 Vue3 + JSX 的可行性,但是却鲜有开源开箱即用的业务项目,担心踩坑没有方案参考或者投入成本的淹没,同时公司内部确实没有一个良好的环境提供开发者进行实践与探索。但开源无疑是最好的方式,这一点也是我做这个项目的原因,于是 Vue-TSX-Admin 就诞生了 🎉 。
Vue TSX Admin 是一个免费开源的中后台管理系统模块版本,UI 参考 acro design pro + ant design pro ,它使用了最新的前端技术栈,完全采用 Vue3 + TSX 的模式进行开发,提供了开箱即用的中后台前端解决方案,内置了 i18n 国际化解决方案,可配置化布局,主题色修改,权限验证,提炼了典型的业务模型,可以帮助你快速搭建起一个中后台前端项目。
主要的开发方案为:
登录用户名:admin 密码:admin 登录用户名:user 密码:user
# 克隆项目
git clone https://github.com/manyuemeiquqi/vue-tsx-admin.git
# 进入项目目录
cd vue-tsx-admin
# 安装依赖
pnpm install
# 启动服务
pnpm run dev
浏览器访问: http://localhost:5173/vue-tsx-admin/ 即可
pnpm run build
# husky 安装
pnpm run husky
# 格式化
pnpm run format
# 代码 lint + fix
pnpm run lint
pnpm run lint-style
可参考 https://github.com/krausest/js-framework-benchmark/pull/1546#issuecomment-1872904990
最后,如果本项目帮助到你,希望你可以帮作者点个 star ⭐ 表示鼓励 如果你发现项目 bug ,欢迎提 PR , 感谢 🤞
这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。
V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。
V2EX is a community of developers, designers and creative people.