基於React企業級SPA專案搭建全記錄

Awbeci發表於2019-10-28

前言

本文是介紹如何搭建企業級react專案,所用的技術都是最新最主流的,後面我會再寫一篇 《基於React企業級SSR專案搭建全記錄》,敬請期待!

技術選型

Package Name Version
antd ^3.16.6
axios ^0.18.0
connected-react-router ^6.4.0
classnames ^2.2.6
immutable ^4.0.0-rc.12
@loadable/component ^5.10.0
react ^16.8.6
react-redux ^7.0.3
react-router-config ^5.0.0
react-router-dom ^5.0.0
react-scripts 3.0.1
redux ^4.0.1
redux-actions ^2.6.5
redux-logger ^3.0.6
redux-persist ^5.10.0
redux-persist-expire ^1.0.2
redux-persist-transform-immutable ^5.0.0
redux-saga ^1.0.2
history ^4.7.2

使用create-react-app建立新專案

create-react-app react-project

目錄結構如下所示

    |-- .gitignore
    |-- README.md
    |-- package.json
    |-- yarn.lock
    |-- public
    |   |-- favicon.ico
    |   |-- index.html
    |   |-- logo192.png
    |   |-- logo512.png
    |   |-- manifest.json
    |   |-- robots.txt
    |-- src
        |-- App.css
        |-- App.js
        |-- App.test.js
        |-- index.css
        |-- index.js
        |-- logo.svg
        |-- serviceWorker.js

然後我們把webpack暴露出來,執行如下命令:

yarn eject

目錄結構如下所示:

    |-- .gitignore
    |-- README.md
    |-- package.json
    |-- yarn.lock
    |-- config
    |   |-- env.js
    |   |-- modules.js
    |   |-- paths.js
    |   |-- pnpTs.js
    |   |-- webpack.config.js
    |   |-- webpackDevServer.config.js
    |   |-- jest
    |       |-- cssTransform.js
    |       |-- fileTransform.js
    |-- public
    |   |-- favicon.ico
    |   |-- index.html
    |   |-- logo192.png
    |   |-- logo512.png
    |   |-- manifest.json
    |   |-- robots.txt
    |-- scripts
    |   |-- build.js
    |   |-- start.js
    |   |-- test.js
    |-- src
        |-- App.css
        |-- App.js
        |-- App.test.js
        |-- index.css
        |-- index.js
        |-- logo.svg
        |-- serviceWorker.js

新增依賴包

"dependencies": {
    "@babel/plugin-proposal-decorators": "^7.4.0",
    "@babel/plugin-syntax-dynamic-import": "^7.2.0",
    "@loadable/component": "^5.10.0",
    "antd": "^3.16.6",
    "axios": "^0.18.0",
    "babel-plugin-import": "^1.11.0",
    "babel-plugin-transform-decorators-legacy": "^1.3.5",
    "classnames": "^2.2.6",
    "connected-react-router": "^6.4.0",
    "history": "^4.7.2",
    "immutable": "^4.0.0-rc.12",
    "node-sass": "^4.11.0",
    "prettier": "^1.16.4",
    "react-redux": "^7.0.3",
    "react-router-config": "^5.0.0",
    "react-router-dom": "^5.0.0",
    "redux": "^4.0.1",
    "redux-actions": "^2.6.5",
    "redux-logger": "^3.0.6",
    "redux-persist": "^5.10.0",
    "redux-persist-expire": "^1.0.2",
    "redux-persist-transform-compress": "^4.2.0",
    "redux-persist-transform-encrypt": "^2.0.1",
    "redux-persist-transform-immutable": "^5.0.0",
    "redux-saga": "^1.0.2"
  },

新增好了依賴包,我們執行起來看看有沒有問題yarn start
image.png

新增框架基礎配置檔案

1、新增.editorconfig檔案統一格式化標準

root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.yml]
indent_style = space
indent_size = 2

2、新增.eslintrc檔案程式碼檢驗標準

{
    "extends": ["react-app", "plugin:prettier/recommended"]
}

3、去掉package.json裡面的babel設定,再新增.babelrc檔案對babel的支援

{
    "presets": [
      "react-app"
    ],
    "plugins": [
      [
        "import",
        {
          "libraryName": "antd",
          "libraryDirectory": "es",
          "style": "css"
        },
        "antd"
      ]
    ]
  }
  

