从零搭建中后台框架的核心流程

本文做者:鲍观霞

背景

随着 React 生态的快速发展,社区基于 React 的状态管理方案层出不穷,这意味着不少方案开发者依然要作不少选择,没有约定的团队,沟通成本和跨团队协做成本,以及长期的维护是很是高的,这时候统一一套开发模式就显得尤其重要。 css

本文将介绍如何从零开始搭建一个高可复用的后台框架,让每个人都能轻松搭出本身的后台,深刻了解本身的框架。 html

亲手实践一套项目框架有诸多好处:
一、业务可定制性强(好比,大家团队有一套业务定制型强的 UI 组件库;大家团队有一套本身的状态管理最佳实践;大家团队有一套复杂的权限管理流程等等)前端

PS: 固然你彻底能够找个第三方框架改形成本身想要的样子,可是入手成本、后续的维护成本、技术更新成本都会很高

二、收敛技术栈、屏蔽底层差别、统一开发体验,帮助团队下降开发和维护成本
三、成为框架掌控者,技术升级、底层改造为所欲为node

写在前面

本文拟讲述从零搭建 React 后台开发框架的核心技术和搭建流程,涉及到的技术并不是惟一可选技术栈,你能够随时用你熟悉的技术栈代替它。同时我会尽可能下降阅读本文的难度,下降前端开发的门槛,可是仍是有一些须要具有的知识:react

- React Hooks       
- React-Redux  
- React Router 5.0   
- Ant Design 4.x

该项目基本搭建了一个企业级管理系统的骨架结构,提供通用功能及扩展需求,不涉及业务逻辑开发,不涉及数据请求,全部数据均为 mock。webpack

开始搭建

基础结构及配置

一、 建立基本项目目录和结构
推荐 Create React App 建立基本项目结构。网上不少相关初始化流程,这里再也不赘述。官方教程在这里git

Create React App 是 React 官方推出的构建 React 单页面应用的脚手架工具。它自己集成了 Webpack,并配置了一系列内置的 loader 和基础的 npm 的脚本,能够很轻松的实现零配置就能够快速开发 React 的应用。

默认的项目目录结构以下:github

├── package.json
├── public                  # 静态目录
│   ├── favicon.ico
│   ├── index.html          # 最终的html的基础模板【默认是单页面应】
│   └── manifest.json
├── src
│   ├── App.css             # App根组件的css
│   ├── App.js              # App组件代码
│   ├── App.test.js
│   ├── index.css           # 启动文件样式
│   ├── index.js            # 启动的文件(执行入口)
│   ├── logo.svg
│   └── serviceWorker.js
└── yarn.lock

二、执行命令web

npm start
# or
yarn start

打开 http://localhost:3000 在浏览器中查看它。npm

至此,一个简易的 React 项目就成了。

项目进阶

React Router

为何选动态化路由

大多数人习惯了配置式路由的开发方式,包括像 Angular,Express, Ember 等,近一点的 包括 Ant Design Pro 和 Umi 框架,都是静态路由配置。React Router V4 以前也沿用了这一方式,可是在 React Router V4 版本作了一次不向前兼容的重构升级。 那 React Router V3 配置式路由的痛点在哪里?为什么要动态化?
我理解这块的 React Router V3 的痛点有如下几点:

为了方便介绍,React Router V3 如下简称 V3;React Router V4 如下简称 V4;
  • V3 脱离了 React 组件化思想。V3 虽然形式上是 React 组件,可是其实它与 UI 没有任何关系,只是提供了一条配置项而已。

这一点能够从相关源码追溯

const Route = createReactClass({
  // 无关代码

  /* istanbul ignore next: sanity check */
  render() {
    invariant(
      false,
      '<Route> elements are for router configuration only and should not be rendered'
    )
  }
});

