likes
comments
collection
share

用 vite + vue3 + ts + pinia 搭建一套自己的后台管理系统 (1)

作者站长头像
站长
· 阅读数 71

前言

一直想着要去写点东西,去记录一下,对自己也是一个提高,但是想是一回事,做是一回事,然后就拖,拖,拖,就到了现在。直到最近看到一本大佬(冴羽)推荐的书《认知觉醒》《认知驱动》,这两本书目前还在看。记得很深刻的一个观点输入与输出要平衡,如果只是一味的去输入,但是从来不进行输出,总结与思考,那也终究是流水而过,并不能让我们很快的提高,只有输入和输出有效平衡,达到知行合一,才能成长。有句话叫做最好的种树时间是十年前,其次是在现在,也不算晚,撸起袖子加油干。那就从今天起,摆脱焦虑,从零出发!

技术选型

公司用的技术是 vue2 ,要做新项目,react 与 vue3 二选一,之前的一个项目也用过 react 不过是用 class 那种方式。算是比较早一点了。最后选择了 vue3,如果有时间可以用 react Hooks方式重构一下,当时是这样想的,现在也在推进中(因为不是特别忙,周末有时间也可以搞一搞)于是就选了 vue3 + ts + vite 脚手架

1 初始化项目

当时用的 vite3 现在已经到 vite4 了,应该差别不大,具体不同跟着api来就行。下面是我的项目配置选项 npm create vite@latest

用 vite + vue3 + ts + pinia 搭建一套自己的后台管理系统 (1)

用 vite + vue3 + ts + pinia 搭建一套自己的后台管理系统 (1)

  1. 根据提示 npm install npm run dev 运行项目(这个是vite4,为了截图演示,和vite3差别不大)

用 vite + vue3 + ts + pinia 搭建一套自己的后台管理系统 (1)

2 路由

  1. 脚手架已经帮我们配置好了路由,需要注意的一个点,就是使用 History 模式时打包时的一个配置要加上,不然会出现路由找不到的问题,这个和发布时 nginx 配置没关系
export default defineConfig({
  base: '/', // 开发或生产环境服务的公共基础路径。合法的值包括以下几种:
  //  绝对 URL 路径名,例如 /foo/
  //  完整的 URL,例如 https://foo.com/
  //  空字符串或 ./(用于开发环境)
})
  1. 然后来说动态路由的实现,我们的路由权限时前后端协调,前端定义路径,路由名称相关的东西,具体的字段定义根据自己实际项目来就好,路由权限存入数据库,超管给用户分配权限,权限精细到按钮级别。左侧菜单用路由返回渲染,按钮进行自定义指令来实现。废话不多说,继续看代码实现,后台返回的是这样格式的权限,分为三级,外层布局,页面路由,按钮路由(如果只是到页面路由,无精确到按钮,或者不用动态赋值权限可以二级,不然后台数据返回无法成树结构)
