从0搭建React+antd+TypeScript+Umi Hooks+Mobx前端框架

2,301 阅读9分钟

背景

因为现在公司的主要技术栈是React,所以也想着能够搭建一个好的React前端框架,方便在工作中使用;框架在打包过程也做了优化,多线程,拆包,缓存等等手段提升打包速度和质量。主要用到的库包括:

  • UI antd
  • mobx-react-lite 它是基于 React 16.8 和 Hooks 的 MobX 的轻量级React绑定。
  • TypeScript
  • Umi Hooks 砖家出品的Hooks库
  • Axios
  • React-router
  • Use Immer 用于替代useState做数据的存储
  • PostCss

创建项目

创建带TypeScript模板的react-app,推荐使用yarn,接下来我也主要以yarn做例子

yarn create react-app react-cli --template typescript
OR
npx create-react-app react-cli --typescript

目录结构

  • api 存放接口
  • assets 存放静态文件、less、iconfont等文件
  • components 存放组件
  • hooks 自定义hooks组件
  • interfaces ts types
  • layout 布局组件
  • router 路由
  • stores mobx状态管理
  • utils 公共方法函数
  • views 页面

image.png

引入Antd

yarn add antd

引入craco

yarn add @craco/craco
yarn add carco-antd
/* package.json */
"scripts": {
-   "start": "react-scripts start",
-   "build": "react-scripts build",
-   "test": "react-scripts test",
+   "start": "craco start",
+   "build": "craco build",
+   "test": "craco test",
}

然后在项目根目录创建一个 craco.config.js 用于修改默认配置。antd按需加载以及自定义主题

/* craco.config.js */
const CracoLessPlugin = require('craco-less');
const CracoAntDesignPlugin = require("craco-antd");
const path = require("path");
module.exports = {
    plugins: [
        // antd 按需加载 less等配置
        {
            plugin: CracoAntDesignPlugin,
            options: {
                //  自定义主题
                customizeThemeLessPath: path.join(__dirname, "src/assets/styles/global.less")
            }
        }
    ],
}
/* assest/styles/global.less */
@global-text-color: #499AF2; // 公共字体颜色
@primary-color    : @global-text-color; // 全局主色

重新打包就可以了,所有的主题配置在这里噢 image.png

React-router

这里利用React-router做路由,同时也会根据用户角色,做权限处理;只有当角色和路由允许的角色一致时才可以访问和展示。

yarn add react-dom-router

子页面

/*  router/routes.ts  */
import LoginIndex from '@/views/Login'
import HomeIndex from '@/views/Home'
import SubPages11 from '@/views/SubPages1/Page1'
import SubPages12 from '@/views/SubPages1/Page2'
import SubPages21 from '@/views/SubPages2/Page1'
import SubPages22 from '@/views/SubPages2/Page2'
import SubPages31 from '@/views/SubPages3/Page1'
import SubPages32 from '@/views/SubPages3/Page2'
import NotFound from '@/views/NotFound'
import { AndroidOutlined, AppleOutlined, DingdingOutlined, IeOutlined, ChromeOutlined, GithubOutlined, AlipayCircleOutlined, ZhihuOutlined } from '@ant-design/icons'
import { routeTypes } from '@/interfaces/routes'
const routes: routeTypes[] = [
     {
        path: '/',
        exact: true,
        component: Index,
        requiresAuth: false,
    },
    {
        path: '/pages',
        component: HomeIndex,
        requiresAuth: true,
        children: [{
            path: '/pages/sub1',
            name: 'SubPages1',
            icon: AndroidOutlined,
            children: [{
                path: "/pages/sub1/page1",
                component: SubPages11,
                name: 'SubPage1',
                icon: AppleOutlined,
                meta: {
                    roles: ['user']
                }
            },
            {
                path: "/pages/sub1/page2",
                component: SubPages12,
                name: 'SubPage2',
                icon: DingdingOutlined,
                meta: {
                    roles: ['admin']
                }
            }]
        }, {
            path: '/pages/sub2',
            name: 'SubPages2',
            icon: IeOutlined,
            children: [...]    
        }, {s
            path: '/pages/sub3',
            name: 'SubPages3',
            icon: GithubOutlined,
            children: [...]
        },]
    },
    {
        path: '/login',
        component: LoginIndex,
        requiresAuth: false,
    },
    {
        path: '*',
        exact: true,
        component: NotFound,
        requiresAuth: false,
    }
]
export default routes

