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

本文的源碼全部位于github項目倉庫react-times夫晌,如果有差異請以github為準。最終線上DEMO可見react-times github page

文章記錄了一次創(chuàng)建獨立React組件并做成NPM包的過程,將會涉及到React開發(fā)拥刻、單頁測試怜瞒、Webpack等內(nèi)容。

先看下最終的效果~

react-times.gif

這里可以玩線上demo

起因

因為我司的業(yè)務(wù)需求,需要有一個日期和時間的選擇器吴汪。最開始我們使用的是pickadate惠窄,一個基于jQuery的比較老牌的時間日期選擇器。在頁面上大致長這樣:

pickadata

這樣:

pickadata-date

還有這樣:

pickadata-time

大體上看著還OK吧漾橙?但是后來隨著我們業(yè)務(wù)的增長和代碼重構(gòu)杆融,前端webpack成為標配,同時越來越多的頁面使用React進行重構(gòu)霜运,pickadata經(jīng)常出現(xiàn)一些莫名的bug脾歇,再加上它本身的API不夠React Style --- 在和React中使用的時候,pickadate組件的初始化還不得不按照老式的jQuery組件那樣淘捡,調(diào)用API藕各,在DOM里插入pickadate。而且焦除,為了獲取date/time變動時的值激况,往往需要通過jQuery選擇器來拿到value,因而pickadate組件選擇器的初始化和一些事件都較多的依賴于React Component的生命周期膘魄。這乌逐。。用久了就感覺越來越蛋疼了创葡。

后來又一次偶爾發(fā)現(xiàn)了Airbnb(業(yè)界良心)開源的React組件--react-dates浙踢。

react-dates是一個基于momentReact的日期選擇器,其插件本身就是一個ReactComponent蹈丸,有NPM成黄,有足夠的測試,有良好的API逻杖。于是當即下定決心要趁此干掉pickadate奋岁。可真正用到項目中才發(fā)現(xiàn)它居然不支持時間選擇]┌佟N帕妗!(或許因為Airbnb本身的業(yè)務(wù)就是更看重日期的够话?)因此才有了自己擼一個的想法蓝翰。

設(shè)計與架構(gòu)

UI設(shè)計

UI方面沒得說,我是妥妥的Material Design黨女嘲。這次也是著急動手擼代碼畜份,所以直接就參考Android6.0+系統(tǒng)上鬧鐘里的時間選擇好了,之后再完善并增加UI主題:

Android-Material-Design

目標差不多就長這個樣子欣尼,再增加一個選擇時間的按鈕和黑白配色的選擇爆雹。

需求整理

搭配我們的“UI稿”和線框稿一起食用:

ui-line

可以看到停蕉,除去上方選擇時間并展示的按鈕之外,我們把真正的時間表盤放在了下面的modal里钙态。而modal表盤里的設(shè)計闯冷,則會模仿上圖的Android時間選擇器碑定,是一個MD風格的擬時鐘樣式的選擇器。初步整理出一些需求:

  • 點擊按鈕彈出表盤modal,再點擊其他區(qū)域關(guān)閉modal
  • 表盤modal里有一個圓形的時間選擇器外永,時間的數(shù)字圍繞圓形環(huán)繞
  • 表盤里有一個指針躯泰,可以以表盤為中心旋轉(zhuǎn)
  • 點擊代表時間的數(shù)字进肯,應(yīng)該改變外層按鈕里對應(yīng)的小時/分鐘牡彻,同時指針改變旋轉(zhuǎn)角度,指向點擊的時間
  • 拖拽指針拴孤,可以環(huán)繞中心旋轉(zhuǎn)脾歧。當放開指針時,它應(yīng)該自動指向距離最近的小時或者分鐘
  • 拖拽指針并松開演熟,指針停止之后鞭执,當前選擇的時間和外層按鈕上顯示的時間應(yīng)該被改變
  • 拖拽指針到兩個整數(shù)數(shù)字之間并放開時,指針應(yīng)該自動旋轉(zhuǎn)到距離最近的時間上

代碼設(shè)計

有了上面的初步需求整理芒粹,我們就可以來構(gòu)思組件的代碼設(shè)計了兄纺。既然是個React組件,那么就應(yīng)該按照邏輯和UI化漆,把整體盡可能的拆分成足夠小的模塊估脆。