const routerMap = [
  {
    authorityName: '一级',
    component: 'layout',
    icon: '',
    label: '一级',
    parentId: null,
    routeName: 'home',
    url: '/home',
    children: [
      {
        authorityName: '我的',
        component: 'home/index',
        icon: '',
        label: '我的',
        routeName: 'my',
        url: 'my',
        children: [
          {
            authorityName: '查看',
            component: 'home/my/index',
            icon: '',
            label: '查看',
            routeName: 'my-query',
            url: '',
            children:[]
          },
          {
            authorityName: '添加,编辑渠道',
            component: 'home/my/index-edit',
            icon: '',
            label: '查看',
            routeName: 'my-edit',
            showInConfig: 1,
            url: 'my-edit',
            children: []
          }
        ]
      }
]

  1. 和使用 vue-cli 脚手架不同的的点,webpack 里可以直接使用 import 进行路由导入,但是在 vite 里面,虽然也可是使用动态import但是文件路径变为深层的就出问题了(支持动态但又不全支持,不好用) 用 vite + vue3 + ts + pinia 搭建一套自己的后台管理系统 (1) 所以我们选择 使用另一种方式 import.meta.glob 具体详细使用查看官网 用 vite + vue3 + ts + pinia 搭建一套自己的后台管理系统 (1) 这个 import.meta.glob 返回的是一个 modules ,这里面存放的是按照你给的路径的所有匹配文件,我们需要根据这个匹配文件和后台返回的路径进行对比,拿到权限,进行动态加载。定义promission.ts 在main.ts内引入
  2. 动态路由添加:根据接口返回的路由权限,进行递归添加路由处理。如果路由名称为 'layout' 说明是我们的第一层级,就用我们引入的 layout 布局组件,直接通过 addRouter 进行路由添加(router4已经废弃掉addRouters)。如果不是,则定义通过 modules 匹配查找,这里的定义的 obj 就是需要动态添加的路由。(meta属性根据自己的需求来添加,如果不需要,则不要加该属性不影响流程)。后面 item.children 判断内的循环主要作用是,给不是 layout 组件的路由添加父级路由名称,不然无法找到子级路由的父级。(leveL和lable属性根据自己的业务看判断是否要加) 然后进行一层递归处理就完成动态路由的实现。
  3. 路由拦截:每次路由跳转都会进入该拦截器里面,所以我们可以在这里面进行逻辑处理,如果没有 token(因为业务逻辑中登录分为两步,所以加了用户数据的判断),说明用户未登录,无论进入哪些页面(路由过滤页除外,比如修改密码之类的)都会重新进入登录页。如果已经登录,判读路由是否添加完成(如果获取的路由长度大于你定义的基础路由,则说明动态路由已经添加完成)如果路由已经添加完成,判读进入的路径是否是登录页,如果不是则放行,如果是则进入首页。如果路由未加载,则需要进行动态加载后再判断进行相同的判断。如果未登录,任何路径都调转到登录页(需要过滤的路由除外)到这一步动态路由已经全部完成。

promission.ts

import router from './router'
import layout from './views/layout/index.vue'
const modules = import.meta.glob('@/views/**/*.vue')
import pinia from '@/stores'
import { userStore } from './stores/users'

// 在router中使用pinia  https://blog.csdn.net/youyacoder/article/details/127244318?utm_medium=distribute.pc_relevant.none-task-blog-2~default~baidujs_baidulandingword~default-0-127244318-blog-124769206.pc_relevant_multi_platform_whitelistv3&spm=1001.2101.3001.4242.1&utm_relevant_index=3

const filterRouter = (promissionList: any) => {
  // console.log(promissionList)
  promissionList = JSON.parse(JSON.stringify(promissionList)) //接口返回的权限数据
  const accessedRouters = promissionList.map((item: any) => {
    if (item.component === 'layout') {
      item.componentVue = layout
    } else {
      // 如果有component  则需要渲染路由,否则则不需要(如弹窗类新增操作,删除操作,接口返回树结构进行按钮权限判断)
      item.componentVue = item.component ? modules[`/src/views/${item.component}.vue`] : ''
    }
    const obj = {
      path: item.url,
      name: item.routeName,
      component: item.componentVue,
      meta: {
        level: item.component === 'layout' ? 1 : item.level, // 如果为一级菜单则取值level 1 否则取值level
        label: item.label
      }
    }
    if (item.component === 'layout') {
      // 如果component 为layout则该组件为根路由
      router.addRoute(obj)
    } else if (item.component) {
      // 无component 无需动态添加路由
      router.addRoute(item.parent, obj)
    }
    if (item.children && item.children.length > 0) {
      item.children = item.children.map((ele: any) => {
        ele.level = ele.url ? obj.meta.level + 1 : obj.meta.level // 左侧菜单栏默认为查询路由,url为空,level等级为父级等级
        ele.parent = item.routeName
        ele.label = item.component && ele.url === '' ? obj.meta.label : ele.label
        return ele
      })
      item.children = filterRouter(item.children)
    }
    return item
  })
  return accessedRouters
}

路由拦截:
const routerFilters: Array<string> = ['/resetpassword'] // 路由过滤,去除不需要校验的路由
router.beforeEach(async (to, from, next) => {
  const { users } = userStore(pinia) // beforeEach 之前store 还未u挂载,不能获取实例,所以在beforeEach 内部获取
  const token: string | null = localStorage.getItem('token')
  if (token && users.authorityTrees && users.authorityTrees.length > 0) {
    const routerPath = `${users.authorityTrees[0].url}/${users.authorityTrees[0].children[0].url}`
    if (router.getRoutes().length <= 4) {
      await filterRouter(users.authorityTrees)
      if (to.path !== '/login') {
        next({ ...to, replace: true })
      } else {
        next(routerPath) // 无权限页面跳转到首页
      }
    } else if (to.path !== '/login') {
      next()
    } else {
      next(routerPath)
    }
  } else if (to.path === '/login') {
    localStorage.removeItem('token')
    next()
  } else if (routerFilters.includes(to.path)) {
    localStorage.removeItem('token')
    next()
  } else {
    localStorage.removeItem('token')
    next('/login')
  }
})

3 全局状态管理(pinia)

  1. 脚手架已经帮我们初始化完成了 pinia, 我们可以稍微改造一下,因为后面有几个地方需要用到暴露出来的 pinia。我们在 store 文件夹下新建 index.ts ,然后在 main.ts 里面直接使用我们暴露出来的 pinia
store/index.ts
    import { createPinia } from 'pinia'
    const pinia = createPinia()
    export default pinia
mian.ts
import { createApp } from 'vue'
import pinia from './stores'
import App from './App.vue'
import router from './router'
import './promission'
import { permission } from '@/utils/directive/index'
const app = createApp(App)
app.directive('permission', permission)
app.use(pinia)
app.use(router)
app.mount('#app')
  1. 有个点需要注意一下,就是非 vue 文件,比如我们的路由拦截器里面,如果使用我们的全局状态数据,按照vue文件里面写法第一次是拿不到值的,beforeEach 之前store 还未挂载,不能获取实例,所以需要在 beforeEach 内部获取,一个是我们暴露出来的 pinia,一个是我们暴露出来的单个module(vue2可以这么叫,vue3可能不太准确),我们在 router.beforeEach里面使用 const { users } = userStore(pinia) 就可以拿到数据

  2. pinia数据持久化处理,使用起来也比较简单,安装完成 pinia-plugin-persist 后直接在 main.ts 使用,在我们的modules里面直接开启设置就可以使用了

import { createApp } from 'vue'
import pinia from './stores'
import piniaPersist from 'pinia-plugin-persist'
import App from './App.vue'
import router from './router'
import './promission'
const app = createApp(App)
app.directive('permission', permission)
pinia.use(piniaPersist)
app.use(pinia)
app.use(router)
app.mount('#app')
store/nav.ts
import { defineStore } from 'pinia'
export const navStore = defineStore('navKeys', {
  state: () => ({
    list:[]
  }),
  actions: {
    setNav() {
      ....
    }
  },
  persist: {
    enabled: true, // 开启存储
    // **strategies: 指定存储位置以及存储的变量都有哪些,该属性可以不写,不写的话默认是存储到sessionStorage里边,默认存储state里边的所有数据**
    strategies: [
      { storage: localStorage }
      // storage 存储到哪里 sessionStorage/localStorage
      // paths是一个数组,要存储缓存的变量,当然也可以写多个
      // paths如果不写就默认存储state里边的所有数据,如果写了就存储指定的变量
    ]
  }
})

4 项目配置

 import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// ant-design-vue
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
// gzip 压缩
import viteCompression from 'vite-plugin-compression'

import { visualizer } from 'rollup-plugin-visualizer' // 打包模块数据分析 在根目录生成stats.html

import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),  
//vue vue-router自动引入,我们在vue文件中使用ref reactive 这样的vue hooks就不用去引入,直接可以使用
    AutoImport({
      imports: ['vue', 'vue-router']
    }), 
// antd组件自动引入 dirs 设置全局组件自动引入
    Components({
      resolvers: [AntDesignVueResolver()],
      dirs: ['src/components']
    }),
// gzip压缩
    viteCompression({
      verbose: true,
      disable: false,
      threshold: 10240,
      algorithm: 'gzip',
      ext: '.gz'
    }),
//打包文件大小查看,在根目录生成stats.html
    visualizer()
  ],
  base: '/', // 开发或生产环境服务的公共基础路径。合法的值包括以下几种:
  //  绝对 URL 路径名,例如 /foo/
  //  完整的 URL,例如 https://foo.com/
  //  空字符串或 ./(用于开发环境)
  build: {
    minify: 'terser',
    terserOptions: {
      compress: {
        keep_infinity: true, // 防止 Infinity 被压缩成 1/0,这可能会导致 Chrome 上的性能问题
        drop_console: true, // 生产环境去除 console
        drop_debugger: true // 生产环境去除 debugger
      }
    },
//打包分类以及超大资源拆分
    rollupOptions: {
      output: {
        chunkFileNames: 'static/js/[name]-[hash].js',
        entryFileNames: 'static/js/[name]-[hash].js',
        // assetFileNames: 'static/[ext]/[name]-[hash].[ext]' 开启会出现异常bug
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // 超大静态资源拆分
            return id.toString().split('node_modules/')[1].split('/')[0].toString()
          }
        }
      }
    },
    chunkSizeWarningLimit: 1500 // chunk 大小警告的限制(以 kbs 为单位)
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src')
    }
  },