新建router下新建indext.tsx 用于渲染页面

/*  router/index.tsx  */
import React from 'react';
import { HashRouter as Router, Switch, Route } from 'react-router-dom';
import { routeTypes } from '@/interfaces/routes'
import routesMap from '@router/routes'
const Routes: React.FC = () => {
    return (
        <Router>
            <Switch>
                {
                    routesMap.map((item: routeTypes [], index: number) => {
                        return <Route  key={index} render={(props) => {
                            const Component: any = item.component
                            return <Component {...props} route={item} />
                        }}></Route>
                    })
                }
            </Switch>
        </Router>
    )
}
export default Routes

引入Router/index.tsx

import React from 'react';
import Routes from '@/router/index';

const App = () =>  <Routes />

export default App;

新建hasPermission.ts,如果页面roles包括用户的角色则返回true,在渲染menu和子页面的时候就根据这个值渲染页面。

export const hasPermission = (roles: string[], userRole: string[]): boolean => {
    if (!userRole) return false
    if (!roles) return true
    return userRole.some((role: string) => roles.includes(role))
}

比如Home页面,渲染子页面的逻辑:

const Home: React.FC<any> = ((props: RouteComponentProps): JSX.Element => {
    const loadFirstPage = useRef<boolean>(false)
    const getPermissionRoutes = usePersistFn((Routes: routeTypes[]): React.ReactNode => {
        const userRole: string[] = ['admin']
        return Routes.map((item: routeTypes, index: number) => {
            if (item.children && item.children.length > 0) {
                return getPermissionRoutes(item.children)
            } else {
                if (item?.meta?.roles) {
                    if (hasPermission(item?.meta?.roles, userRole)) {
                        if (!loadFirstPage.current) {
                            props.history.replace(item.path)
                            loadFirstPage.current = true
                        }
                        return <Route key={index} path={item.path} component={item.component} />
                    } else {
                        return null
                    }
                } else {
                    return <Route key={index} path={item.path} component={item.component} />
                }
            }
        })
    })
    return <div className="wrapper">
        <Layout className="layout">
            <div className="layout_Left">
                <Menu />
            </div>
            <Layout className="layout_right">
                <Header></Header>
                <Content className="wrapper_box">
                    <div className="wrapper_content">
                        <Switch>
                            {getPermissionRoutes(Routes[1].children as routeTypes[])}
                            <Route component={NotFound} />
                        </Switch>
                    </div>
                </Content>
            </Layout>
        </Layout>
    </div>
})
export default withRouter(Home)

在这里SubPages1下面的page1 就无法展示出来和访问,如果直接输入路由也会访问页面不存在,因为page1允许的角色user 而我们角色是admin所以无法展示。 chrome-capture (11).gif

Use Immer

yarn add use-immer

useImmer很好的解决了ReactHooks中的赋值的性能问题,可以单独更新某个对象的某个属性。

    const [state, setState] = useImmer<stateProps>({
        menuMode: "inline",
        list: {
            test: "test",
            otherKey: "otherKey"
        }
    })
    const onChangeCollapse = usePersistFn((val: boolean) : void => {
        setState(state => {
            state.menuMode = !val ? 'inline' : 'horizontal'    //只更新state.menuMode属性
        })
        setState(state => {
            state.list.test = 'test update'          //只更新state.list.test属性
        })
    }) 

效果 上面的赋值方法也可以写到一起,效果是一样的:

        setState(state => {
             state.menuMode = !val ? 'inline' : 'horizontal';    //只更新state.menuMode属性
             state.list.test = 'test update'          //只更新state.list.test属性
        })