有幾點代碼層面的架構(gòu)需要考慮:

  • 考慮到“點擊按鈕彈出表盤modal,再點擊其他區(qū)域關(guān)閉modal”這個需求座云,或許我們應(yīng)該在分離出一個OutsideClickHandler疙赠,專門用來處理用戶點擊了表盤以外其他區(qū)域時的modal關(guān)閉事件。
  • Android時間選擇的表盤其實有兩個朦拖,一個是小時的選擇圃阳,另一個則是分鐘的選擇。用戶可以點擊modal里圓形表盤上的小時/分鐘璧帝,來切換不同的表盤捍岳。那么這意味著或許會有大量的代碼可供我們復(fù)用。

那么就先按照這個思路進行拆分:

  • TimePicker
    • 按鈕
    • 處理外層點擊事件的組件(OutsideClickHandler
    • 表盤modal
      • modal + 表盤(TimePickerModal
      • 環(huán)繞的數(shù)字(PickerPoint
      • 指針(PickerDargHandler

在這樣的結(jié)構(gòu)下睬隶,TimePicker.jsx文件將是我們最后export出去的組件锣夹。在TimePicker,jsx中,包含了按鈕組件和Modal組件苏潜。而Modal組件的各個組成部分被拆分成粒度更小的組件银萍,以便組合和復(fù)用。

這樣有哪些好處呢恤左?舉個栗子:

  • 我們在做組件的時候贴唇,先做了小時的選擇贰锁,然后做分鐘的選擇。但兩個picker的UI不同點主要集中在數(shù)字在表盤的布局上滤蝠,以及一些選擇的代碼邏輯。這樣的話我們就可以保持大體框架不變授嘀,只改變表盤中心渲染的數(shù)字布局即可物咳。

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

hour-picker

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

minute-picker
  • 而我們按照這樣的架構(gòu)擼完代碼之后,如果想額外做一些其他的東西蹄皱,比如支持12小時制览闰,那么小時和分鐘的選擇則應(yīng)該集中在一個表盤modal上(也就是長得和正常是時鐘一樣)。在這樣的需求下巷折,我們需要在一個表盤里同時渲染小時和分鐘的數(shù)字布局压鉴,而其他的東西,比如說modal啊锻拘,指針啊依舊保持原樣(一樣的指針組件油吭,只不過渲染了兩個)。

下圖是24小時制署拟,點擊modal上的小時/分鐘來切換不同表盤:

24hours mode

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

12hours mode

文件結(jié)構(gòu)

So, 目前這樣的結(jié)構(gòu)設(shè)計應(yīng)該可以滿足我們的簡單的需求。接下來就開始卷起袖子擼代碼嘍推穷。

新建項目心包,基本的文件結(jié)構(gòu)如下:

# 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;

環(huán)境搭建

既然是寫一個獨立的React組件区宇,那它的開發(fā)則和我們項目的開發(fā)相互獨立娃殖。

那么問題來了:該如何搭建開發(fā)和測試環(huán)境呢?這個組件我想使用ReactES6的語法萧锉,而單元測試則使用mocha+chai和Airbnb的enzyme(再次感謝業(yè)界良心)珊随。那么在發(fā)布之前,應(yīng)該使用構(gòu)建工具將其初步打包柿隙,針對于這點我選用了webpack叶洞。

而在開發(fā)過程中,需要能夠啟動一個server禀崖,以便能在網(wǎng)頁上渲染出組件衩辟,進行調(diào)試。因此波附,可以使用react-storybook這個庫艺晴,它允許我們啟動一個server昼钻,把自己的組件渲染在頁面上,并支持webpack進行編譯封寞。具體的使用大家可以去看storybook文檔然评,非常簡單易懂,便于配置狈究。

那么進入正題碗淌,組件的編寫。

組件編寫

TimePicker

對于傳入組件的props

  • defaultTime:默認初始化時間抖锥。默認為當前時間
  • focused:初始化時modal是否打開亿眠。默認為false
  • onFocusChange:modal開/關(guān)狀態(tài)變化時的回調(diào)
  • onHourChange:選擇的小時變化時的回調(diào),以小時作為參數(shù)
  • onMinuteChange:選擇的分鐘變化時的回調(diào)磅废,以分鐘作為參數(shù)
  • onTimeChange:任意時間變化時的回調(diào)纳像,以hour:minute作為參數(shù),參數(shù)類型是String
// src/components/TimePicker.jsx
// 省略了一些方法的具體內(nèi)容和組件屬性的傳遞
import React, {PropTypes} from 'react';
import moment from 'moment';

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

// 組件開發(fā)要養(yǎng)成良好的習慣:檢查傳入的屬性拯勉,并設(shè)定默認屬性值
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竟趾,并觸發(fā)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外點擊事件潭兽,來關(guān)閉modal的組件*/}
        <OutsideClickHandler onOutsideClick={this.onClearFocus}>
          {this.renderTimePickerModal()}
        </OutsideClickHandler>
      </div>
    )
  }
}

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

