react技術棧實踐

react技術棧實踐

來自專欄前端組隊小夥伴

背景

最近開發一個全新AB測試平台,思考了下正好可以使用react技術開發。

實踐前技術準備

首先遇到一個概念,redux。這貨還真不好理解,大體的理解:Store包含所有數據,視圖觸發一個Action,Store收到Action後,返回一個新的 State,這樣視圖就發生變化,State計算過程叫做 Reducer,Reducer其實就是一個處理數據的函數,接受 Action和 當前State作為參數,返回一個新的 State。

明白這個後,就可以開始實踐了。

搭建平台的腳手架

對於我這方面沒搞過的菜鳥,還真是不容易。接下來說下作為新手如何實踐的。

第一步:依賴包

"devDependencies": { "babel-core": "^6.26.0", "babel-eslint": "^8.2.2", "babel-loader": "^7.1.2", "babel-plugin-import": "^1.6.6", "babel-preset-es2015": "^6.22.0", "babel-preset-react": "^6.24.1", "babel-preset-stage-0": "^6.24.1", "css-loader": "^0.28.7", "eslint": "^4.18.2", "eslint-config-airbnb": "^16.1.0", "eslint-loader": "^2.0.0", "eslint-plugin-import": "^2.9.0", "eslint-plugin-jsx-a11y": "^6.0.3", "eslint-plugin-react": "^7.7.0", "extract-text-webpack-plugin": "^3.0.2", "html-webpack-plugin": "^3.0.4", "less": "^2.7.3", "less-loader": "^4.0.6", "style-loader": "^0.19.1", "url-loader": "^1.0.1", "webpack": "^3.1.0" }, "dependencies": { "normalize.css": "^8.0.0", "react": "^16.2.0", "react-dom": "^16.2.0", "react-redux": "^5.0.7", "react-router-dom": "^4.2.2", "redux": "^3.7.2" }

dependencies 中引入的依賴包,是react的標配了,不用解釋。

devDependencies 中引入了 webpack,babel,babel插件,eslint語法檢測,eslint配置包airbnb,html模板資源替換插件 html-webpack-plugin,css提取插件 extract-text-webpack-plugin,less編譯相關插件,圖片等靜態資源路徑處理插件 url-loader。

這裡作為新手,一般都是參考網上的配置,比如我就是github上找了個項目,摸索一下。推薦一本教程書《React全棧》,作者寫的很詳細,對入門絕對有幫助。

至此,基本依賴包已載入完。

第二步:webpack配置

這裡不得不說,新手真不容易。

首先介紹下項目結構:

views/entry.html(靜態模板),

src/entry.jsx(入口文件),

src/actions(redux概念中Actions所在的文件夾) ,

src/reducers(redux概念中Reducers所在的文件夾) ,

src/store(redux概念中Store所在的文件夾) ,

src/pages(存放頁面的文件夾,jsx),

src/compinents(存放業務組件的文件夾,jsx),

src/style(公共樣式文件夾,less),

src/utils(幫助類文件夾),

src/constants(常量所在文件夾,保存各自的actions的type),

src/plugins(第三方插件文件夾),

build/(編譯後文件),

webpack/(webpack編譯配置所在文件夾),

.eslintrc(eslint配置文件),

.gitignore(git配置文件),

package.json

接下來就是webpack的配置了,先上代碼