Umi Hooks

yarn add ahooks

Umi Hooks 是一个 React Hooks 库,致力提供常用且高质量的 Hooks。提供了非常多的Hooks组件,比如上面使用的usePersistFn,他的作用:在某些场景中,你可能会需要用 useCallback 记住一个回调,但由于内部函数必须经常重新创建,记忆效果不是很好,导致子组件重复 render。对于超级复杂的子组件,重新渲染会对性能造成影响。通过 usePersistFn,可以保证函数地址永远不会变化。Umi Hooks功能还是非常强大的,有很多功能很强大的API。大家可以去官方文档看看hooks.umijs.org/zh-CN/hooks…

自定义hooks

自定义hooks其实在我们的开发工作中,还是很常遇到的。hooks的好处就是可以抽离公共方法,像组件一样的随意使用,对于快节奏的开发工作还是很舒服的,比如你觉得react hooks 或者 umi hooks的api,不能满足自己的需求,也可以自己创新一些api。我这里举个例子,大家写class组件写的很多的话,会经常用的this.setState(),大家都知道this.setState()是异步执行,你无法直接拿到最新的statehooks中的useState同样也是异步的,你无法直接获取到最新的state,所以我自己写了一个useSetState 方法,用于在修改完状态后能够立即拿到最新的state。 我们在src/hooks文件夹下新建useSetState.ts

/* hooks/useSetState.ts */
import { useState, useEffect, useRef, useCallback } from 'react'
export const useSetState = <T extends any>(
    initialState: T = {} as T,
): [T, (patch: Partial<T> | ((prevState: T) => Partial<T>), cb?: Function) => void] => {
    const [state, setState] = useState<T>(initialState);
    const callBack = useRef<Function | null>(null)
    const setMergeState = useCallback(
        (patch, cb) => {
            callBack.current = cb;
            setState((prevState) => {
                if (Object.prototype.toString.call(patch).slice(8, -1) === 'Object') {
                    return Object.assign({}, prevState, patch)
                } else {
                    return patch
                }
            });
        },
        [setState],
    );
    useEffect(() => {
        callBack.current && callBack.current(state)
    }, [state])
    return [state, setMergeState];
};

export default useSetState

使用的方式也很简单,基本和useState一致,只是在setState的时候提供一个回调函数。

import { useSetState } from '@/hooks/useSetState' //引入

const [state, setState] = useSetState<number>(12)
useUpdateEffect(() => {
        console.log("counter change:" + counter)
        setState(333, (newState: any) => {
            console.log("setState的回调:", newState)
        })
        console.log("修改完毕后的当前数值:", state)
}, [counter])
useEffect(() => {
    console.log('useEffect监听数值变化:', state)
}, [state])

image.png

这就完成了带回调的useSetState hooks 的编写,不过这种写法不太推荐在hooks中使用,建议需要获取最新的数值都在useEffect或者 useUpdateEffect(umi hooks)中去。

Mobx

状态管理选择的Mobx,Mobx和Redux我都用过,不过当我习惯用Mobx后,就感觉还是Mobx更方便一些,所以更喜欢在项目中用Mobx,现在Mobx已经更新到5.0版本了,不过5.0版本并不支持ie11,所以如果想要兼容性可以选择4.0的版本,或者Redux。 这里推荐一个针对Mobx的库,mobx-react-lite:它是基于 React 16.8 和 Hooks 的 MobX 的轻量级React绑定。

yarn add mobx mobx-react-lite

这个主要影响的是调用方法的形式,对于Mobx的书写是一样的,比如写一个加减数值:

/*   stores/test/index.ts */
import { observable, action } from 'mobx'
export type CountStoreType = {
    counter: number,
    onIncrement: () => void,
    onDecrement: () => void
};
// 观察者方式
class counterStoreClass {
    @observable counter: number = 0
    @action.bound
    onIncrement() {
        this.counter++;
    }
    onDecrement = () => {
        this.counter--;
    }
}
const counterStore: CountStoreType = new counterStoreClass();
export default counterStore;

