一言不合造轮子--撸一个ReactTimePicker

本文的源码悉数位于github项目堆栈react-times,假如有差别请以github为准。终究线上DEMO可见react-times github page

文章纪录了一次建立自力React组件并做成NPM包的历程,将会涉及到React开辟、单页测试、Webpack等内容。

先看下终究的效果~

《一言不合造轮子--撸一个ReactTimePicker》

原由

由于我司的营业需求,须要有一个日期和时刻的挑选器。最最先我们运用的是pickadate,一个基于jQuery的比较老牌的时刻日期挑选器。在页面上大抵长如许:

《一言不合造轮子--撸一个ReactTimePicker》

如许:

《一言不合造轮子--撸一个ReactTimePicker》

另有如许:

《一言不合造轮子--撸一个ReactTimePicker》

大致上看着还OK吧?然则厥后跟着我们营业的增进和代码重构,前端webpack成为标配,同时愈来愈多的页面运用React举行重构,pickadata经常涌现一些莫名的bug,再加上它自身的API不够React Style — 在和React中运用的时刻,pickadate组件的初始化还不得不依据老式的jQuery组件那样,挪用API,在DOM里插进去pickadate。而且,为了猎取date/time更改时的值,每每须要经由过程jQuery挑选器来拿到value,因而pickadate组件挑选器的初始化和一些事宜都较多的依靠于React Component的生命周期。这。。用久了就觉得愈来愈蛋疼了。

厥后又一次偶然发清楚明了Airbnb(业界良知)开源的React组件–react-dates

react-dates是一个基于momentReact的日期挑选器,其插件自身就是一个ReactComponent,有NPM,有充足的测试,有优越的API。因而立即下定决心要趁此干掉pickadate。可真正用到项目中才发明它竟然不支撑时刻挑选!!!(也许由于Airbnb自身的营业就是更注重日期的?)因而才有了本身撸一个的主意。

设想与架构

UI设想

UI方面没得说,我是妥妥的Material Design党。此次也是焦急着手撸代码,所以直接就参考Android6.0+体系上闹钟里的时刻挑选好了,以后再完美并增添UI主题:

《一言不合造轮子--撸一个ReactTimePicker》

目的差不多就长这个模样,再增添一个挑选时刻的按钮和是非配色的挑选。

需求整顿

搭配我们的“UI稿”和线框稿一同食用:

《一言不合造轮子--撸一个ReactTimePicker》

可以看到,撤除上方挑选时刻并展现的按钮之外,我们把真正的时刻表盘放在了下面的modal里。而modal表盘里的设想,则会模仿上图的Android时刻挑选器,是一个MD作风的拟时钟款式的挑选器。开端整顿出一些需求:

  • 点击按钮弹出表盘modal,再点击其他地区封闭modal

  • 表盘modal里有一个圆形的时刻挑选器,时刻的数字缭绕圆形围绕

  • 表盘里有一个指针,可以以表盘为中间扭转

  • 点击代表时刻的数字,应当转变外层按钮里对应的小时/分钟,同时指针转变扭转角度,指向点击的时刻

  • 拖拽指针,可以围绕中间扭转。当摊开指针时,它应当自动指向间隔近来的小时或许分钟

  • 拖拽指针并松开,指针住手以后,当前挑选的时刻和外层按钮上显现的时刻应当被转变

  • 拖拽指针到两个整数数字之间并摊开时,指针应当自动扭转到间隔近来的时刻上

代码设想

有了上面的开端需求整顿,我们就可以来构想组件的代码设想了。既然是个React组件,那末就应当依据逻辑和UI,把团体尽量的拆分红充足小的模块。

有几点代码层面的架构须要斟酌:

  • 斟酌到“点击按钮弹出表盘modal,再点击其他地区封闭modal”这个需求,也许我们应当在星散出一个OutsideClickHandler,特地用来处置惩罚用户点击了表盘之外其他地区时的modal封闭事宜。

  • Android时刻挑选的表盘实在有两个,一个是小时的挑选,另一个则是分钟的挑选。用户可以点击modal里圆形表盘上的小时/分钟,来切换差别的表盘。那末这意味着也许会有大批的代码可供我们复用。