const path = require(path);const webpack = require(webpack);// html中替換編譯後的jsconst HtmlwebpackPlugin = require(html-webpack-plugin);// css提取const ExtractTextPlugin = require(extract-text-webpack-plugin);const ROOT_PATH = path.resolve(__dirname);const APP_PATH = path.resolve(ROOT_PATH, ../src);const BUILD_PATH = path.resolve(ROOT_PATH, ../build);module.exports = { entry: { entry: path.resolve(APP_PATH, ./entry.jsx), vendor: [react, react-dom, pace] }, output: { filename: [name].js, path: BUILD_PATH, chunkFilename: [name].js, publicPath: ../ }, devtool: eval-source-map, module: { rules: [ { test: /.(js|jsx)$/, exclude: /node_modules/, use: [ { loader: babel-loader, query: { presets: [es2015, react, stage-0], plugins: [syntax-dynamic-import, [import, { libraryName: antd, style: css }]] } } ] }, { test: /.(css|less)$/, use: ExtractTextPlugin.extract({ fallback: style-loader, use: [ css-loader?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5], less-loader ] }), exclude: /node_modules/ }, { test: /.(css)$/, use: ExtractTextPlugin.extract({ fallback: style-loader, use: [ css-loader ] }), include: /node_modules/ }, { test: /.(jpg|jpeg|png|svg|gif|bmp)/i, use: [ url-loader?limit=5000&name=img/[name].[sha512:hash:base64:8].[ext] ] }, { test: /.(woff|woff2|ttf|eot)($|?)/i, use: [ url-loader?limit=5000&name=fonts/[name].[sha512:hash:base64:8].[ext] ] } ] }, resolve: { extensions: [.js, .jsx, .less, .css, .png, .jpg, .svg, .gif, .eot], alias: { pace: path.resolve(ROOT_PATH, ../src/plugins/pace/index.js), ImagesPath: path.resolve(ROOT_PATH, ../src/) } }, devServer: { historyApiFallback: true, hot: true, inline: true, progress: true }, plugins: [ new webpack.optimize.CommonsChunkPlugin({ name: [commons, vendor], minChunks: 2 }), new ExtractTextPlugin(commons.css, { allChunks: true }), new HtmlwebpackPlugin({ template: path.resolve(ROOT_PATH, ../views/entry.html), filename: path.resolve(ROOT_PATH, ../build/entry.html), chunks: [entry, vendor], hash: false }), // 加署名 new webpack.BannerPlugin(Copyright by xxx) ]};

第一次接觸配置,真的找不到北,太多插件,太多功能。作為新手,那需要怎麼個思路,我總結:按項目需求來配置。不要認為其他人配置的就適合自己項目,要不然給自己帶來各種麻煩。

摸索這個過程還挺長的:

首先需求還是明確的:less編譯、jsx編譯、公共文件單獨打包、html靜態模板中插入編譯後的文件路徑、css提取。

上面這些對應配置:

const path = require(path);const webpack = require(webpack);// html中替換編譯後的jsconst HtmlwebpackPlugin = require(html-webpack-plugin);// css提取const ExtractTextPlugin = require(extract-text-webpack-plugin);const ROOT_PATH = path.resolve(__dirname);const APP_PATH = path.resolve(ROOT_PATH, ../src);const BUILD_PATH = path.resolve(ROOT_PATH, ../build);module.exports = { entry: { entry: path.resolve(APP_PATH, ./entry.jsx), vendor: [react, react-dom, pace] }, output: { filename: [name].js, path: BUILD_PATH, chunkFilename: [name].js, publicPath: ../ }, devtool: eval-source-map, module: { rules: [ { test: /.(js|jsx)$/, exclude: /node_modules/, use: [ { loader: babel-loader, query: { presets: [es2015, react, stage-0] } } ] }, { test: /.(css|less)$/, use: ExtractTextPlugin.extract({ fallback: style-loader, use: [ css-loader, less-loader ] }), exclude: /node_modules/ }, { test: /.(jpg|jpeg|png|svg|gif|bmp)/i, use: [ url-loader?limit=5000&name=img/[name].[sha512:hash:base64:8].[ext] ] }, { test: /.(woff|woff2|ttf|eot)($|?)/i, use: [ url-loader?limit=5000&name=fonts/[name].[sha512:hash:base64:8].[ext] ] } ] }, plugins: [ new webpack.optimize.CommonsChunkPlugin({ name: [commons, vendor], minChunks: 2 }), new ExtractTextPlugin(commons.css, { allChunks: true }), new HtmlwebpackPlugin({ template: path.resolve(ROOT_PATH, ../views/entry.html), filename: path.resolve(ROOT_PATH, ../build/entry.html), chunks: [entry, vendor], hash: false }) ]};

配置到這步後,就能滿足基本開發了。試用之後,這時候對自己提出了幾個問題:

1. 命名css,開發的時候能不能不用擔心命名衝突的問題。

2. css中引入圖片後,編譯失敗問題。

3. 第三方插件 載入效果pace組件,引入問題。

4. 現在文件過大,有根據路由按需載入需求。

針對上面4個問題,重新配置:

第2個和3個解決方案一致:即聲明別名

resolve: { extensions: [.js, .jsx, .less, .css, .png, .jpg, .svg, .gif, .eot], alias: { pace: path.resolve(ROOT_PATH, ../src/plugins/pace/index.js), ImagesPath: path.resolve(ROOT_PATH, ../src/) } }

