Code前端首页关于Code前端联系我们

Vue3项目从0开发生产级后台系统,核心坑和实用落地策略有哪些?

terry 11小时前 阅读数 138 #Vue

最近两年接手或重构Vue3后台系统的开发者越来越多,毕竟官方明确说过Vue3是未来的主推版本,性能优化、组合式API、TypeScript原生支持这些特性,确实能解决Vue2遗留的不少问题,但真正从0搭到上线,踩过的坑、踩坑后总结的落地方法,才是新手最缺的干货,今天就结合3个不同量级项目的实战经验,跟大家聊透这个话题。

为什么建议生产级后台必须用组合式API?

很多刚从Vue2转过来的开发者,总觉得组合式API“代码散、没结构、不如Options API直观”,一开始写甚至会把所有逻辑堆在setup()里,反而搞得比Options还乱,但实际跑过生产级后台——尤其是有大量复杂表单、实时数据交互、权限控制关联的模块,组合式API的优势就会凸显出来。

逻辑复用的自由度,Options API做逻辑复用,主要靠mixin,但mixin有个致命问题:命名冲突和数据来源不透明,比如你引入3个mixin,分别叫useTable、useForm、usePermission,这三个里都有loading、confirmModal这两个属性或方法,最后生效的是谁的?出了bug要找代码,得翻遍所有引入的mixin才能定位,组合式API就不一样了,你可以把useTable的状态和方法单独命名,比如const { loading: tableLoading, getTableData } = useTable(),命名冲突一目了然,数据来源也通过解构清晰展现。

然后是代码的可维护性,Options API里,一个功能的代码会被拆到data、methods、computed、watch这些地方,比如一个“用户列表筛选+分页+导出”的功能,筛选条件写在data,筛选的触发方法写在methods,总页数的计算写在computed,监听筛选条件变化重置分页写在watch,想改这个功能得上下跳着找代码,组合式API可以把这一整套逻辑封装成一个独立的composable文件,比如useUserList,所有相关的状态、方法、计算属性都在这一个文件里,哪里有问题点哪里,维护起来特别爽。

还有TypeScript的类型支持,Options API虽然也能加TS,但需要用Vue.extend或者@Component装饰器,写起来繁琐不说,对某些复杂类型的推导也不够友好,组合式API的setup()天然支持TS,你可以直接定义类型,编译器会自动推导属性和方法的类型,开发时就能发现很多潜在的类型错误,上线后的bug率能降不少。

所以别再纠结用不用组合式API了,生产级后台必须用,关键是要学会怎么合理组织代码结构。

Vue3项目从0搭建时,哪些配置是必须提前做的?

很多新手搭项目,直接用Vite或者Vue CLI的默认配置就开始写代码,结果写着写着遇到各种问题:比如路径别名用不了、ESLint校验太严太松都不行、打包后代码体积太大、环境变量不生效等等,这些问题如果提前做好配置,后面写代码会省很多事。

路径别名和TypeScript配置

路径别名是开发后台系统时必加的,毕竟后台的文件层级可能很深,用@/components/Table比../../../../../components/Table舒服多了,Vite和Vue CLI的配置方法不一样,但核心都是修改配置文件和tsconfig.json(如果用TS的话)。

比如用Vite + TS的项目,vite.config.ts里要加resolve.alias:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src')
    }
  }
})

然后tsconfig.json里要加baseUrl和paths,让TS编译器也能识别路径别名:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

ESLint和Prettier的统一配置

团队开发的话,ESLint和Prettier的统一配置是必不可少的,不然每个人写的代码风格不一样,合并代码时会有一堆冲突,看着也难受,很多新手容易把ESLint和Prettier的配置搞混,其实ESLint是用来做代码质量校验的,比如有没有未使用的变量、有没有使用eval这种危险函数;Prettier是用来做代码格式化的,比如换行、缩进、分号、引号。

这里推荐一个比较通用的配置组合:eslint-plugin-vue + @typescript-eslint/eslint-plugin(如果用TS)+ eslint-config-prettier + eslint-plugin-prettier,eslint-config-prettier用来关闭ESLint和Prettier冲突的规则,eslint-plugin-prettier用来把Prettier的规则当成ESLint的规则来校验。