目錄結構如下所示:

    |-- .babelrc
    |-- .editorconfig
    |-- .gitignore
    |-- README.md
    |-- package.json
    |-- yarn.lock
    |-- config
    |   |-- env.js
    |   |-- modules.js
    |   |-- paths.js
    |   |-- pnpTs.js
    |   |-- webpack.config.js
    |   |-- webpackDevServer.config.js
    |   |-- jest
    |       |-- cssTransform.js
    |       |-- fileTransform.js
    |-- public
    |   |-- favicon.ico
    |   |-- index.html
    |   |-- logo192.png
    |   |-- logo512.png
    |   |-- manifest.json
    |   |-- robots.txt
    |-- scripts
    |   |-- build.js
    |   |-- start.js
    |   |-- test.js
    |-- src
        |-- App.css
        |-- App.js
        |-- App.test.js
        |-- index.css
        |-- index.js
        |-- logo.svg
        |-- serviceWorker.js

程式碼執行起來看看有沒有問題。

刪除src下所有檔案

目錄結構如下:

    |-- .babelrc
    |-- .editorconfig
    |-- .gitignore
    |-- README.md
    |-- package.json
    |-- yarn.lock
    |-- config
    |   |-- env.js
    |   |-- modules.js
    |   |-- paths.js
    |   |-- pnpTs.js
    |   |-- webpack.config.js
    |   |-- webpackDevServer.config.js
    |   |-- jest
    |       |-- cssTransform.js
    |       |-- fileTransform.js
    |-- public
    |   |-- favicon.ico
    |   |-- index.html
    |   |-- logo192.png
    |   |-- logo512.png
    |   |-- manifest.json
    |   |-- robots.txt
    |-- scripts
    |   |-- build.js
    |   |-- start.js
    |   |-- test.js
    |-- src

src目錄

1、新增index.js

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import { ConfigProvider, message } from "antd";
import zhCN from "antd/es/locale/zh_CN";
import moment from "moment";
import "moment/locale/zh-cn";
import * as serviceWorker from "./serviceWorker";
import "./assets/css/index.css";
import "./assets/css/base.scss";
import "./assets/css/override-antd.scss";

moment.locale("zh-cn");
message.config({
  duration: 2,
  maxCount: 1
});
//去掉所有頁面的console.log
if (process.env.NODE_ENV === "production") {
  console.log = function() {};
}
ReactDOM.render(
  //增加antd對中文的支援
  <ConfigProvider locale={zhCN}>
    <App />
  </ConfigProvider>,
  document.getElementById("root")
);

2、新增App.js檔案
App.js裡面包含對redux的配置,程式碼如下:

import React, { Component } from "react";
import { Spin } from "antd";
import { Provider } from "react-redux";
import { renderRoutes } from "react-router-config";
import { ConnectedRouter } from "connected-react-router/immutable";
import { PersistGate } from "redux-persist/es/integration/react";
import configureStore, { history } from "./redux/store";
import AppRoute from "./layout/AppRoute";

const { persistor, store } = configureStore();
store.subscribe(() => {
  // console.log("subscript", store.getState());
});

class App extends Component {
  constructor(props) {
    super(props);
  }
  render() {
    const customContext = React.createContext(null);
    return (
      <Provider store={store}>
        <PersistGate loading={<Spin />} persistor={persistor}>
          <ConnectedRouter history={history}>
            <AppRoute />
          </ConnectedRouter>
        </PersistGate>
      </Provider>
    );
  }
}

export default App;

3、新增assets資料夾
目錄結構如下:

|-- assets
            |-- audio
            |-- css
            |-- fonts
            |-- image
            |-- video

4、新增components元件資料夾
同時建立common資料夾,目錄結構如下:

|-- components
            |-- common

5、新增config資料夾
並新增base.config.js檔案,裡面包含一些框架基礎資訊以及後臺的url和port資料,程式碼如下:

export default {
  company: "Awbeci",
  title: "後臺管理系統平臺",
  subTitle: "後臺管理系統平臺",
  copyright: "Copyright © 2019 Awbeci All Rights Reserved.",
  logo: require("../assets/image/hiy_logo.png"),
  host: "http://10.0.91.189",
  port: "19101",
  persist: "root"
};