當中第3個問題,網上找了好多資料,都沒有結果,後來請教了前端群的同行,才解決該問題。

解決第1個問題過程中,我學習到了cssModule的概念,一開始菜鳥還不好理解,實踐了後,還真是個好東西。

{ test: /.(css|less)$/, use: ExtractTextPlugin.extract({ fallback: style-loader, use: [ css-loader?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5], less-loader ] }), exclude: /node_modules/ },

只要css-loader啟動modules就好了。為了支持 react,引入了 react-css-modules 依賴包。

這時候還沒完,又有兩個問題引出來了。

1. 按照上面的配置,第三方庫 antd 竟然也被編譯了,導致樣式失敗。

2. react中,一旦包裹了子組件,子組件沒辦法直接使用 styleName。

第2個問題,還好解決,查了下 react-css-modules 資料,子組件中通過props獲取

const template = ( <div className={this.props.styles[loadingBox]}> <Loading /> </div>);

第1個問題糾結了好久,後來找了個折中的方案,好心酸。

在entry.jsx中引入的antd組件樣式,改成

import antd/dist/antd.css;

對,直接引入 css文件,跳過less編譯。

然後在webpack中新增配置

{ test: /.(css|less)$/, use: ExtractTextPlugin.extract({ fallback: style-loader, use: [ css-loader?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5], less-loader ] }), exclude: /node_modules/ }, { test: /.(css)$/, use: ExtractTextPlugin.extract({ fallback: style-loader, use: [ css-loader ] }), include: /node_modules/ },

到這一步,大家應該明白我的方案了,就是 node_modules 文件夾中的 css文件不啟動 cssmoduls,其它文件夾中 啟動 cssmoduls。

接下來就是第4個大問題待解決,路由按需載入。

作為新手,當然首先是搜索一下 react-router 4.x 如何實現按需載入的,果然好多答案。至於如何選擇,當然是哪個方便哪個來的原則。

react-loadable 這個插件,當然這個貨得依賴 babel-plugin-syntax-dynamic-import 包。

webpack配置,加入 babel的 syntax-dynamic-import插件

