一言不合造輪子--擼一個ReactTimePicker

本文的源碼所有位於github項目倉庫react-times,若是有差別請以github爲準。最終線上DEMO可見react-times github pagejavascript

文章記錄了一次建立獨立React組件並作成NPM包的過程,將會涉及到React開發、單頁測試、Webpack等內容。css

先看下最終的效果~html

react-times

原由

由於我司的業務需求,須要有一個日期和時間的選擇器。最開始咱們使用的是pickadate,一個基於jQuery的比較老牌的時間日期選擇器。在頁面上大體長這樣:前端

pickadata

這樣:java

pickadata

還有這樣:node

pickadata

大致上看着還OK吧?可是後來隨着咱們業務的增加和代碼重構,前端webpack成爲標配,同時愈來愈多的頁面使用React進行重構,pickadata常常出現一些莫名的bug,再加上它自己的API不夠React Style --- 在和React中使用的時候,pickadate組件的初始化還不得不按照老式的jQuery組件那樣,調用API,在DOM裏插入pickadate。並且,爲了獲取date/time變更時的值,每每須要經過jQuery選擇器來拿到value,於是pickadate組件選擇器的初始化和一些事件都較多的依賴於React Component的生命週期。這。。用久了就感受愈來愈蛋疼了。react

後來又一次偶爾發現了Airbnb(業界良心)開源的React組件--react-dateswebpack

react-dates是一個基於momentReact的日期選擇器,其插件自己就是一個ReactComponent,有NPM,有足夠的測試,有良好的API。因而立即下定決心要趁此幹掉pickadate。可真正用到項目中才發現它竟然不支持時間選擇!!!(或許由於Airbnb自己的業務就是更看重日期的?)所以纔有了本身擼一個的想法。git

設計與架構

UI設計

UI方面沒得說,我是妥妥的Material Design黨。此次也是着急動手擼代碼,因此直接就參考Android6.0+系統上鬧鐘裏的時間選擇好了,以後再完善並增長UI主題:github

UI

目標差很少就長這個樣子,再增長一個選擇時間的按鈕和黑白配色的選擇。

需求整理

搭配咱們的「UI稿」和線框稿一塊兒食用:

線框稿

能夠看到,除去上方選擇時間並展現的按鈕以外,咱們把真正的時間表盤放在了下面的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不一樣點主要集中在數字在錶盤的佈局上,以及一些選擇的代碼邏輯。這樣的話咱們就能夠保持大致框架不變,只改變錶盤中心渲染的數字佈局便可。

假設下圖是小時選擇器:(請原諒我可憐的繪圖)

小時選擇器

假設下圖是分鐘選擇器:(請原諒我可憐的繪圖)

分鐘選擇器

  • 而咱們按照這樣的架構擼完代碼以後,若是想額外作一些其餘的東西,好比支持12小時制,那麼小時和分鐘的選擇則應該集中在一個錶盤modal上(也就是長得和正常是時鐘同樣)。在這樣的需求下,咱們須要在一個錶盤裏同時渲染小時和分鐘的數字佈局,而其餘的東西,好比說modal啊,指針啊依舊保持原樣(同樣的指針組件,只不過渲染了兩個)。

下圖是24小時制,點擊modal上的小時/分鐘來切換不一樣錶盤:

24小時制

下圖是12小時制,在同一個錶盤上顯示小時和分鐘:

12小時制

文件結構

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 之間。

所以這個計算方法直接上圖表示,清晰明瞭:

getRadian

// 處理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...

相關文章
相關標籤/搜索