//全局css
  css: {
    preprocessorOptions: {
      javascriptEnabled: true,
      less: { additionalData: '@import "@/assets/style/var.less";' }
    }
  },
//环境变量
  envDir: path.resolve(__dirname, './env'),
  server: {
    hmr: true,
    proxy: {
      '/apis': {
        target: 'https://xxx-xxx.com/',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/apis/, '')
      }
    }
  }
})

还有一点需要注意的是我们在 tsconfig.json 文件里面配置自动生成(vue,antd自动引入配置)的 auto-imports.d.ts components.d.ts 文件

tsconfig.json
   "include": ["env.d.ts", "src/**/*", "src/**/*.vue","./auto-imports.d.ts","./components.d.ts"],

开启gzip需要在nginx里面做相应的配置

gzip  on; gzip_min_length  1k; gzip_buffers     4 16k; gzip_http_version 1.1; gzip_comp_level 9; gzip_types       text/plain application/x-javascript text/css application/xml text/javascript application/x-httpd-php application/javascript application/json; gzip_disable "MSIE [1-6]."; gzip_vary on;

环境变量的话我们在根目录创建env文件夹,,里面配置我们的接口请求域名

用 vite + vue3 + ts + pinia 搭建一套自己的后台管理系统 (1) .env 开发环境使用默认 .uat 测试环境 .proud 生产环境 我们需要在 package.json 里面更新运行命令

  "scripts": {
    "dev": "vite --port 8888 --open",
    "dev:uat": "vite --mode uat",
    "dev:prod": "vite --mode prod",
    "build": "run-p type-check build-only",
    "build:uat": "run-p type-check && vite build --mode uat",
    "build:prod": "run-p type-check && vite build --mode prod",
    "preview": "vite preview --open"
  },