这里你的typeScirpt可能会编译不了,会报错:Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning. 解决方法是在tsconfig.json加入配置:

 "compilerOptions": {
  ...
    "experimentalDecorators": true,
  ...
  }
/*   stores/index.tsx */
import { useLocalStore } from 'mobx-react-lite';
import * as React from 'react';
import { createStore, TStore } from './config';
const storeContext = React.createContext<TStore | null>(null);
export const StoreProvider = ({ children }: any) => {
    const store = useLocalStore(createStore);
    return <storeContext.Provider value={store}>{children}</storeContext.Provider>;
};
export const useStore = () => {
    const store = React.useContext(storeContext);
    if (!store) {
        throw new Error('You have forgot to use StoreProvider.');
    }
    return store;
};

完毕以后,一定要把storeProvider包裹所需要共享状态的页面,我这里直接放到app.tsx

/*   app.tsx */
import { StoreProvider } from '@stores/index';

const App = () =>
  <>
    ...
    <StoreProvider>
      <Routes />
    </StoreProvider>
    ...
  </>
export default App;

剩下来就仅仅是调用的事情了:

import React from 'react'
import { useDebounceEffect, useMount, useUnmount, useUpdateEffect } from 'ahooks'
import { Button } from 'antd'
import { observer } from 'mobx-react-lite'
import { useStore } from '@/stores';
import { getTestApi } from '@/api/testApi'
import ButtonCom from '@/components/Button'
interface IProps { }
const SubPage: React.FC<IProps> = ((): JSX.Element => {
    const { counterStore } = useStore();          //引入store对象
    const { counter, onIncrement, onDecrement } = counterStore   // 获取属性和方法
    useMount(() => {
        console.log("执行了页面加载")
    })
    useUnmount(() => {
        console.log("执行了页面卸载")
    })
    useUpdateEffect(() => {
        console.log("counter change:" + counter)
    }, [counter])
    useDebounceEffect(() => {
        console.log("counter debounce:" + counter)
    }, [counter])
    return <div>
        这是SubPages-1
        <Button onClick={(): void => {
            getTestApi()
            onIncrement()
        }}>增加</Button>
        <Button onClick={(): void => {
            onDecrement()
        }}>减少</Button>
        count:{counter}
        <ButtonCom></ButtonCom>
    </div>
})
export default observer(SubPage)          //observer组件

chrome-capture (12).gif

此外axios的配置应该大家都知道,所以我这也不多说了,具体在我的源码里面也有,utils下的axios.ts

拆包、多线程打包、缓存等等打包优化

加入了打包分析 webpack-bundle-analyzer speed-measure-webpack-plugin 加入了打包进度条 webpackbar 加入了打包压缩 compression-webpack-plugin terser-webpack-plugin 还对包进行拆包 开发环境的域名代理 devServer 加快打包速度,还可以考虑删除antd-icons,单独去iconfont网站下,按需引入。不然打包会费很多时间

/*    craco.config.js  */
const { POSTCSS_MODES, whenProd } = require("@craco/craco");
const CracoAliasPlugin = require("craco-alias");
const CracoAntDesignPlugin = require("craco-antd");
// 打包信息配置
const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer");
// webpack 进度条
const WebpackBar = require('webpackbar');
// 开启gzip
const CompressionWebpackPlugin = require('compression-webpack-plugin');
// 压缩js
const TerserPlugin = require('terser-webpack-plugin');
// 分析打包时间
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeasurePlugin();

const threadLoader = require('thread-loader');

const path = require("path");
const resolve = dir => path.join(__dirname, '..', dir);

const jsWorkerPool = {
  workers: 2,
  poolTimeout: 2000
};

threadLoader.warmup(jsWorkerPool, ['babel-loader']);

// 打包取消sourceMap
process.env.GENERATE_SOURCEMAP = "false";