目錄結構如下:

|-- config
            |-- base.conf.js

6、新增HOC高階元件資料夾(可選)
同時建立control.js檔案,作用是根據螢幕解析度自動計算寬高,程式碼如下:

import React from "react";
import { is, Map, fromJS } from "immutable";

const control = WrappedComponent =>
  class extends React.Component {
    constructor(props) {
      super(props);
      this.state = {
        // 可視區高度和寬度
        document: {
          body: {
            width: 0,
            height: 0
          },
          //側邊欄高度和寬度
          sidebar: {
            width: 0,
            height: 0
          },
          //內容區域高度和寬度
          content: {
            width: 0,
            height: 0
          },
          header: Map({
            height: 64,
            width: 0,
            menu: Map({
              height: 0,
              width: 0
            })
          })
        }
      };
    }
    componentWillMount() {
      let cw = document.body.clientWidth;
      let ch = document.body.clientHeight;
      this.computedLayout(cw, ch);
    }
    componentDidMount() {
      window.addEventListener("resize", this.computedLayout);
    }
    componentWillUnmount() {
      window.removeEventListener("resize", this.computedLayout);
    }
    computedLayout = () => {
      let width = document.body.clientWidth;
      let height = document.body.clientHeight;

      this.setState((state, props) => ({
        //todo:
      }));
    };

    shouldComponentUpdate(nextProps, nextState) {
      const thisProps = this.props || {};
      const thisState = this.state || {};
      nextState = nextState || {};
      nextProps = nextProps || {};

      if (Object.keys(thisProps).length !== Object.keys(nextProps).length || Object.keys(thisState).length !== Object.keys(nextState).length) {
        return true;
      }

      for (const key in nextProps) {
        if (!is(thisProps[key], nextProps[key])) {
          return true;
        }
      }

      for (const key in nextState) {
        if (!is(thisState[key], nextState[key])) {
          return true;
        }
      }
      return false;
    }
    render() {
      return <WrappedComponent {...this.state} {...this.props} />;
    }
  };

export default control;

目錄結構如下:

|-- HOC
        |-- control.js

7、新增app資料夾
app資料夾包含框架佈局和頁面佈局元件以及路由等配置檔案,目錄結構如下:

|-- app
       |-- AppRoute.js
       |-- Loading.js
       |-- RouterView.js
       |-- layout
       |   |-- index.js
       |   |-- index.scss
       |-- master
           |-- index.js
           |-- index.scss

8、新增pages資料夾
pages資料夾包含登入和首頁頁面檔案,目錄結構如下:

|-- pages
        |-- Index.js
        |-- NoFound.js
        |-- NoPermission.js
        |-- login
            |-- Login.js
            |-- login.scss

9、新增redux資料夾
redux資料夾包含redux-actions和redux-saga以及middleware中介軟體配置,目錄結構如下:

|-- redux
        |-- reducers.js
        |-- sagas.js
        |-- store.js
        |-- auth
        |   |-- authAction.js
        |   |-- authReducer.js
        |   |-- authSaga.js
        |-- layout
        |   |-- layoutPageAction.js
        |   |-- layoutPageReducer.js
        |-- middleware
            |-- authTokenMiddleware.js

10、新增router資料夾
同時新增index.js檔案,目錄結構如下:

|-- router
        |-- index.js

11、新增service資料夾
service資料夾封裝了對後臺api介面的請求 ,目錄結構如下:

|-- service
        |-- apis
        |   |-- 1.0
        |       |-- index.js
        |       |-- urls.js
        |-- request
            |-- ApiRequest.js

image.png

詳細配置

上面把程式碼和目錄結構都已經給出,下面我們詳細講解下如何配置redux、redux-saga、react-acitons、immutable等等

1、配置action
配置action我們選用的是 redux-actions外掛,如下所示

import { createActions } from "redux-actions";

export const authTypes = {
  AUTH_REQUEST: "AUTH_REQUEST",
  AUTH_SUCCESS: "AUTH_SUCCESS",
  AUTH_FAILURE: "AUTH_FAILURE",
  SIGN_OUT: "SIGN_OUT",
  CHANGE_PASSWORD: "CHANGE_PASSWORD"
};