我们需要在 axios 实例里面配置环境变量

用 vite + vue3 + ts + pinia 搭建一套自己的后台管理系统 (1)

5 axios接口封装

  1. 创建aios实例,如上图
  2. require 配置 配置请求头,以及登录成功请求头添加token
  3. Response 配置 正常返回,接口异常处理等。出参数据类型我们可以进行定义,这样我们在请求完成后就能拿到定义这层的代码提示,具体的返回数据 data 我们可以在 api 中定义,然后在 catch 里面我们根据自己的业务来统一处理异常,如401 403 500等
// 引入axios
import axios from 'axios'
import type { AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
import { userStore } from '@/stores/users'
import { Message } from './../utils/index'
import router from '../router'
// 使用指定配置创建axios实例
const instance = axios.create({
  baseURL: import.meta.env.VITE_BASE_API,
  timeout: 30000
  // ....其他配置
})
instance.interceptors.request.use(
  (config: AxiosRequestConfig) => {
    const store = userStore()
    // console.log(store)
    const token: string | null = localStorage.getItem('token')
    config.headers = {
      ...{ 'Content-Type': 'application/json', 'Accept-Language': 'zh-CN' },
      ...config.headers
    }
    if (token) {
      config.headers['Authorization'] = `bearer ${token}`
      config.headers['userId'] = store.users.userId
    }
    // console.log(config)
    return config
  },
  (err: AxiosError) => Promise.reject(err)
)

// 后台给我们的数据类型如下
// 泛型T指定了Response类型中result的类型,默认为any
type Response<T = any> = {
  // 描述
  desc: string
  message: string
  msgParam: string
  status: string
  data: T
}

// AxiosRequestConfig从axios中导出的,将config声明为AxiosRequestConfig,这样我们调用函数时就可以获得TS带来的类型提示
// 泛型T用来指定Reponse类型中result的类型
export default <T>(config: AxiosRequestConfig) =>
  // 指定promise实例成功之后的回调函数的第一个参数的类型为Response<T>
  new Promise<Response<T>>((resolve, reject) => {
    // console.log(config)
    // instance是我们在上面创建的axios的实例
    // 我们指定instance.request函数的第一个泛型为Response,并把Response的泛型指定为函数的泛型T
    // 第二个泛型AxiosResponse的data类型就被自动指定为Response<T>
    // AxiosResponse从axios中导出的,也可以不指定,TS会自动类型推断
    instance
      .request<Response<T>>(config)
      .then((response: AxiosResponse<Response<T>>) => {
        // console.log(response.data)
        // response类型就是AxiosResponse<Response<T>>,而data类型就是我们指定的Response<T>
        // 请求成功后就我们的数据从response取出并使返回的promise实例的状态变为成功的
        resolve(response.data)
      })
      .catch((err: AxiosError) => {
          swith(){
             case....;
             case....;
          }
  })

有个点需要注意一下就是类似 antd 里面Message 这样的方法,我们使用时是需要自己再引入的,因为它和组件还是有点区别的

引入封装好的请求 request 暴露出我们的接口就可以使用了

// 登录相关接口
import request from '../axios'
// 登录
export interface ILogin {
  username: string
  password: string
}
// 登录
export const Login = (data: ILogin) =>
  // request中的出参类型根据我们实际的业务来定义
  request<any>({
    url: 'xxx/login',
    method: 'POST',
    data
  })

6 代码格式化

采用 eslint + prettier 进行代码格式化 .eslintrc.cjs 如下

/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')

module.exports = {
  root: true,
  extends: ['plugin:vue/vue3-essential', 'eslint:recommended', '@vue/eslint-config-typescript', '@vue/eslint-config-prettier', 'vue-global-api'],
  parserOptions: { parser: '@typescript-eslint/parser', ecmaVersion: 'latest', sourceType: 'module' },
  rules: {
    'no-inferrable-types': 'off', // **此处为修改*
    'vue/multi-word-component-names': ['error', { ignores: ['index'] }],
    // --以下是Possible Errors JS代码中的逻辑错误相关
    'no-extra-parens': 'error', // 禁止不必要的括号
    // "no-console": "error" // 不允许打印console.log
    'no-template-curly-in-string': 'error', // 禁止在常规字符串中出现模板字符串语法${xxx}
    // --以下是Best Practices 最佳实践
    'default-case': 'error', // 强制switch要有default分支
    'dot-location': ['error', 'property'], // 要求对象的点要跟属性同一行
    eqeqeq: 'error', // 要求使用 === 和 !==
    'no-else-return': 'error', // 禁止在else前有return,return和else不能同时存在
    'no-empty-function': 'error', // 禁止出现空函数,有意而为之的可以在函数内部加条注释
    'no-multi-spaces': 'error', // 禁止出现多个空格,如===前后可以有一个空格,但是不能有多个空格
    'no-multi-str': 'error', // 禁止出现多行字符串,可以使用模板字符串换行
    'no-self-compare': 'error', // 禁止自身比较
    'no-unmodified-loop-condition': 'error', // 禁止一成不变的循环条件,如while条件,防止死循环
    'no-useless-concat': 'error', // 禁止没有必要的字符串拼接,如'a'+'b'应该写成'ab'
    'require-await': 'error', // 禁止使用不带await的async表达式
    // --以下是Stylistic Issues 主观的代码风格
    'array-element-newline': ['error', 'consistent'], // 数组元素要一致的换行或者不换行
    'block-spacing': 'error', // 强制函数/循环等块级作用域中的花括号内前后有一个空格(对象除外)
    'brace-style': ['error', '1tbs', { allowSingleLine: true }], // if/elseif/else左花括号要跟if..同行,右花括号要换行;或者全部同一行
    'comma-dangle': ['error', 'only-multiline'], // 允许在对象或数组的最后一项(不与结束括号同行)加个逗号
    'comma-spacing': 'error', // 要求在逗号后面加个空格,禁止在逗号前面加一个空格
    'comma-style': 'error', // 要求逗号放在数组元素、对象属性或变量声明之后,且在同一行
    'computed-property-spacing': 'error', // 禁止在计算属性中出现空格,如obj[ 'a' ]是错的,obj['a']是对的
    'eol-last': 'error', // 强制文件的末尾有一个空行
    'func-call-spacing': 'error', // 禁止函数名和括号之间有个空格
    'function-paren-newline': 'error', // 强制函数括号内的参数一致换行或一致不换行
    // 'implicit-arrow-linebreak': 'error', // 禁止箭头函数的隐式返回 在箭头函数体之前出现换行
    // indent: ['error', 2], // 使用一致的缩进,2个空格
    'key-spacing': 'error', // 强制对象键值冒号后面有一个空格
    'lines-around-comment': 'error', // 要求在块级注释/**/之前有一个空行

    'newline-per-chained-call': ['error', { ignoreChainWithDepth: 2 }], // 链式调用长度超过2时,强制要求换行
    'no-lonely-if': 'error', // 禁止else中出现单独的if
    'no-multiple-empty-lines': 'error', // 限制最多出现两个空行
    'no-trailing-spaces': 'error', // 禁止在空行使用空白字符
    'no-unneeded-ternary': 'error', // 禁止多余的三元表达式,如a === 1 ? true : false应缩写为a === 1
    'no-whitespace-before-property': 'error', // 禁止属性前有空白,如console. log(obj['a']),log前面的空白有问题
    'nonblock-statement-body-position': 'error', // 强制单行语句不换行
    'one-var-declaration-per-line': 'error', // 强制变量初始化语句换行
    'operator-assignment': 'error', // 尽可能的简化赋值操作,如x=x+1 应简化为x+=1
    'semi-spacing': 'error', // 强制分号后面有空格,如for (let i=0; i<20; i++)
    'semi-style': 'error', // 强制分号出现在句末
    'space-before-blocks': 'error', // 强制块(for循环/if/函数等)前面有一个空格,如for(...){}是错的,花括号前面要空格:for(...) {}
    'space-infix-ops': 'error', // 强制操作符(+-/*)前后有一个空格
    'spaced-comment': 'error', // 强制注释(//或/*)后面要有一个空格
    // // --以下是ECMAScript 6 ES6相关的
    'arrow-body-style': 'error', // 当前头函数体的花括号可以省略时,不允许出现花括号
    // 'arrow-parens': ['error', 'as-needed'], // 箭头函数参数只有一个时,不允许写圆括号
    'arrow-spacing': 'error', // 要求箭头函数的=>前后有空格
    'no-confusing-arrow': 'error', // 禁止在可能与比较操作符混淆的地方使用箭头函数
    'no-duplicate-imports': 0, // 禁止重复导入
    'no-useless-computed-key': 'error', // 禁止不必要的计算属性,如obj3={['a']: 1},其中['a']是不必要的,直接写'a'
    'no-var': 'error', // 要求使用let或const,而不是var
    'object-shorthand': 'error', // 要求对象字面量使用简写
    'prefer-const': 'error', // 要求使用const声明不会被修改的变量
    'prefer-template': 'error', // 使用模板字符串,而不是字符串拼接
    'rest-spread-spacing': 'error', // 扩展运算符...和表达式之间不允许有空格,如... re1错误,应该是...re1
    'template-curly-spacing': 'error', // 禁止模板字符串${}内前后有空格
    'vue/comment-directive': 'off'
  }
}

.prettierrc

{
  "semi": false,
  "singleQuote": true,
  "useTabs": false,
  "tabWidth": 2,
  "printWidth": 200,
  "trailingComma": "none",
  "endOfLine": "auto"
}

最后

差不多配置已经完成了,代码规范,公共组件之类的根据自己项目业务来处理就可以了!组件封装,开发规范会在下一篇文章整理出来(第一次写文章,如果有不对的地方,请大佬指点!)

转载自:https://juejin.cn/post/7204998680066555962
评论
请登录