V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
爱意满满的作品展示区。
manyuemeiquqi
V2EX  ›  分享创造

🎉Vue TSX Admin, 中后台管理系统开发的新方向

  •  
  •   manyuemeiquqi · 119 天前 · 2145 次点击
    这是一个创建于 119 天前的主题,其中的信息可能已经有所发展或是发生改变。

    大家好,我是蔓越莓曲奇,今天我想给大家分享的是我最近开源的中后台管理系统模板,Vue TSX Admin 。 正如项目名称所表述的,该项目是完全通过 Vue3 + TSX 开发的。

    为什么使用 JSX 写中后台管理

    在讲为什么使用 JSX 前,我想先说些在中后台业务开发中,使用 template 开发的痛点。

    template 写中后台管理系统的痛点

    • 表格自定义列冗余

    将 list 数据进行表格形状的展示在中后台管理系统是最为通用的需求,然而渲染如下图这样一个表格 image.png

    如果直接使用 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 的内容一旦增多,我就需要这样开发 image.png 一份文件分割成两个屏幕(一个分成模板,一个分成 script ),然后进行开发,一般改 bug 时,我需要两边一起定位开发,这不得不说是很痛苦的开发体验。

    但是在 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 的灵活性,弹窗内部的大量状态跟行为都需要向外暴露为参数,这就导致了开发者使用需要查看文档,维护者拓展需要继续加参数的局面。 image.png image.png

    以上各种解决方案都是命令式弹窗在 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

    大部分开发者反驳 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 开发的无非以下几点

    • 入门成本与迁移成本

      1. 如果是几年前的我,还可能会颇有微词地认为 JSX 并不适合前端开发初学者,但是在大环境越来越卷的今天,各种 mini vue 跟 Vue 原理的文章层出不穷,JSX 的入门成本基本为 0 ,如果你能流畅的进行 SFC 的开发,JSX 的开发也基本不在话下,同时,使用 JSX 还会让你更加深刻的理解 Vue 这个框架。

      2. 关于语法迁移,babel-plugin-jsx 已经完成了大量的语法转换,同时业界已经涌现了许多文章进行详细说明,我就不过多介绍,只说常用的几点

      • 模板指令

    v-showv-model目前可以在 JSX 中使用 事件修饰符可以通过 withModifiers 进行替换 但是 v-prev-cloakv-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 ,否则就会造成回调函数自动触发。

    • DSL 的性能

    JSX 性能是比不过模板的,这点无可否认,但是模板的性能优化究竟占据了多大一个部分? Vue 模板比 JSX 更高效的原因在于,Vue 的编译过程可以在编译阶段对模板进行静态分析,并生成更精确的渲染函数。我们可以将其理解为在编译过程中,Vue 在以一种 treeshaking 的思路进行优化,通过删除无用的逻辑分支,以此生成最优代码。听起来很高大上是不是,但是按照计算机科学的角度来讲,这一部分进行的优化的效果是极为有限的,这一点我也向官方求证了,维护者对模板跟 JSX 的性能差异是这么形容的: image.png

    但是前端好歹是一门工科,a bit less 如何用数字衡量呢? 为此,我找到了 js-framework-benchmark ,一个基准测试框架性能的工具,也就是我们俗称的跑分,这个工具的原理是让各种渲染框架都去实现一个业务场景,然后使用 puppeteer 模拟各种浏览器行为进行测试获取性能指标。 js-framework-benchmark 目前是没有 Vue JSX 的跑分结果的,为此我 clone 了项目进行了本地测试。 动画.gif 由于目前渲染框架众多,我只选取了几个主流框架进行对比,测试结果如下,有兴趣的同学可以跟开源的测试结果进行比对,由于计算机硬件的不一致,性能指标的毫秒数字并没有太大的对比意义,只需查看每个表格最后一行的 weighted geometric mean 查看一个大概趋势就可以,同时我也提交了 PR(内部我认为还有优化空间,有兴趣的同学可以进行 PR 调优),或许过几天后就可以看到官方测试结果了。 image.png image.png image.png

    通过表格可以看出,Vue + JSX 的性能是差,但也是只略差,并不能成为抵触 Vue + JSX 开发的理由,换一方面来说,中后台开发中能触碰到到 Vue 性能瓶颈的场景真的多么? 这个问题打个比方,就好像我在玩 LOL ,你在跟我说玩 LOL ,用 4090 跟 4070 存在性能差距、4090 开启超频后体验会更好,这不是跟我扯犊子么,我玩个 LOL 还需要特别在意用 4090 还是 4070, 4090 显卡是否超频么? 中后台业务中虚拟化数据渲染跟增量更新基本已经满足大部分性能场景,如果说一个业务方案的性能瓶颈都需要考虑到 DSL 方面的性能,那么这个业务本身的设计方案也需要重新审视跟考量了。

    • 业界鲜有实践

    大家都在讨论 Vue3 + JSX 的可行性,但是却鲜有开源开箱即用的业务项目,担心踩坑没有方案参考或者投入成本的淹没,同时公司内部确实没有一个良好的环境提供开发者进行实践与探索。但开源无疑是最好的方式,这一点也是我做这个项目的原因,于是 Vue-TSX-Admin 就诞生了 🎉 。

    Vue TSX Admin 是什么

    logo-8b7cc132.svg

    简介

    Vue TSX Admin 是一个免费开源的中后台管理系统模块版本,UI 参考 acro design pro + ant design pro ,它使用了最新的前端技术栈,完全采用 Vue3 + TSX 的模式进行开发,提供了开箱即用的中后台前端解决方案,内置了 i18n 国际化解决方案,可配置化布局,主题色修改,权限验证,提炼了典型的业务模型,可以帮助你快速搭建起一个中后台前端项目。

    主要的开发方案为:

    • CSS 方案 modules css + tailwind
    • 网络请求 axios
    • 鉴权方案 token + jwt
    • 模拟数据方案 mockjs
    • 全局数据状态管理 pinia
    • ui 组件库 arco desigin vue
    • 工具库 lodash vue-use
    • 国际化切换方案 vue-i18n
    • 打包方案+静态服务器 vite

    预览地址

    访问地址

    登录用户名:admin 密码:admin 登录用户名:user 密码:user

    代码地址

    安装使用

    • 项目条件
      • Node.js 18+
      • pnpm 8.5.0
    • 使用
    # 克隆项目
    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
    
    

    浏览器支持

    • Chrome >=87
    • Firefox >=78
    • Safari >=14
    • Edge >=88
    • Vue3 不支持 IE

    关于 Vue JSX 的性能问题

    可参考 https://github.com/krausest/js-framework-benchmark/pull/1546#issuecomment-1872904990

    演示

    动画.gif

    作者

    manyuemeiquqi

    License

    MIT License

    最后,如果本项目帮助到你,希望你可以帮作者点个 star ⭐ 表示鼓励 如果你发现项目 bug ,欢迎提 PR , 感谢 🤞

    8 条回复    2024-01-03 14:27:23 +08:00
    superpeaser
        1
    superpeaser  
       119 天前 via iPhone
    支持
    BeijingBaby
        2
    BeijingBaby  
       119 天前
    感觉 antd 表格之类的复杂组件挺好用的。
    twofox
        3
    twofox  
       119 天前   ❤️ 1
    挺好看的,但是我不是很喜欢 defineComponent 这个语法,用上这个之后,会显得很繁琐

    我都是这样用的

    ```vue
    <template>
    <div></div>
    </template>

    <script lang="tsx" setup>
    import {ref} from 'vue'

    defineOptions({name: 'testVue'})

    </script>
    ```

    这样我既能用 jsx ,又能用上 setup 语法糖,template 也能用
    wuzhanggui
        4
    wuzhanggui  
       119 天前
    支持,我也做了一个🤣
    https://github.com/wurencaideli/dumogu-admin
    QlanQ
        5
    QlanQ  
       119 天前
    我觉得越复杂,中台系统里面,这种 大量重复的 其实并不多,一个功能的列表可能就只需要一个就够了,
    而且 不同功能的列表,至少后面的 功能列都需要单独区分的

    最重要的一点 用 js 和 html 混编 ,感觉就是开倒车
    BugCry
        6
    BugCry  
       119 天前 via Android   ❤️ 1
    恭喜你重新发明了 react
    xudashan
        7
    xudashan  
       119 天前
    点赞
    szmx
        8
    szmx  
       118 天前
    哇趣,和我现在的中台模版差不多了,不过我没用 tsx
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   1001 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 29ms · UTC 18:28 · PVG 02:28 · LAX 11:28 · JFK 14:28
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.