export default createActions({
  [authTypes.AUTH_REQUEST]: ({ username, password }) => ({ username, password }),
  [authTypes.AUTH_SUCCESS]: data => ({ data }),
  [authTypes.AUTH_FAILURE]: () => ({}),
  [authTypes.SIGN_OUT]: () => ({}),
  [authTypes.CHANGE_PASSWORD]: (oldPassword, newPassword) => ({ oldPassword, newPassword })
});

2、配置reducer
跟actions類似,使用的也是redux-actions,程式碼如下:

import { handleActions } from "redux-actions";
import { authTypes } from "./authAction";
import { Map, fromJS, merge } from "immutable";

const initState = fromJS({
  user: null,
  token: ""
});

const authReducer = handleActions(
  {
    [authTypes.AUTH_SUCCESS]: (state, action) => {
      return state.merge({
        user: action.data.user,
        token: action.data.token
      });
    },
    [authTypes.SIGN_OUT]: (state, action) => {
      return state.merge({
        user: null,
        token: ""
      });
    }
  },
  initState
);

export default authReducer;

新增完了,不要忘了註冊一下reducer

import { combineReducers } from "redux";
import { connectRouter, LOCATION_CHANGE } from "connected-react-router/immutable";
import layoutReducer from "./layout/layoutReducer";
import authReducer from "./auth/authReducer";

export default history =>
  combineReducers({
    router: connectRouter(history),
    layoutReducer,
    authReducer
  });

3、配置redux-saga

程式碼如下所示:

import { call, put, takeLatest, select } from "redux-saga/effects";
import { push } from "connected-react-router";
import authAction, { authTypes } from "./authAction";
import { layoutPageTypes } from "../layout/layoutAction";
import { message } from "antd";
import Apis from "../../service/apis/1.0";
import config from "../../config/base.conf";

function strokeItem(name, value) {
  localStorage.setItem(name, value);
}

function clearItem(name) {
  localStorage.removeItem(name);
}

function* test() {
  yield put({
    type: authTypes.AUTH_SUCCESS,
    data: {
      user: {
        name: "Awbeci"
      },
      token: "awbeci token"
    }
  });
  yield put({
    type: layoutPageTypes.GET_MENUS,
    menus: [
      {
        icon: "file",
        id: 1,
        isShow: "1",
        title: "頁面一",
        url: "/"
      },
      {
        icon: "file",
        id: 2,
        isShow: "1",
        title: "頁面二",
        url: "/departmentManage"
      },
      {
        icon: "file",
        id: 3,
        isShow: "1",
        title: "頁面三",
        url: "/userManage"
      }
    ]
  });
  yield put({
    type: layoutPageTypes.SAVE_MENU_INDEX,
    payload: {
      keyPath: ["1"]
    }
  });
  yield put(push("/"));
}

function* signout(action) {
  yield call(clearItem, "token");
  yield call(clearItem, `persist:${config.persist}`);

  //清除token
  // 設定選中第一個選單
  yield put({
    type: layoutPageTypes.SAVE_MENU_INDEX,
    payload: {
      keyPath: ["126"]
    }
  });
  yield put({
    type: layoutPageTypes.SAVE_MENU_COLLAPSED,
    payload: {
      collapsed: false
    }
  });
  yield put({
    type: layoutPageTypes.GET_MENUS,
    menus: []
  });
  //跳轉到登入頁面
  yield put(push("/login"));
}

function* signin(action) {
  try {
    yield call(test);
  } catch (error) {
    message.info("使用者名稱或密碼錯誤");
    yield call(clearItem, "token");
  } finally {
  }
}

export default function* watchAuthRoot() {
  yield takeLatest(authTypes.AUTH_REQUEST, signin);
  yield takeLatest(authTypes.SIGN_OUT, signout);
}

新增saga檔案不要忘了註冊一下,如下:

import { all, fork } from "redux-saga/effects";
import authSaga from "./auth/authSaga";

/*新增對action的監聽 */
export default function* rootSaga() {
  yield all([fork(authSaga)]);
}

4、配置store
配置store的時候其實已經把redux-logger、redux-persist、immutable.js一起配置了,程式碼如下所示:

import { createStore, compose, applyMiddleware } from "redux";
import { routerMiddleware } from "connected-react-router/immutable";