// 覆盖默认配置
module.exports = {
  webpack: smp.wrap({
    configure: {
      /*在这里添加任何webpack配置选项: https://webpack.js.org/configuration */
      module: {
        rules: [
        {
          test: /\.js$/,
          exclude: /node_modules/,
          use: [
            {
              loader: 'thread-loader',
              options: jsWorkerPool
            },
            'babel-loader?cacheDirectory'
          ]
        }]
      },
      resolve: {
        modules: [ // 指定以下目录寻找第三方模块,避免webpack往父级目录递归搜索
          resolve('src'),
          resolve('node_modules'),
        ],
        alias: {
          "@": resolve("src") // 缓存src目录为@符号,避免重复寻址
        }
      },
      optimization: {
        // 开发环境不压缩
        minimize: process.env.REACT_APP_ENV !== 'development' ? true : false,
        splitChunks: {
          chunks: 'all', // initial、async和all
          minSize: 30000, // 形成一个新代码块最小的体积
          maxAsyncRequests: 5, // 按需加载时候最大的并行请求数
          maxInitialRequests: 3, // 最大初始化请求数
          automaticNameDelimiter: '~', // 打包分割符
          name: true,
          cacheGroups: {
            vendors: { // 基本框架
              chunks: 'all',
              test: /(react|react-dom|react-dom-router|babel-polyfill|mobx)/,
              priority: 100,
              name: 'vendors',
            },
            'async-commons': { // 其余异步加载包
              chunks: 'async',
              minChunks: 2,
              name: 'async-commons',
              priority: 90,
            },
            commons: { // 其余同步加载包
              chunks: 'all',
              minChunks: 2,
              name: 'commons',
              priority: 80,
            }
          }
        }
      },
    },
    plugins: [
      // webpack进度条
      new WebpackBar({ color: 'green', profile: true }),
      // 打包时,启动插件
      ...whenProd(() => [
        // 压缩js 同时删除console debug等
        new TerserPlugin({
          parallel: true, // 多线程
          terserOptions: {
            ie8: true,
            // 删除注释
            output: {
              comments: false
            },
            //删除console 和 debugger  删除警告
            compress: {
              drop_debugger: true,
              drop_console: true
            }
          }
        }),
        // 开启gzip
        new CompressionWebpackPlugin({
          // 是否删除源文件,默认: false
          deleteOriginalAssets: false
        }),
        // 打包分析
        new BundleAnalyzerPlugin()
      ], [])
    ]
  }),
  style: {
    // 自适应方案
    postcss: {
      mode: POSTCSS_MODES.file
    }
  },
  plugins: [
    // antd 按需加载 less等配置
    {
      plugin: CracoAntDesignPlugin,
      options: {
        //  自定义主题
        customizeThemeLessPath: path.join(__dirname, "src/assets/styles/global.less")
      }
    },
    // 插件方式,设置别名  
    {
      plugin: CracoAliasPlugin,
      options: {
        source: "tsconfig",
        tsConfigPath: "tsconfig.paths.json"
      }
    },
  ],
  devServer: {
    proxy: {
      '/': {
        target: 'www.test.com', // 开发路由代理
        ws: false, // websocket
        changeOrigin: true, //是否跨域
        secure: false, // 如果是https接口,需要配置这个参数
        pathRewrite: {}
      }
    }
  }
};

环境配置

引入dotenv-cli

yarn add dotenv-cli

新增开发环境配置文件.env.development.env.production两个文件

/*  .env.development */
# 方便打包不同接口环境
# 开发环境
# 自定义变量 必须以 REACT_APP_ 开头

PORT = 3000
NODE_ENV= development
REACT_APP_ENV = development
REACT_APP_BASE_API = ""

/*  .env.production*/
# 生产环境
# 自定义变量 必须以 REACT_APP_ 开头

NODE_ENV= production
REACT_APP_ENV = production
REACT_APP_BASE_API = ""

然后修改package.json中的启动脚本: image.png

现在yarn start 或者 yarn build 就会根据环境配置来处理。

Package.json:

image.png

还有一些细节的调整,会尽力将这个框架更加完善的。

github地址:github.com/Benzic/Reac… 欢迎star 和提意见