從零搭建中後臺框架的核心流程

本文做者:鮑觀霞css

背景

隨着 React 生態的快速發展,社區基於 React 的狀態管理方案層出不窮,這意味着不少方案開發者依然要作不少選擇,沒有約定的團隊,溝通成本和跨團隊協做成本,以及長期的維護是很是高的,這時候統一一套開發模式就顯得尤其重要。html

本文將介紹如何從零開始搭建一個高可複用的後臺框架,讓每個人都能輕鬆搭出本身的後臺,深刻了解本身的框架。前端

親手實踐一套項目框架有諸多好處:
一、業務可定製性強(好比,大家團隊有一套業務定製型強的 UI 組件庫;大家團隊有一套本身的狀態管理最佳實踐;大家團隊有一套複雜的權限管理流程等等)node

PS: 固然你徹底能夠找個第三方框架改形成本身想要的樣子,可是入手成本、後續的維護成本、技術更新成本都會很高react

二、收斂技術棧、屏蔽底層差別、統一開發體驗,幫助團隊下降開發和維護成本
三、成爲框架掌控者,技術升級、底層改造爲所欲爲webpack

寫在前面

本文擬講述從零搭建 React 後臺開發框架的核心技術和搭建流程,涉及到的技術並不是惟一可選技術棧,你能夠隨時用你熟悉的技術棧代替它。同時我會盡可能下降閱讀本文的難度,下降前端開發的門檻,可是仍是有一些須要具有的知識:git

- React Hooks       
- React-Redux  
- React Router 5.0   
- Ant Design 4.x  
複製代碼

該項目基本搭建了一個企業級管理系統的骨架結構,提供通用功能及擴展需求,不涉及業務邏輯開發,不涉及數據請求,全部數據均爲 mock。github

開始搭建

基礎結構及配置

一、 建立基本項目目錄和結構
推薦 Create React App 建立基本項目結構。網上不少相關初始化流程,這裏再也不贅述。官方教程在這裏web

Create React App 是 React 官方推出的構建 React 單頁面應用的腳手架工具。它自己集成了 Webpack,並配置了一系列內置的 loader 和基礎的 npm 的腳本,能夠很輕鬆的實現零配置就能夠快速開發 React 的應用。npm

默認的項目目錄結構以下:

├── 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
複製代碼

二、執行命令

npm start
# or
yarn start
複製代碼

打開 http://localhost:3000 在瀏覽器中查看它。

至此,一個簡易的 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

本文僅以我的經驗產出,如對本文有任何意見和建議,歡迎討論。

本文發佈自 網易雲音樂前端團隊,文章未經受權禁止任何形式的轉載。咱們一直在招人,若是你剛好準備換工做,又剛好喜歡雲音樂,那就 加入咱們

相關文章
相關標籤/搜索