可以看到,OutsideClickHandler包裹著TimePickerModal斗遏,而在OutsideClickHandler中山卦,我們進行modal外點擊事件的處理,關(guān)閉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上綁定點擊事件監(jiān)聽
    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) {
    // 如果點擊區(qū)域不在該組件內(nèi)部账蓉,則調(diào)用關(guān)閉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和一些參數(shù)類型檢查

class TimePickerModal extends React.Component {
  constructor(props) {
    super(props);
    /*
    - 獲取初始化時的旋轉(zhuǎn)角度
    - 以step 0代表hour的選擇逾一,1代表minute的選擇
    */
    let pointerRotate = this.resetHourDegree();
    this.state = {
      step: 0,
      pointerRotate
    }
  }

  handleStepChange(step) {}

  handleTimePointerClick(time, pointerRotate) {
    /*
    - 當表盤上某一個數(shù)字被點擊時
    - 或者拖拽完指針并放下時铸本,所調(diào)用的回調(diào)
    - 參數(shù)是該數(shù)字或指針所代表的時間和旋轉(zhuǎn)角度
    */
  }

  // 在切換step的時候,根據(jù)當前的hour/minute來重新改變旋轉(zhuǎn)角度
  resetHourDegree() {}
  resetMinuteDegree() {}

  /*
  + 兩個方法會return PickerPoint組件
  + 之所以分兩個是因為小時/分鐘表盤在UI上有較多不同遵堵,因而傳入的props需要不同的計算
  + 但在PickerPoint組件內(nèi)部的邏輯是一樣的
  */
  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>
           : 
          <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組件的編寫。但還不夠好陌宿。為什么呢锡足?

按照我們的邏輯,這個時間選擇器應(yīng)該根據(jù)step來切換表盤上表示小時/分鐘的數(shù)字壳坪。也就是說舶得,第一步選擇小時,第二部選擇分鐘 -- 它是一個24小時制的時間選擇器爽蝴。那么沐批,如果是要變成12小時制呢纫骑?讓小時和分鐘在同一個表盤上渲染,而step只改變AM/PM呢九孩?

那么考慮12小時制的情況:

  • 一個表盤上要同時有小時和分鐘兩種數(shù)字
  • 一個表盤上要有小時和分鐘的兩個指針
  • 切換step改變的是AM/PM

鑒于我們不應(yīng)該在TimePickerModal中放入太多的邏輯判斷先馆,那么還是針對12小時制專門創(chuàng)建一個組件TwelveHoursModal比較好,但也會提取出TimePickerModal組件中可以獨立的方法躺彬,作為專門渲染PickerPoint的中間層磨隘,PickerPointGenerator.jsx

PickerPointGenerator

PickerPointGenerator其實算是一個中間層組件顾患。在它內(nèi)部會進行一些邏輯判斷,最終渲染出我們想要的表盤數(shù)字个唧。

// 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內(nèi)的邏輯很簡單,就是渲染數(shù)字徙歼,并處理點擊事件:

// 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組件里犁河,我們主要處理指針的拖拽事件,并將處理好的結(jié)果通過callback向上傳遞魄梯。

在這個組件里桨螺,它擁有自己的state:

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

其中,pointerRotate是從父層傳入酿秸,用來給組件初始化時定位指針的位置灭翔。而draging則用于處理拖拽事件,標記著當前是否處于被拖拽狀態(tài)辣苏。

對于拖拽事件的處理肝箱,大致思路如下:

先寫一個獲取坐標位置的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
  }
};

然后需要明確的是,我們在處理拖拽事件過程中稀蟋,需要記錄的數(shù)據(jù)有:

  • this.originX/this.originY 旋轉(zhuǎn)所環(huán)繞的中心坐標煌张。在componentDidMount事件中記錄并保存
  • this.startX/this.startY 每次拖拽事件開始時的坐標。在onMouseDown事件中記錄并保存
  • dragX/dragY 移動過程中的坐標退客,隨著移動而不斷改變骏融。在onMouseMove事件中記錄并保存
  • endX/endY 移動結(jié)束時的坐標。在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) {
    // 實時獲取更新當前坐標冤留,用于計算旋轉(zhuǎn)角度碧囊,來更新state中的pointerRotate,而pointerRotate用來改變渲染的視圖
    let pos = mousePosition(e);
    let dragX = pos.x;
    let dragY = pos.y;

    if (this.originX !== dragX && this.originY !== dragY) {
      // 獲取旋轉(zhuǎn)的弧度纤怒。getRadian方法在下面講解
      let sRad = this.getRadian(dragX, dragY);
      // 將弧度轉(zhuǎn)為角度
      let pointerRotate = sRad * (360 / (2 * Math.PI));
      this.setState({
        // 記錄下來的state會改變渲染出來的指針角度
        pointerRotate
      });
    }
  }
}