import { createMigrate, persistStore, persistReducer } from "redux-persist";
import createEncryptor from "redux-persist-transform-encrypt";
import immutableTransform from "redux-persist-transform-immutable";
import storage from "redux-persist/es/storage";

import createSagaMiddleware from "redux-saga";
import logger from "redux-logger";
import { createBrowserHistory } from "history";
import createRootReducer from "./reducers";
import rootSaga from "./sagas";
import config from "../config/base.conf";
import { authTokenMiddleware } from "./middleware/authTokenMiddleware";

export const history = createBrowserHistory();
// create the router history middleware
const historyRouterMiddleware = routerMiddleware(history);
// create the saga middleware
const sagaMiddleware = createSagaMiddleware();

// 組合middleware
const middleWares = [sagaMiddleware, historyRouterMiddleware, logger];
// 加密localstorage
const encryptor = createEncryptor({
  secretKey: "hiynn",
  onError: function(error) {}
});

const persistConfig = {
  transforms: [
    immutableTransform()
  ],
  key: config.persist,
  storage,
  version: 2
};

const finalReducer = persistReducer(persistConfig, createRootReducer(history));
export default function configureStore(preloadedState) {
  const store = createStore(finalReducer, preloadedState, compose(applyMiddleware(...middleWares)));
  let persistor = persistStore(store);
  sagaMiddleware.run(rootSaga);
  return { persistor, store };
}

5、使用store
在App.js檔案中新增對store的引用,程式碼如下:

import React, { Component } from "react";
import { Spin } from "antd";
import { Provider } from "react-redux";
import { renderRoutes } from "react-router-config";
import { ConnectedRouter } from "connected-react-router/immutable";
import { PersistGate } from "redux-persist/es/integration/react";
import configureStore, { history } from "./redux/store";
import AppRoute from "./app/AppRoute";

const { persistor, store } = configureStore();
store.subscribe(() => {
  // console.log("subscript", store.getState());
});

class App extends Component {
  constructor(props) {
    super(props);
  }
  render() {
    const customContext = React.createContext(null);
    return (
      <Provider store={store}>
        <PersistGate loading={<Spin />} persistor={persistor}>
          <ConnectedRouter history={history}>
            <AppRoute />
          </ConnectedRouter>
        </PersistGate>
      </Provider>
    );
  }
}

export default App;

AppRoute.js

import React, { Component } from "react";
import { connect } from "react-redux";
import { Switch, Redirect } from "react-router";
import { BrowserRouter as Router, HashRouter, Route } from "react-router-dom";
import Login from "../pages/login/Login";
import LayoutContainer from "./layout";
import NoFound from "../pages/NoFound";

@connect(store => ({
  store
}))
class AppRoute extends Component {
  // 使用者認證
  Authentication() {
    return this.props.store.authReducer.get("token") ? <Redirect to="/" /> : <Login />;
  }
  render() {
    return (
      <>
        {/* 解決github gh-pages釋出必須以Hash瀏覽否則history模式就會報錯問題,
      如果想使用history模式去掉下面的HashRouter即可 */}
        {/* <HashRouter> */}
          <Switch>
            <Route path="/login" render={() => this.Authentication()} />
            <Route path="/" exact component={LayoutContainer} />
            <Route component={NoFound} />
          </Switch>
        {/* </HashRouter> */}
      </>
    );
  }
}

export default AppRoute;

6、配置middleware
中介軟體作用是當重新整理頁面的時候重新把token設定到ApiRequest這樣token就不會丟失了。

import { REHYDRATE } from "redux-persist/lib/constants";
import ApiRequest from "../../service/request/ApiRequest";
import { authTypes } from "../auth/authAction";
import { fromJS } from "immutable";

/**儲存token中介軟體 */
export const authTokenMiddleware = store => next => action => {
  /**當重新整理頁面 persist會觸發 action = REHYDRATE*/
  if (action.type === REHYDRATE) {
    if (typeof action.payload !== "undefined") {
      let authReducer = action.payload.authReducer;
      if (authReducer) {
        const token = authReducer.get("token");
        ApiRequest.setToken(token ? token : null);
      }
    }
  }
  /**當登入成功會觸發 action = AUTH_SUCCESS*/
  if (action.type === authTypes.AUTH_SUCCESS) {
    ApiRequest.setToken(action.data.token);
  }
  return next(action);
};