这里 Route 的 render 方法中,没有作任何 UI 渲染相关的工做,不是一个正宗的组件。

  • V3 路由写法须要知足约定的格式,好比不能将 Route 脱离 Router 使用,这与 React 倡导的“能够声明式灵活性进行组件组装”的理念相违背。
  • V3 提供了不少相似生命周期的方法,如:onEnter, onUpdate, onLeave 等用来为处于不一样阶段的路由提供钩子方法。可是 React 自己有一套完善的生命周期方法。V3 路由方式的问题在于,它在 React 组件思想以外,设计了一套独立的 API,这有侵入性。
  • 集中式路由层层嵌套,在配置中你须要关心路由所属的祖先层级,页面展现由顶级路由来决定,没法体现动态路由的灵活性。

固然,V4 版本已经解决了这些问题。在 V4 版本中,抛弃了传统的路由概念,Route 回归组件化。

V4 开始采用单代码仓库模型结构,每一个仓库负责不一样的功能场景,他们分别相互独立。

  • react-router 路由基础库
  • react-router-dom 浏览器中使用的封装
  • react-router-native React native 封装

本文咱们只须要用到 react-router-dom 这个仓库,若是你不明白为何,看这里

你须要掌握 react-router-dom 这些组件:

  • BrowserRouter
  • Route
  • Switch
  • Link

你须要掌握 react-router-dom 这些对象及其方法:

  • history
  • location
  • match

React Router 从 4.0 开始彻底移除中心化配置,再也不主张集中式路由,让 React 回归组件化开发,它自己只是提供了导航功能的组件。
这里咱们根据推荐的动态化思路设计路由,入口只设计一级菜单,业务管理各自子路由。

篇幅问题,这里只列举二级路由的状况,多级路由同理。

一、安装依赖

npm install --save react-router-dom
cd src
touch router.js  // 构造咱们的一级路由

二、构造 src 目录(你能够灵活定制),我但愿它是这样的

.
├── src
│   ├── index.js                      // 入口文件
│   ├── pages
│   │   ├── demo1                     // 一级菜单A
│   │   │   ├── index.js
│   │   │   ├── page1                 // A下面的二级页面a
│   │   │   │   └── index.js
│   │   │   └── page2                 // A下面的二级页面b
│   │   │       └── index.js
│   │   └── demo2                     // 一级菜单B
│   │       ├── index.js
│   │       ├── page1                 // B下面的二级页面a
│   │       │   └── index.js
│   │       └── page2                 // B下面的二级页面b
│   │           └── index.js
│   └── router.js

三、构造一级路由

router.js
import { Switch, Route } from 'react-router-dom';

// 一级菜单
import demo1 from './pages/demo1';
import demo2 from './pages/demo2';

const router = (
    <Route render={() => {
        return (
            <Switch>
                <Route path="/demo1" component={demo1}></Route>
                <Route path="/demo2" component={demo2}></Route>
            </Switch>
        )
    }}></Route>
);

四、让一级路由去管理咱们的二级路由

pages/demo1/index.js(同级页面相似)
import { Switch, Route } from 'react-router-dom';

import page1 from './page1';
import page2 from './page2';

const Router = ({ match }) => (
    <Switch>
        <Route path={`${match.path}`} exact component={page1} />
        <Route path={`${match.path}/page1`} component={page1} />
        <Route path={`${match.path}/page2`} component={page2} />
    </Switch>
);

Switch 中包含 Route,只渲染第一个匹配的路由。所以主路由匹配加上 exact 去精确匹配,不会拦截后面的匹配。

五、入口文件加入路由

src/index.js
import React from 'react';
import ReactDom from 'react-dom';
import { BrowserRouter as Router  } from 'react-router-dom';

import routeChildren from './router';

ReactDom.render(
    <Router>
        {routeChildren}
    </Router>,
    document.getElementById('app')
);

这里咱们用的是 BrowserRouter 组件,打开 BrowserRouter 文件能够看到它声明了实例属性 history 对象,history 对象的建立来自 history 包的 createBrowserHistory 方法。

import { createBrowserHistory as createHistory } from "history";

class BrowserRouter extends React.Component {
  
  history = createHistory(this.props);

  render() {
    return <Router history={this.history} children={this.props.children} />;
  }
}

