前言
本文是介紹如何搭建企業級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
新增框架基礎配置檔案
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
詳細配置
上面把程式碼和目錄結構都已經給出,下面我們詳細講解下如何配置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、本文程式碼