getRadian方法中糯而,通過起始點和中心點的坐標來計算旋轉(zhuǎn)結(jié)束后的弧度:

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
    });

    // 獲取結(jié)束時的坐標
    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是四舍五入之后的對準的表盤上的時間數(shù)字
    let roundSeg = Math.round(degree / (360 / 12));
    let pointerRotate = roundSeg * (360 / 12);

    // 分鐘表盤的每一格都是小時表盤的5倍
    let time = step === 0 ? time : time * 5;
    // 將結(jié)果回調(diào)給父組件
    let {handleTimePointerClick} = this.props;
    handleTimePointerClick && handleTimePointerClick(time, pointerRotate);
  }
}

你可能注意到只有在onMouseUp的最后,我們才把計算得到的角度回調(diào)到父組件里憔鬼,龟劲,改變父組件的state。而在handleMouseMove方法里轴或,我們只把角度存在當前state里昌跌。那是因為在每次移動過程中,都需要知道每次開始移動時的角度偏移量照雁。這個數(shù)值我們是從父組件state里拿到的蚕愤,因此只有在放手時才會更新它。而PickerDargHandler組件內(nèi)部存的state饺蚊,只是用來在拖拽的過程中改變审胸,以便渲染指針UI的旋轉(zhuǎn)角度:

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上,也能夠繼續(xù)響應(yīng)移動事件
  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>
  )
}

至此碍庵,我們的工作就已經(jīng)完成了(才沒有)。其實除了控制旋轉(zhuǎn)角度以外悟狱,還有指針的坐標静浴、長度等需要進行計算和控制。但即便完成這些挤渐,離一個合格的NPM包還有一段距離苹享。除了基本的代碼編寫,我們還需要有單元測試,需要對包進行編譯和發(fā)布得问。

測試

關(guān)于更多的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()獲取組件實例
    // 并調(diào)用了它的方法onFocus,并期待該方法能夠改變組件的focused狀態(tài)
    wrapper.instance().onFocus();
    expect(wrapper.state().focused).to.equal(true);
  });

  it('should change callback when hour change', () => {
    // 給組件傳入onHourChangeStub方法作為onHourChange時的回調(diào)
    // 之后手動調(diào)用onHourChange方法孤个,并期待onHourChangeStub方法被調(diào)用了一次
    const onHourChangeStub = sinon.stub();
    const wrapper = shallow(<TimePicker onHourChange={onHourChangeStub}/ />);
    wrapper.instance().handleHourChange(1);
    expect(onHourChangeStub.callCount).to.equal(1);
  });
})

編譯

如同上面所說亩冬,我最后選用的是當今最火的webpack同學(xué)來編譯我們的代碼。相信ReactES6的webpack編譯配置大家已經(jīng)配煩了硼身,其基本的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組件的人都應(yīng)該知道佳遂,React打包進代碼里是比較大的(即便在Production+UglifyJsPlugin的情況下),更何況撒顿,我們這個組件作為獨立的node_module包丑罪,不應(yīng)該把React打包進去,因為:

  1. 打包React之后會讓組件文件體積增大數(shù)倍
  2. 打包React之后凤壁,安裝這個組件的用戶會出現(xiàn)“重復(fù)安裝React”的嚴重bug

因此吩屹,我們在打包的時候應(yīng)該將第三方依賴獨立出去,這就需要配置webpackexternals

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

什么意思呢拧抖?你可以看webpack externals官方文檔煤搜。鑒于webpack文檔一般都很爛,我來大致解釋一下:

在配置externals的時候唧席,可以把它作為一個要復(fù)寫的function:

官方栗子

// request是webpack在打包過程中要處理了某一個依賴擦盾,無論是自己寫的文件之間的相互引用,還是對第三方包的引用淌哟,都會將這次引用作為request參數(shù)迹卢,走這個方法
// callback接收兩個參數(shù),error和result
// 當result返回true或者一個String的時候徒仓,webpack就不會把這個request依賴編譯到文件里去腐碱。而返回false則會正常編譯
// 因此,我們在每次依賴調(diào)用的時候掉弛,通過這個方法來判斷症见,某些依賴是否應(yīng)該編譯進文件里
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包發(fā)布的事情谬晕。本來想一次性把這個也說了的碘裕,但是鑒于有更詳細的文章在,大家可以參考前端掃盲-之打造一個Node命令行工具來學(xué)習Node包創(chuàng)建和發(fā)布的過程攒钳。