7、配置靜態路由

import React from "react";
import loadable from "@loadable/component";
import RouterView from "../app/RouterView";
import NoFound from "../pages/NoFound";
import NoPermission from "../pages/NoPermission";
import Loading from "../app/Loading";
const Index = loadable(() => import("../pages/Index"), { fallback: <Loading /> });
// 注意區分前端路由和前端選單是兩個不同的東西
// 注:選單和路由都是基於該路由資料生成
// 選單可以不全部展示在頁面上(隱藏),但路由必須全部要定義
// 後期可以加入許可權控制
const routes = [
  {
    key: "1",
    name: "首頁",
    path: "/",
    exact: true,
    component: Index
  }
];

export default routes;

靜態路由需要react-router-config配合使用,程式碼如下:

import { renderRoutes } from "react-router-config";
import routes from "../../router";

// 這裡的routes就是上面的路由檔案
renderRoutes(routes)

8、封裝axios
包含get、post、delete、put、upload等等

import axios from "axios";
import { message } from "antd";
import config from "../../config/base.conf";

/**
 * Http服務類
 * get
 * post
 * upload
 * put
 * patch
 * delete
 */
class ApiRequest {
  constructor() {
    //建立axios例項
    this.instance = axios.create({
      baseURL: `${config.host}:${config.port}`
    });
  }

  /**
   * 透過authTokenMiddleware中介軟體監聽action=REHYDRATE|AUTH_SUCCESS來設定token
   */
  setToken = token => {
    this.instance.defaults.headers.common["Authorization"] = token;
  };

  authentication = str => {
    let errJson = JSON.parse(str);
    if (errJson.response && errJson.response.status === 401) {
      message.error("使用者認證出錯,正在跳轉登入頁面!");
      setTimeout(() => {
        localStorage.removeItem(`persist:${config.persist}`);
        window.location.href = "/login";
      }, 1500);
    }
  };

  upload(url, formData) {
    return new Promise((resolve, reject) => {
      this.instance
        .post(url, formData, {
          headers: {
            "Content-Type": "multipart/form-data"
          }
        })
        .then(({ data }) => {
          resolve(data);
        })
        .catch(error => {
          let errStr = JSON.stringify(error);
          this.authentication(errStr);
          reject(errStr);
        });
    });
  }

  get(url, params = {}) {
    return new Promise((resolve, reject) => {
      this.instance
        .get(url, { params: { ...params } })
        .then(({ data }) => {
          resolve(data);
        })
        .catch(error => {
          let errStr = JSON.stringify(error);
          this.authentication(errStr);
          reject(errStr);
        });
    });
  }

  delete(url, params = {}) {
    return new Promise((resolve, reject) => {
      this.instance
        .delete(url, { params: { ...params } })
        .then(({ data }) => {
          resolve(data);
        })
        .catch(error => {
          let errStr = JSON.stringify(error);
          this.authentication(errStr);
          reject(errStr);
        });
    });
  }

  post(url, params = {}) {
    return new Promise((resolve, reject) => {
      this.instance
        .post(url, { ...params })
        .then(({ data }) => {
          resolve(data);
        })
        .catch(error => {
          let errStr = JSON.stringify(error);
          if (url.includes("login")) {
            reject(errStr);
          } else {
            this.authentication(errStr);
          }
        });
    });
  }

  put(url, params = {}) {
    return new Promise((resolve, reject) => {
      this.instance
        .put(url, { ...params })
        .then(({ data }) => {
          resolve(data);
        })
        .catch(error => {
          let errStr = JSON.stringify(error);
          this.authentication(errStr);
          reject(errStr);
        });
    });
  }

  patch(url, params = {}) {
    return new Promise((resolve, reject) => {
      this.instance
        .patch(url, { ...params })
        .then(({ data }) => {
          resolve(data);
        })
        .catch(error => {
          let errStr = JSON.stringify(error);
          this.authentication(errStr);
          reject(errStr);
        });
    });
  }
}

export default new ApiRequest();

總結

1、其實redux、redux-saga、react-router都有介紹如何配置,只是整合時外掛前後順序有問題
2、ConnectedRouter是連線redux reducer和react-router的外掛,並且要支援immutable.js
3、React專案整合Immutable.js
4、antd-layoutui
5、本文程式碼

相關文章