Umi

Umi

Umi简介

Umi,中文发音为「乌米」,是可扩展的企业级前端应用框架。Umi 以路由为基础的,同时支持配置式路由和约定式路由,保证路由的功能完备,并以此进行功能扩展。然后配以生命周期完善的插件体系,覆盖从源码到构建产物的每个生命周期,支持各种功能扩展和业务需求。

Umi 是蚂蚁集团的底层前端框架,已直接或间接地服务了 10000+ 应用,包括 Java、Node、H5 无线、离线(Hybrid)应用、纯前端 assets 应用、CMS 应用、Electron 应用、Serverless 应用等。他已经很好地服务了我们的内部用户,同时也服务了不少外部用户,包括淘系、飞猪、阿里云、字节、腾讯、口碑、美团等。在 2021 年字节的调研报告中,Umi 是其中 25.33% 开发者的选择。

Umi 有很多非常有意思的特性,比如。

1、企业级,在安全性、稳定性、最佳实践、约束能力方面会考虑更多
2、插件化,啥都能改,Umi 本身也是由插件构成
3、MFSU,比 Vite 还快的 Webpack 打包方案
4、基于 React Router 6 的完备路由
5、默认最快的请求
6、SSR & SSG
7、稳定白盒性能好的 ESLint 和 Jest
8、React 18 的框架级接入
9、Monorepo 最佳实践

什么时候不用 Umi?

如果你的项目,

1、需要支持 IE 8 或更低版本的浏览器
2、需要支持 React 16.8.0 以下的 React
3、需要跑在 Node 14 以下的环境中
4、有很强的 webpack 自定义需求和主观意愿
5、需要选择不同的路由方案

Umi 可能不适合你。

为什么不是?

create-react-app

create-react-app 是脚手架,和 Umi、next.js、remix、ice、modern.js 等元框架不是同一类型。脚手架可以让我们快速启动项目,对于单一的项目够用,但对于团队而言却不够。因为使用脚手架像泼出去的水,一旦启动,无法迭代。同时脚手架所能做的封装和抽象都非常有限。

next.js

如果要做 SSR,next.js 是非常好的选择(当然,Umi 也支持 SSR);而如果只做 CSR,Umi 会是更好的选择。相比之下,Umi 的扩展性会更好;并且 Umi 做了很多更贴地气的功能,比如配置式路由、补丁方案、antd 的接入、微前端、国际化、权限等;同时 Umi 会更稳定,因为他锁了能锁的全部依赖,定期主动更新,某一个子版本的 Umi,不会因为重装依赖之后而跑不起来。

remix

Remix 是我非常喜欢的框架,Umi 4 从中(学)了不少东西。但 Remix 是 Server 框架,其内置的 loader 和 action 都是跑在 server 端的,所以会对部署环境会有一定要求。Umi 将 loader、action 以及 remix 的请求机制同时运用到 client 和 server 侧,不仅 server 请求快,纯 CSR 的项目请求也可达到理论的最快值。同时 Remix 基于 esbuild 做打包,可能不适用于对兼容性有要求或者依赖尺寸特别大的项目。

以上均摘自Umi官网,很有意思的介绍与对比。开整

安装脚手架:

  • mkdir myapp && cd myapp //空目录
  • npx @umijs/create-umi-app

安装项目依赖:

  • 进入myapp,执行 npm install

1.创建组件

  • 在根目录下的pages目录下创建对应名字的组件即可
  • 例如:Film.tsx 此时在页面访问:http://localhost:8000/film 即可访问到Film组件对应的内容。

2.redirect重定向

1
2
3
4
5
6
7
8
import React from 'react';
import {Redirect} from "umi";

export default function index() {
return (
<Redirect to="/film"/>
)
}

3.404

创建pages/404.tsx 当没有匹配的路由的时候,会默认访问404.tsx的组件内容

4.嵌套路由及重定向

目录层级如下:

pages

  • film
    • _layout.tsx
    • Comingsoon.tsx
    • Nowplaying.tsx

例如:pages下新建film文件夹,在film文件夹下创建**_layout.tsx** ,在该文件中编写Film组件对应的页面,在film文件夹下创建子路由组件ComingSoon组件、Nowplaying组件。在_layout.tsx中使用插槽的方式,去渲染二级路由(嵌套路由)

此时,在嵌套路由的重定向,依然与一级路由的使用方式相同,同样是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import React from 'react';
import {Redirect,useLocation} from "umi";

export default function Film(props:any) {
const location = useLocation()
if (location.pathname === "/film" || location.pathname === "/film/") {
return <Redirect to="/film/nowplaying" />
}
return (
<div>
<div style={{background:"yellow",height:"200px"}}>大轮播</div>
{props.children}
</div>
)
}

5.路由根页面和声明式路由导航