配置好之后,可以在package.json里加两条命令:一条是检查代码格式的lint,一条是自动修复的lint:fix,开发完代码先跑一下lint:fix,大部分格式问题和简单的质量问题都会自动解决。

环境变量的配置

后台系统一般会有开发环境、测试环境、预发布环境、生产环境这四个环境,不同环境的接口地址、全局配置不一样,所以环境变量的配置也很重要。

Vite的环境变量配置很简单,在项目根目录下创建.env.development(开发环境)、.env.test(测试环境)、.env.pre(预发布环境)、.env.production(生产环境)这四个文件,文件名要和Vite的模式对应,环境变量必须以VITE_开头,不然不会被Vite暴露到客户端代码里。

env.development文件:

VITE_API_BASE_URL=http://localhost:3000/api
VITE_APP_TITLE=后台管理系统(开发)

然后在代码里可以通过import.meta.env.VITE_API_BASE_URL来访问这些环境变量,注意import.meta.env是Vite提供的全局对象,只能在客户端代码和Vite配置文件里使用,不能在Node.js的普通模块里使用。

打包体积的预优化

生产级后台系统对打包体积有要求,不然首屏加载会很慢,用户体验不好,Vite默认的打包配置已经做了一些优化,但还有一些地方可以提前配置:

  1. 按需引入UI组件库,现在主流的Vue3 UI组件库,比如Element Plus、Ant Design Vue、Naive UI,都支持按需引入,不用把整个组件库都打包进去,比如按需引入Element Plus,可以用vite-plugin-element-plus插件,配置起来很简单。
  2. 开启gzip压缩,可以用vite-plugin-compression插件,开启gzip或者brotli压缩,打包后的文件体积能减少60%左右。
  3. 代码分割,Vite默认会把第三方库和业务代码分开打包,但可以进一步优化,比如把一些常用的第三方库单独打包成一个chunk,比如把lodash-es单独打包。

这些预优化配置做好之后,后面再根据实际情况调整,首屏加载速度应该不会有太大问题。

Vue3后台系统开发中,有哪些高频踩坑点?

响应式数据的陷阱

Vue3的响应式系统和Vue2不一样,Vue2用的是Object.defineProperty,Vue3用的是Proxy,Proxy确实比Object.defineProperty强大,能监听数组的索引变化和对象属性的新增删除,但也有一些陷阱需要注意:

  1. 不要直接替换reactive的对象,比如你有一个reactive的对象:const state = reactive({ list: [] }),如果你直接把state替换成state = reactive({ list: [1,2,3] }),state就不再是响应式的了,因为你替换的是整个对象的引用,Proxy监听的是原来的引用,正确的做法是修改state的属性:state.list = [1,2,3],如果你非要替换整个对象,可以用ref来包裹:const state = ref({ list: [] }),然后直接state.value = { list: [1,2,3] }。
  2. reactive不能监听基本数据类型,比如const count = reactive(1),count的变化不会触发视图更新,因为reactive只能监听对象或数组,基本数据类型要用ref来包裹。
  3. ref在模板里不需要加.value,但在setup()、composable文件、普通JS/TS文件里需要加.value,很多新手一开始会忘加.value,导致代码报错。
  4. 不要解构reactive的对象,比如const state = reactive({ name: '张三', age: 18 }),然后const { name, age } = state,这时候name和age就不再是响应式的了,正确的做法是用toRefs来解构:const { name, age } = toRefs(state),这样解构出来的name和age都是ref类型的,还是响应式的。

watch和watchEffect的区别

Vue3提供了watch和watchEffect两个监听API,很多新手一开始不知道用哪个,其实它们的区别很明显:

  1. watch需要指定监听的源,只有源变化时才会执行回调函数,watchEffect不需要指定监听的源,会自动收集回调函数里用到的响应式数据,只要其中任何一个变化就会执行回调函数。
  2. watch可以获取旧值和新值,watchEffect只能获取新值。
  3. watch默认是惰性执行的,第一次渲染时不会执行回调函数,只有源变化时才会执行,watchEffect是立即执行的,第一次渲染时就会执行一次回调函数。
  4. watch可以监听多个源,用数组包裹,比如watch([count1, count2], ([newCount1, newCount2], [oldCount1, oldCount2]) => { ... })。