本文的源碼全部位于github項目倉庫react-times帮孔,如果有差異請以github為準。最終線上DEMO可見react-times github page

轉(zhuǎn)載請注明來源:

ecmadao不撑,https://github.com/ecmadao/Coding-Guide/blob/master/Notes/React/ReactJS/Write%20a%20React%20Timepicker%20Component%20hand%20by%20hand.md

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末文兢,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子焕檬,更是在濱河造成了極大的恐慌姆坚,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,378評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件实愚,死亡現(xiàn)場離奇詭異兼呵,居然都是意外死亡,警方通過查閱死者的電腦和手機腊敲,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,356評論 2 382
  • 文/潘曉璐 我一進店門击喂,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人碰辅,你說我怎么就攤上這事懂昂。” “怎么了没宾?”我有些...
    開封第一講書人閱讀 152,702評論 0 342
  • 文/不壞的土叔 我叫張陵忍法,是天一觀的道長。 經(jīng)常有香客問我榕吼,道長饿序,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,259評論 1 279
  • 正文 為了忘掉前任羹蚣,我火速辦了婚禮原探,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己咽弦,他們只是感情好徒蟆,可當我...
    茶點故事閱讀 64,263評論 5 371
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著型型,像睡著了一般段审。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上闹蒜,一...
    開封第一講書人閱讀 49,036評論 1 285
  • 那天寺枉,我揣著相機與錄音,去河邊找鬼绷落。 笑死姥闪,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的砌烁。 我是一名探鬼主播筐喳,決...
    沈念sama閱讀 38,349評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼函喉!你這毒婦竟也來了避归?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 36,979評論 0 259
  • 序言:老撾萬榮一對情侶失蹤管呵,失蹤者是張志新(化名)和其女友劉穎梳毙,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體撇寞,經(jīng)...
    沈念sama閱讀 43,469評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,938評論 2 323
  • 正文 我和宋清朗相戀三年堂氯,在試婚紗的時候發(fā)現(xiàn)自己被綠了蔑担。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,059評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡咽白,死狀恐怖啤握,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情晶框,我是刑警寧澤排抬,帶...
    沈念sama閱讀 33,703評論 4 323
  • 正文 年R本政府宣布,位于F島的核電站授段,受9級特大地震影響蹲蒲,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜侵贵,卻給世界環(huán)境...
    茶點故事閱讀 39,257評論 3 307
  • 文/蒙蒙 一届搁、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦卡睦、人聲如沸宴胧。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,262評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽恕齐。三九已至,卻和暖如春瞬逊,著一層夾襖步出監(jiān)牢的瞬間显歧,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,485評論 1 262
  • 我被黑心中介騙來泰國打工码耐, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留追迟,地道東北人。 一個月前我還...
    沈念sama閱讀 45,501評論 2 354
  • 正文 我出身青樓骚腥,卻偏偏與公主長得像敦间,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子束铭,可洞房花燭夜當晚...
    茶點故事閱讀 42,792評論 2 345

推薦閱讀更多精彩內(nèi)容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 171,510評論 25 707
  • 發(fā)現(xiàn) 關(guān)注 消息 iOS 第三方庫廓块、插件、知名博客總結(jié) 作者大灰狼的小綿羊哥哥關(guān)注 2017.06.26 09:4...
    肇東周閱讀 12,024評論 4 62
  • 總有一天,你會等到一個讓你安心的人懈万,是秋褲扎在襪子里的那種安心拴清。 1. 今天被冷冷的狗糧在臉上胡亂的拍,班長買了糖...
    夢槑閱讀 3,283評論 19 24
  • 截止今日会通,畢業(yè)已經(jīng)整整五個月口予。從當初畢業(yè)前的迷茫,不知所措涕侈,到現(xiàn)在越來越上手沪停,自己真是成長了很多。畢業(yè)了裳涛,你會發(fā)...
    人生你奈我何閱讀 206評論 0 0
  • 舍友無意間對我說:“我覺得你活得好粗糙端三∠侠瘢” 我知道她為什么會這樣說。 我是一個這樣的人郊闯。 我從高中開始愛上死亡且轨。不...
    兔紙Elf閱讀 314評論 1 1