module: { rules: [ { test: /.(js|jsx)$/, exclude: /node_modules/, use: [ { loader: babel-loader, query: { presets: [es2015, react, stage-0], plugins: [syntax-dynamic-import] } } ] }, ...

react中使用 react-loadable,特別方便

import Loadable from react-loadable;...const MyLoadingComponent = ({isLoading, error, pastDelay}) => { // Handle the loading state if (pastDelay) { return <div>Loading...</div>; } // Handle the error state else if (error) { return <div>Sorry, there was a problem loading the page.</div>; } else { return null; }}const AsyncTestManager = Loadable({ loader: () => import(./pages/TestManager/Index), loading: MyLoadingComponent});ReactDOM.render( <Provider store={Store}> <BrowserRouter basename="/" forceRefresh={!supportsHistory} keyLength={12}> <div> <Route exact path="/testManager" component={AsyncTestManager}/> </div> </BrowserRouter> </Provider>, document.getElementById(root));

這個插件具體使用大家查看相關文檔,很方便強大。記得上線打包的時候,webpack要啟動hash

output: { filename: [name][chunkhash].js, path: BUILD_PATH, chunkFilename: [name][chunkhash].js, publicPath: ./ },

至此,腳手架搭建走過的坑結束了。

順便提下

output: { ... publicPath: ../ },

這裡一定要配置為 ../ ,不要配置為 ./,因為不小心配錯,導致路由按需載入的時候,js路徑錯誤了。

實戰階段

這裡要介紹下 redux的一個中間件,redux-thunk。何為中間件,以及 redux-thunk的作用,大家可以參考下阮一峰的一篇教程《Redux 入門教程(二):中間件與非同步操作》 。 正常情況下,actions返回的只是一個對象,但是我們想發送數據前最好能處理下,所以呢,就需要重寫下Store.dispath方法了。中間件就是這樣的作用,改寫 dispatch,在發出 Action 和執行 Reducer 這兩步之間,添加了其他功能。比如非同步操作:發起ajax請求。視圖發起一個action,觸發了一個請求,但是action不能返回函數,這時候redux-thunk就起作用了。

Store初始化

這個過程,就是把 reducer跟Store綁定在一起,同時引入需要的中間件

import { createStore, applyMiddleware } from redux;import thunkMiddleware from redux-thunk;import reducers from ../reducers;const store = applyMiddleware( thunkMiddleware)(createStore)(reducers);export default store;

applyMiddleware 方法它是 Redux 的原生方法,作用是將所有中間件組成一個數組,依次執行。

createStore 方法創建一個 Store。

至於這個參數寫法,其實就是es6的柯里化語法。用es3,es5實現其實原理很簡單,就是利用了閉包保存了上一次的數據,實現過單列模式的同學應該很清楚。

function add(number1) { return function(number2) { return number1 + number2; };}var addTwo = add(1)(2);

Reducer實例

至於Reducer,其實很好實現,它其實就是單純的函數。

例如:

import * as CONSTANTS from ../../constants/TestControl;const initialState = {};const testControl = (state = initialState, action) => { switch (action.type) { case CONSTANTS.GET_DETAILS_PENDING: return { ...state, isFetching: true, data: action.payload, success: false }; case CONSTANTS.GET_DETAILS_SUCCEEDED: return { ...state, isFetching: false, data: action.data.relatedObject, success: true }; case CONSTANTS.GET_DETAILS_FAILED: return { ...state, isFetching: false, success: false, errorCode: action.data.errorCode }; default: return state; }};export default testControl;

大家應該注意到,這個其實是對應action的一個ajax請求,其中,action.type中 ,

_PENDING 結尾的表示 ajax正在發起請求;

_SUCCEEDED 結尾的表示 ajax 請求成功;

_FAILED 結尾的表示 ajax 請求失敗;

這個我是作為ajax actions的標準命名,大家也可以用其它方式,原則就是:好理解,統一。

當然其它非ajax的actions(包括ajax的action),我的規則就是,命名要表意,常量要大寫。

由於我的項目中reduce有n個,所以 reducers/index.js 是這樣的

import { combineReducers } from redux;import testManagerList from ./TestManager/list;import common from ./Common;import system from ./System;import evaluate from ./Evaluate;import ComponentsAddLayer from ./Components/addLayer;import testNew from ./TestNew;import testControl from ./TestControl;export default combineReducers({ testManagerList, system, evaluate, ComponentsAddLayer, testNew, common, testControl});

引入 redux 的combineReducers 方法,這樣就把多個 reducer集合到一起了,調用state的時候,只要如此:

const mapStateToProps = state => ({ type: state.testManagerList.type});

大家看明白了吧,testManagerList 是我的一個 reducer。

actions

Actions 我是作為存放數據的,比如ajax數據請求,視圖默認數據這些。

const testManager = { testManager_get_list(options) { return (dispatch) => { const fetchData = axios.get(/abtest/getList, options); dispatch({ type: TABLE_GET_LIST_PENDING, payload: fetchData }); fetchData.then((response) => { if (response.data.success) { dispatch({ type: TABLE_GET_LIST_SUCCEEDED, ...response }); } else { dispatch({ type: TABLE_GET_LIST_FAILED, ...response }); } }).catch((error) => { dispatch({ type: TABLE_GET_LIST_FAILED, ...error }); }); }; }, testManager_change_tabs(activeTabs) { return { type: TABS_CHANGE, active: activeTabs }; }, testManager_search(value) { return { type: SEARCH, keyWord: value }; }, testManager_parameters(options) { return { type: TEST_MANAGER, parameters: Object.assign({}, { page: 1, pageSize: 10, sort: , type: , keyWord: }, options || {}) }; }, testManager_pagination_change(noop) { return { type: PAGINATION_CHANGE, page: noop }; }};

這個模塊觸發的actions:獲取表格列表數據,搜索,分頁操作,獲取默認配置,很好理解,這裡就不說了。

具體如何使用,請看下面的 view 實踐

View實踐

開始的時候,提出幾個問題:

1. 視圖如何跟Store綁定;

2. ACTIONS如何在視圖中使用;

3. 引入的第三方組件樣式有什麼好的方式修改;

4. 視圖中的props如何獲取路由信息;

先解決第3個問題,一開始我是想重寫覆蓋第三方的css文件的,後來一看代碼量,果斷放棄了。還好被我發現了 styled-components 這個插件,果然好用。

import styled from styled-components;import Tabs from antd/lib/tabs;const TabsStyle = styled(Tabs)` float: left; .ant-tabs-nav-wrap { margin-bottom: 0; } .ant-tabs-tab { text-align: center; transition: background 0.3s; color: #666666; padding: 6px 12px; font-size: 14px; font-weight: 400; cursor: pointer; user-select: none; background-image: none; margin-left: -10px; }`;

這裡面跟寫less一樣就好了。我是這麼覺得。具體大家可以查看下對應的文檔。開發過react-native的同學,都很清楚這個插件的給力。

再結晶第4個問題。react-router 官方提供了 withRouter的api,這個api就是專門為了解決這個問題。

import CSSModules from react-css-modules;import { connect } from react-redux;import { withRouter } from react-router-dom;......componentDidMount() { // props中就可拿到路由信息了 const { ACTIONS, match } = this.props; ACTIONS.TestControl_get_testing_detail({ id: match.params.id });}const turnCss = CSSModules(TestManager, styles, { allowMultiple: true });export default withRouter(connect(mapStateToProps, mapDispatchToProps)(turnCss));

非常方便。

再來說第一個問題,視圖如何跟Store綁定

Store提供了三個方法

store.getState()

store.dispatch()

store.subscribe()

其中,Store 允許使用store.subscribe方法設置監聽函數,一旦 State 發生變化,就自動執行這個函數。所以綁定視圖,調用這個方法就好了。

不過redux作者專門針對react,封裝了一個庫:React-Redux,這裡我就直接引用了,這樣我就不用處理state了。

import { connect } from react-redux;const mapStateToProps = state => ({ isFetching: state.testControl.isFetching, success: state.testControl.success, detail: state.testControl.data});const mapDispatchToProps = dispath => ({ ACTIONS: bindActionCreators(actions, dispath)});const turnCss = CSSModules(TestControl, styles, { allowMultiple: true });export default withRouter(connect(mapStateToProps, mapDispatchToProps)(turnCss));

這樣 TestControl 視圖就跟 Store綁定到一起了。

具體的API介紹,大家可以查看下文檔,還是很好理解的。

解決了第一個問題,再來看第2個問題:ACTIONS如何在視圖中使用

ACTIONS的作用,其實就是消息訂閱/發布 模式中,發布那個步驟了。這樣理解,大家應該明白了吧,

比如: 視圖中點擊了一個按鈕後,回調函數中就直接調用對應的ACTIONS方法即可。

還要介紹下redux的bindActionCreators方法:

主要用處:

一般情況下,我們可以通過Provider將store通過React的connext屬性向下傳遞,bindActionCreators的唯一用處就是需要傳遞action creater到子組件,並且該子組件並沒有接收到父組件上傳遞的store和dispatch。

import { bindActionCreators } from redux;import actions from ../../actions;class TestControl extends Component { componentDidMount() { const { ACTIONS, match } = this.props; ACTIONS.TestControl_get_testing_detail({ id: match.params.id }); } // 開始 start() { const { ACTIONS, match } = this.props; ACTIONS.TestControl_start({ id: match.params.id }); } render() { ... }}const mapStateToProps = state => ({ isFetching: state.testControl.isFetching, success: state.testControl.success, detail: state.testControl.data});const mapDispatchToProps = dispath => ({ ACTIONS: bindActionCreators(actions, dispath)});const turnCss = CSSModules(TestControl, styles, { allowMultiple: true });export default withRouter(connect(mapStateToProps, mapDispatchToProps)(turnCss));

至此,redux實踐結束。

nginx配置

因為是單頁面模式,且使用了 BrowserRouter,故nginx配置如下:

location / { root E:/program/ark2/abtest-statics/build/; index index.html index.htm; expires -1; try_files $uri $uri/ /entry.html; }

其它

開發一個項目,最好需要一個合理的約定,比如代碼風格、模塊定義、方法定義、參數定義等等,這些約定中,還要考慮如何便於寫和維護單元測試這個因素。這些其實還是挺有挑戰的,只能不斷去完善。

上面方案其實還有很多缺陷待解決,需要慢慢改進了。

@作者:白雲飄飄(534591395@qq.com)

@github: github.com/534591395

歡迎關注我的微信公眾號:

weixin.qq.com/r/EUgWEgX (二維碼自動識別)

或者微信公眾號搜索 新夢想兔,關注我哦。


推薦閱讀:

現代前端-近年的發展與有趣實踐
用Lerna管理多包JS項目
再談前後端分離
持久化存儲與HTTP緩存
Web 後端第 10 期、Web 前端第 9 期報名公告

TAG:前端開發 | React | 前端框架 |