react技术栈实践

react技术栈实践


背景

最近开发一个全新AB测试平台,思考了下正好可以使用react技术开发。

实践前技术准备

首先遇到一个概念,redux。这货还真不好理解,大体的理解:Store包含所有数据,视图触发一个Action,Store收到Action后,返回一个新的 State,这样视图就发生变化,State计算过程叫做 Reducer,Reducer其实就是一个处理数据的函数,接受 Action和 当前State作为参数,返回一个新的 State。
明白这个后,就可以开始实践了。

搭建平台的脚手架

对于我这方面没搞过的菜鸟,还真是不容易。接下来说下作为新手如何实践的。
1. 第一步:依赖包

  "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全栈》,作者写的很详细,对入门绝对有帮助。
至此,基本依赖包已加载完。

  1. 第二步: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中替换编译后的js
const 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')
  ]
};

第一次接触配置,真的找不到北,太多插件,太多功能。作为新手,那需要怎么个思路,我总结:按项目需求来配置。不要认为其他人配置的就适合自己项目,要不然给自己带来各种麻烦。
摸索这个过程还挺长的:
A. 首先需求还是明确的:less编译、jsx编译、公共文件单独打包、html静态模板中插入编译后的文件路径、css提取。
上面这些对应配置:

const path = require('path');
const webpack = require('webpack');
// html中替换编译后的js
const 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
    })
  ]
};

B. 配置到这步后,就能满足基本开发了。试用之后,这时候对自己提出了几个问题:
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;
        }

其它

开发一个项目,最好需要一个合理的约定,比如代码风格、模块定义、方法定义、参数定义等等,这些约定中,还要考虑如何便于写和维护单元测试这个因素。这些其实还是挺有挑战的,只能不断去完善。
上面方案其实还有很多缺陷待解决,需要慢慢改进了。

@作者:白云飘飘([email protected]
@github: https://github.com/534591395
欢迎关注我的微信公众号:微信公众号
或者微信公众号搜索 新梦想兔,关注我哦。

猜你喜欢

转载自blog.csdn.net/ihaveahappyfamily/article/details/79787495