举个例子,比如你有一个表单,需要监听表单的变化,然后自动保存草稿,这时候用watchEffect比较合适,因为表单里有很多字段,不用一个个指定监听的源,只要任何一个字段变化就会执行保存草稿的回调函数,但如果你只需要监听某个特定字段的变化,然后执行某个操作,比如监听用户名的变化,检查用户名是否已被注册,这时候用watch比较合适,因为可以获取旧值,避免重复请求。

路由跳转和组件销毁的时机问题

Vue3的路由跳转和组件销毁的时机,有时候会和新手想的不一样,导致一些问题:

  1. 路由跳转时,如果是从同一个组件跳转到同一个组件(比如从用户列表第1页跳转到第2页,路由参数变了但组件没变),组件不会重新创建,只会复用,这时候created、mounted这些生命周期钩子不会再执行,这时候需要监听路由的变化,比如用useRoute().params或者watch(useRoute(), () => { ... })来重新获取数据。
  2. 组件销毁时,定时器、事件监听器、WebSocket连接这些资源如果没有及时清理,会导致内存泄漏,Vue3的组合式API里,不需要像Options API那样在beforeUnmount或者unmounted里清理,只要在setup()里返回一个清理函数,或者用onBeforeUnmount、onUnmounted钩子即可。
    import { ref, onBeforeUnmount } from 'vue'

const timer = ref(null)

timer.value = setInterval(() => { console.log('定时器执行') }, 1000)

onBeforeUnmount(() => { clearInterval(timer.value) })

