美文网首页前端开发
从0搭建React+antd+TypeScript+Umi Ho

从0搭建React+antd+TypeScript+Umi Ho

作者: Benzic | 来源:发表于2020-09-16 20:41 被阅读0次

    背景

    因为现在公司的主要技术栈是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。大家可以去官方文档看看https://hooks.umijs.org/zh-CN/hooks/life-cycle/use-update-effect

    自定义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地址:https://github.com/Benzic/React-typescript-umihooks-mobx
    欢迎star 和提意见

    相关文章

      网友评论

        本文标题:从0搭建React+antd+TypeScript+Umi Ho

        本文链接:https://www.haomeiwen.com/subject/uxbaektx.html