在项目根目录下创建layouts目录,目录下创建index.tsx为路由的根页面,通过props.children插槽方式来渲染其他已经渲染好的路由组件。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import React from 'react';
import {NavLink} from "umi";
import'./index.less'
export default function IndexLayout(props:any) {
if (props.location.pathname === "/city" || props.location.pathname.includes("/detail")){
return <div>{props.children}</div>
}
return (
<div>
{props.children}
<ul>
<li>
<NavLink to="/film" activeClassName="active">film</NavLink>
</li>
<li>
<NavLink to="/cinema" activeClassName="active">cinema</NavLink>
</li>
<li>
<NavLink to="/center" activeClassName="active">center</NavLink>
</li>
</ul>
</div>
)
}

8.编程式路由导航

umi中的编程式路由导航与react-router中的相同,无差异

  1. props.history.push(“/home”)
  2. 使用useHistory hook,history.push(“/home”)

编程式导航传参:

目录结构如下 :

pages

  • detail
    • [id].tsx

pages下创建detail目录,detail目录下创建[id].tsx 在该tsx文件下通过props或者useParams来获取参数信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// [id].tsx
import React from 'react';
import {useParams} from "umi";
interface IParams {
id:string
}
export default function Detail(props:any) {
const params = useParams<IParams>()
return (
<div>
Detail - {params.id}
</div>
)
}

路由拦截

目录解构如下:

src

  • wrappers
    • auth.tsx

在项目根目录下的src目录下创建wrappers文件夹,该文件夹下创建auth.tsx,在需要校验的Center组件下,写下如下代码Center.wrappers = ["@/wrappers/Auth"] 对Center组件进行包装,实际上,此时的Auth组件相当于是Center组件的父组件

auth.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import React from 'react';
import {Redirect} from "umi";

export default function Auth(props:any) {
if (localStorage.getItem("token")){
return (
<div>
{props.children}
</div>
)
}
return <Redirect to="/login" />

}

Center.tsx

1
2
3
4
5
6
7
8
9
10
11
import React from 'react';

function Center() {
return (
<div>
center
</div>
)
}
Center.wrappers = ["@/wrappers/Auth"]
export default Center

hash模式与browser模式

  • .umirc.js下添加 history属性

    1
    2
    3
    history:{
    type:"hash"
    },

mock及反向代理

  • 在mock目录下创建api.tsx即可,里面写好对应的GET或者POST

    1
    2
    3
    4
    5
    6
    7
    8
    9
    export default {
    "GET /users":{name:"sola",age:100},
    "POST /users/login":(req,res) => {
    console.log(req.body);
    res.send({
    ok:1
    })
    }
    }
  • 反向代理:umirc.js

    1
    2
    3
    4
    5
    6
    proxy:{
    "/api":{
    target:"https://i.maoyan.com",
    changeOrigin:true
    }
    },

umi集成组件库

当umi自带antd组件库版本过低时,需要重新安装antd最新版,但是同时要关闭umi自身携带的antd,在umirc.js下添加

1
2
3
antd:{
mobile:false
}

dva集成

  • 按目录约定注册 model,无需手动 app.model
  • 文件名即 namespace,可以省去 model 导出的 namespace key
  • 无需手写 router.js,交给 umi 处理,支持 model 和component 的按需加载
  • 内置 query-string 处理,无需再手动解码和编码
  • 内置 dva-loading 和 dva-immer,其中 dva-immer需通过配开启(简化 reducer 编写)

同步获取数据

业务场景:在City组件中点击不同的城市,将城市信息(名称、ID)传递到Cinema组件

目录结构如下:

src

  • models
    • CityModel.ts

1.src下新建models目录,在目录下创建CityModel.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export default {
namespace:"city", // 命名空间
state : {
cityName:"北京",
cityId:"110100"
},
reducers:{
changeCity(prevState:any,action:any){
console.log(action);
return {
...prevState,
cityName: action.payload.cityName,
cityId: action.payload.cityId
}
}
}
}

2.通过connect组件将City组件封装成高阶组件,通过dispatch方法派发action

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import React, {useEffect, useState} from 'react';
import {IndexBar,List} from 'antd-mobile'
import {useHistory,connect} from "umi";

function City(props:any) {
const history = useHistory()
const [list,setList] = useState<any>([])
const filterCity = (cities:any) => {
const letterArr:Array<string> = []
const newList = []
for (let i = 65; i < 91; i++) {
letterArr.push(String.fromCharCode(i))
}

for (var m in letterArr) {
var cityItems = cities.filter((item:any) =>
item.pinyin.substring(0,1).toUpperCase() === letterArr[m]
)
cityItems.length && newList.push({
title:letterArr[m],
items:cityItems
})
}
return newList
}
useEffect(() => {
fetch("https://m.maizuo.com/gateway?k=5490218", {
headers: {
"X-Client-Info": '{"a":"3000","ch":"1002","v":"5.2.0","e":"1651225177596601022185473"}',
"X-Host": 'mall.film-ticket.city.list'
}
}).then(res => res.json()).then(res => {
setList(filterCity(res.data.cities))
})
})
const changeCity = (item:any) => {
// 修改state中的状态
props.dispatch({
type:"city/changeCity",
payload:{
cityName:item.name,
cityId:item.cityId
}
})
history.push("/cinema")
}
return (
<div style={{ height: window.innerHeight }}>
<IndexBar>
{list.map((group:any) => {
const { title, items } = group
return (
<IndexBar.Panel
index={title}
title={title}
key={title}
>
<List>
{items.map((item:any, index:number) => (
<List.Item onClick={() => changeCity(item)} key={index}>{item.name}</List.Item>
))}
</List>
</IndexBar.Panel>
)
})}
</IndexBar>
</div>
)
}
export default connect(() =>({}))(City)

