前端初学者的Ant Design Pro V6总结(下)
前端初学者的Ant Design Pro V6总结(上)
前端初学者的Ant Design Pro V6总结(下)
文章目录
- @umi 请求相关
- 一个能用的请求配置
- Service层 TS 类型规范
- Service层 函数定义
- @umi 请求代理 Proxy
- @umi/max 简易数据流
- useModel 没有类型提示?
- useModel 书写规范
- ProForm 复杂表单
- 当外部数据发生变化,ProForm不更新?
- ProForm onFinish中请求错误,提交按钮一直Loading
- EditorTable 可编辑表格
- 提交按钮一直Loading?
- columns 自定义表单、自定义渲染
- form / formRef 的 setFieldValue / getFieldsValue 无效?
- Upload / ProUploader 文件上传
- ImgCrop 实现图片裁切
- ImgCrop 组件注意事项
- StepsForm 分布表单
- 如何在 StepsForm 中 更新子表单?
- 如何手动控制 步骤 前进、后退?
- 微前端 Qiankun
- 子应用配置(@umi)
- 父应用配置(@umi/max)
@umi 请求相关
一个能用的请求配置
Antd Pro的默认的请求配置太复杂了,我写了个简单的,能用,有需要可以做进一步拓展。
import { message } from 'antd'; import { history } from '@umijs/max'; import type { RequestOptions } from '@@/plugin-request/request'; import { RequestConfig } from '@@/plugin-request/request'; import { LOGIN_URL } from '@/common/constant'; export const httpCodeDispose = async (code: string | number) => { if (code.toString().startsWith('4')) { message.error({ content: `请求错误` }); if (code === 401) { message.error({ content: `登录已过期,请重新登录` }); history.replace({ pathname: LOGIN_URL }); } if (code === 403) { message.error({ content: `登录已过期,请重新登录` }); localStorage.removeItem('UserInfo'); history.replace({ pathname: LOGIN_URL }); } } // 500状态码 if (code.toString().startsWith('5')) { message.error({ content: `服务器错误,请稍后再试` }); } }; // 运行时配置 export const errorConfig: RequestConfig = { // 统一的请求设定 timeout: 20000, headers: { 'X-Requested-With': 'XMLHttpRequest' }, // 错误处理: umi@3 的错误处理方案。 errorConfig: { /** * 错误接收及处理,主要返回状态码非200,Axios错误的情况 * @param error 错误类型 * @param opts 请求参数,请求方法 */ errorHandler: async (error: any, opts: any) => { if (opts?.skipErrorHandler) throw error; // 我们的 errorThrower 抛出的错误。 if (error.response) { // Axios 的错误 // 请求成功发出且服务器也响应了状态码,但状态代码超出了 2xx 的范围 if ((error.message as string).includes('timeout')) { message.error('请求错误,请检查网络'); } await httpCodeDispose(error.response.status); } else if (error.request) { // 请求已经成功发起,但没有收到响应 // \`error.request\` 在浏览器中是 XMLHttpRequest 的实例, // 而在node.js中是 http.ClientRequest 的实例 // message.error('无服务器相应,请重试'); } else { // 发送请求时出了点问题 message.error('请求错误,请重试'); } }, }, // 请求拦截器 requestInterceptors: [ (config: RequestOptions) => { // 拦截请求配置,进行个性化处理。 const userInfo = JSON.parse(localStorage.getItem('UserInfo') ?? '{}'); const token = userInfo.token ?? ''; const headers = { ...config.headers, 'Content-Type': 'application/json', Whiteverse: token, // Authorization: { // key: 'Whiteverse', // value: `Bearer ${token}` // }, }; return { ...config, headers }; }, ], /** * 响应拦截器,主要处理服务器返回200,但是实际请求异常的问题 */ responseInterceptors: [ (response: any) => response, (error: any) => { const code = error.data.code; if (!code.toString().startsWith('2')) { httpCodeDispose(code); return Promise.reject(error); } return error; }, ], };
Service层 TS 类型规范
目前团队采用 [name].d.ts 的方式定义公用类型
- src > - types > service.d.ts env.d.ts module.d.ts
服务层命名 nameplace 要求全部大写
type SortOrder = 'descend' | 'ascend' | null; /** * 通用API */ declare namespace API { type Response = { message: string; code: number; data: T; }; type QuerySort = Record; } declare namespace COMMON { interface Select { value: string; label: string; } } /** * 分页相关 */ declare namespace PAGINATE { type Data = { total: number; data: T }; type Query = { current?: number; pageSize?: number }; } /** * 用户服务相关 */ declare namespace USER { /** * 用户 */ interface User { id: string; /** * 头像 */ avatar: string; /** * 昵称 */ nickname: string; } /** * 用户基本信息 */ type UserInfo = Omit; type UsersQuery = PAGINATE.Query & { sort?: API.QuerySort; nickname?: string; mobile?: string; roleId?: string; }; /** * 创建用户 */ type Create = Omit; /** * 登录信息 */ interface Login { Mobile: string; VerificationCode: string; } /** * 管理员登录参数 */ interface ALoginParam { Mobile: string; VerificationCode: string; } /** * 验证码 */ interface Captcha { base64: string; id: string; } }
Service层 函数定义
- 为了与普通的函数做区别,方法名全部大写
- 使用 PREFIX_URL 请求前缀,方便后期维护
src -> services -> activity -> index.ts
export async function GetActivityList( body: ACTIVITY.ActivitiesQuery, options?: { [key: string]: any }, ) { return request(`${PREFIX_URL}/activity/list`, { method: 'POST', data: body, ...(options || {}), }); }
@umi 请求代理 Proxy
在开发阶段,如果后端服务的端口经常发生变化,可以使用umi 请求代理 替换原有的请求前缀,转发请求。
/** * @name 代理的配置 * @see 在生产环境 代理是无法生效的,所以这里没有生产环境的配置 * ------------------------------- * The agent cannot take effect in the production environment * so there is no configuration of the production environment * For details, please see * https://pro.ant.design/docs/deploy * * @doc https://umijs.org/docs/guides/proxy */ export default { // 如果需要自定义本地开发服务器 请取消注释按需调整 dev: { '/api-mock/': { // 要代理的地址 target: 'http://127.0.0.1:4523/m1/3280694-0-default', // 配置了这个可以从 http 代理到 https // 依赖 origin 的功能可能需要这个,比如 cookie changeOrigin: true, pathRewrite: { '^/api-mock': '' }, }, '/api-sys/': { // 要代理的地址 target: 'http://192.168.50.131:8021', // 配置了这个可以从 http 代理到 https // 依赖 origin 的功能可能需要这个,比如 cookie changeOrigin: true, pathRewrite: { '^/api-sys': '' }, }, '/api-user/': { // 要代理的地址 target: 'http://192.168.50.131:8020', // 配置了这个可以从 http 代理到 https // 依赖 origin 的功能可能需要这个,比如 cookie changeOrigin: true, pathRewrite: { '^/api-user': '' }, }, }, /** * @name 详细的代理配置 * @doc https://github.com/chimurai/http-proxy-middleware */ test: { // localhost:8000/api/** -> https://preview.pro.ant.design/api/** '/api/': { target: 'https://proapi.azurewebsites.net', changeOrigin: true, pathRewrite: { '^': '' }, }, }, pre: { '/api/': { target: 'your pre url', changeOrigin: true, pathRewrite: { '^': '' }, }, }, };
@umi/max 简易数据流
useModel 没有类型提示?
还原 tsconfig.json 为默认配置
{ "extends": "./src/.umi/tsconfig.json" }
useModel 书写规范
定义Model仓库时,推荐使用匿名默认导出语法
export default () => {}
如果为页面绑定Model,注意页面的层级不要过深,页面组件的名称尽量短
- 文件名定义
- pages - Activity - components - ActivityList.tsx - models - ActivityModels.ts
- 使用Model
const { getActivityData } = useModel('Activity.ActivityModels', (models) => ({ getActivityData: models.getActivityData, }));
带有分页查询的 Model
带有loading,query,分页
可使用Ahooks 的 useRequest 或 自定封装 useRequest
注意Ahooks的 usePagination函数 对Service层的参数有要求
- service 的第一个参数为 { current: number, pageSize: number }
- service 返回的数据结构为 { total: number, list: Item[] }
- 具体看Ahooks文档,不推荐使用或二封分页Hook.
import { useEffect, useState } from 'react'; import { useSetState } from 'ahooks'; import to from 'await-to-js'; import { GetActivityList } from '@/services/activity'; export default () => { const initialParam = { current: 1, pageSize: 20 }; const [query, queryChange] = useSetState(initialParam); const [loading, setLoading] = useState(false); const [error, setError] = useState(); const [activityData, setActivityData] = useState(); const [total, setTotal] = useState(0); const getActivityData = async (_param: ACTIVITY.ActivitiesQuery) => { // 请求前 if (loading) await Promise.reject(); // 请求中 setLoading(true); const [err, res] = await to(GetActivityList(_param)); setLoading(false); // 请求结束 if (!err && res.code === 200) { setActivityData(res.data.data); setTotal(res.data.total); return res.data; } else { setError(err); return await Promise.reject(); } }; useEffect(() => { if (!activityData) getActivityData(query); }, []); return { // 状态 loading, setLoading, error, setError, query, queryChange, total, setTotal, activityData, setActivityData, // 方法 getActivityData, }; };
ProForm 复杂表单
当外部数据发生变化,ProForm不更新?
解决方案一:
(图片来源网络,侵删)// 监测外部值的变化,更新表单内的数据 useEffect(() => formRef.current && formRef.current.setFieldsValue(selectedNode), [selectedNode]);
解决方案二:
{ formRef.current?.resetFields(); const res = await GetRole({id: params.id}); return res.data }} > // ...
ProForm onFinish中请求错误,提交按钮一直Loading
onFinish 方法需要返回一个Promise.resolve(boolean),reject时,会一直loading
(图片来源网络,侵删)一个综合案例
const handleAddActivity = async (fields: ACTIVITY.Create) => { const hide = message.loading('正在创建活动'); try { const response = await CreateActivity({ ...fields }); hide(); message.success('活动创建成功!'); return response; } catch (error) { hide(); message.error('添加失败,请重试!'); return Promise.reject(false); } }; "创建活动"} stepProps={{ description: "请输入活动信息", }} onFinish={async (formData: ACTIVITY.Create & { ActivityTime?: string[] }) = { try { const requestBody = { ...formData }; requestBody.StartTime = formData.ActivityTime![0]; requestBody.EndTime = formData.ActivityTime![1]!; delete requestBody["ActivityTime"]; const response = await handleAddActivity(requestBody); const ActivityId = response.data; uploadFormsRef.current?.setFieldValue("ActivityId", ActivityId); return Promise.resolve(true); } catch (e) { return Promise.resolve(true); } }} />
更加优雅的办法是给onFinish 提交的数据添加一个convertValues
(图片来源网络,侵删)const convertValues = useMemo((values: FormColumn) => { return { ...values }; }, []);
注意:
ProForm中的transform和convertValue属性,仅能操作本字段内容,这个特性在某种情况下会出现一些问题
例如:
'lg'} rules={[{required: true, message: '请选择活动投放时间!'}]} dataFormat={FORMAT_DATE_TIME_CN} /
时间范围组件返回的数据格式是
ActivityTime: string[] // 如果不给dataFormat,就是 Dayjs[]
如果后端接口的数据格式是
{startTime: string, endTime: string}
这个时候如果使用convertValue无法解决业务问题,需要在onFinish或onSubmit中进行数据转化。
EditorTable 可编辑表格
提交按钮一直Loading?
如果onSave时网络请求错误或者发生异常,返回Promise.reject,onSave就不会生效。
if (!activityIdField) { const errorContent = '请先创建活动'; message.error(errorContent); return Promise.reject(errorContent); } return handleSaveRow(record);
columns 自定义表单、自定义渲染
const columns: ProColumns[] = [ { title: '模型文件', dataIndex: '_File', width: 150, render: (_, entity) => { return ( 'link'} onClick={() = { downloadFile(entity._File!.originFileObj!); }} > {entity._File?.name} ); }, formItemProps: { valuePropName: 'file', trigger: 'fileChange', rules: [{ required: true, message: '此项是必填项.' }], }, renderFormItem: () => , } ]
formItemProps 它本质就是,基本照着Form.Item那边去配置就行。
form / formRef 的 setFieldValue / getFieldsValue 无效?
原因一:
由于EditorTable的 Form实际上是新增的一行,是动态的,formRef 更新不及时可能导致formRef.current 为 undefined。
原因二:
普通的form组件内部的数据模型形如这样:
{ "homePath": "/", "status": true, "sort": 1 }
但是editorForm在编辑时内部的数据模型是这样的:
{ "229121": { "ModelLoadName": "11", "ModelShowName": "222", "ModelNo": "333", "MobileOS": "android", "_Position": [ { "position": [ 123.42932734052755, 41.79745486673118 ] } ], } }
它在外面包了一层,因此设置列的时候需要这么写
renderFormItem: (schema, config, form, action) => { const fieldsValue = form.getFieldsValue() const key = Object.keys(fieldsValue)[0]; const fields = fieldsValue[key]; const fieldName = schema.dataIndex! as keyof typeof fields // you want setting field fields[fieldName] = 'you want setting value'; formRef?.current?.setFieldValue(key, fields); return },
Upload / ProUploader 文件上传
ImgCrop 实现图片裁切
实现功能:
- 文件格式限制
- 文件上传尺寸限制
- 文件缩放大小限制
工具函数
function getImageFileAsync(file: File): Promise width: number; height: number; aspectRatio: number; image: HTMLImageElement; } { return new Promise((resolve, reject) => { const reader = new FileReader(); const img = new Image(); reader.onload = () => { img.src = reader.result as string; }; img.onload = () => { const width = img.width; const height = img.height; const aspectRatio = width / height; resolve({ width, height, aspectRatio, image: img, }); }; img.onerror = () => { reject(new Error('图片加载失败')); }; reader.onerror = () => { reject(new Error('文件读取错误')); }; // 读取文件内容 reader.readAsDataURL(file); }); }
组件
import { FC, ReactNode, useRef, useState } from 'react'; import { message, Modal, Upload, UploadFile, UploadProps } from 'antd'; import ImgCrop, { ImgCropProps } from 'antd-img-crop'; import { RcFile } from 'antd/es/upload'; import { getBase64, getImageFileAsync } from '@/utils/common'; const fileTypes = ['image/jpg', 'image/jpeg', 'image/png']; interface PictureUploadProps { // 上传最大数量 maxCount?: number; // 文件更新 filesChange?: (files: UploadFile[]) => void; // 图片最小大小,宽,高 minImageSize?: number[]; // 图片裁切组件配置 imgCropProps?: Omit; // 上传提示内容文本 children?: ReactNode | ReactNode[]; } const PictureUpload: FC = ({ maxCount, filesChange, minImageSize, imgCropProps, children, }) => { const [previewOpen, setPreviewOpen] = useState(false); const [previewImage, setPreviewImage] = useState(''); const [previewTitle, setPreviewTitle] = useState(''); const [fileList, setFileList] = useState([]); const [maxZoom, setMaxZoom] = useState(2); const isCropRef = useRef(false); const handleChange: UploadProps['onChange'] = ({ fileList: newFileList }) => { setFileList(newFileList); if (filesChange) filesChange(fileList); }; const handleCancel = () => setPreviewOpen(false); const handlePreview = async (file: UploadFile) => { if (!file.url && !file.preview) { file.preview = await getBase64(file.originFileObj as RcFile); } setPreviewImage(file.url || (file.preview as string)); setPreviewOpen(true); setPreviewTitle(file.name || file.url!.substring(file.url!.lastIndexOf('/') + 1)); }; return ( 1} zoomSlider={true} minZoom={1} maxZoom={maxZoom} aspect={minImageSize && minImageSize[0] / minImageSize[1]} beforeCrop={async (file) = { isCropRef.current = false; // 判断文件类型 const typeMatch = fileTypes.some((type) => type === file.type); if (!typeMatch) { await message.error( '图片格式仅支持' + fileTypes.reduce( (prev, cur, index, array) => prev + cur + (index === array.length - 1 ? '' : ','), '', ), ); return false; } // 判断图片大小限制 if (minImageSize) { const { width: imageWidth, height: imageHeight } = await getImageFileAsync(file); if (imageWidth fileList} onPreview={handlePreview} onChange={(files) = { handleChange(files); console.log(files); }} maxCount={maxCount} accept={'.jpg, .jpeg, .png'} beforeUpload={async (file) => { if (!isCropRef.current) return Upload.LIST_IGNORE; return file; }} > {maxCount ? fileList.length
ImgCrop 组件注意事项
-
拦截裁切事件
- ImgCrop 组件 的 beforeCrop 返回 false 后不再弹出模态框,但是文件会继续走 Upload 的 beforeUpload 流程,如果想要拦截上传事件,需要在beforeUpload 中返回 Upload.LIST_IGNORE。
- 判断是否拦截的状态变量需要用 useRef ,useState测试无效。
-
Upload组件 配合 ImgCrop组件时,一定要在 beforeUpload 中返回 事件回调中的 file,否则裁切无效。
-
如果不想做像素压缩,设置quality={1}
StepsForm 分布表单
如何在 StepsForm 中 更新子表单?
通过StepsForm的 formMapRef 属性,它可以拿到子StepForm的全部ref。
const stepFormMapRef = useRef([]); return stepFormMapRef} /
打印 ref.current
[ { "current": { // getFieldError: f(name) } }, { "current": { // getFieldError: f(name) } }, { "current": { // getFieldError: f(name) } } ]
如何手动控制 步骤 前进、后退?
灵活使用 current、onCurrentChange、submitter属性
const [currentStep, setCurrentStep] = useState(0); return ( currentStep} onCurrentChange={setCurrentStep} submitter={{ render: (props) = { switch (props.step) { case 0: { return ( () = props.onSubmit?.()}> 下一步 ); } case 1: { return ( () = props.onSubmit?.()}> 下一步 ); } case 2: { return ( () = { setCurrentStep(0); onCancel(); }} > 完成 ); } } }, }} stepsProps={{ direction: 'horizontal', style: { padding: '0 50px' } }} > { // StepForm } )
微前端 Qiankun
文档:https://umijs.org/docs/max/micro-frontend
子应用配置(@umi)
一、使用umi创建React App
二、配置umi
这里有一些WASM的配置,不想要可以去掉
import { defineConfig } from 'umi'; export default defineConfig({ title: 'xxxxxx', routes: [ { path: '/', component: 'index', }, { path: '/scene-obj', component: 'OBJScene' }, { path: '/*', redirect: '/' }, ], npmClient: 'pnpm', proxy: { '/api': { target: 'http://jsonplaceholder.typicode.com/', changeOrigin: true, pathRewrite: { '^/api': '' }, }, }, plugins: [ '@umijs/plugins/dist/model', '@umijs/plugins/dist/qiankun', '@umijs/plugins/dist/request', ], model: {}, qiankun: { slave: {}, }, request: { dataField: 'data', }, mfsu: { mfName: 'umiR3f', // 默认的会冲突,所以需要随便取个名字避免冲突 }, chainWebpack(config) { config.set('experiments', { ...config.get('experiments'), asyncWebAssembly: true, }); const REG = /\.wasm$/; config.module.rule('asset').exclude.add(REG).end(); config.module .rule('wasm') .test(REG) .exclude.add(/node_modules/) .end() .type('webassembly/async') .end(); }, });
三、跨域配置
import type { IApi } from 'umi'; export default (api: IApi) => { // 中间件支持 cors api.addMiddlewares(() => { return function cors(req, res, next) { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Headers', '*'); next(); }; }); api.onBeforeMiddleware(({ app }) => { app.request.headers['access-control-allow-origin'] = '*'; app.request.headers['access-control-allow-headers'] = '*'; app.request.headers['access-control-allow-credentials'] = '*'; app.request.originalUrl = '*'; }); };
四、修改app.ts,子应用配置生命周期钩子.
export const qiankun = { // 应用加载之前 async bootstrap(props: any) { console.log('app1 bootstrap', props); }, // 应用 render 之前触发 async mount(props: any) { console.log('app1 mount', props); }, // 应用卸载之后触发 async unmount(props: any) { console.log('app1 unmount', props); }, };
父应用配置(@umi/max)
config.ts
export default defineConfig({ qiankun: { master: { apps: [ { name: 'r3f-viewer', // 子应用的名称 entry: 'http://localhost:5174', // your microApp address }, ], }, }, })
使用路由的方式引入子应用
export default [ { name: 'slave', path: '/slave/*', microApp: 'slave', microAppProps: { autoSetLoading: true, autoCaptureError: true, className: 'MicroApp', wrapperClassName: 'MicroAppWrapper' }, }, ]
使用组件的方式引入子应用
index.tsx
import { PageContainer } from '@ant-design/pro-components'; import { memo } from 'react'; import { MicroAppWithMemoHistory } from '@umijs/max'; import './index.less'; const Role = () => { return ( true} className={'microApp'} / ); }; export default memo(Role);
index.less
.microApp, #root { min-height: 800px !important; height: 800px !important; max-height: 800px !important; width: 100% !important; }
-
- 使用Model