或者可以用一个更优雅的方式,比如封装一个composable文件useInterval:
```typescript
import { ref, onBeforeUnmount } from 'vue'
export function useInterval(callback: () => void, delay: number) {
  const timer = ref<number | null>(null)
  const start = () => {
    if (timer.value) return
    timer.value = setInterval(callback, delay)
  }
  const stop = () => {
    if (timer.value) {
      clearInterval(timer.value)
      timer.value = null
    }
  }
  start()
  onBeforeUnmount(() => {
    stop()
  })
  return { start, stop }
}

这样在组件里用useInterval,组件销毁时会自动清理定时器。

权限控制的实现方式

生产级后台系统肯定离不开权限控制,比如菜单权限、按钮权限、接口权限,Vue3后台系统的权限控制,一般有两种实现方式:前端控制和后端控制,推荐前端控制+后端验证的方式,这样既能提升用户体验,又能保证安全性。

前端控制的流程大概是这样的:

  1. 用户登录成功后,后端返回用户的token和权限信息(比如角色、菜单列表、按钮权限列表)。
  2. 前端把token存储在localStorage或者sessionStorage里,把权限信息存储在Vuex/Pinia里。
  3. 路由守卫(比如router.beforeEach)里检查用户是否登录,如果没登录就跳转到登录页;如果登录了,就根据用户的权限信息动态生成可访问的路由表,然后用router.addRoutes或者router.addRoute添加到路由里。
  4. 菜单组件根据Vuex/Pinia里存储的菜单列表渲染。
  5. 按钮权限可以用自定义指令实现,比如v-permission,当用户没有这个按钮的权限时,就把按钮隐藏或者禁用。

后端验证的流程大概是这样的:

  1. 前端每次请求接口时,都在请求头里带上token。
  2. 后端验证token是否有效,如果无效就返回401,前端跳转到登录页。
  3. 后端验证用户是否有这个接口的权限,如果没有就返回403。

这里要注意,前端控制只是为了提升用户体验,不能代替后端验证,因为前端的代码是可以被篡改的,比如用户可以通过浏览器的开发者工具修改路由表或者权限信息,所以所有的关键操作都必须在后端做权限验证。

Vue3后台系统开发中,有哪些实用的composable封装思路?

组合式API的核心就是composable,合理封装composable可以大大提高代码的复用率和可维护性,下面给大家介绍几个后台系统开发中常用的composable封装思路:

useRequest:封装接口请求

后台系统最常用的就是接口请求,封装一个useRequest composable,可以统一处理接口请求的loading状态、错误状态、成功回调、失败回调、取消请求等逻辑。

比如useRequest可以接受一个请求函数和一些配置项,返回loading、error、data、run、cancel这些状态和方法:

import { ref, onUnmounted } from 'vue'
import axios from 'axios'
export function useRequest<T>(
  requestFn: () => Promise<T>,
  options?: {
    immediate?: boolean
    onSuccess?: (data: T) => void
    onError?: (error: any) => void
  }
) {
  const { immediate = true, onSuccess, onError } = options || {}
  const loading = ref(false)
  const error = ref<any>(null)
  const data = ref<T | null>(null)
  const controller = ref<AbortController | null>(null)
  const run = async () => {
    loading.value = true
    error.value = null
    controller.value = new AbortController()
    try {
      const res = await requestFn()
      data.value = res
      onSuccess?.(res)
    } catch (err) {
      if (!axios.isCancel(err)) {
        error.value = err
        onError?.(err)
      }
    } finally {
      loading.value = false
    }
  }
  const cancel = () => {
    controller.value?.abort()
  }
  if (immediate) {
    run()
  }
  onUnmounted(() => {
    cancel()
  })
  return { loading, error, data, run, cancel }
}

这样在组件里用useRequest,就不用每次都写loading状态、错误处理这些重复代码了:

import { useRequest } from '@/composables/useRequest'
import { getUserList } from '@/api/user'
const { loading, error, data: userList, run } = useRequest(getUserList)
// 如果需要手动触发请求,比如点击搜索按钮
const handleSearch = () => {
  run()
}

useTable:封装表格逻辑

后台系统的表格一般都有筛选、分页、排序、导出这些功能,封装一个useTable composable,可以把这些通用逻辑封装起来:

import { ref, reactive } from 'vue'
import { useRequest } from './useRequest'
export function useTable<T>(
  requestFn: (params: any) => Promise<{ list: T[]; total: number }>,
  defaultParams?: any
) {
  const searchParams = reactive({ ...defaultParams })
  const pagination = reactive({
    current: 1,
    pageSize: 10,
    total: 0
  })
  const { loading, data, run } = useRequest(
    () => requestFn({ ...searchParams, ...pagination }),
    {
      immediate: false,
      onSuccess: (res) => {
        pagination.total = res.total
      }
    }
  )
  const handleSearch = () => {
    pagination.current = 1
    run()
  }
  const handleReset = () => {
    Object.assign(searchParams, defaultParams)
    pagination.current = 1
    run()
  }
  const handlePageChange = (page: number) => {
    pagination.current = page
    run()
  }
  const handlePageSizeChange = (pageSize: number) => {
    pagination.pageSize = pageSize
    pagination.current = 1
    run()
  }
  // 初始化数据
  run()
  return {
    searchParams,
    pagination,
    loading,
    data,
    handleSearch,
    handleReset,
    handlePageChange,
    handlePageSizeChange
  }
}

这样在组件里用useTable,只需要传入接口请求函数和默认筛选参数,就能得到所有表格相关的状态和方法:

import { useTable } from '@/composables/useTable'
import { getUserList } from '@/api/user'
const {
  searchParams,
  pagination,
  loading,
  data: userList,
  handleSearch,
  handleReset,
  handlePageChange,
  handlePageSizeChange
} = useTable(getUserList, { name: '', status: '' })

除了useRequest和useTable,还可以封装useForm(表单逻辑)、usePermission(权限检查)、useLocalStorage/sessionStorage(本地存储)、useDebounce/throttle(防抖节流)这些常用的composable。

Vue3开发生产级后台系统,核心就是合理利用组合式API、提前做好项目配置、避开高频踩坑点、封装通用的composable,只要掌握了这些方法,开发效率和代码质量都会大大提升,实战中还会遇到其他问题,比如WebSocket的封装、大数据量表格的性能优化、国际化的实现等等,这些以后有机会再跟大家聊。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门