介绍
Umi,中文可发音为乌米,是可扩展的企业级前端应用框架。Umi 以路由为基础的,同时支持配置式路由和约定式路由,保证路由的功能完备,并以此进行功能扩展。然后配以生命周期完善的插件体系,覆盖从源码到构建产物的每个生命周期,支持各种功能扩展和业务需求。
为什么使用Umi
Umi 是蚂蚁金服的底层前端框架,已直接或间接地服务了 3000+ 应用,包括 java、node、H5 无线、离线(Hybrid)应用、纯前端 assets 应用、CMS 应用等。他已经很好地服务了我们的内部用户,同时希望他也能服务好外部用户。
它主要具备以下功能:
- 🎉 可扩展,Umi 实现了完整的生命周期,并使其插件化,Umi 内部功能也全由插件完成。此外还支持插件和插件集,以满足功能和垂直域的分层需求。
- 📦 开箱即用,Umi 内置了路由、构建、部署、测试等,仅需一个依赖即可上手开发。并且还提供针对 React 的集成插件集,内涵丰富的功能,可满足日常 80% 的开发需求。
- 🐠 企业级,经蚂蚁内部 3000+ 项目以及阿里、优酷、网易、飞猪、口碑等公司项目的验证,值得信赖。
- 🚀 大量自研,包含微前端、组件打包、文档工具、请求库、hooks 库、数据流等,满足日常项目的周边需求。
- 🌴 完备路由,同时支持配置式路由和约定式路由,同时保持功能的完备性,比如动态路由、嵌套路由、权限路由等等。
- 🚄 面向未来,在满足需求的同时,我们也不会停止对新技术的探索。比如 dll 提速、modern mode、webpack@5、自动化 external、bundler less 等等。
什么时候不用 umi?
如果你,
- 需要支持 IE 8 或更低版本的浏览器
- 需要支持 React 16.8.0 以下的 React
- 需要跑在 Node 10 以下的环境中
- 有很强的 webpack 自定义需求和主观意愿
- 需要选择不同的路由方案
Umi 可能不适合你。
架构
下图是 umi 的架构图
从源码到上线的生命周期管理
市面上的框架基本都是从源码到构建产物,很少会考虑到各种发布流程,而 umi 则多走了这一步。
下图是 umi 从源码到上线的一个流程。
umi 首先会加载用户的配置和插件,然后基于配置或者目录,生成一份路由配置,再基于此路由配置,把 JS/CSS 源码和 HTML 完整地串联起来。用户配置的参数和插件会影响流程里的每个环节。
开始
环境准备
全局安装umi,并确保保本是2.0.0或以上
npm i umi -g
umi -v
umi@3.5.20
脚手架
先找个地方建个空目录,然后umi g创建一些页面
umi g page index
umi g page user
umi g 是 umi generate 的别名,可用于快速生成 component、page、layout 等,并且可在插件里被扩展,比如 umi-plugin-dva 里扩展了 dva:model,然后就可以通过 umi g dva:model foo 快速创建 dva 的 model
index.css
.title {
background: rgb(147, 121, 242);
}
可以看到clssName是以模块化的方式读取的样式
启动项目
umi目录没有正常项目的package.json等一系列的配置文件,它是通过 umi dev启动项目的
umi dev
.umi 临时文件
.umi 临时目录是整个 Umi 项目的发动机,你的入口文件、路由等等都在这里,这些是由 umi 内部插件及三方插件生成的。
你通常会在 .umi 下看到以下目录,
+ .umi
+ core # 内部插件生成
+ pluginA # 外部插件生成
+ presetB # 外部插件生成
+ umi.ts # 入口文件
临时文件是 Umi 框架中非常重要的一部分,框架或插件会根据你的代码生成临时文件,这些原来需要放在项目里的脏乱差的部分都被藏在了这里。
你可以在这里调试代码,但不要在 .git 仓库里提交他,因为他的临时性,每次启动 umi 时都会被删除并重新生成。
路由
路由的基本使用
Umi 支持约定式路由。约定式路由也叫文件路由,就是不需要手写配置,文件系统即路由,通过目录和文件及其命名分析出路由配置。
启动项目时会进行编译生成.umi目录,包含项目运行的代码,这些都是依据约定自动生成的
在目录下方空白处右键,选择 在集成终端中打开 ,防止在当前页面输入新的命令,服务器会被终止
在输入命令创建about页面
umi g page about
项目会自动重新加载生成路由
动态路由
约定 [] 包裹的文件或文件夹为动态路由,例如创建访问 user/1 这类需要传参的路由页面,则是输入命令
umi g page users/[id]
动态路由组件通过match.params读取路由参数
import React from "react";
import styles from "./[id].css";
export default function Page({ match }) {
return (
<div>
<h1 className={styles.title}>Page users/{match.params.id}</h1>
</div>
);
}
可选动态路由
可选动态路由则是可以有id参数也可以没有,约定 [ $] 包裹的文件或文件夹为动态可选路由
umi g page users/[id$]
嵌套路由
Umi 里约定目录下有 _layout.tsx 时会生成嵌套路由,以 _layout.tsx 为该目录的 layout。layout 文件需要返回一个 React 组件,并通过 props.children 渲染子组件。
umi g page users/_layout
接下来创建一个users/index.js路由组件
umi g page users/index
访问 /users 就会加载layout包裹index组件
在users/_layout.js内,通过 props.children 渲染子组件
import React from "react";
import styles from "./_layout.css";
export default function Page(props) {
return (
<div>
<h1 className={styles.title}>Page users/_layout</h1>
<div>{props.children}</div>
</div>
);
}
访问地址
Link组件
通过导入Link组件添加路由链接
import React from "react";
import {Link} from "umi";
import styles from "./index.css";
export default function Page() {
return (
<div>
<h1 className={styles.title}>Page index</h1>
<Link to="/users/100">用户100</Link>
</div>
);
}
Link 只用于单页应用的内部跳转,如果是外部地址跳转请使用 a 标签
编程式导航
umi导出history,调用 history.push 跳转到指定路由
import { history } from 'umi';
// 跳转到指定路由
history.push('/list');
// 带参数跳转到指定路由
history.push('/list?a=b');
history.push({
pathname: '/list',
query: {
a: 'b',
},
});
// 跳转到上一个路由
history.goBack();
例如:
import React from "react";
import {history} from "umi";
import styles from "./index.css";
export default function Page() {
return (
<div>
<h1 className={styles.title}>Page index</h1>
<button onClick={()=> {history.push('/users/50')}}>/users/50</button>
</div>
);
}
404路由
约定 src/pages/404.tsx 为 404 页面,但是使用 umi g page 404 会因为不是字符串而报错,'404'也不行,为了省去手动创建,建议:
umi g page x404
然后把x删掉,创建404路由组件后,凡是不能匹配的路由都会跳转到这个组件
权限路由
通过指定高阶组件 wrappers 达成效果,如page/users/_layout.js
import React from "react";
import styles from "./_layout.css";
function Page(props) {
return (
<div>
<h1 className={styles.title}>Page users/_layout</h1>
<div>{props.children}</div>
</div>
);
}
Page.wrappers =['@/wrappers/auth']
export default Page
umi g page
然后手动在 wrappers/auth 中
// 导入重定向组件
import { Redirect } from 'umi'
export default (props) => {
// 官方文档例子使用useAuth验证,这里替换
// const { isLogin } = useAuth();
const isLogin = true
if (isLogin) {
return <div>{ props.children }</div>;
} else {
return <Redirect to="/login" />;
}
}
这样,访问 /user,就通过 useAuth 做权限校验,如果通过,渲染 src/pages/user,否则跳转到 /login,由 src/pages/login 进行渲染
值为true则可以访问组件,false则跳转到登录页面
扩展路由属性
支持在代码层通过导出静态属性的方式扩展路由,比如更改页面标题:
import React from "react";
import styles from "./login.css";
function Page() {
return (
<div>
<h1 className={styles.title}>Page login</h1>
</div>
);
}
Page.title = "登录页";
export default Page;
其中的 title 会附加到路由配置中
layout布局
启用方式
配置开启。
介绍
为了进一步降低研发成本,我们尝试将布局通过 umi 插件的方式内置,只需通过简单的配置即可拥有 Ant Design 的 Layout,包括导航以及侧边栏。从而做到用户无需关心布局。
- 默认为 Ant Design 的 Layout @ant-design/pro-layout,支持它全部配置项。
- 侧边栏菜单数据根据路由中的配置自动生成。
- 默认支持对路由的 403/404 处理和 Error Boundary。
- 搭配 @umijs/plugin-access 插件一起使用,可以完成对路由权限的控制。
- 搭配 @umijs/plugin-initial-state 插件和 @umijs/plugin-model 插件一起使用,可以拥有默认用户登陆信息的展示。
想要动态菜单?查看这里 菜单的高级用法
配置
构建时配置
可以通过配置文件配置 layout 的主题等配置, 在 config/config.ts 中这样写:
import { defineConfig } from 'umi';
export const config = defineConfig({
layout: {
// 支持任何不需要 dom 的
// https://procomponents.ant.design/components/layout#prolayout
name: 'Ant Design',
locale: true,
layout: 'side',
},
});
更详细的配置:@umijs/plugin-layout
MFSU-启动加速
项目启动和更新时间过长,终端提示使用MFSU,提高研发效率。不管多大的项目,有缓存时启动 1s~3s+,热更新平均 500ms 内
首先确保umi版本在3.5.0以上
config/config.js
export default {
mfsu: {},
};
Ant-Design
安装依赖
npm i @ant-design/pro-layout antd
npm i @umijs/preset-react -D
config/config.js 添加配置,由于 Umi 3 的配置方式是拍平的方式,省去大量默认配置,只需这样写
export default {
antd: {}
}
无需手动导入样式,组件中导入antd组件使用即可
import React from "react";
import {Link, history} from "umi";
import styles from "./index.css";
import { Button } from "antd"
function Page() {
return (
<div>
<h1 className={styles.title}>Page index</h1>
<Link to="/users/100">用户100</Link>
<Button onClick={()=> {history.push('/users/50')}}>/users/50</Button>
</div>
);
}
Page.title = "主页"
export default Page
Dva-数据流管理
介绍
dva 首先是一个基于 redux 和 redux-saga 的数据流方案,然后为了简化开发体验,dva 还额外内置了 react-router 和 fetch,所以也可以理解为一个轻量级的应用框架。
特性
- 易学易用,仅有 6 个 api,对 redux 用户尤其友好,配合 umi 使用后更是降低为 0 API
- elm 概念,通过 reducers, effects 和 subscriptions 组织 model
- 插件机制,比如 dva-loading 可以自动处理 loading 状态,不用一遍遍地写 showLoading 和 hideLoading
- 支持 HMR,基于 babel-plugin-dva-hmr 实现 components、routes 和 models 的 HMR
命名由来?
D.Va拥有一部强大的机甲,它具有两台全自动的近距离聚变机炮、可以使机甲飞跃敌人或障碍物的推进器、 还有可以抵御来自正面的远程攻击的防御矩阵。
—— 来自 守望先锋
数据流向
数据的改变发生通常是通过用户交互行为或者浏览器行为(如路由跳转等)触发的,当此类行为会改变数据的时候可以通过 dispatch 发起一个 action,如果是同步行为会直接通过 Reducers 改变 State ,如果是异步行为(副作用)会先触发 Effects 然后流向 Reducers 最终改变 State,所以在 dva 中,数据流向非常清晰简明,并且思路基本跟开源社区保持一致(也是来自于开源社区)
依赖&配置
umi已经内置了dva,只需在配置里加上dva就可以使用了
export default {
dva: {}
}
使用
创建goods组件进行演示
umi g page goods
配置模型
models/goods.js
export default {
namespace: "goods", // 命名空间,区分多个model
// 实际可能为空数据,从服务器获取赋值
state: [
{
titile: "Vue",
},
{
title: "React",
},
],
reducers: {
// 触发reducer中的方法,直接修改当前的state
},
effects: {
// 副作用,处理异步的操作
},
};
组件使用
使用装饰器的组件必须为类组件,
import React, { Component } from "react";
import styles from "./goods.css";
import { connect } from "dva";
import { Card } from "antd";
@connect((state) => ({
goodsList: state.goods, // 获取指定命名空间的模型状态
}))
class Page extends Component {
render() {
return (
<div>
<h1 className={styles.title}>Page goods</h1>
<Card title="课程列表" style={{ width: 200 }}>
{this.props.goodsList.map((goods) => {
return <p key={goods.title}>{goods.title}</p>;
})}
</Card>
</div>
);
}
}
Page.title = "全部课程"
export default Page;
配置 jsconfig.json,消除装饰器导致的组件名称报红
{
"compilerOptions": {
"experimentalDecorators": true
},
"experimentalDecorators": true,
"exclude": ["node_modules", "dist"]
}
正确显示数据
触发事件更改state
先声明reducer事件
export default {
namespace: "goods", // 命名空间,区分多个model
// 实际可能为空数据,从服务器获取赋值
state: [
{
title: "Vue",
},
{
title: "React",
},
],
reducers: {
// 声明更改state的事件,第一个参数为当前state,第二个参数action为有效载荷payload,值为传入的goods
// reducer需要return用来直接覆盖state,这里将state结构到新数组并添加传入的goods
addGoods(state, { payload: goods }) {
return [...state, goods];
},
},
effects: {
// 副作用,处理异步的操作
},
};
组件内的使用方法,导入dva的connect装饰器并使用
import React, { Component } from "react";
import styles from "./goods.css";
import { connect } from "dva";
import { Card, Button } from "antd";
@connect(
(state) => ({
goodsList: state.goods, // 获取指定命名空间的模型状态
}),
{
// 声明方法并接收点击事件传入的对象goods赋值给payload传递到事件
// type为要触发的事件,通过payload将数据传递给事件
addGoods: (goods) => ({
type: "goods/addGoods", // action的type需要以 命名空间/reducer方法定位
payload: goods,
}),
}
)
class Page extends Component {
// 点击事件调用addGoods方法并传递goods
handleAdd = () => {
this.props.addGoods({
title: "课程" + new Date().getTime(),
});
};
render() {
return (
<div>
<h1 className={styles.title}>Page goods</h1>
<Card title="课程列表" style={{ width: 200 }}>
{this.props.goodsList.map((goods) => {
return <p key={goods.title}>{goods.title}</p>;
})}
</Card>
<Button onClick={this.handleAdd}>添加课程</Button>
</div>
);
}
}
Page.title = "全部课程";
export default Page;
Mock数据
Mock 数据是前端开发过程中必不可少的一环,是分离前后端开发的关键链路。通过预先跟服务器端约定好的接口,模拟请求数据甚至逻辑,能够让前端开发独立自主,不会被服务端的开发所阻塞。
约定式Mock文件
Umi 约定 /mock 文件夹下所有文件为 mock 文件
如果 /mock/api.ts 的内容如下,
export default {
'GET /api/users': { users: [1, 2] }, // 支持值为 Object 和 Array
'/api/users/1': { id: 1 }, // GET 可忽略
'POST /api/users/create': (req, res) => { // 支持自定义函数,API 参考 express@4
res.setHeader('Access-Control-Allow-Origin', '*'); // 添加跨域请求头
res.end('ok');
},
}
然后访问 /api/users 就能得到 { users: [1,2] } 的响应,其他以此类推
effects副作用处理异步操作
在Dva中,异步行为 ( 副作用 ) 会先触发 effects,然后流向 Reducers 最终改变 State,在dva中数据流向非常清晰简明
通常在项目开发中需要mock模拟数据,新建一个mock/goods.js
let data = [
{
title: "Vue",
},
{
title: "React",
},
];
// 抛出一个模拟请求的方法,接收req并返回res
// 模拟延迟1秒后返回data数据
export default {
"get /api/goods": function (req, res) {
setTimeout(() => {
res.json({ result: data });
}, 1000);
},
};
安装axios依赖
npm i axios
接下来在models/goods.js的effect中处理请求
import axios from "axios";
// 异步处理的方法,返回一个Promise对象
// 将被call方法使用
function getGoods() {
return axios.get("/api/goods");
}
export default {
namespace: "goods", // 命名空间,区分多个model
state, // 修改为空数据,从服务器获取赋值
reducers: {
addGoods(state, { payload: goods }) {
return [...state, goods];
},
// 初始化课程列表接收effects内的*getList调用并传递来的action
// 返回数据赋值给state
initGoods(state, action) {
// 同步操作
return action.payload;
},
},
effects: {
// for redux saga
// 副作用,处理异步的操作
// action需要触发的事件,call呼叫请求,put将数据传递给事件
*getList(action, { call, put }) {
const res = yield call(getGoods);
yield put({ type: "initGoods", payload: res.data.result });
},
},
};
接下来在组件中声明方法并使用
import React, { Component } from "react";
import styles from "./goods.css";
import { connect } from "dva";
import { Card, Button } from "antd";
@connect(
(state) => ({
goodsList: state.goods, // 获取指定命名空间的模型状态
}),
{
// 声明方法并接收点击事件传入的对象goods赋值给payload传递到事件
// type为要触发的事件,通过payload将数据传递给事件
addGoods: (goods) => ({
type: "goods/addGoods", // action的type需要以 命名空间/reducer方法命名
payload: goods,
}),
getList: () => ({
type: "goods/getList", // 触发的是effects内的*getList
}),
}
)
class Page extends Component {
// 点击事件调用addGoods方法并传递goods
componentDidMount() {
this.props.getList();
console.log(this.props.goodsList);
}
handleAdd = () => {
this.props.addGoods({
title: "课程" + new Date().getTime(),
});
};
render() {
return (
<div>
<h1 className={styles.title}>Page goods</h1>
<Card title="课程列表" style={{ width: 200 }}>
{this.props.goodsList.map((goods) => {
return <p key={goods.title}>{goods.title}</p>;
})}
</Card>
<Button onClick={this.handleAdd}>添加课程</Button>
</div>
);
}
}
Page.title = "全部课程";
export default Page;