记录一次使用 umi3 搭建移动端项目的过程。
老规矩先赞后看呀😘。。。
通过本篇文章你将收获:
- 如何使用 umi3 快速搭建项目框架
- vw 移动端适配
- react-vant2 的使用
- 路由的配置
- Typescript 的配置
- 请求的封装
- hooks范式的简易数据管理方案
初始化项目
如果你是第一次使用 umi,建议先将官方文档过一遍,这将有助你对 umi 提供的一些功能建立大概的认识, 在遇到一些问题的时候能够快速的去定位问题查阅文档。
先创建一个空的项目文件夹。
$ mkdir myapp && cd myapp
通过官方工具创建项目
yarn create @umijs/umi-app
编辑器打开项目的 package.json 文件,因为要搭建的是移动端的项目,所以 pc 的组件就不需要了。
{
"dependencies": {
"@ant-design/pro-layout": "^6.5.0",
// 删除该依赖
"react": "17.x",
"react-dom": "17.x",
"umi": "^3.5.21"
}
}
删除后安装依赖 yarn
添加 vw 移动端适配
需要使用到 postcss 的插件postcss-px-to-viewport。先安装依赖。
yarn add postcss-px-to-viewport
提示版本过低安装这个(推荐)
yarn add postcss-px-to-viewport-8-plugin
在项目根目录下.umirc.ts 文件中添加extraPostCSSPlugins配置。
// ++ 添加导入
import px2vw from "postcss-px-to-viewport";
export default defineConfig({
// ++ 添加下面配置
extraPostCSSPlugins: [
px2vw({
unitToConvert: "px", //需要转换的单位,默认为"px";
viewportWidth: 375, //设计稿的视口宽度
unitPrecision: 5, //单位转换后保留的小数位数
propList: ["*"], //要进行转换的属性列表,*表示匹配所有,!表示不转换
viewportUnit: "vw", //转换后的视口单位
fontViewportUnit: "vw", //转换后字体使用的视口单位
selectorBlackList: [], //不进行转换的css选择器,继续使用原有单位
minPixelValue: 1, //设置最小的转换数值
mediaQuery: false, //设置媒体查询里的单位是否需要转换单位
replace: true, //是否直接更换属性值,而不添加备用属性
// exclude: [/node_modules/], //忽略某些文件夹下的文件
landscape: false,
}),
],
});
项目如果有横屏需求,可以将vw单位更换成vmin。 注释掉 exclude 项时因为我们需要组件库也使用 vw 单位, 随窗口宽度变化组件库的组件的大小也变化。
导入 react-vant2 组件库
安装
通过 yarn 安装依赖
yarn add react-vant@next
笔者使用的时候 react-vant2 还没有发布正式版本吧。 如果在你使用的时候已经发布了正式版,安装包名会改变,请以官方文档的为准。 安装 icon 图标库。
yarn add @react-vant/icons
主题的定制
在 umi3 中约定项目中src/pages/global.css为全局 css 样式文件,会放在所有样式文件之前。 react-vant2 全部使用的 css 变量。
react-vant2 的 css 变量有两种:
- 基础变量
- 组件变量
自定义 react-vant2 变量有两种方式。
-
使用 root 选择器,基础变量和组件变量都可以修改
在 global.css 中修改 css 变量达到修改主题的效果,也可以自己创建一个 theme.css 在项目入口处 app.ts 中导入。
:root:root {
--rv-black: #000;
--rv-white: #fff;
--rv-gray-1: #f7f8fa;
--rv-gray-2: #f2f3f5;
--rv-gray-3: #ebedf0;
--rv-gray-4: #dcdee0;
--rv-gray-5: #c8c9cc;
--rv-gray-6: #969799;
--rv-gray-7: #646566;
--rv-gray-8: #323233;
--rv-red: #ee0a24;
--rv-blue: #3f45ff;
--rv-orange: #ff976a;
--rv-orange-dark: #ed6a0c;
--rv-orange-light: #fffbe8;
--rv-green: #07c160;
/*...*/
}
- 使用 react-vant 提供的 ConfigProvider 组件,只能修改组件变量。
import {ConfigProvider} from "react-vant";
const themeVars = {
rateIconFullColor: "#ffc****c56",
};
export default () => {
const [rate, updateRate] = useState(4);
return (
<div className="demo-config-prodiver">
<ConfigProvider themeVars={themeVars}>
<Field label="评分">
<Rate value={rate} onChange={updateRate}/>
</Field>
</ConfigProvider>
</div>
);
};
内置样式
react-vant2 默认包含了一些常用样式,可以直接通过 className 的方式使用,提升开发效率。
- 文字省略
rv-ellipsis 一行省略
rv-multi-ellipsis--l数字 数字为几就显示几行文字,多的文字省略。
<div className="demo-styles">
<div className="rv-ellipsis">
这是一段最多显示一行的文字,后面的内容会省略
</div>
<div className="rv-multi-ellipsis--l2">
这是一段最多显示两行的文字,后面的内容会省略。这是一段最多显示两行的文字,后面的内容会省略
</div>
<div className="rv-multi-ellipsis--l3">
这是一段最多显示三行的文字,多余的内容会被省略这是一段最多显示三行的文字,多余的内容会被省略这是一段最多显示三行的文字,多余的内容会被省略这是一段最多显示三行的文字,多余的内容会被省略
</div>
</div>
- 1px 边框
基于伪类 transform 实现的 hairline。 相较于之前使用 antd-mobile 从库里找 hairline.less 导入使用,直接使用 className 方便的多。
rv-hairline--位置 可选:
.rv-hairline--bottom
.rv-hairline--top
.rv-hairline--left
.rv-hairline--right
.rv-hairline--surround
.rv-hairline--top-bottom
使用:
<div className="rv-hairline--top"></div>
- 动画
react-vant2 内置了五种动画:- rv-fade
- rv-slide-up
- rv-slide-down
- rv-slide-left
- rv-slide-right
搭配
react-transition-group使用,示例见 官方文档
路由配置
将.umirc.ts中的 routes 抽离。创建src/routes/routes.ts文件,导出路由数组,并在.umirc.ts中导入。
路由鉴权
在配置路由之前,要先考虑项目的登录和鉴权再做路由的配置,
甚至还要考虑到一些公共的tabBar或者tabs等一些页面的公共组件显示隐藏规则。
如需求没有明确,或者需求中展示没有复杂的要求,保险做法还是要预留一些可能出现的情况的应对规则。
若一些公共组件很难拿捏,那就把组件在每个需要的地方都写一遍,就不要在顶层做成公共的组件 再定义一些全局状态和规则来控制显隐,项目一旦复杂起来就很难维护了。
回归正题,umi3配置路由时提供wrappers选项来指定HOC组件。
在HOC组件中可以发起请求做一些路由级别的权限校验,或者做一些针对页面的特殊处理。 用法:
export default {
routes: [
{
path: '/user', component: 'user',
wrappers: [
'@/wrappers/auth', // 在auth组件中可以进行权限处理
],
},
{path: '/login', component: 'login'},
]
}
然后在 src/wrappers/auth 中,
import {Redirect} from 'umi'
export default (props) => {
const {isLogin} = useAuth();
if (isLogin) {
return <div>{props.children}</div>; //已经登录渲染路由组件
} else {
return <Redirect to="/login"/>; // 没登录重定向到登录页
}
}
题外话:
如果后端接口没有对未登录做统一的处理,即返回的状态码、数据形形色色,可以使用路由鉴权。
如果后端有做统一处理,优先在response拦截器中做全局的处理。
配置路由
个人觉得要考虑以下几点:
- 结合业务,考虑鉴权好不好做
- 二级页面要不要放到一级页面路由下
- 要不要使用严格模式
- 扩展性 在业务复杂的时候、或者有特殊需求的时候,更改下路由的配置也许能优雅解决问题。
Typescript配置
在项目的根目录tsconfig.json中配置项目的ts一些规则。
- 配置项目的alias
// tsconfig.json
"compilerOptions": {
// ...
"paths": {
"@/*": ["src/*"],
"@public/*": ["public/*"],
"@@/*": ["src/.umi/*"]
}
}
- 配置项目的model,
include配置项可以配置全局的一些ts类型定义文件。可以在各个文件中不需要导入直接使用, 适用于type、interface, enum 类型不会报错但是无法使用其值,并且类型定义文件中不能有任何的导入import导出export。
{
"include": [
"mock/**/*",
"src/**/*",
"config/**/*",
".umirc.ts",
"typings.d.ts",
"src/types/**/*" // src/types/**/*下的所有文件中的类型定义都会成为全局的类型定义
]
}
User.ts
type User = {
name: string
// ...
}
// userPage.tsx
const user: User = {
name: 'Peter'
}
- typings.d.ts 用来定义一些ts无法识别的模块,如我们之前使用的
postcss-px-to-viewport, 在使用的时候会报红。在文件中定义一下就🆗了。
declare module "postcss-px-to-viewport"
如果想使用jsx-control-statements,也要对标签进行一下定义,当然也可以使用tsx版本,
但是我没有配置成功😭,项目时间有限不想浪费时间就直接上jsx,类型定义如下。
declare module "jsx-control-statements"
declare const If: React.FC<{ condition: any }>
declare const Choose: React.FC
declare const When: React.FC<{ condition: any }>
declare const Otherwise: React.FC
declare const For: React.FC<{
of: any[]
body?: (item: any, index: number) => React.ReactElement
each?: any
index?: string
}>
请求封装
使用umi-request请求库。 先配置一个我们需要的请求
const request = extend({
timeout: Config.requestTimeOut,
headers: { // 配置全局请求头
// ...
"X-Requested-With": "XMLHttpRequest",
"Content-Type": "application/json",
},
errorHandler: errorHandler, // 配置错误处理
})
错误处理
- 网络请求状态码处理(请求错误、无数据返回、状态码非200) 配置errorHandler,官方处理示例:
const errorHandler = function(error) {
const codeMap = {
'021': '发生错误啦',
'022': '发生大大大大错误啦',
// ....
};
if (error.response) {
// 请求已发送但服务端返回状态码非 2xx 的响应
// 注意这里时非2xx的响应,1xx、3xx、4xx、5xx也会走这个错误处理,
// 有的时候 有些状态码我们并不想作为错误来处理,例如重定向301,就需要我们来写一些判断
console.log(error.response.status);
console.log(error.response.headers);
console.log(error.data);
console.log(error.request);
console.log(codeMap[error.data.status]);
} else {
// 请求初始化时出错或者状态码为200但是没有响应返回的异常
console.log(error.message);
}
throw error; // 如果throw. 错误将继续抛出.
// 如果return, 则将值作为返回. 'return;' 相当于return undefined, 在处理结果时判断response是否有值即可.
// return {some: 'data'};
};
使用:
- 作为统一错误处理,如上文中的的extend中errorHandler配置使用
- 单独特殊处理, 如果配置了统一处理, 但某个api需要特殊处理. 则在请求时, 将errorHandler作为参数传入.
const xxxErrorHandler = function (error) {
// 特殊处理...
}
request('/api/v1/xxx', { errorHandler });
- 通过 Promise.catch 做错误处理
const errorHandler = function (error) {
//...
}
request('/api/v1/xxx')
.then(function(response) {
console.log(response);
})
.catch(function(error) {
return errorHandler(error);
})
拦截器
在请求或响应被 then 或 catch 处理前拦截它们。
拦截器分为请求拦截器和响应拦截器。
- 在响应拦截器里可以对请求返回的状态码进行统一处理。例如登录过期跳转至登录页,系统错误做一些提示等操作。
- 在请求拦截器里可以对请求的请求头、请求地址、请求参数等进行一些处理,也可以提前对响应做异常处理。例如请求头携带token,query统一携带platform等一些参数等操作。
拦截器的设置可以分为全局拦截器、实例内部拦截器
- 全局拦截器
直接在导入的request下设置全局的拦截器。
官方示例:
// request拦截器, 改变url 或 options.
request.interceptors.request.use((url, options) => {
return {
url: `${url}&interceptors=yes`,
options: { ...options, interceptors: true },
};
});
// 和上一个相同
request.interceptors.request.use(
(url, options) => {
return {
url: `${url}&interceptors=yes`,
options: { ...options, interceptors: true },
};
},
{ global: true }
);
// response拦截器, 处理response
request.interceptors.response.use((response, options) => {
const contentType = response.headers.get('Content-Type');
return response;
});
// 提前对响应做异常处理
request.interceptors.response.use(response => {
const codeMaps = {
502: '网关错误。',
503: '服务不可用,服务器暂时过载或维护。',
504: '网关超时。',
};
message.error(codeMaps[response.status]);
return response;
});
// 克隆响应对象做解析处理
request.interceptors.response.use(async response => {
const data = await response.clone().json();
if (data && data.NOT_LOGIN) {
location.href = '登录url';
}
return response;
});
- 局部拦截器
在extend创建的实例上设置拦截器就是局部拦截器。
针对一些特殊的接口,通用的拦截器规则不适用可以extend创建配置相同的实例,再单独设置拦截器规则。 例如某个页面需要对返回的错误状态进行特殊处理,就可以extend创建一个新的实例,设置新的拦截器, 不这样做也可以在全局的拦截器中写if else 逻辑判断,但是项目发杂起来if else 多了就难以维护。 官方示例:
function createClient(baseUrl) {
const request = extend({
prefix: baseUrl,
});
return request;
}
const clientA = createClient('/api');
const clientB = createClient('/api');
// 局部拦截器使用
clientA.interceptors.request.use(
(url, options) => {
return {
url: `${url}&interceptors=clientA`,
options,
};
},
{ global: false }
);
clientB.interceptors.request.use(
(url, options) => {
return {
url: `${url}&interceptors=clientB`,
options,
};
},
{ global: false }
);
请求封装
先对请求做一下参数错误等处理
class Http {
static get<T>(url: string, options?: RequestOptionsInit): Promise<baseRes<T>> {
if (!url) return Promise.reject(null)
// ...
return request.get(url, options)
}
}
将一类请求放置在一个class中,并在做好ts的接口返回定义即可。
export default class ProductRequest {
// ... 定义一些公共方法,请求实例,拦截器等
static getProductList(params: ProdListReqParams) {
// ... 做一些处理
return Http.get<ProdListRes>(Api.productList, {
params,
})
}
// ...
}
// ts 类型定义
export interface ProdListReqParams {
order?: string
sortType?: string
currentPage: number
pageSize: number
filter?: string
}
export interface ProdListRes {
prodTags: ProdTags[]
packListItemPage: PackListItemPage
}
// ....
hooks范式的简易数据管理方案
使用umi提供的@umijs/plugin-model插件可以像hox一样使用hooks来进行全局数据的共享。
部分场景可以取代dva,如果你的项目很复杂建议你还是使用dva
如果项目里只是需要进行发起一些请求,全局数据共享,使用hooks的方案我觉得完全够应付了。
如果需要一些中间件,使用自定义hook也能达到类似的效果,这要看具体的需求了。
初始化全局数据
使用umi提供的插件@umijs/plugin-initial-state约定一个地方生产和消费初始化数据。
创建src/app.ts文件,app.ts中声明的getInitialState方法会在整个应用最开始执行,
返回值会作为全局共享的数据。
Layout 插件、Access 插件以及用户都可以通过 useModel('@@initialState') 直接获取到这份数据。
// src/app.ts
export async function getInitialState() {
const data = await fetchXXX(); // 请求数据
return data;
}
然后在页面中使用useModel开获取初始值:
import { useModel } from 'umi';
export default () => {
const { initialState, loading, error, refresh, setInitialState } = useModel('@@initialState');
if(loading) return <Loading /> // loading显示加载中
if(error) return <Error /> // 错误显示错误提示
function reload() {
refresh() // 重新获取初始数据
}
return <>{initialState}</>
};
model全局共享状态
在src目录下创建models文件夹。
约定在 src/models 目录下的文件为项目定义的 model 文件。 每个文件需要默认导出一个 function,该 function 定义了一个 Hook,不符合规范的文件我们会过滤掉。 文件名则对应最终 model 的 name,你可以通过插件提供的 API 来消费 model 中的数据。 所谓 hooks model 文件,就是自定义 hooks 模块,没有任何需要使用者关注的黑魔法。
例如models文件夹下创建useUserInfo.js文件:
import { useState, useCallback } from 'react'
export default function useUserInfo() {
const [user, setUser] = useState(null)
const signin = useCallback((account, password) => {
// signin implementation
// setUser(user from signin API)
}, [])
const signout = useCallback(() => {
// signout implementation
// setUser(null)
}, [])
return {
user,
signin,
signout
}
}
这里使用useCallback处理方法函数是为了防止每次状态更新,
返回的对象中signin、signout方法引用地址不一致,会导致一些不必要的重复渲染。
在页面中使用:
import { useModel } from 'umi';
export default () => {
const { user, signout } = useModel('useUserInfo', model => ({ user: model.user, signout: model.signout }));
return <>{user.name}</>
};
其他一些配置
- mfsu 开启后若出现页面改变刷新无变化,需要删除
.umi文件夹,重新来一下。 publicPath: "/"构建部署要设置的静态资源目录ignoreMomentLocale: true,没有国际化的需求可以忽略moment的国际化文件,减少打包文件体积。hash: true, 防止页面缓存- 使用额外字体需要先安装一下
file-loader:
yarn add file-loader
// .umirc.ts添加下面配置项
chainWebpack(config) {
config.module.rule("otf").test(/.otf$/).use("file-loader").loader("file-loader")
}