history 对象上拥有许多的属性和方法,这些将在后面给咱们提供很大的便利,若是你想了解更多关于 history 的访问,看这里

六、修改咱们的业务页面

pages/demo1/page1/index.js(同级页面相似)
import React from 'react';

const Page1 = history => {
    return (
        <div>demo2 page1</div>
    );
};

export default Page1;

至此,咱们的路由设计就完成了。

如今,npm run start 跑起来看看~

运行结果

项目路由基本配置结束。

配置式菜单管理

后台项目中,路由和菜单是组织起一个应用的关键骨架。设计完路由,接下来咱们考虑导航菜单管理。
这一步,咱们开始搭建框架核心能力: 菜单配置,UI 集成,状态管理,用户登录,路由鉴权。

导航应集成在 Layout 结构中,和业务逻辑解耦,为了避免让开发者菜单耦合到业务逻辑中,这里采用配置式菜单管理,开发者只须要关心菜单配置。
为了方便理解,UI 组件库选用 Ant Design。

一、 菜单配置 & UI 集成

既然打算作配置式菜单,那么咱们设计一个菜单配置,根据配置生成菜单。
cd src
touch menuConfig.js

menuConfig.js

const menu = [
    {
        path: 'demo1',
        name: '一级菜单A',
        children: [{
            name: 'subnav1',
            path: 'page1'
        },
        {
            name: 'subnav2',
            path: 'page2'
        }]
    },
    {
        path: 'demo2',
        name: '一级菜单B'
        children: [{
            name: '测试',
            path: 'page2'
        }]
    }
];

固然,你能够在配置中加入任意元素来丰富你的配置,好比 icon,redirect 等等;

二、生成菜单配置

接下来须要根据这份配置,构造咱们的导航,看一下 Ant Design 提供的 Menu 组件须要哪些数据?
官方给的 demo 是:
<Menu
    theme="dark"
    mode="inline"
    defaultSelectedKeys={['2']}>
    <Menu.Item key="1">nav1</Menu.Item>
    <Menu.Item key="2">nav2</Menu.Item>
</Menu>

为了让咱们的配置能很方便的生成 Menu 组件,咱们须要写个方法把咱们的菜单转成平铺形式。用 path 做为 key,能够很方便的解析 selectKey。
咱们但愿咱们的菜单能够根据 path 选中或切换,咱们须要根据 MenuConfig 构造这样一份结构:

