这是一个vue3从0到1的项目,本意是记录一下使用心得,如果不对或者更好的方法请指正。
主要讲述从项目创建、功能增强、通用组件编写遇到的问题。至于为什么使用Vue3,是因为项目方要求。
vue@3.3.31
vue-router@4.2.5
eslint@8.49.0
typescript@4.7.4
vite@4.4.9
@vueuse/core@10.5.0
本文使用的主题是
channing-cyan
开始创建项目
开始创建项目,推荐使用vue官方方式,自己如果有能力或者公司已经固定好了开发框架就可以不考虑。
输入命令开始项目初始化
npm create vue@latest
根据提示选择需要的功能
安装依赖,推荐pnpm
pnpm i
构建命令非必要不做修改,因为项目使用了docker,会根据环境(域名)自动生成不同的环境文件。
功能增强
需要什么功能是根据项目需要来完善的,这里仅列出来本项目需要的一些功能
配置通用环境文件
在开发过程中比如地图key、接口访问域名等在各个部署环境是不同的,那就需要通过可配置的方式来区别,下面两个方式是不同类型的方案,供参考。
docker容器方式
这种方式的优点是一次部署,多环境通用,不用等待长时间的构建过程,仅需要给不同的环境配置不同的参数。
并且因为是一次部署,而且是经过测试过的代码,相对来说更安全,唯一有区别的是系统环境,保证系统环境一致将能降低问题出现的概率。
当然也有缺点,因为采用的是将配置的参数通过js文件的方式引入,所以代码在混淆上不足,很容易让人发现我们配置的参数。
下面看下是怎么配置的:
- 项目根目录下面创建并编写
env_config.js
配置文件,并指定开发环境的环境变量, 文件中内容很简单,给window全局增加环境变量,比如:
增加了window.__BAST_NAME__ = 'base' // ...更多变量
__
前缀是为了能更好的和系统内容做区分。在需要使用的地方直接使用(window.__BAST_NAME__
)就可以,不需要有太多顾虑 - 给
Window
对象增加环境变量参数类型, 当我们直接使用window
上自定义属性的时候,eslint
会检查报错这时需要我们添加一个全局类型
- 在
types
文件夹下面创建global.d.ts
文件 - 指定一个
declare
类型的Window
接口类型declare interface Window { /** * 全局base */ __BAST_NAME__: string }
- 在
tsconfig.app.json
中引入global.d.ts
文件,让eslint
识别到"compilerOptions": { "typeRoots": ["./types"], }
- 在
- 在
index.html
中添加配置文件引用<script src="/env_config.js"></script>
, 开发环境可以自动读取本地的配置,部署环境读取docker生成的文件 - docker配置指定环境变量 待完善
.env配置文件方式
还有一种方法是配置 Vite
的 .env
文件方法,可以参考vite项目全局配置1:.env文件 - 知乎 (zhihu.com),变量命名必须是VITE_开头才能被读取到
- 优点:这种方法会把环境变量的值直接写入构建文件中,达到了混淆代码的目的,
- 缺点:如果想修改变量的值就需要重新部署代码,导致每次部署到不同环境的时候都需要重新构建代码,等待时间很长。
- 全部环境默认加载
.env
文件,开发环境默认加载.env.development
文件,生产环境默认加载.env.production
文件 - 假设存在多个环境就需要在
vite
构建命令后面增加--model xxx
参数,同时要有对应的.env.xxx
文件。 - 在项目中读取变量直接使用
import.meta.env
就好了,但是vite.config.ts
中需要通过loadEnv
方法才能读取到import { defineConfig, loadEnv, type ConfigEnv } from "vite"; import vue from "@vitejs/plugin-vue"; // https://vitejs.dev/config/ export default defineConfig(({ mode, command, ssrBuild }: ConfigEnv) => { const root = process.cwd(); // 这里的mode就是传入的,或者默认的 const env = loadEnv(mode, root); console.log(env); return { plugins: [vue()], } });
vite.config.js 区分开发和部署的配置
- 首先想要区分开发和部署的文件就需要找到区分的办法,最好的办法就是执行
npm run xxx
的时候带上环境变量或者直接指定对应对应的配置文件,本文采用的是环境变量的方法。
在执行命令vite
和vite build
的时候会默认指定环境变量process.env.NODE_ENV
的值为development
和production
,只需要根据这个环境变量的值进行判断即可,vite.config.ts
的配置如下:import { defineConfig } from 'vite' import viteDev from './build/vite.config.dev' import viteProd from './build/vite.config.prod' const ENV = process.env.NODE_ENV || 'development' console.log('[ENV]: ', ENV) export default defineConfig((cfg: any) => { let config if (ENV === 'development') config = viteDev() if (ENV === 'production') config = viteProd() console.log('[vite config]', ENV, config) return config })
- 然后针对开发和部署 通用的配置 需要单独提取出来,当然 开发 和 部署 的配置需要在通用配置的基础上增加各自特有的配置。开发配置需要增加 代理服务,部署配置需要增加 代码包分包功能。
这里需要注意合并问题,需要把通用配置和特有的配置完全合并起来,不要丢失,否则功能将不完整。 - 通用配置包括:自动引入
vue
vue-router
pinia
@vueuse/core
中的api和自定义api;自动引入自定义组件库、ant-design-vue组件库,配置ant-design-vue的样式变量值。 这些功能会在下面单独展开描述 - 配置代理服务
启用代理服务就是因为接口跨域,不需要其他原因。
import base from './vite.config.base' export default () => { const config = merge({}, base, { server: { host: '0.0.0.0', // port: 8231, proxy: { '/xxxxx': { target: 'http://192.168.1.xxx', // dev changeOrigin: true, }, }, headers: { 'Access-Control-Allow-Origin': '*', }, }, }) return config }
- 配置分包功能
待完善
import base from './vite.config.base' export default () => { const config = merge({}, base, { }) return config }
接下来讲的三个自动引入,可以从尤大推荐的神器unplugin-vue-components,解放双手!以后再也不用呆呆的手动引入(组件,ui(Element-ui)库,vue hooks等) - 掘金 (juejin.cn)看到详细的解释。
让你的Vue代码 “学会” 自动按需引入 - 掘金 (juejin.cn) 这个的讲解也很全面
自动引入 api
或自定义方法
vue
vue-router
pinia
@vueuse/core
中的 api
或自定义方法当配置好unplugin-auto-import 以后,在.vue
、.tsx
、.jsx
、.ts
文件中使用对应 api
的时候可以不需要手动引入(import
),直接就可以使用里面的api
。自定义的方法也可以加入进来。具体的使用方法可以在下面代码内部注释里面理解
import AutoImport from 'unplugin-auto-import/vite'
export default defineConfig({
plugins: [
AutoImport({
// 指定自动引入生效的文件
include: [
/\.[tj]sx?$/, // .ts, .tsx, .js, .jsx
/\.vue$/, /\.vue\?vue/, // .vue
/\.md$/, // .md
],
// 自定义方法
dirs: [
'src/stores',
'src/components/UITable/hooks',
],
// 指定自动引入的api,vue、vue-router、pinia是全部引入,@vueuse/core是部分引入
imports: [ 'vue', 'vue-router', 'pinia', {
// 部分引入
'@vueuse/core': [
// named imports
'useMouse', // import { useMouse } from '@vueuse/core',
'useResizeObserver',
'useElementBounding',
// alias
// [ 'useFetch', 'useMyFetch' ], // import { useFetch as useMyFetch } from '@vueuse/core',
],
}],
// 指定配置文件生成位置
dts: 'src/auto-imports.d.ts',
// Generate corresponding .eslintrc-auto-import.json file.
// eslint globals Docs - https://eslint.org/docs/user-guide/configuring/language-options#specifying-globals
// 让eslint识别到我们添加的方法,不至于报错
eslintrc: {
enabled: false,
// 根目录下生成一个 `.eslintrc-auto-import.json` 文件
filepath: './.eslintrc-auto-import.json',
globalsPropValue: true,
},
}),
]
})
- 对于配置文件生成位置,需要在
tsconfig.app.json
中的include
指定一下,目的是让ts
识别到我们已经引入的api
。- 虽然我们在开发环境下即使没有这个文件也会自动创建而且不报错,代码也会正常运行;但是在编译的时候没有这个文件是会报错的,编译的时候也会生成这个文件,可能是因为文件生成的太晚了,才导致有问题。
- 所以生成的
auto-imports.d.ts
文件需要上传到代码仓库(不用担心这个文件变化太多导致代码冲突,因为自动引入的api
已经基本都引入进去了,后期加入的都是特殊用法)。
- 针对eslint识别我们配置的
api
也需要单独配置,在.eslintrc.cjs
文件中,属性extends
增加一行'./.eslintrc-auto-import.json',
。这个配置主要是解决自动引入ElMessage,解决ts声明及ESLint报错问题 - 掘金 (juejin.cn)这一类问题,因为eslint不知道已经按需引入了。
代码对比:
// 引入前
import { ref, computed } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
//引入后
const count = ref(0)
const doubled = computed(() => count.value * 2)
自动引入自定义组件
ant-design-vue组件库
因为这两个库使用的是同一个插件,所以就一起讲了。当配置好unplugin-vue-components后,下面这段代码是自动按需引入自定义组件
和ant-design-vue
组件库,没什么太多需要讲的,看到应该就能明白。
ant-design-vue组件的样式是自动引入的
自动引入组件:
import Components from 'unplugin-vue-components/vite'
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
Components({
// 要搜索组件的目录的相对路径
dirs: [
// 这里使用一个*仅搜索下面一层文件夹,如果想搜索下面的所有组件,需要使用**
'src/components/*',
// 特殊组件单独写
'src/components/EditCore/**',
'src/layouts/**',
],
// 组件的有效文件扩展名。
extensions: [ 'vue', 'tsx' ],
// 不向下搜索组件
deep: false,
// 自定义组件的解析器
resolvers: [
AntDesignVueResolver({
importStyle: 'less',
// importStyle: false,
resolveIcons: true,
prefix: 'A',
}),
],
// 指定配置文件生成位置
dts: 'src/components.default.d.ts',
}),
]
})
加好以后就可以直接使用官网例子里面的代码了,不需要import部分的代码。这种方式对于.tsx
文件不会生效,需要重新手动引入。
需要注意的是配置文件生成位置,需要在tsconfig.app
中的include
指定一下,目的是让ts
识别到我们已经引入的组件。
- 原打算给不同的目录、组件库生成不同的引用文件,结果是只有最后一个的配置才会生效,所以还是将所有的组件放在了一起引入了。
src/components.default.d.ts
因为变化很大所以最开始就没有把生成的文件上传到git
服务器,当执行构建命令的时候也没有报错,可以放心使用unplugin-vue-components
插件无法处理非组件模块,如 message、Modal、notification、Icon等,这种组件需要手动加载,或者下面的方式单独处理。
//挂载全局对象 app.config.globalProperties.$message=message //在某个页面使用的时候 const {proxy}=getCurrentInstance() proxy.$message.info('1111111')
- 在tsx中还是要手动引入组件
两种自动引入的思考
两种自动引入生成的文件一个需要上传服务器,一个不需要上传服务器,不知道是不是执行顺序的原因,我是先配置的自动引入组件,然后再配置的自动引入api
这篇文章对于错误的配置也有详细的解释 Vue3+Vite项目按需自动导入配置以及一些常见问题修复 - 掘金 (juejin.cn)
自定义 eslint rules
eslint
一般都是用默认配置,但是由于喜欢 对象或者数组在换行的时候后面有个逗号 ,所以加了自定义的配置。
加这个配置的原因是正常默认配置在对象或数组换行的时候,最后一行是不加逗号的,当我们在给最后一行后面追加一行逗号的时候,首先要给原来最后一行后面追加一个逗号才行,这样我们在提交git仓库的时候就会产生三行的变更,和别人代码冲突的概率就增加了,特别是多个开发者一同维护的公共的配置文件(如router文件,但是对于这种配置文件期望有一个自动生成的办法),代码分隔不好的话很容易就代码冲突了。
加上了这个限制以后,以后自己仅修改自己的代码,不会改动别人的代码,降低代码冲突的概率。
基础功能/组件开发
- 指定组件名称在开发组件过程中,在调试过程中,组件树默认使用文件名字作为显示的名字,如果文件名字相同的话就容易混淆,在
Vue2
里面直接增加一个name
属性就可以自定义,在setup
中需要借助hooks
的能力才能完成。defineOptions({ name: 'FullSpin', })
- 通过defineProps定义组件属性类型时推荐使用ts的接口定义
defineProps<{ loading: boolean; }>()
- 定义组件属性默认值时,推荐使用withDefaults
withDefaults(defineProps<{loading: boolean}>(), { loading: false, })
- 当解构组件属性的时候注意解构以后不具有响应式,所以可以通过
computed
解决,或者watch
和watchEffect
- 当需要 跨组件传递信息时除了
pinia
还可以通过provide
inject
,这个特性在下面的表单组件里面使用到了 - 待完善
通用接口请求方法
在同一个项目中访问后端接口的方式(域名、模块名、cookie、token等)是相对固定的,返回的响应码、错误码也是相对固定的,因此可以有一个统一处理接口请求的 api
。
本次使用axios处理接口请求。为了不污染axios
全局配置,所以调用axios.create
创建一个实例,后续只操作这个实例。
const instance = axios.create({
baseURL,
timeout,
})
return instance
接口请求通用参数配置
通过配置拦截器自定义请求处理方法实现。
const requestHandlers = [
(config: AxiosRequestConfig) => setConfig(config),
(err: any) => Promise.reject(err),
]
instance.interceptors.request.use(...requestHandlers)
接口响应通用错误判断
通过配置拦截器自定义响应处理方法实现。并根据系统的特点去自定义返回成功标志,其他情况都会进入返回失败的逻辑。
const responseHandlers = [
(response: AxiosResponse) => {
if (response.data instanceof ArrayBuffer || typeof response.data === 'string' || response.data instanceof Blob) {
// 处理文件下载
return response
}
// 自定义返回成功标志
const responseCode = parseInt(response.data.code, 10)
if (responseCode === 0) {
return response.data.data
}
return Promise.reject(response)
},
(error: any) => {
return Promise.reject(error)
},
]
instance.interceptors.response.use(...responseHandlers)
通用分页请求
分页部分代码
// 从请求接口获取查询参数,因为需要给表格组件使用,所以参数需要符合表格组件的格式
const getPagination = (res: any): TablePaginationConfig => {
const pagination = {
pageSize: 20,
total: 0,
current: 1,
}
// 格式化参数
pagination.pageSize = Number(res.size) || 20
pagination.total = Number(res.total) || 0
pagination.current = Number(res.current) || 1
return pagination
}
// 分页请求
const useQuery = <Q extends ApiQuery, R>(
query: Q,
result: R,
url: string,
method: (url: string, query: any, options?: any) => any,
options?: any,
): ApiHook<Q, R> => {
// 表格显示数据
const listData: Ref<R> = ref(result) as Ref<R>
const loading = ref<boolean>(false)
// 表格查询参数
const queryForm: Ref<Q> = ref({ ...query }) as Ref<Q>
// 表格分页参数
const pagination = ref<TablePaginationConfig>({
pageSize: 20,
total: 0,
current: 1,
})
// 接口请求方法
const getList = async (req: Q, isReset = false) => {
loading.value = true
let newReq = { ...req }
if (isReset) {
newReq = query
queryForm.value = { ...query }
} else {
queryForm.value = { ...req }
}
try {
const res = await method(url, newReq, options)
// 根据接口返回数据格式化表格数据
listData.value = res?.records || res
// 根据接口返回数据格式化分页参数
pagination.value = getPagination(res)
loading.value = false
} catch (error) {
loading.value = false
return Promise.reject(error)
}
}
// 执行查询
const doQuery = (req: Q) => getList(req)
// 重置查询参数
const reset = () => getList(query, true)
// 执行查询并重置分页到第一页
const search = async () => {
queryForm.value.current = 1
// 默认20条数据
queryForm.value.size = queryForm.value.size || 20
await getList(queryForm.value)
}
// 表格分页事件
const handleTableChange = async (page: any): Promise<void> => {
const { pageSize, current } = page
queryForm.value.size = pageSize
queryForm.value.current = current
await getList(queryForm.value)
}
// 返回结果
return {
result: listData, loading, query: queryForm, pagination, doQuery, reset, search, handleTableChange,
}
}
const usePost = <Q extends ApiQuery, R>(
query: Q,
result: R,
url: string,
options?: any,
): ApiHook<Q, R> => useQuery(query, result, url, post, options)
使用的代码
<template #table>
<UITable
:dataSource="api.result.value"
:columns="tableColumns"
rowKey="id"
:loading="api.loading.value"
:pagination="api.pagination.value"
@change="api.handleTableChange"
/>
</template>
<script setup lang="ts">
const api = usePost(initValue, [], '/api/xxxx')
</script>
修改查询参数,重置方法也和这个类似
const searchHandle = async (values: DataType) => {
api.query.value = values
await api.search()
selectedKeys.value = []
selectedRows.value = []
}
完整代码
核心代码
import axios, { type AxiosPromise, type AxiosRequestConfig } from 'axios'
import type { AxiosResponse } from 'axios'
import { type TablePaginationConfig } from 'ant-design-vue'
import { type Ref } from 'vue'
interface ApiQuery {
[key: string]: any,
size?: number,
current?: number,
}
interface ApiHook<Q extends ApiQuery, R> {
result: Ref<R>,
loading: Ref<boolean>,
query: Ref<Q>,
pagination: Ref<TablePaginationConfig>,
doQuery: (query: Q) => Promise<void>,
reset: () => Promise<void>,
handleTableChange: (page: any) => void;
search: () => Promise<void>;
}
/**
* @param setConfig
* @param baseURL
* @param timeout
* @returns
*/
export const useApi = (setConfig: (config: AxiosRequestConfig) => AxiosRequestConfig, baseURL = '/api', timeout = 10 * 60 * 1000) => {
const instance = axios.create({
baseURL,
timeout,
})
const requestHandlers = [
(config: AxiosRequestConfig) => setConfig(config),
(err: any) => Promise.reject(err),
]
const responseHandlers = [
(response: AxiosResponse) => {
if (response.data instanceof ArrayBuffer || typeof response.data === 'string' || response.data instanceof Blob) {
// 处理文件下载
return response
}
const responseCode = parseInt(response.data.code, 10)
if (responseCode === 0) {
return response.data.data
}
return Promise.reject(response)
},
(error: any) => {
return Promise.reject(error)
},
]
instance.interceptors.request.use(...requestHandlers)
instance.interceptors.response.use(...responseHandlers)
const post = (url: string, data?: any, options?: any): AxiosPromise => instance({
url,
method: 'post',
data,
...options,
})
const get = (url: string, data?: any, options?: any): AxiosPromise => instance({
url,
method: 'get',
params: data,
...options,
})
// 从请求接口获取查询参数,因为需要给表格组件使用,所以参数需要符合表格组件的格式
const getPagination = (res: any): TablePaginationConfig => {
const pagination = {
pageSize: 20,
total: 0,
current: 1,
}
// 格式化参数
pagination.pageSize = Number(res.size) || 20
pagination.total = Number(res.total) || 0
pagination.current = Number(res.current) || 1
return pagination
}
// 分页请求
const useQuery = <Q extends ApiQuery, R>(
query: Q,
result: R,
url: string,
method: (url: string, query: any, options?: any) => any,
options?: any,
): ApiHook<Q, R> => {
// 表格显示数据
const listData: Ref<R> = ref(result) as Ref<R>
const loading = ref<boolean>(false)
// 表格查询参数
const queryForm: Ref<Q> = ref({ ...query }) as Ref<Q>
// 表格分页参数
const pagination = ref<TablePaginationConfig>({
pageSize: 20,
total: 0,
current: 1,
})
// 接口请求方法
const getList = async (req: Q, isReset = false) => {
loading.value = true
let newReq = { ...req }
if (isReset) {
newReq = query
queryForm.value = { ...query }
} else {
queryForm.value = { ...req }
}
try {
const res = await method(url, newReq, options)
// 根据接口返回数据格式化表格数据
listData.value = res?.records || res
// 根据接口返回数据格式化分页参数
pagination.value = getPagination(res)
loading.value = false
} catch (error) {
loading.value = false
return Promise.reject(error)
}
}
// 执行查询
const doQuery = (req: Q) => getList(req)
// 重置查询参数
const reset = () => getList(query, true)
// 执行查询并重置分页到第一页
const search = async () => {
queryForm.value.current = 1
// 默认20条数据
queryForm.value.size = queryForm.value.size || 20
await getList(queryForm.value)
}
// 表格分页事件
const handleTableChange = async (page: any): Promise<void> => {
const { pageSize, current } = page
queryForm.value.size = pageSize
queryForm.value.current = current
await getList(queryForm.value)
}
// 返回结果
return {
result: listData, loading, query: queryForm, pagination, doQuery, reset, search, handleTableChange,
}
}
const useGet = <Q extends ApiQuery, R>(
query: Q,
result: R,
url: string,
options?: any,
): ApiHook<Q, R> => useQuery(query, result, url, get, options)
const usePost = <Q extends ApiQuery, R>(
query: Q,
result: R,
url: string,
options?: any,
): ApiHook<Q, R> => useQuery(query, result, url, post, options)
return {
instance,
post,
get,
useGet,
usePost,
}
}
实例化代码 /src/apis/index.ts
import { type AxiosRequestConfig } from 'axios'
const getToken = () => {
return ''
}
const setConfig = (config: AxiosRequestConfig) => {
const token = getToken()
if (!token) {
config.headers = config.headers || {}
config.headers.authorization = token
}
return config
}
export const {
post,
get,
useGet,
usePost,
} = useApi(setConfig, window.__BAST_NAME__)
代码的使用可以参考分页示例里面的使用
全局错误提示方法
通过 onErrorCaptured
钩子,在 App.vue
中监听子组件内部的错误,或者添加errorHandler
全局错误处理函数。但是还是推荐 onErrorCaptured
钩子,因为这种使用方法可以使用其他的hooks方法,而errorHandler
不行。
因此需要每一个组件都要把错误抛出去:
- 事件中的接口请求,使用
async
await
让接口请求变成同步的,不要写catch
或者then
的第二个参数,如果一定要写也要再手动把错误抛出去,这样钩子才能监听到 - onMounted 中接口请求也是使用上面同样的方法
- 还有其他类型自定义错误,可以通过约定好错误信息格式的方式提示
如果错误不能抛出去,或者即使抛出去也没有被钩子捕获,可以手动调用处理错误的方法showErrorMsg
当出现登录失效的时候执行清理登录数据并去登录程序。
import { message } from 'ant-design-vue'
const userError = () => {
const useLoading = useLoadingStore()
const router = useRouter()
// 判断是否有接口请求错误信息
const hasResponseErrorMsg = function (err: any) {
return err.status !== 200 ||
err.data &&
err.data.code !== '0' &&
(err.data.message || err.data.msg)
}
// 显示接口请求错误信息
const showErrorMsg = async (err: any) => {
// console.log(err, err.response?.data)
if (err && Object.prototype.toString.call(err.request) === '[object XMLHttpRequest]') {
// 接口请求错误处理
if (hasResponseErrorMsg(err)) {
const error = err?.data?.message || err?.data?.msg || '后台服务出现错误'
await message.error(error)
if (err.data?.code === '1001') { // 登录失效
router.push({ name: 'login' })
}
} else {
message.error(err?.data?.message || '请求失败,请重试')
}
} else if (err?.errorFields?.length > 0) {
// form表单校验错误处理
await message.error('信息填写不完整,请检查')
}
// 其他错误不处理
}
// 监听子组件错误
onErrorCaptured((err: any) => {
console.log('--onErrorCaptured--', err)
showErrorMsg(err)
if (err) {
useLoading.setLoading(false)
}
return false
})
return {
showErrorMsg,
}
}
export default userError
在 App.vue
文件中添加
import { useError } from '@/hooks'
useError()
在 main.ts
中添加
import { useError } from '@/hooks'
app.config.errorHandler = (error, instance, info) => {
console.log('--app.config.errorHandler--', error, instance, info)
useError().showErrorMsg(error)
}
全局loading
变量控制显示还是隐藏
ant-design-vue
中显示loading的组件是 <a-spin :spinning="spinning" />
,因此需要一个变量控制显示还是隐藏。
src/stores
文件夹下面的api已经可以做到自动引入了,因此创建一个 useLoadingStore.ts
的文件,并使用pinia
进行管理。代码如下:
const useLoadingStore = defineStore('loadingStore', () => {
const loading = ref<boolean>(false)
const setLoading = (value: boolean) => {
loading.value = value
}
return {
loading,
setLoading,
}
})
export default useLoadingStore
用法:
const loadingStore = useLoadingStore()
loadingStore.setLoading(true)
// ...
loadingStore.setLoading(false)
也考虑过api
使用show
和hide
的形式,就看个人的取舍了。
全屏显示组件
loading
为true
的时候要把页面全部盖住,所以有了组件FullSpin
,并且当显示的时候加上一个500ms的延迟,防止多个请求或者请求很短出现闪烁的情况。
<template>
<div class="ui-fullspin">
<a-spin :spinning="loading" :delay="500" size="large">
<slot/>
</a-spin>
</div>
</template>
<script setup lang="ts">
defineOptions({
name: 'FullSpin',
})
defineProps<{
loading: boolean;
}>()
</script>
<style lang="less">
.ui-fullspin{
width: 100%;
height: 100%;
}
</style>
new Vue
方式
尝试使用 new Vue
然后挂载的方式,未成功,不知道哪个地方弄错了。
import Vue from 'vue'
import { Spin } from 'ant-design-vue'
let instance = null
const getInstance = () => {
if (!instance) {
instance = new Vue({
data () {
return {
show: false,
}
},
methods: {
loading () {
this.show = true
},
close () {
this.show = false
},
},
render () {
const fullscreenLoading = {
position: 'fixed',
left: 0,
top: 0,
width: '100%',
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}
return this.show ?
<div style={fullscreenLoading}>
<Spin />
</div>
:
''
},
})
const component = instance.$mount()
document.body.appendChild(component.$el)
}
return instance
}
Spin.show = function () {
getInstance().loading()
}
Spin.hide = function () {
getInstance().close()
}
export default Spin
全局字典数据定义
为了统一一个方式使用数据,需要有一个统一的格式,然后需要一个统一的地方进行存储,这样才让其他地方在使用的时候才能无感。还要支持异步加载数据的方式。
定义数据格式
在tyeps
文件夹下面创建 options.d.ts
文件,添加如下内容,定义基础格式和专用格式。color是为了区分不同状态加上颜色
// antd options基础格式
import type { DefaultOptionType } from 'ant-design-vue/es/select/index'
declare global {
interface OptionType extends DefaultOptionType {
value?: boolean | string | number | null
color?: string
}
// antd options基础格式 string
interface OptionTypeString extends DefaultOptionType {
label: string
value: string
}
// 状态管理专用格式
type OptionsType = {
options: OptionType[]
mapping: Record<string | number, string>
color?: Record<string | number, string>
}
}
在tyeps
文件夹下面创建 store.d.ts
文件,添加如下内容,定义使用的状态格式
// 状态管理
declare interface OptionsStatePrivate {
/**
* 商品状态
*/
GOODS_STATUS: OptionType
/**
* 商品分类树
*/
GOODS_CATEGORY_TREE: OptionsType
}
declare type OptionsStateKeys = keyof OptionsStatePrivate
上面这两段代码也展示了定义全局接口存在import和不存在时候代码写法上的区别
因为直接定义 OptionsType
类型会有些麻烦,所以先只定义其中的options
数据,通过一个方法转换成对应的格式,通常使用下面代码的options2store
方法。
const options2color = (options: OptionType[]): DataTypeString => {
// 映射数据
const result: DataTypeString = {}
options.forEach((option: OptionType) => {
const { color, value } = option
result[`${value}`] = color || ''
})
return result
}
const options2mapping = (options: OptionType[]): DataTypeString => {
// 映射数据
const result: DataTypeString = {}
options.forEach((option: OptionType) => {
const { label, value } = option
result[`${value }`] = label
})
return result
}
// 转换成状态管理专用格式
const options2store = (options: OptionType[]): OptionsType => {
return {
options,
mapping: options2mapping(options),
color: options2color(options),
} as any
}
定义数据
所有的数据定义都在/src/data
文件夹下面,根据需要分成多个文件夹,比如创建 goods.ts
文件
export const goodsStatusOptions: OptionType[] = [
{ value: 1, label: '未上架', color: '#52C41A' },
{ value: 2, label: '已上架', color: '#FF4D4F' },
{ value: 3, label: '已隐藏', color: '#1890FF' },
{ value: 4, label: '已下架', color: '#D9D9D9' },
]
export const GOODS_STATUS = options2store(goodsStatusOptions)
然后在data根目录下面有一个文件index.ts,统一导出所有的数据,供需要使用的地方去消费
export { goodsStatusOptions } from './goods.ts'
统一存储方式
存储使用pinia
方式,在stores
文件夹下面创建文件 useOptionsStore.ts
,将/src/data/下面的所有数据都导入进来,并增加获取数据的方法。
import * as optionsData from '@/data'
const useOptionsStore = defineStore('optionsStore', () => {
const options = reactive<DataType>({})
// 更新
const update = (field: OptionsStateKeys | string, data: any) => {
options[field] = data
}
const get = (field: OptionsStateKeys) : OptionsType | undefined => {
return options[field]
}
const getLabel = (field: OptionsStateKeys, key: string) : any | undefined => {
const map = options[field]?.mapping || {}
return map[key]
}
const getColor = (field: OptionsStateKeys, key: string) : any | undefined => {
const map = options[field]?.color || {}
return map[key]
}
const init = () => {
Object.keys(optionsData).forEach(key => {
// @ts-ignore
update(key, optionsData[key])
})
}
return {
options,
update,
get,
getLabel,
getColor,
init,
}
})
异步加载数据
异步加载数据首先有个通用的方法把自定义的数据可以以异步的方式加载进来
// 更新API数据
const updateApi = (field: OptionsStateKeys | string, api: Promise<any>, afterRequest?: (result: DataType) => DataType) => {
api.then((result: any) => {
update(field, afterRequest?.(result) || result)
})
}
然后需要根据情况加载数据
import { get as getApi } from '@/apis'
const updateCategoryTree = async () => {
updateApi('GOODS_CATEGORY_TREE', getApi('/api/queryCategoryTree'), (result) => {
return options2store(loop(result, 'name', 'id', 'children'))
})
}
这段代码里面的loop方法就是转换数据的方法,搞一个通用的就行
const loop = (list: DataType, labelKey: string = 'label', valueKey: string = 'value', childrenKey: string = 'children') => {
if (!Array.isArray(list)) return []
list.forEach((item: any) => {
if (item) {
item.label = item[labelKey]
item.value = item[valueKey]
item.children = loop(item[childrenKey], labelKey, valueKey, childrenKey)
}
})
return list
}
当需要使用 GOODS_CATEGORY_TREE
数据的时候,需要优先加载数据才能使用异步获取之后的数据。推荐使用路由独享函数钩子 beforeEnter
,防止异步赋值在数据调用之后产生而没有拿到数据。
beforeEnter: async (to, from, next) => {
const optionsStore = useOptionsStore()
const loadingStore = useLoadingStore()
loadingStore.setLoading(true)
await Promise.allSettled([
optionsStore.updateCategoryTree(),
])
loadingStore.setLoading(false)
next()
},
使用方法
在使用前需要在App.vue中初始化
const optionsStore = useOptionsStore()
onMounted(() => {
optionsStore.init()
})
获取数据
// 选项
const options = optionsStore.get('GOODS_STATUS')?.options || undefined
// 文本
const label = optionsStore.getLabel('GOODS_STATUS', value)
// 颜色
const color = optionsStore.getColor('GOODS_STATUS', value)
全局布局组件
待完善
页面路由配置
使用history路由模式
每个路由单独编译一个文件
component: () => import('@/views/Goods/index.vue'),
开发多页签组件
不考虑缓存页面数据的情况下每次路由跳转增加一个页签,切换页签就是切换路由,当需要考虑缓存数据的情况时,就需要使用keep-alive,这里需要考虑当页面缓存过多如何清理缓存的问题,并且如何配置哪些页面缓存
待完善
通用组件
表单组件
写这个组件的目的是使用json
开发Form表单
类型的功能,在尽量贴近ant-design-vue
组件库文档使用方式的基础上增加新的功能。
实现这个功能最好是通过包裹组件库已经封装好的组件并保持原本的api来实现,但是因为时间关系直接使用原生组件并通过预先初始化参数的方式来实现。
组件名字叫
UIForm
,在目录/src/components
下面
为了有更好的开发体验我们需要:
- 定义好类型体系,让开发过程中可以有使用组件库提示的体验
- 定义好初始化的方法让组件去调用,初始化默认参数和方法,还有增强的功能,然后组件就可以完成响应的功能
- 定义表单的一些属性、事件和暴露的方法,主要是通用样式参数和表单组件默认参数设置
- 根据json配置展示出组件,本次只讲解
Input
Select
和自定义三种方式,而自定义推荐使用tsx的方式 - 表单分区块去使用,为了应对组件内部多变的样式,需要把根据json展示组件的功能提炼出来
类型体系
类型定义放在type.ts
文件,下面这些定义不是全部的定义,先展示部分基础类型
import type {
FormProps,
FormItemProps,
InputProps,
SelectProps,
RowProps,
ColProps,
} from 'ant-design-vue'
// 表单项基础类型
interface BaseProps extends FormItemProps {
span?: ColProps['span']
align?: 'start' | 'end' | 'center' | 'baseline'
customRender?: any
beforeRender?: any
afterRender?: any
width?: '100%' | 'auto'
}
// 表单项input组件类型
export interface FormInputProps extends BaseProps {
type?: 'Input'
options?: InputProps
}
// 表单项select组件类型
export interface FormSelectProps extends BaseProps {
type?: 'Select'
options?: SelectProps & {
/**
* 选项,在缓存中的名字,优先级最高
*/
optionsStoreName?: OptionsStateKeys
}
}
// 表单项聚合类型,配置的时候通过设置type的值,自动配置不同options对应的类型
export type UIFormItemProps =
FormInputProps |
FormSelectProps |
undefined | null | false
初始化方法
初始化方法定义在util.ts
文件中,首先定义好几本方法,然后要定义设置值,还有数据赋值
- 基本初始化方法
export const initSelect = (item: FormSelectProps) => {
const optionsStore = useOptionsStore()
item.options = item.options || {}
const { optionsStoreName, options, placeholder } = item.options
const getOptions = () => {
if (optionsStoreName) {
return optionsStore.get(optionsStoreName)?.options || undefined
// return optionsStore.options[optionsStoreName]?.options || undefined
}
return options
}
const getFilterOption = () => {
if ((item.options?.showSearch === true || item.options?.mode === 'multiple') && !item.options.filterOption) {
return (inputValue: string, option: OptionType) => {
const index = (option?.label || '').indexOf(inputValue)
// eslint-disable-next-line eqeqeq
const indexValue = (option?.value || '') == inputValue
return index >= 0 || indexValue
}
}
return item.options?.filterOption
}
item.options.options = getOptions() as any
item.options.placeholder = placeholder || `请选择${item.label}`
item.options.filterOption = getFilterOption() as any
}
export const initInput = (item: FormInputProps) => {
item.options = item.options || {}
const { placeholder } = item.options
item.options.placeholder = placeholder || `请输入${item.label}`
}
export const init = (items: UIFormItemProps[]) => {
const getRules = (item: UIFormItemProps) => {
if (!item) return []
const _rules = item.rules && (Array.isArray(item.rules) ? item.rules : [ item.rules ]) || []
const hasRequired = _rules.find(rule => hasIn(rule, 'required'))
if (item.required && !hasRequired) {
_rules.unshift({
required: true,
message: `请${item.type === 'Select' ? '选择' : '输入'}${item.label}`,
})
}
return _rules
}
items.forEach((item) => {
if (item) {
item.options = item.options || {}
item.rules = getRules(item)
switch (item.type) {
case 'Select':
initSelect(item)
break
case 'Input':
initInput(item)
break
default:
}
}
})
}
- 单个赋值
增加这个方法主要目的是当name
是数组的时候,快速赋值
import { hasIn, isArray } from 'lodash'
export const valueSetter = (values: DataType, name: string | number | (string | number)[], value: any) => {
if (isArray(name)) {
name.reduce((valuesTemp, n, index) => {
if (index === name.length - 1) {
valuesTemp[n] = value
} else {
valuesTemp[n] = hasIn(valuesTemp, n) ? valuesTemp[n] : {}
}
return valuesTemp[n]
}, values)
} else {
values[name] = value
}
}
- 单个取值
增加这个方法主要目的是当name
是数组的时候,快速取值
import { hasIn, isArray, isObject } from 'lodash'
export const valueGetter: (values: DataType, name: string | number | (string | number)[]) => any = (values, name) => {
if (isArray(name)) {
return name.reduce((valuesTemp, n) => {
if (isObject(valuesTemp) && hasIn(valuesTemp, n)) {
return valuesTemp[n]
}
return valuesTemp
}, values)
}
return hasIn(values, name) ? values[name] : undefined
}
- 整体赋值
赋值时直接给表单model
绑定的formState
即可,因为表单的model
是非必填的参数,当需要赋值时需要一定要制定model
参数 - 整体取值
import { getValues } from 'lodash'
export const getValues = (initValue: DataType, formState: DataType) => {
const values = cloneDeep(Object.assign({}, initValue, isRef(formState) ? formState.value : formState))
return values
}
定义组件属性、事件和方法
组件属性定义在type.ts中
import type { Ref } from 'vue'
import type {
FormProps,
FormItemProps,
InputProps,
SelectProps,
RowProps,
ColProps,
} from 'ant-design-vue'
export type UIFormProps = Omit<FormProps, 'onSubmit'> & {
model: any | Ref<any>
gutter?: RowProps['gutter']
span?: ColProps['span']
items?: UIFormItemProps[]
/**
* 为 null 的时候没用按钮
*/
footer?: any
okText?: string
preview?: boolean
}
组件事件也定义在type.ts中
export interface UIFormEmits {
(event: 'cancel', values?: DataType): void
(event: 'submit', values: DataType): void
}
组件暴露的方法
defineExpose({
ref: formRef,
submit: submitHandle,
})
根据json配置展示出组件
表单分区块去使用
自动翻译字典 待完善
搜索组件
待完善
表格组件
表格通用render 待完善
列表页面布局组件
待完善
按钮组件
待完善
ECharts组件
待完善
批量导入导出组件
待完善
上传组件
待完善
给slot添加事件组件
待完善
ModalTrigger
待完善
ConfirmTrigger
待完善
PopoverTrigger
待完善
弹窗表单组件
待完善
弹窗选择组件
待完善
其他组件
小程序页面装修
待完善
其他功能
待完善
权限控制
待完善
思考
组件是否使用tsx开发好一点
待完善
正常页面是否使用tsx开发好一点
待完善
docker部署如何使老版本不失效
待完善