那末就先依据这个思绪举行拆分:

  • TimePicker

    • 按钮

    • 处置惩罚外层点击事宜的组件(OutsideClickHandler

    • 表盘modal

      • modal + 表盘(TimePickerModal

      • 围绕的数字(PickerPoint

      • 指针(PickerDargHandler

在如许的构造下,TimePicker.jsx文件将是我们末了export出去的组件。在TimePicker,jsx中,包含了按钮组件和Modal组件。而Modal组件的各个构成部分被拆分红粒度更小的组件,以便组合和复用。

如许有哪些优点呢?举个栗子:

  • 我们在做组件的时刻,先做了小时的挑选,然后做分钟的挑选。但两个picker的UI差别点主要集合在数字在表盘的规划上,以及一些挑选的代码逻辑。如许的话我们就可以坚持大致框架稳定,只转变表盘中间衬着的数字规划即可。

假定下图是小时挑选器:(请原谅我不幸的画图)

《一言不合造轮子--撸一个ReactTimePicker》

假定下图是分钟挑选器:(请原谅我不幸的画图)

《一言不合造轮子--撸一个ReactTimePicker》

  • 而我们依据如许的架构撸完代码以后,假如想分外做一些其他的东西,比方支撑12小时制,那末小时和分钟的挑选则应当集合在一个表盘modal上(也就是长得和一般是时钟一样)。在如许的需求下,我们须要在一个表盘里同时衬着小时和分钟的数字规划,而其他的东西,比方说modal啊,指针啊照旧坚持原样(一样的指针组件,只不过衬着了两个)。

下图是24小时制,点击modal上的小时/分钟来切换差别表盘:

《一言不合造轮子--撸一个ReactTimePicker》

下图是12小时制,在同一个表盘上显现小时和分钟:

《一言不合造轮子--撸一个ReactTimePicker》

文件构造

So, 现在如许的构造设想应当可以满足我们的简朴的需求。接下来就最先卷起袖子撸代码喽。

新建项目,文件构造以下:

# react-times
- src/
    - components/
        TimePicker.jsx
        OutsideClickHandler.jsx
        TimePickerModal.jsx
        PickerPoint.jsx
        PickerDargHandler.jsx
    - utils.js
    - ConstValue.js
+ css/
+ test/
+ lib/
index.js
package.json
webpack.config.js

个中,src文件夹下是我们的源码,而lib则是编译事后的代码。而index.js则是全部包终究的出口,我们在这里将做好的组件暴露出去:

var TimePicker = require('./lib/components/TimePicker').default;

module.exports = TimePicker;

环境搭建

既然是写一个自力的React组件,那它的开辟则和我们项目的开辟互相自力。

那末题目来了:该怎样搭建开辟和测试环境呢?这个组件我想运用ReactES6的语法,而单元测试则运用mocha+chai和Airbnb的enzyme(再次谢谢业界良知)。那末在宣布之前,应当运用构建东西将其开端打包,针关于这点我选用了webpack

而在开辟历程当中,须要可以启动一个server,以便能在网页上衬着出组件,举行调试。因而,可以运用react-storybook这个库,它许可我们启动一个server,把本身的组件衬着在页面上,并支撑webpack举行编译。细致的运用人人可以去看storybook文档,异常简朴易懂,便于设置。

那末进入正题,组件的编写。

组件编写

TimePicker

关于传入组件的props

  • defaultTime:默许初始化时刻。默许为当前时刻

  • focused:初始化时modal是不是翻开。默许为false

  • onFocusChange:modal开/关状况变化时的回调

  • onHourChange:挑选的小时变化时的回调,以小时作为参数

  • onMinuteChange:挑选的分钟变化时的回调,以分钟作为参数

  • onTimeChange:恣意时刻变化时的回调,以hour:minute作为参数,参数范例是String

// src/components/TimePicker.jsx
// 省略了一些要领的细致内容和组件属性的通报
import React, {PropTypes} from 'react';
import moment from 'moment';

import OutsideClickHandler from './OutsideClickHandler';
import TimePickerModal from './TimePickerModal';

// 组件开辟要养成优越的习气:搜检传入的属性,并设定默许属性值
const propTypes = {
  defaultTime: PropTypes.string,
  focused: PropTypes.bool,
  onFocusChange: PropTypes.func,
  onHourChange: PropTypes.func,
  onMinuteChange: PropTypes.func,
  onTimeChange: PropTypes.func
};

const defaultProps = {
  defaultTime: moment().format("HH:mm"),
  focused: false,
  onFocusChange: () => {},
  onHourChange: () => {},
  onMinuteChange: () => {},
  onTimeChange: () => {}
};

export default class TimePicker extends React.Component {
  constructor(props) {
    super(props);
    let {defaultTime, focused} = props;
    let [hour, minute] = initialTime(defaultTime);
    this.state = {
      hour,
      minute,
      focused
    }
    this.onFocus = this.onFocus.bind(this);
    this.onClearFocus = this.onClearFocus.bind(this);
    this.handleHourChange = this.handleHourChange.bind(this);
    this.handleMinuteChange = this.handleMinuteChange.bind(this);
  }

  // 转变state,并触发onFocusChange callback
  onFocus() {}
  onClearFocus() {}
  handleHourChange() {}
  handleMinuteChange() {}

  renderTimePickerModal() {
    let {hour, minute, focused} = this.state;
    // 给组件传入小时/分钟,以及handleHourChange,handleMinuteChange
    return (
      <TimePickerModal />
    )
  }

  render() {
    let {hour, minute, focused} = this.state;
    let times = `${hour} : ${minute}`;
    return (
      <div className="time_picker_container">
        <div onClick={this.onFocus} className="time_picker_preview">
          <div className={previewContainerClass}>
            {times}
          </div>
        </div>
        {/*OutsideClickHandler 就是上面说到了,特地用于处置惩罚modal外点击事宜,来封闭modal的组件*/}
        <OutsideClickHandler onOutsideClick={this.onClearFocus}>
          {this.renderTimePickerModal()}
        </OutsideClickHandler>
      </div>
    )
  }
}

TimePicker.propTypes = propTypes;
TimePicker.defaultProps = defaultProps;

可以看到,OutsideClickHandler包裹着TimePickerModal,而在OutsideClickHandler中,我们举行modal外点击事宜的处置惩罚,封闭modal

OutsideClickHandler

// src/components/OutsideClickHandler.jsx

// ...

const propTypes = {
  children: PropTypes.node,
  onOutsideClick: PropTypes.func,
};

const defaultProps = {
  children: <span />,
  onOutsideClick: () => {},
};

export default class OutsideClickHandler extends React.Component {
  constructor(props) {
    super(props);
    this.onOutsideClick = this.onOutsideClick.bind(this);
  }

  componentDidMount() {
    // 组件didMount以后,直接在document上绑定点击事宜监听
    if (document.addEventListener) {
      document.addEventListener('click', this.onOutsideClick, true);
    } else {
      document.attachEvent('onclick', this.onOutsideClick);
    }
  }

  componentWillUnmount() {
    if (document.removeEventListener) {
      document.removeEventListener('click', this.onOutsideClick, true);
    } else {
      document.detachEvent('onclick', this.onOutsideClick);
    }
  }

  onOutsideClick(e) {
    // 假如点击地区不在该组件内部,则挪用封闭modal的要领
    // 经由过程ReactDOM.findDOMNode来拿到原生的DOM,防止分外的jQuery依靠
    const isDescendantOfRoot = ReactDOM.findDOMNode(this.childNode).contains(e.target);
    if (!isDescendantOfRoot) {
      let {onOutsideClick} = this.props;
      onOutsideClick && onOutsideClick(e);
    }
  }

  render() {
    return (
      <div ref={(c) => this.childNode = c}>
        {this.props.children}
      </div>
    )
  }
}

OutsideClickHandler.propTypes = propTypes;
OutsideClickHandler.defaultProps = defaultProps;

TimePickerModal

TimePickerModal主要用来衬着PickerDargHandlerPickerPoint组件:

// src/components/TimePickerModal.jsx
// ...
// 为了轻便我们在文章中疏忽引入的React和一些参数范例搜检

class TimePickerModal extends React.Component {
  constructor(props) {
    super(props);
    /*
    - 猎取初始化时的扭转角度
    - 以step 0代表hour的挑选,1代表minute的挑选
    */
    let pointerRotate = this.resetHourDegree();
    this.state = {
      step: 0,
      pointerRotate
    }
  }

  handleStepChange(step) {}

  handleTimePointerClick(time, pointerRotate) {
    /*
    - 当表盘上某一个数字被点击时
    - 或许拖拽完指针并放下时,所挪用的回调
    - 参数是该数字或指针所代表的时刻和扭转角度
    */
  }

  // 在切换step的时刻,依据当前的hour/minute来从新转变扭转角度
  resetHourDegree() {}
  resetMinuteDegree() {}

  /*
  + 两个要领会return PickerPoint组件
  + 之所以分两个是由于小时/分钟表盘在UI上有较多差别,因而传入的props须要差别的盘算
  + 但在PickerPoint组件内部的逻辑是一样的
  */
  renderMinutePointes() {}
  renderHourPointes() {}

  render() {
    let {step, pointerRotate} = this.state;
    return (
      <div className="time_picker_modal_container">
        <div className="time_picker_modal_header">
          <span onClick={this.handleStepChange.bind(this, 0)}>
            {hour}
          </span>
          &nbsp;:&nbsp;
          <span onClick={this.handleStepChange.bind(this, 1)}>
            {minute}
          </span>
        </div>
        <div className="picker_container">
          {step === 0 ? this.renderHourPointes() : this.renderMinutePointes()}
          <PickerDargHandler
              pointerRotate={pointerRotate}
              time={step === 0 ? parseInt(hour) : parseInt(minute)}
              handleTimePointerClick={this.handleTimePointerClick} />
        </div>
      </div>
    )
  }
}

上面如许,就基础完成了TimePickerModal组件的编写。但还不够好。为何呢?

依据我们的逻辑,这个时刻挑选器应当依据step来切换表盘上示意小时/分钟的数字。也就是说,第一步挑选小时,第二部挑选分钟 — 它是一个24小时制的时刻挑选器。那末,假如是要变成12小时制呢?让小时和分钟在同一个表盘上衬着,而step只转变AM/PM呢?

那末斟酌12小时制的状况:

  • 一个表盘上要同时有小时和分钟两种数字

  • 一个表盘上要有小时和分钟的两个指针

  • 切换step转变的是AM/PM

鉴于我们不该当在TimePickerModal中放入太多的逻辑推断,那末照样针对12小时制特地建立一个组件TwelveHoursModal比较好,但也会提掏出TimePickerModal组件中可以自力的要领,作为特地衬着PickerPoint的中间层,PickerPointGenerator.jsx

PickerPointGenerator

PickerPointGenerator实在算是一个中间层组件。在它内部会举行一些逻辑推断,终究衬着出我们想要的表盘数字。

// src/components/PickerPointGenerator.jsx
// ...
import {
  MINUTES,
  HOURS,
  TWELVE_HOURS
} from '../ConstValue.js';
import PickerPoint from './PickerPoint';

const pickerPointGenerator = (type = 'hour', mode = 24) => {
  return class PickerPointGenerator extends React.Component {
    constructor(props) {
      super(props);
      this.handleTimePointerClick = props.handleTimePointerClick.bind(this);
    }
    // 返回PickerPoint
    renderMinutePointes() {}
    renderHourPointes() {}

    render() {
      return (
        <div
          ref={ref => this.pickerPointerContainer = ref}
          id="picker_pointer_container">
          {type === 'hour' ? this.renderHourPointes() : this.renderMinutePointes()}
        </div>
      )
    }
  }
};

export default pickerPointGenerator;

有了它以后,我们之前的TimePickerModal可以这么写:

// src/components/TimePickerModal.jsx
// ...
class TimePickerModal extends React.Component {
  render() {
    const {step} = this.state;
    const type = step === 0 ? 'hour' : 'minute';
    const PickerPointGenerator = pickerPointGenerator(type);

    return (
      ...
      <PickerPointGenerator
        handleTimePointerClick={this.handleTimePointerClick}
      />
      ...
    )
  }
}

而假如是12小时制呢:

// src/components/TwelveHoursModal.jsx
// ...
class TwelveHoursModal extends React.Component {
  render() {
    const HourPickerPointGenerator = pickerPointGenerator('hour', 12);
    const MinutePickerPointGenerator = pickerPointGenerator('minute', 12);
    return (
      ...
      <HourPickerPointGenerator
        handleTimePointerClick={this.handleHourPointerClick}
      />
      <MinutePickerPointGenerator
        handleTimePointerClick={this.handleMinutePointerClick}
      />
      ...
    )
  }
}

PickerPoint

PickerPoint内的逻辑很简朴,就是衬着数字,并处置惩罚点击事宜:

// src/components/PickerPoint.jsx
// ...

const propTypes = {
  index: PropTypes.number,
  angle: PropTypes.number,
  handleTimeChange: PropTypes.func
};

class PickerPoint extends React.Component {
  render() {
    let {index, handleTimeChange, angle} = this.props;
    let inlineStyle = getInlineRotateStyle(angle);
    let wrapperStyle = getRotateStyle(-angle);

    return (
      <div
        style={inlineStyle}
        onClick={() => {
          handleTimeChange(index, angle)
        }}
        onMouseDown={disableMouseDown}>
        <div className="point_wrapper" style={wrapperStyle}>
          {index}
        </div>
      </div>
    )
  }
}

PickerDargHandler

PickerDargHandler组件里,我们主要处置惩罚指针的拖拽事宜,并将处置惩罚好的效果经由过程callback向上通报。

在这个组件里,它具有本身的state:

this.state = {
  pointerRotate: this.props.pointerRotate,
  draging: false
}

个中,pointerRotate是从父层传入,用来给组件初始化时定位指针的位置。而draging则用于处置惩罚拖拽事宜,标记住当前是不是处于被拖拽状况。

关于拖拽事宜的处置惩罚,大抵思绪以下:

先写一个猎取坐标位置的util:

export const mousePosition = (e) => {
  let xPos, yPos;
  e = e || window.event;
  if (e.pageX) {
    xPos = e.pageX;
    yPos = e.pageY;
  } else {
    xPos = e.clientX + document.body.scrollLeft - document.body.clientLeft;
    yPos = e.clientY + document.body.scrollTop - document.body.clientTop;
  }
  return {
    x: xPos,
    y: yPos
  }
};

然后须要明白的是,我们在处置惩罚拖拽事宜历程当中,须要纪录的数据有:

  • this.originX/this.originY 扭转所围绕的中间坐标。在componentDidMount事宜中纪录并保留

  • this.startX/this.startY 每次拖拽事宜最先时的坐标。在onMouseDown事宜中纪录并保留

  • dragX/dragY 挪动历程当中的坐标,跟着挪动而不停转变。在onMouseMove事宜中纪录并保留

  • endX/endY 挪动完毕时的坐标。在onMouseUp事宜中举行处置惩罚,并猎取末了的角度degree,算出指针住手时瞄准的时刻time,并将time和degree经由过程callback向父层组件通报。

// 处置惩罚onMouseDown
handleMouseDown(e) {
  let event = e || window.event;
  event.preventDefault();
  event.stopPropagation();
  // 在鼠标按下的时刻,将draging state标记为true,以便在挪动时对坐标举行纪录
  this.setState({
    draging: true
  });

  // 猎取此时的坐标位置,作为此次拖拽的最先位置保留下来
  let pos = mousePosition(event);
  this.startX = pos.x;
  this.startY = pos.y;
}
// 处置惩罚onMouseMove
handleMouseMove(e) {
  if (this.state.draging) {
    // 及时猎取更新当前坐标,用于盘算扭转角度,来更新state中的pointerRotate,而pointerRotate用来转变衬着的视图
    let pos = mousePosition(e);
    let dragX = pos.x;
    let dragY = pos.y;

    if (this.originX !== dragX && this.originY !== dragY) {
      // 猎取扭转的弧度。getRadian要领在下面解说
      let sRad = this.getRadian(dragX, dragY);
      // 将弧度转为角度
      let pointerRotate = sRad * (360 / (2 * Math.PI));
      this.setState({
        // 纪录下来的state会转变衬着出来的指针角度
        pointerRotate
      });
    }
  }
}

getRadian要领中,经由过程起始点和中间点的坐标来盘算扭转完毕后的弧度:

getRadian(x, y) {
  let sRad = Math.atan2(y - this.originY, x - this.originX);
  sRad -= Math.atan2(this.startY - this.originY, this.startX - this.originX);
  sRad += degree2Radian(this.props.rotateState.pointerRotate);
  return sRad;
}

Math.atan2(y, x)要领返回从x轴到点(x, y)的弧度,介于 -PI/2 与 PI/2 之间。

因而这个盘算要领直接上图示意,清楚清楚明了:

《一言不合造轮子--撸一个ReactTimePicker》

// 处置惩罚onMouseUp
handleMouseUp(e) {
  if (this.state.draging) {
    this.setState({
      draging: false
    });

    // 猎取完毕时的坐标
    let pos = mousePosition(e);
    let endX = pos.x;
    let endY = pos.y;

    let sRad = this.getRadian(endX, endY);
    let degree = sRad * (360 / (2 * Math.PI));

    // 在住手拖拽时,请求指针要瞄准表盘的刻度。因而,除了要对角度的正负举行处置惩罚之外,还对其四舍五入。终究猎取的pointerRotate是瞄准了刻度的角度。
    if (degree < 0) {
      degree = 360 + degree;
    }
    // roundSeg是四舍五入以后的瞄准的表盘上的时刻数字
    let roundSeg = Math.round(degree / (360 / 12));
    let pointerRotate = roundSeg * (360 / 12);

    // 分钟表盘的每一格都是小时表盘的5倍
    let time = step === 0 ? time : time * 5;
    // 将效果回调给父组件
    let {handleTimePointerClick} = this.props;
    handleTimePointerClick && handleTimePointerClick(time, pointerRotate);
  }
}

你能够注意到只要在onMouseUp的末了,我们才把盘算获得的角度回调到父组件里,,转变父组件的state。而在handleMouseMove要领里,我们只把角度存在当前state里。那是由于在每次挪动历程当中,都须要晓得每次最先挪动时的角度偏移量。这个数值我们是从父组件state里拿到的,因而只要在松手时才会更新它。而PickerDargHandler组件内部存的state,只是用来在拖拽的历程当中转变,以便衬着指针UI的扭转角度:

componentDidUpdate(prevProps) {
  let {step, time, pointerRotate} = this.props;
  let prevStep = prevProps.step;
  let prevTime = prevProps.time;
  let PrevRotateState = prevProps.pointerRotate
  if (step !== prevStep || time !== prevTime || pointerRotate !== PrevRotateState) {
    this.resetState();
  }
}

而这些要领,会在组件初始化时绑定,在卸载时作废绑定:

componentDidMount() {
  // 纪录中间坐标
  if (!this.originX) {
    let centerPoint = ReactDOM.findDOMNode(this.refs.pickerCenter);
    let centerPointPos = centerPoint.getBoundingClientRect();
    this.originX = centerPointPos.left;
    this.originY = centerPointPos.top;
  }
  // 把handleMouseMove和handleMouseUp绑定在document,如许纵然鼠标挪动时不在指针或许modal上,也可以继承相应挪动事宜
  if (document.addEventListener) {
    document.addEventListener('mousemove', this.handleMouseMove, true);
    document.addEventListener('mouseup', this.handleMouseUp, true);
  } else {
    document.attachEvent('onmousemove', this.handleMouseMove);
    document.attachEvent('onmouseup', this.handleMouseUp);
  }
}

componentWillUnmount() {
  if (document.removeEventListener) {
    document.removeEventListener('mousemove', this.handleMouseMove, true);
    document.removeEventListener('mouseup', this.handleMouseUp, true);
  } else {
    document.detachEvent('onmousemove', this.handleMouseMove);
    document.detachEvent('onmouseup', this.handleMouseUp);
  }
}

末了看一眼render要领:

render() {
  let {time} = this.props;
  let {draging, height, top, pointerRotate} = this.state;
  let pickerPointerClass = draging ? "picker_pointer" : "picker_pointer animation";

  // handleMouseDown事宜绑定在了“.pointer_drag”上,它位于指针最顶端的位置
  return (
    <div className="picker_handler">
      <div
        ref={(d) => this.dragPointer = d}
        className={pickerPointerClass}
        style={getInitialPointerStyle(height, top, pointerRotate)}>
        <div
          className="pointer_drag"
          style={getRotateStyle(-pointerRotate)}
          onMouseDown={this.handleMouseDown}>{time}</div>
      </div>
      <div
        className="picker_center"
        ref={(p) => this.pickerCenter = p}></div>
    </div>
  )
}

至此,我们的事变就已完成了(才没有)。实在除了掌握扭转角度之外,另有指针的坐标、长度等须要举行盘算和掌握。但即使完成这些,离一个及格的NPM包另有一段间隔。除了基础的代码编写,我们还须要有单元测试,须要对包举行编译和宣布。

测试

关于更多的React测试引见,可以戳这两篇文章入个门:

UI Testing in React

React Unit Testing with Mocha and Enzyme

运用mocha+chaienzyme来举行React组件的单元测试:

$ npm i mocha --save-dev
$ npm i chai --save-dev
$ npm i enzyme --save-dev
$ npm i react-addons-test-utils --save-dev

# 除此之外,为了模仿React中的事宜,还须要装置:
$ npm i sinon --save-dev
$ npm i sinon-sandbox --save-dev

然后设置package.json

"scripts": {
  "mocha": "./node_modules/mocha/bin/mocha --compilers js:babel-register,jsx:babel-register",
  "test": "npm run mocha test"
}

请注意,为了可以搜检ES6和React,确保本身装置了须要的babel插件:

$ npm i babel-register --save-dev
$ npm i babel-preset-react --save-dev
$ npm i babel-preset-es2015 --save-dev

并在项目根目录下设置了.babelrc文件:

{
  "presets": ["react", "es2015"]
}

然后在项目根目录下新建test文件夹,最先编写测试。

编写TimePicker组件的测试:

// test/TimePicker_init_spec.jsx

import React from 'react';
import {expect} from 'chai';
import {shallow} from 'enzyme';
import moment from 'moment';

import OutsideClickHandler from '../../src/components/OutsideClickHandler';
import TimePickerModal from '../../src/components/TimePickerModal';

describe('TimePicker initial', () => {
  it('should be wrappered by div.time_picker_container', () => {
    // 搜检组件是不是被准确的衬着。期待检测到组件最外层div的class
    const wrapper = shallow(<TimePicker />);
    expect(wrapper.is('.time_picker_container')).to.equal(true);
  });

  it('renders an OutsideClickHandler', () => {
    // 期待衬着出来的组件中含有OutsideClickHandler组件
    const wrapper = shallow(<TimePicker />);
    expect(wrapper.find(OutsideClickHandler)).to.have.lengthOf(1);
  });

  it('should rendered with default time in child props', () => {
    // 供应默许time,期待TimePickerModal可以猎取准确的hour和minute
    const wrapper = shallow(<TimePicker defaultTime="22:23" />);
    expect(wrapper.find(TimePickerModal).props().hour).to.equal("22");
    expect(wrapper.find(TimePickerModal).props().minute).to.equal("23");
  });

  it('should rendered with current time in child props', () => {
    // 在没有默许时刻的状况下,期待TimePickerModal猎取的hour和minute与当前的小时和分钟雷同
    const wrapper = shallow(<TimePicker />);
    const [hour, minute] = moment().format("HH:mm").split(':');
    expect(wrapper.find(TimePickerModal).props().hour).to.equal(hour);
    expect(wrapper.find(TimePickerModal).props().minute).to.equal(minute);
  });
})
// test/TimePicker_func_spec.jsx
import React from 'react';
import {expect} from 'chai';
import {shallow} from 'enzyme';
import sinon from 'sinon-sandbox';
import TimePicker from '../../src/components/TimePicker';

describe('handle focus change func', () => {
  it('should focus', () => {
    const wrapper = shallow(<TimePicker />);
    // 经由过程wrapper.instance()猎取组件实例
    // 并挪用了它的要领onFocus,并期待该要领可以转变组件的focused状况
    wrapper.instance().onFocus();
    expect(wrapper.state().focused).to.equal(true);
  });

  it('should change callback when hour change', () => {
    // 给组件传入onHourChangeStub要领作为onHourChange时的回调
    // 以后手动挪用onHourChange要领,并期待onHourChangeStub要领被挪用了一次
    const onHourChangeStub = sinon.stub();
    const wrapper = shallow(<TimePicker onHourChange={onHourChangeStub}/ />);
    wrapper.instance().handleHourChange(1);
    expect(onHourChangeStub.callCount).to.equal(1);
  });
})

编译

犹如上面所说,我末了选用的是现今最火的webpack同学来编译我们的代码。置信ReactES6的webpack编译设置人人已配烦了,其基础的loader也就是babel-loader了:

const webpack = require('webpack');

// 经由过程node的要领遍历src文件夹,来构成一切的webpack entry
const path = require('path');
const fs = require('fs');
const srcFolder = path.join(__dirname, 'src', 'components');
// 读取./src/components/文件夹下的一切文件
const components = fs.readdirSync(srcFolder);

// 把文件存在entries中,作为webpack编译的进口
const files = [];
const entries = {};
components.forEach(component => {
  const name = component.split('.')[0];
  if (name) {
    const file = `./src/components/${name}`;
    files.push(file);
    entries[name] = file;
  }
});

module.exports = {
  entry: entries,
  output: {
    filename: '[name].js',
    path: './lib/components/',
    // 模块化作风为commonjs2
    libraryTarget: 'commonjs2',
  },
  module: {
    loaders: [
      {
        test: /\.jsx?$/,
        exclude: /(node_modules)/,
        include: path.join(__dirname, 'src'),
        loader: ["babel-loader"],
        query: {
          presets: ["react", "es2015"]
        }
      }
    ],
  },
  resolve: {
    extensions: ['', '.js', '.jsx'],
  },
  plugins: [
    new webpack.optimize.UglifyJsPlugin({
      compress: {
          warnings: false
      }
    }),
    new webpack.optimize.OccurenceOrderPlugin(),
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': '"production"'
    }),
    new webpack.NoErrorsPlugin()
  ]
};

但有一个很主要很主要的题目须要申明一下:

编译过React组件的人都应当晓得,React打包进代码里是比较大的(即使在Production+UglifyJsPlugin的状况下),更何况,我们这个组件作为自力的node_module包,不该当把React打包进去,由于:

  1. 打包React以后会让组件文件体积增大数倍

  2. 打包React以后,装置这个组件的用户会涌现“反复装置React”的严峻bug

因而,我们在打包的时刻应当将第三方依靠自力出去,这就须要设置webpackexternals

externals(context, request, callback) {
  if (files.indexOf(request) > -1) {
    return callback(null, false);
  }
  return callback(null, true);
},

什么意思呢?你可以看webpack externals官方文档。鉴于webpack文档平常都很烂,我来大抵解释一下:

在设置externals的时刻,可以把它作为一个要复写的function:

官方栗子

// request是webpack在打包历程当中要处置惩罚了某一个依靠,无论是本身写的文件之间的互相援用,照样对第三方包的援用,都会将此次援用作为request参数,走这个要领
// callback吸收两个参数,error和result
// 当result返回true或许一个String的时刻,webpack就不会把这个request依靠编译到文件里去。而返回false则会一般编译
// 因而,我们在每次依靠挪用的时刻,经由过程这个要领来推断,某些依靠是不是应当编译进文件里
function(context, request, callback) {
  // Every module prefixed with "global-" becomes external
  // "global-abc" -> abc
  if(/^global-/.test(request))
      return callback(null, "var " + request.substr(7));
  callback();
}

所以,就可以解释一下我们本身在webpack设置中的externals

externals(context, request, callback) {
  // 假如这个依靠存在于files中,也就是在./src/components/文件夹下,申明这是我们本身编写的文件,妥妥的要打包
  if (files.indexOf(request) > -1) {
    return callback(null, false);
  }
  // 不然他就是第三方依靠,自力出去不打包,而是期待运用了该组件的用户本身去打包React
  return callback(null, true);
},

至此,这个组件的编写可以告一段落了。以后要做的就是NPM包宣布的事变。本来想一次性把这个也说了的,然则鉴于有更细致的文章在,人人可以参考前端扫盲-之打造一个Node命令行东西来进修Node包建立和宣布的历程。

本文的源码悉数位于github项目堆栈react-times,假如有差别请以github为准。终究线上DEMO可见react-times github page

转载请说明泉源:

ecmadao,https://github.com/ecmadao/Co…

    原文作者:ecmadao
    原文地址: https://segmentfault.com/a/1190000006883180
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