{
  "selectMainMenu": {    // 当前访问一级菜单信息【用于标记一级菜单选中】
    "path": "demo1",
    "name": "一级菜单A" 
  },
  "mainMenu": [          // 当前全部一级菜单信息【用于渲染一级导航】
    {
      "path": "demo1",
      "name": "一级菜单A"
    },
    {
      "path": "demo2",
      "name": "一级菜单B" 
    }
  ],
  "subMenu": [           // 当前一级菜单下的全部子菜单【用于渲染子导航】
    {
      "name": "subnav1",
      "path": "page1",
    {
      "name": "subnav2",
      "path": "page2"
    }
  ],
  "paths": [
    {
      "name": "一级菜单A",
      "path": "/demo1"
    }
  ],
  "prePath": "/demo1"   // 一级路由+二级路由做为子菜单惟一 key【标识二级菜单状态】
}

生成的 HeadMenu 组件:

<Menu theme="dark"
    mode="horizontal"
    selectedKeys={[selectMainMenu.path]} >
    {
        mainMenu.map(item => {
            <Menu.Item key={item.path}>
                <Link to={item.path === '/' ? '/' : `/${item.path}`}>{item.name}</Link>
            </Menu.Item>
        })
    }
</Menu>

生成的 SideMenu 组件:

<Menu theme="dark"
    mode="horizontal"
    selectedKeys={[currentPath]} >
    {
        subMenu.map(item => {
            <Menu.Item key={`${prePath}/${item.path}`}>
                <Link to={item.path === '/' ? '/' : `${prePath}/${item.path}`}>
                    <span>{item.name}</span>
                </Link>
            </Menu.Item>
        })
    }
</Menu>
这一步转换并不复杂,自行实现。主要提供根据路由 path 标记菜单状态的思路。

三、Layout 集成 Menu 组件

const BaseLayout = ({ location, children }) => {
    const { pathname } = location;

    const [menuInfo, setMenuInfo] = useState({});

    // 菜单信息随着路径变化
    useEffect(() => {
        const newInfo = pathChange(pathname, menuConfig);
        setMenuInfo(newInfo);
    }, [pathname]);

    return (
        <Layout>
            <Header className="header" >
                <div className="logo" />
                <HeadMenu menuInfo={menuInfo}></HeadMenu>
            </Header>
            <Content>
                <Layout>
                    <Sider width={200}>
                        <SideMenu menuInfo={menuInfo}></SideMenu>
                    </Sider>
                    <Content>{children}</Content>
                </Layout>
            </Content>
        </Layout>
    )
}

四、将 Layout 应用于全部路由

改造一下咱们的路由入口(加上 Layout 布局结构):
import React from 'react';
import { Switch, Route } from 'react-router-dom';

import BaseLayout from './layouts';

// 各个一级路由
import demo1 from './pages/demo1';
import demo2 from './pages/demo2';

const router = (
    <Route render={(props) => {
        return (
            <BaseLayout {...props}>
                <Switch>
                    <Route path="/demo1" component={demo1}></Route>
                    <Route path="/demo2" component={demo2}></Route>
                </Switch>
            </BaseLayout>
        )
    }}></Route>
);

export default router;

咱们的配置式菜单就完成了,它看起来是这样的:

菜单

路由鉴权

toB 项目最大不一样于 toC 的逻辑就在于权限控制,这也几乎是后台框架集成最复杂的部分。

在一个大型系统中,一个误操做产生的后果多是很是严重的,权限管理是不可或缺的一个环节。

权限系统的存在最大程度上避免了这类问题 — 只要是界面上出现的功能,都是能够操做或不会产生严重后果的。
每一个账号登录后只能看到和本身有关的信息,能够更快速地理解本身工做范围内的业务。

后台权限的基本构成

权限设计主要由三个要素构成:账号,角色,权限。

- 账号:登陆系统的惟一身份识别,一个帐号表明一个用户;  

- 角色:为帐号批量分配权限。在一个系统中,不可能为每一个账号订制权限,因此给同一类账号赋予一个“角色”,以达到批量分配权限的目的;  

- 权限:对于前端来讲,权限又分为页面权限和操做权限;其中页面权限分为菜单权限和路由权限;

设计基本思路为:

一、登录实现

login.js

import { connect } from 'react-redux';

const Login = ({
    loginStatus,
    location,
    setLoginInfo,
    history
}) => {
    let { redirectUrl = '/' } = location.state || {};

    // 获取登陆信息伪代码
    const onFinish = values => {
        /**** 此处去获取登陆信息并存放在全局 Store ****/
        setLoginInfo({
            username: '小A',
            role: 1
        });
        history.push(redirectUrl);
    };

    return (
        <div className="login layer">
            <Form
                name="basic"
                onFinish={onFinish} >
                <Form.Item
                    label="用户名"
                    name="username"
                    rules={[{ required: true, message: '输入用户名' }]} >
                    <Input />
                </Form.Item>
                <Form.Item
                    label="密码"
                    name="password"
                    rules={[{ required: true, message: '输入密码' }]} >
                    <Input.Password />
                </Form.Item>
                <Form.Item>
                    <Button type="primary" htmlType="submit">登录</Button>
                </Form.Item>
            </Form>
        </div>
    );
};

const mapStateToProps = state => ({
    loginStatus: state.login.loginStatus
});

const mapDispatchToProps = dispatch => ({
    setLoginInfo: (...args) => dispatch(setLoginInfo(...args))
});

export default connect(
    mapStateToProps,
    mapDispatchToProps
)(Login);

connect() 的做用是将 Store 和 Component 链接起来。connect负责从 Redux state 树中读取部分数据,并经过 Props 来把这些数据提供给要渲染的组件。也传递 action 函数到 Props。
connect 函数接收两个参数,一个 mapStateToProps,把 Redux 的 state,转为组件的 Props;还有一个参数是 mapDispatchToprops,
把发射 actions 的方法,转为 Props 属性函数。

二、用户状态管理
store/login.js存储

// 设置state初始值
const initState = {
    loginStatus: false,
    userInfo: {
        username: '',
        role: -1  // 用户权限标识
    }
};

const SET_LOGIN = 'SET_LOGIN';

// action
export const setLoginInfo = (payload) => {
    return {
        payload,
        type: SET_LOGIN
    };
};

// reducer
export const loginReducer = (state = initState, action) => {
    switch (action.type) {
        case SET_LOGIN:
            return {
                ...state,
                loginStatus: true,
                userInfo: action.payload
            };
        default:
            return state;
    }
};

store/index.js

import { createStore, combineReducers } from 'redux';
import { loginReducer } from './login';

const allReducers = {
    login: loginReducer
};

const reducers = combineReducers(allReducers);

const store = createStore(reducers);

export default store;

入口 index.js 增长 Provider 下发 Store

import React from 'react';
import ReactDom from 'react-dom';
import { BrowserRouter as Router } from 'react-router-dom';
import { Provider } from 'react-redux';
import store from './redux';
import routeChildren from './router';

ReactDom.render(
    <Provider store={store}>
        <Router>
            {routeChildren}
        </Router>
    </Provider>,
    document.getElementById('app')
);

Provider 的做用是让 Store 在整个 App 中可用。

三、登录校验

咱们须要在全部页面访问以前,校验用户登陆状态,以避免发生重复登录;
咱们的 Layout 管理着全部页面入口,须要改造 layout.js

layout.js 增长以下逻辑:

const loginPath = '/login';
const { pathname } = location;
const redirectUrl = pathname === loginPath ? '/' : pathname;

useEffect(() => {
    <!--校验是否登录-->
    if (loginStatus) {
        history.push(redirectUrl);
    } else {
        history.push('/login', {
            redirectUrl
        });
    }
}, []);

if (pathname === '/login') {
    return <div>{children}</div>;
}

这一步须要把当前页面做为 redirectUrl 带到登录页,登录后需返回原路径。

为了看演示效果,咱们须要稍微调整咱们的样式,样式效果自行添加。

登录拦截

三、用户鉴权

后台系统鉴权是个复杂且差别化很大的话题,本文只作抛砖引玉,为了方便理解思路,只介绍一种简单的权限方案。

咱们设定,权限标识越小,拥有的权限越高,逐级之间为包含关系。
构造权限思路以下:

根据这份权限方案,menuConfig.js 须要增长权限标识:

const menu = [
    {
        path: 'demo1',
        name: '一级菜单A',
        role: [4],   // demo1 权限标识
        children: [{
            name: 'subnav1',
            path: 'page1',
            role: [2]     // demo1/page1 权限标识
        },
        {
            name: 'subnav2',
            path: 'page2',
            role: [2]    // demo1/page2 权限标识
        },
        {
            name: 'subnav3',
            path: 'page3',
            role: [3]     // demo1/page3 权限标识
        },
        {
            name: 'subnav4',
            path: 'page4',
            role: [4]      // demo1/page4 权限标识
        }]
    },
    {
        path: 'demo2',
        name: '一级菜单B',
        role: [4],          // demo2 权限标识
        children: [{
            name: '测试',
            path: 'page2',
            role: [3]       // demo1/page2 权限标识
        }]
    }
];

layout.js增长鉴权拦截,其他逻辑不变:

let authChildren = children;
const { role = -1 } = userInfo;
const [menuInfo, setMenuInfo] = useState({});

// 用户角色配置,预留
const filterMenu = menus => menus
    .filter(item => (role !== -1 && item.role >= role))
    .map((item) => {
        if (item.children && item.children.length > 0) {
            return { ...item, children: filterMenu(item.children) };
        }
        return item;
    });

useEffect(() => {
    // 过滤菜单权限
    const newMenuInfo = filterMenu(menuConfig);
    
    const curMenuInfo = onPathChange(pathname, newMenuInfo);
    setMenuInfo(curMenuInfo);
}, [pathname]);

// 过滤路由权限
const curPathAuth = menuInfo.paths
    ? menuInfo.paths.find(item => item.path === pathname) : {};

// 路由权限拦截
if (JSON.stringify(curPathAuth) === '{}') {
    authChildren = (
        <div className="n-privileges">
            <p>对不起你没有访问该页面的权限</p>
        </div>
    );
}

为了演示权限效果,咱们增长用户权限切换。

框架结构基本造成。

后续

固然,系统还需更多细节的完善,咱们仅仅完成了核心流程。
多人合做的系统发展到后期的时候,咱们须要考虑性能问题、跨域配置、数据 mock、eslint 等等。不属于核心流程的内容,在这里仅做讨论。

一、按需加载
单页应用的首屏渲染一直都是个大问题。优化资源加载,咱们能够参考 React 16.3.0 新增的 Suspense 和 lazy 特性。
React.lazy 提供了按需加载组件的方法,而且方法内部必须用到 import() 语法导入组件,配合 webpack 特性:遇到 import...from 语法会将依赖的包,合并到 bundle.js 中。能够如此实现:

const page1 = React.lazy(() => import(/* webpackChunkName: "page1" */'./page1'));

便可将 page1 打包为名为 page1.js 的文件。
配合 React.Suspense 能够很方便的实现懒加载过渡动画。

二、通用 NotFound
咱们的路由设计使得咱们能很方便的处理 Not Found 的状况。
在每一级 Switch 最后加上 path="*" 能够拦截全部未匹配路由。

<Switch>
    <Route path={`${match.path}`} exact component={Home} />
    <Route path={`${match.path}/page1`} component={page1} />
    <Route path={`${match.path}/page2`} component={page2} />
    <Route path="*" component={NotFound} />
</Switch>

三、跨域配置
当咱们本地开发作服务代理的时候,通常会选择在 dev_server 处进行代理。

devServer: {
    proxy: {
        '/api': {
            target: 'http://www.baidu.com/',
            changeOrigin: true,
            secure: false,
        },
        '/api2': {
            .....
        }
    }
}

但这种方法在 create-react-app 生成的应用中无效,对于这种状况,create-react-app 的版本在低于 2.0 的时候能够在 package.json 增长 proxy 配置, 配置以下:

"proxy": {
    '/api': {
        target: 'http://www.baidu.com/',
        changeOrigin: true,
        secure: false,
    },
}

create-react-app 的版本高于 2.0 版本的时候在 package.json 只能配置 string 类型,能够考虑用 http-proxy-middleware 代替。

src/setupProxy.js

const { createProxyMiddleware } = require('http-proxy-middleware');

module.exports = function(app) {
  app.use(
    '/api',
    createProxyMiddleware({
      target: 'http://localhost:5000',
      changeOrigin: true,
    })
  );
};

固然,你能够也执行 npm run eject 命令,暴露 webpack 等配置,去修改 devServer。

四、数据 mock 能力
项目开发中,前端工程师须要依赖后端工程师的数据接口以及后端联调环境。可是其实咱们也能够根据后端接口文档在接口没有开发完成以前本身 mock 数据进行调试,让接口消费者脱离接口生产者进行开发。

mock 数据常见的解决方案有:

  • 在代码层硬编码
  • 在前端JS中拦截
  • 代理软件 (Fiddler、Charles)
  • mock server

这些方案要么对代码有侵入性,要么数据没法溯源,要么成本较高。
云音乐已开源一款 mock 平台,能帮助开发者管理接口。欢迎入坑:NEI

本文仅以我的经验产出,如对本文有任何意见和建议,欢迎讨论。

本文发布自 网易云音乐前端团队,文章未经受权禁止任何形式的转载。咱们一直在招人,若是你刚好准备换工做,又刚好喜欢云音乐,那就 加入咱们
相关文章
相关标签/搜索