UmiJS - React应用框架

1,053 阅读13分钟

介绍

UmiJS - 插件化的企业级前端应用框架

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 的架构图

img

从源码到上线的生命周期管理

市面上的框架基本都是从源码到构建产物,很少会考虑到各种发布流程,而 umi 则多走了这一步。

下图是 umi 从源码到上线的一个流程。

img

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

image-20220131225203803

index.css

.title {
  background: rgb(147, 121, 242);
}

可以看到clssName是以模块化的方式读取的样式

启动项目

umi目录没有正常项目的package.json等一系列的配置文件,它是通过 umi dev启动项目的

umi dev
image-20220131225832110 image-20220131225946546

.umi 临时文件

.umi 临时目录是整个 Umi 项目的发动机,你的入口文件、路由等等都在这里,这些是由 umi 内部插件及三方插件生成的。

你通常会在 .umi 下看到以下目录,

+ .umi
  + core     # 内部插件生成
  + pluginA  # 外部插件生成
  + presetB  # 外部插件生成
  + umi.ts   # 入口文件

临时文件是 Umi 框架中非常重要的一部分,框架或插件会根据你的代码生成临时文件,这些原来需要放在项目里的脏乱差的部分都被藏在了这里。

你可以在这里调试代码,但不要在 .git 仓库里提交他,因为他的临时性,每次启动 umi 时都会被删除并重新生成。

路由

路由的基本使用

Umi 支持约定式路由。约定式路由也叫文件路由,就是不需要手写配置,文件系统即路由,通过目录和文件及其命名分析出路由配置。

启动项目时会进行编译生成.umi目录,包含项目运行的代码,这些都是依据约定自动生成的

image-20220131230148072

在目录下方空白处右键,选择 在集成终端中打开 ,防止在当前页面输入新的命令,服务器会被终止

在输入命令创建about页面

umi g page about

项目会自动重新加载生成路由

image-20220131231037082 image-20220131230917367

动态路由

约定 [] 包裹的文件或文件夹为动态路由,例如创建访问 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>
  );
}
image-20220201104412776

可选动态路由

可选动态路由则是可以有id参数也可以没有,约定 [ $] 包裹的文件或文件夹为动态可选路由

umi g page users/[id$]
image-20220201104819473

嵌套路由

Umi 里约定目录下有 _layout.tsx 时会生成嵌套路由,以 _layout.tsx 为该目录的 layout。layout 文件需要返回一个 React 组件,并通过 props.children 渲染子组件。

umi g page users/_layout
image-20220201112045052

接下来创建一个users/index.js路由组件

umi g page users/index

访问 /users 就会加载layout包裹index组件

image-20220201112457625

在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>
  );
}

访问地址

image-20220201114025535image-20220201114049621

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>
  );
}

image-20220201152800071image-20220201152901915

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路由组件后,凡是不能匹配的路由都会跳转到这个组件

image-20220201155740844

权限路由

通过指定高阶组件 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则跳转到登录页面

image-20220201162511669image-20220201162428394

扩展路由属性

支持在代码层通过导出静态属性的方式扩展路由,比如更改页面标题:

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 会附加到路由配置中

image-20220201164154001

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: {},
};
image-20220201205731211

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
image-20220201212218205

Dva-数据流管理

介绍

dva 首先是一个基于 reduxredux-saga 的数据流方案,然后为了简化开发体验,dva 还额外内置了 react-routerfetch,所以也可以理解为一个轻量级的应用框架。

特性

  • 易学易用,仅有 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拥有一部强大的机甲,它具有两台全自动的近距离聚变机炮、可以使机甲飞跃敌人或障碍物的推进器、 还有可以抵御来自正面的远程攻击的防御矩阵。

—— 来自 守望先锋

img

数据流向

数据的改变发生通常是通过用户交互行为或者浏览器行为(如路由跳转等)触发的,当此类行为会改变数据的时候可以通过 dispatch 发起一个 action,如果是同步行为会直接通过 Reducers 改变 State ,如果是异步行为(副作用)会先触发 Effects 然后流向 Reducers 最终改变 State,所以在 dva 中,数据流向非常清晰简明,并且思路基本跟开源社区保持一致(也是来自于开源社区)

img

依赖&配置

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"]
}

正确显示数据

image-20220201223841716

触发事件更改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;
image-20220201235909509

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;