3.通过reducer来接收City组件派发过来的数据,并更新state,Cinema组件获取redux中的数据进行页面渲染

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import React, {useEffect} from 'react';
import {NavBar,DotLoading} from "antd-mobile";
import {useHistory} from "umi"
import {connect} from "umi";
import {SearchOutline} from "antd-mobile-icons"

function Cinema(props:any) {
let history = useHistory()
useEffect(() => {
if (props.list.length === 0){
// 取数据
props.dispatch({
type:"cinema/getList",
payload:{
cityId: props.cityId
}
})
}else {
console.log("缓存")
}

},[])
console.log(props);
return (
<div>
<NavBar onBack={() => {
props.dispatch({
type:"cinema/clearList"
})
history.push("/city")
}} back={props.cityName} backArrow={false} right={<SearchOutline/>}>标题</NavBar>

{
props.loading && <div style={{fontSize:14,textAlign:"center"}}>
<DotLoading/>
</div>
}
<ul>
{
props.list.map((item:any) =>
<li key={item.cinemaId}>{item.name}</li>
)
}
</ul>
</div>
)
}

export default connect((state:any) => {
console.log(state);
return {
a:1,
loading:state.loading.global,
cityName:state.city.cityName,
cityId:state.city.cityId,
list:state.cinema.list
}
})(Cinema)

异步获取数据

业务场景:获取各城市影院数据信息展示到Cinema组件中,第一次正常发请求进行获取,后续再次访问时,走缓存。

首先在src/models 下创建CinemaModel.ts,实现异步获取影院数据的方法,此时与dva 的获取方式相同,通过effects副作用函数来进行异步转同步:call方法解决异步,put方法将异步获取的数据派发到reducers,reducers中进行同步处理,更新state,进行页面渲染

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
export default {
namespace:"cinema",
state:{
list:[]
},
reducers:{
changeList(prevState:any,action:any){
return {
...prevState,
list: action.payload
}
},
clearList(prevState:any,action:any){
return {
...prevState,
list:[]
}
}
},

effects:{
*getList(action:any,obj:any):any{
const {put,call} = obj
var res = yield call(getListForCinema,action.payload.cityId)
console.log(res);

yield put({
type:"changeList",
payload:res
})

}
}
}

async function getListForCinema(cityId:string) {
console.log(cityId);
var res = await fetch(`https://m.maizuo.com/gateway?cityId=${cityId}&ticketFlag=1&k=878555`,{
headers: {
'X-Client-Info': '{"a": "3000", "ch": "1002", "v": "5.2.0", "e": "1646462402616989231939585"}',
'X-Host': 'mall.film-ticket.cinema.list'
}
}).then(res => res.json())
return res.data.cinemas
}

Cinema.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import React, {useEffect} from 'react';
import {NavBar,DotLoading} from "antd-mobile";
import {useHistory} from "umi"
import {connect} from "umi";
import {SearchOutline} from "antd-mobile-icons"

function Cinema(props:any) {
let history = useHistory()
useEffect(() => {
if (props.list.length === 0){
// 取数据
props.dispatch({
type:"cinema/getList",
payload:{
cityId: props.cityId
}
})
}else {
console.log("缓存")
}

},[])
console.log(props);
return (
<div>
<NavBar onBack={() => {
props.dispatch({
type:"cinema/clearList"
})
history.push("/city")
}} back={props.cityName} backArrow={false} right={<SearchOutline/>}>标题</NavBar>

{
props.loading && <div style={{fontSize:14,textAlign:"center"}}>
<DotLoading/>
</div>
}
<ul>
{
props.list.map((item:any) =>
<li key={item.cinemaId}>{item.name}</li>
)
}
</ul>
</div>
)
}

export default connect((state:any) => {
console.log(state);
return {
a:1,
loading:state.loading.global,
cityName:state.city.cityName,
cityId:state.city.cityId,
list:state.cinema.list
}
})(Cinema)

umi中的loading

umi中的loading是通过connect组件封装后的state参数中获取的,它可以判断在异步处理的时候,副作用函数effects的执行状态,在数据没有返回之前,loading 是false,数据返回后则是true,可以用过它来进行页面的优化,从而提升用户体验

具体代码见dva异步获取数据中的Cinema组件。