成人无码视频,亚洲精品久久久久av无码,午夜精品久久久久久毛片,亚洲 中文字幕 日韩 无码

資訊專欄INFORMATION COLUMN

一言不合造輪子--擼一個(gè)ReactTimePicker

lifesimple / 496人閱讀

摘要:時(shí)間選擇的表盤其實(shí)有兩個(gè),一個(gè)是小時(shí)的選擇,另一個(gè)則是分鐘的選擇。也就是說,第一步選擇小時(shí),第二部選擇分鐘它是一個(gè)小時(shí)制的時(shí)間選擇器。而則用于處理拖拽事件,標(biāo)記著當(dāng)前是否處于被拖拽狀態(tài)。

本文的源碼全部位于github項(xiàng)目倉(cāng)庫(kù)react-times,如果有差異請(qǐng)以github為準(zhǔn)。最終線上DEMO可見react-times github page

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

先看下最終的效果~

起因

因?yàn)槲宜镜臉I(yè)務(wù)需求,需要有一個(gè)日期和時(shí)間的選擇器。最開始我們使用的是pickadate,一個(gè)基于jQuery的比較老牌的時(shí)間日期選擇器。在頁(yè)面上大致長(zhǎng)這樣:

這樣:

還有這樣:

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

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

react-dates是一個(gè)基于momentReact的日期選擇器,其插件本身就是一個(gè)ReactComponent,有NPM,有足夠的測(cè)試,有良好的API。于是當(dāng)即下定決心要趁此干掉pickadate??烧嬲玫巾?xiàng)目中才發(fā)現(xiàn)它居然不支持時(shí)間選擇?。。。ɑ蛟S因?yàn)锳irbnb本身的業(yè)務(wù)就是更看重日期的?)因此才有了自己擼一個(gè)的想法。

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

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

目標(biāo)差不多就長(zhǎng)這個(gè)樣子,再增加一個(gè)選擇時(shí)間的按鈕和黑白配色的選擇。

需求整理

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

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

點(diǎn)擊按鈕彈出表盤modal,再點(diǎn)擊其他區(qū)域關(guān)閉modal

表盤modal里有一個(gè)圓形的時(shí)間選擇器,時(shí)間的數(shù)字圍繞圓形環(huán)繞

表盤里有一個(gè)指針,可以以表盤為中心旋轉(zhuǎn)

點(diǎn)擊代表時(shí)間的數(shù)字,應(yīng)該改變外層按鈕里對(duì)應(yīng)的小時(shí)/分鐘,同時(shí)指針改變旋轉(zhuǎn)角度,指向點(diǎn)擊的時(shí)間

拖拽指針,可以環(huán)繞中心旋轉(zhuǎn)。當(dāng)放開指針時(shí),它應(yīng)該自動(dòng)指向距離最近的小時(shí)或者分鐘

拖拽指針并松開,指針停止之后,當(dāng)前選擇的時(shí)間和外層按鈕上顯示的時(shí)間應(yīng)該被改變

拖拽指針到兩個(gè)整數(shù)數(shù)字之間并放開時(shí),指針應(yīng)該自動(dòng)旋轉(zhuǎn)到距離最近的時(shí)間上

代碼設(shè)計(jì)

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

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

考慮到“點(diǎn)擊按鈕彈出表盤modal,再點(diǎn)擊其他區(qū)域關(guān)閉modal”這個(gè)需求,或許我們應(yīng)該在分離出一個(gè)OutsideClickHandler,專門用來(lái)處理用戶點(diǎn)擊了表盤以外其他區(qū)域時(shí)的modal關(guān)閉事件。

Android時(shí)間選擇的表盤其實(shí)有兩個(gè),一個(gè)是小時(shí)的選擇,另一個(gè)則是分鐘的選擇。用戶可以點(diǎn)擊modal里圓形表盤上的小時(shí)/分鐘,來(lái)切換不同的表盤。那么這意味著或許會(huì)有大量的代碼可供我們復(fù)用。

那么就先按照這個(gè)思路進(jìn)行拆分:

TimePicker

按鈕

處理外層點(diǎn)擊事件的組件(OutsideClickHandler

表盤modal

modal + 表盤(TimePickerModal

環(huán)繞的數(shù)字(PickerPoint

指針(PickerDargHandler

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

這樣有哪些好處呢?舉個(gè)栗子:

我們?cè)谧鼋M件的時(shí)候,先做了小時(shí)的選擇,然后做分鐘的選擇。但兩個(gè)picker的UI不同點(diǎn)主要集中在數(shù)字在表盤的布局上,以及一些選擇的代碼邏輯。這樣的話我們就可以保持大體框架不變,只改變表盤中心渲染的數(shù)字布局即可。

假設(shè)下圖是小時(shí)選擇器:(請(qǐng)?jiān)徫铱蓱z的繪圖)

假設(shè)下圖是分鐘選擇器:(請(qǐng)?jiān)徫铱蓱z的繪圖)

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

下圖是24小時(shí)制,點(diǎn)擊modal上的小時(shí)/分鐘來(lái)切換不同表盤:

下圖是12小時(shí)制,在同一個(gè)表盤上顯示小時(shí)和分鐘:

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

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

新建項(xià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則是整個(gè)包最終的出口,我們?cè)谶@里將做好的組件暴露出去:

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

module.exports = TimePicker;
環(huán)境搭建

既然是寫一個(gè)獨(dú)立的React組件,那它的開發(fā)則和我們項(xiàng)目的開發(fā)相互獨(dú)立。

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

而在開發(fā)過程中,需要能夠啟動(dòng)一個(gè)server,以便能在網(wǎng)頁(yè)上渲染出組件,進(jìn)行調(diào)試。因此,可以使用react-storybook這個(gè)庫(kù),它允許我們啟動(dòng)一個(gè)server,把自己的組件渲染在頁(yè)面上,并支持webpack進(jìn)行編譯。具體的使用大家可以去看storybook文檔,非常簡(jiǎn)單易懂,便于配置。

那么進(jìn)入正題,組件的編寫。

組件編寫 TimePicker

對(duì)于傳入組件的props

defaultTime:默認(rèn)初始化時(shí)間。默認(rèn)為當(dāng)前時(shí)間

focused:初始化時(shí)modal是否打開。默認(rèn)為false

onFocusChange:modal開/關(guān)狀態(tài)變化時(shí)的回調(diào)

onHourChange:選擇的小時(shí)變化時(shí)的回調(diào),以小時(shí)作為參數(shù)

onMinuteChange:選擇的分鐘變化時(shí)的回調(diào),以分鐘作為參數(shù)

onTimeChange:任意時(shí)間變化時(shí)的回調(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)成良好的習(xí)慣:檢查傳入的屬性,并設(shè)定默認(rèn)屬性值
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;
    // 給組件傳入小時(shí)/分鐘,以及handleHourChange,handleMinuteChange
    return (
      
    )
  }

  render() {
    let {hour, minute, focused} = this.state;
    let times = `${hour} : ${minute}`;
    return (
      
{times}
{/*OutsideClickHandler 就是上面說到了,專門用于處理modal外點(diǎn)擊事件,來(lái)關(guān)閉modal的組件*/} {this.renderTimePickerModal()}
) } } TimePicker.propTypes = propTypes; TimePicker.defaultProps = defaultProps;

可以看到,OutsideClickHandler包裹著TimePickerModal,而在OutsideClickHandler中,我們進(jìn)行modal外點(diǎn)擊事件的處理,關(guān)閉modal

OutsideClickHandler
// src/components/OutsideClickHandler.jsx

// ...

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

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

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

  componentDidMount() {
    // 組件didMount之后,直接在document上綁定點(diǎn)擊事件監(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) {
    // 如果點(diǎn)擊區(qū)域不在該組件內(nèi)部,則調(diào)用關(guān)閉modal的方法
    // 通過ReactDOM.findDOMNode來(lái)拿到原生的DOM,避免額外的jQuery依賴
    const isDescendantOfRoot = ReactDOM.findDOMNode(this.childNode).contains(e.target);
    if (!isDescendantOfRoot) {
      let {onOutsideClick} = this.props;
      onOutsideClick && onOutsideClick(e);
    }
  }

  render() {
    return (
      
this.childNode = c}> {this.props.children}
) } } OutsideClickHandler.propTypes = propTypes; OutsideClickHandler.defaultProps = defaultProps;
TimePickerModal

TimePickerModal主要用來(lái)渲染PickerDargHandlerPickerPoint組件:

// src/components/TimePickerModal.jsx
// ...
// 為了簡(jiǎn)便我們?cè)谖恼轮泻雎砸氲腞eact和一些參數(shù)類型檢查

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

  handleStepChange(step) {}

  handleTimePointerClick(time, pointerRotate) {
    /*
    - 當(dāng)表盤上某一個(gè)數(shù)字被點(diǎn)擊時(shí)
    - 或者拖拽完指針并放下時(shí),所調(diào)用的回調(diào)
    - 參數(shù)是該數(shù)字或指針?biāo)淼臅r(shí)間和旋轉(zhuǎn)角度
    */
  }

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

  /*
  + 兩個(gè)方法會(huì)return PickerPoint組件
  + 之所以分兩個(gè)是因?yàn)樾r(shí)/分鐘表盤在UI上有較多不同,因而傳入的props需要不同的計(jì)算
  + 但在PickerPoint組件內(nèi)部的邏輯是一樣的
  */
  renderMinutePointes() {}
  renderHourPointes() {}

  render() {
    let {step, pointerRotate} = this.state;
    return (
      
{hour}  :  {minute}
{step === 0 ? this.renderHourPointes() : this.renderMinutePointes()}
) } }

上面這樣,就基本完成了TimePickerModal組件的編寫。但還不夠好。為什么呢?

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

那么考慮12小時(shí)制的情況:

一個(gè)表盤上要同時(shí)有小時(shí)和分鐘兩種數(shù)字

一個(gè)表盤上要有小時(shí)和分鐘的兩個(gè)指針

切換step改變的是AM/PM

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

PickerPointGenerator

PickerPointGenerator其實(shí)算是一個(gè)中間層組件。在它內(nèi)部會(huì)進(jìn)行一些邏輯判斷,最終渲染出我們想要的表盤數(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 (
        
this.pickerPointerContainer = ref} id="picker_pointer_container"> {type === "hour" ? this.renderHourPointes() : this.renderMinutePointes()}
) } } }; 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 (
      ...
      
      ...
    )
  }
}

而如果是12小時(shí)制呢:

// src/components/TwelveHoursModal.jsx
// ...
class TwelveHoursModal extends React.Component {
  render() {
    const HourPickerPointGenerator = pickerPointGenerator("hour", 12);
    const MinutePickerPointGenerator = pickerPointGenerator("minute", 12);
    return (
      ...
      
      
      ...
    )
  }
}
PickerPoint

PickerPoint內(nèi)的邏輯很簡(jiǎn)單,就是渲染數(shù)字,并處理點(diǎn)擊事件:

// 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 (
      
{ handleTimeChange(index, angle) }} onMouseDown={disableMouseDown}>
{index}
) } }
PickerDargHandler

PickerDargHandler組件里,我們主要處理指針的拖拽事件,并將處理好的結(jié)果通過callback向上傳遞。

在這個(gè)組件里,它擁有自己的state:

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

其中,pointerRotate是從父層傳入,用來(lái)給組件初始化時(shí)定位指針的位置。而draging則用于處理拖拽事件,標(biāo)記著當(dāng)前是否處于被拖拽狀態(tài)。

對(duì)于拖拽事件的處理,大致思路如下:

先寫一個(gè)獲取坐標(biāo)位置的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
  }
};

然后需要明確的是,我們?cè)谔幚硗献录^程中,需要記錄的數(shù)據(jù)有:

this.originX/this.originY 旋轉(zhuǎn)所環(huán)繞的中心坐標(biāo)。在componentDidMount事件中記錄并保存

this.startX/this.startY 每次拖拽事件開始時(shí)的坐標(biāo)。在onMouseDown事件中記錄并保存

dragX/dragY 移動(dòng)過程中的坐標(biāo),隨著移動(dòng)而不斷改變。在onMouseMove事件中記錄并保存

endX/endY 移動(dòng)結(jié)束時(shí)的坐標(biāo)。在onMouseUp事件中進(jìn)行處理,并獲取最后的角度degree,算出指針停止時(shí)對(duì)準(zhǔn)的時(shí)間time,并將time和degree通過callback向父層組件傳遞。

// 處理onMouseDown
handleMouseDown(e) {
  let event = e || window.event;
  event.preventDefault();
  event.stopPropagation();
  // 在鼠標(biāo)按下的時(shí)候,將draging state標(biāo)記為true,以便在移動(dòng)時(shí)對(duì)坐標(biāo)進(jìn)行記錄
  this.setState({
    draging: true
  });

  // 獲取此時(shí)的坐標(biāo)位置,作為這次拖拽的開始位置保存下來(lái)
  let pos = mousePosition(event);
  this.startX = pos.x;
  this.startY = pos.y;
}
// 處理onMouseMove
handleMouseMove(e) {
  if (this.state.draging) {
    // 實(shí)時(shí)獲取更新當(dāng)前坐標(biāo),用于計(jì)算旋轉(zhuǎn)角度,來(lái)更新state中的pointerRotate,而pointerRotate用來(lái)改變渲染的視圖
    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({
        // 記錄下來(lái)的state會(huì)改變渲染出來(lái)的指針角度
        pointerRotate
      });
    }
  }
}

getRadian方法中,通過起始點(diǎn)和中心點(diǎn)的坐標(biāo)來(lái)計(jì)算旋轉(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軸到點(diǎn)(x, y)的弧度,介于 -PI/2 與 PI/2 之間。

因此這個(gè)計(jì)算方法直接上圖表示,清晰明了:

// 處理onMouseUp
handleMouseUp(e) {
  if (this.state.draging) {
    this.setState({
      draging: false
    });

    // 獲取結(jié)束時(shí)的坐標(biāo)
    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));

    // 在停止拖拽時(shí),要求指針要對(duì)準(zhǔn)表盤的刻度。因此,除了要對(duì)角度的正負(fù)進(jìn)行處理以外,還對(duì)其四舍五入。最終獲取的pointerRotate是對(duì)準(zhǔn)了刻度的角度。
    if (degree < 0) {
      degree = 360 + degree;
    }
    // roundSeg是四舍五入之后的對(duì)準(zhǔn)的表盤上的時(shí)間數(shù)字
    let roundSeg = Math.round(degree / (360 / 12));
    let pointerRotate = roundSeg * (360 / 12);

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

你可能注意到只有在onMouseUp的最后,我們才把計(jì)算得到的角度回調(diào)到父組件里,,改變父組件的state。而在handleMouseMove方法里,我們只把角度存在當(dāng)前state里。那是因?yàn)樵诿看我苿?dòng)過程中,都需要知道每次開始移動(dòng)時(shí)的角度偏移量。這個(gè)數(shù)值我們是從父組件state里拿到的,因此只有在放手時(shí)才會(huì)更新它。而PickerDargHandler組件內(nèi)部存的state,只是用來(lái)在拖拽的過程中改變,以便渲染指針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();
  }
}

而這些方法,會(huì)在組件初始化時(shí)綁定,在卸載時(shí)取消綁定:

componentDidMount() {
  // 記錄中心坐標(biāo)
  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,這樣即使鼠標(biāo)移動(dòng)時(shí)不在指針或者modal上,也能夠繼續(xù)響應(yīng)移動(dò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 (
    
this.dragPointer = d} className={pickerPointerClass} style={getInitialPointerStyle(height, top, pointerRotate)}>
{time}
this.pickerCenter = p}>
) }

至此,我們的工作就已經(jīng)完成了(才沒有)。其實(shí)除了控制旋轉(zhuǎn)角度以外,還有指針的坐標(biāo)、長(zhǎng)度等需要進(jìn)行計(jì)算和控制。但即便完成這些,離一個(gè)合格的NPM包還有一段距離。除了基本的代碼編寫,我們還需要有單元測(cè)試,需要對(duì)包進(jìn)行編譯和發(fā)布。

測(cè)試

關(guān)于更多的React測(cè)試介紹,可以戳這兩篇文章入個(gè)門:

UI Testing in React

React Unit Testing with Mocha and Enzyme

使用mocha+chaienzyme來(lái)進(jìn)行React組件的單元測(cè)試:

$ 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"
}

請(qǐng)注意,為了能夠檢查ES6和React,確保自己安裝了需要的babel插件:

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

并在項(xiàng)目根目錄下配置了.babelrc文件:

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

然后在項(xiàng)目根目錄下新建test文件夾,開始編寫測(cè)試。

編寫TimePicker組件的測(cè)試:

// 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", () => {
    // 檢查組件是否被正確的渲染。期待檢測(cè)到組件最外層div的class
    const wrapper = shallow();
    expect(wrapper.is(".time_picker_container")).to.equal(true);
  });

  it("renders an OutsideClickHandler", () => {
    // 期待渲染出來(lái)的組件中含有OutsideClickHandler組件
    const wrapper = shallow();
    expect(wrapper.find(OutsideClickHandler)).to.have.lengthOf(1);
  });

  it("should rendered with default time in child props", () => {
    // 提供默認(rèn)time,期待TimePickerModal能夠獲取正確的hour和minute
    const wrapper = shallow();
    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", () => {
    // 在沒有默認(rèn)時(shí)間的情況下,期待TimePickerModal獲取的hour和minute與當(dāng)前的小時(shí)和分鐘相同
    const wrapper = shallow();
    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();
    // 通過wrapper.instance()獲取組件實(shí)例
    // 并調(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時(shí)的回調(diào)
    // 之后手動(dòng)調(diào)用onHourChange方法,并期待onHourChangeStub方法被調(diào)用了一次
    const onHourChangeStub = sinon.stub();
    const wrapper = shallow();
    wrapper.instance().handleHourChange(1);
    expect(onHourChangeStub.callCount).to.equal(1);
  });
})
編譯

如同上面所說,我最后選用的是當(dāng)今最火的webpack同學(xué)來(lái)編譯我們的代碼。相信ReactES6的webpack編譯配置大家已經(jīng)配煩了,其基本的loader也就是babel-loader了:

const webpack = require("webpack");

// 通過node的方法遍歷src文件夾,來(lái)組成所有的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/",
    // 模塊化風(fēng)格為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()
  ]
};

但有一個(gè)很重要很重要的問題需要說明一下:

編譯過React組件的人都應(yīng)該知道,React打包進(jìn)代碼里是比較大的(即便在Production+UglifyJsPlugin的情況下),更何況,我們這個(gè)組件作為獨(dú)立的node_module包,不應(yīng)該把React打包進(jìn)去,因?yàn)椋?/p>

打包React之后會(huì)讓組件文件體積增大數(shù)倍

打包React之后,安裝這個(gè)組件的用戶會(huì)出現(xiàn)“重復(fù)安裝React”的嚴(yán)重bug

因此,我們?cè)诖虬臅r(shí)候應(yīng)該將第三方依賴獨(dú)立出去,這就需要配置webpackexternals

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

什么意思呢?你可以看webpack externals官方文檔。鑒于webpack文檔一般都很爛,我來(lái)大致解釋一下:

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

官方栗子

// request是webpack在打包過程中要處理了某一個(gè)依賴,無(wú)論是自己寫的文件之間的相互引用,還是對(duì)第三方包的引用,都會(huì)將這次引用作為request參數(shù),走這個(gè)方法
// callback接收兩個(gè)參數(shù),error和result
// 當(dāng)result返回true或者一個(gè)String的時(shí)候,webpack就不會(huì)把這個(gè)request依賴編譯到文件里去。而返回false則會(huì)正常編譯
// 因此,我們?cè)诿看我蕾囌{(diào)用的時(shí)候,通過這個(gè)方法來(lái)判斷,某些依賴是否應(yīng)該編譯進(jìn)文件里
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) {
  // 如果這個(gè)依賴存在于files中,也就是在./src/components/文件夾下,說明這是我們自己編寫的文件,妥妥的要打包
  if (files.indexOf(request) > -1) {
    return callback(null, false);
  }
  // 否則他就是第三方依賴,獨(dú)立出去不打包,而是期待使用了該組件的用戶自己去打包React
  return callback(null, true);
},

至此,這個(gè)組件的編寫可以告一段落了。之后要做的就是NPM包發(fā)布的事情。本來(lái)想一次性把這個(gè)也說了的,但是鑒于有更詳細(xì)的文章在,大家可以參考前端掃盲-之打造一個(gè)Node命令行工具來(lái)學(xué)習(xí)Node包創(chuàng)建和發(fā)布的過程。

本文的源碼全部位于github項(xiàng)目倉(cāng)庫(kù)react-times,如果有差異請(qǐng)以github為準(zhǔn)。最終線上DEMO可見react-times github page

轉(zhuǎn)載請(qǐng)注明來(lái)源:

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

文章版權(quán)歸作者所有,未經(jīng)允許請(qǐng)勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。

轉(zhuǎn)載請(qǐng)注明本文地址:http://m.hztianpu.com/yun/90966.html

相關(guān)文章

  • 手摸手,帶你封裝一個(gè)vue component

    摘要:靈活性和針對(duì)性。所以我覺得大部分組件還是自己封裝來(lái)的更為方便和靈活一些。動(dòng)手開干接下來(lái)我們一起手摸手教改造包裝一個(gè)插件,只要幾分鐘就可以封裝一個(gè)專屬于你的。 項(xiàng)目地址:vue-countTo配套完整后臺(tái)demo地址:vue-element-admin系類文章一:手摸手,帶你用vue擼后臺(tái) 系列一(基礎(chǔ)篇)系類文章二:手摸手,帶你用vue擼后臺(tái) 系列二(登錄權(quán)限篇)系類文章三:手摸手,帶...

    pkhope 評(píng)論0 收藏0
  • 徒手UI之DatePicker

    摘要:是一個(gè)組件庫(kù)目前擁有的組件語(yǔ)法編寫,無(wú)依賴原生模塊化,以上支持,請(qǐng)開啟靜態(tài)服務(wù)器預(yù)覽效果,靜態(tài)服務(wù)器傳送門采用變量配置樣式辛苦造輪子,歡迎來(lái)倉(cāng)庫(kù)四月份找工作,求內(nèi)推,坐標(biāo)深圳寫在前面去年年底項(xiàng)目中嘗試著寫過一個(gè)分頁(yè)的組件,然后就有了寫的想法 QingUI是一個(gè)UI組件庫(kù)目前擁有的組件:DatePicker, TimePicker, Paginator, Tree, Cascader, ...

    zilu 評(píng)論0 收藏0
  • 徒手UI之Paginator

    摘要:是一個(gè)組件庫(kù)目前擁有的組件語(yǔ)法編寫,無(wú)依賴原生模塊化,以上支持,請(qǐng)開啟靜態(tài)服務(wù)器預(yù)覽效果,靜態(tài)服務(wù)器傳送門采用變量配置樣式辛苦造輪子,歡迎來(lái)倉(cāng)庫(kù)四月份找工作,求內(nèi)推,坐標(biāo)深圳寫在前面去年年底項(xiàng)目中嘗試著寫過一個(gè)分頁(yè)的組件,然后就有了寫的想法 QingUI是一個(gè)UI組件庫(kù)目前擁有的組件:DatePicker, TimePicker, Paginator, Tree, Cascader, ...

    liuhh 評(píng)論0 收藏0
  • 用typescript個(gè)前端框架InDiv

    摘要:暫時(shí)沒有指令和。當(dāng)前模塊內(nèi)的組件可以使用來(lái)自根模塊和當(dāng)前模塊的任何服務(wù)及組件,也可以使用被導(dǎo)入模塊中導(dǎo)出的組件。作為一個(gè)前端菜雞,還是在深知自己眾多不足以及明白好記性不如爛筆頭的道理下,多造輪子總歸不會(huì)錯(cuò)的。 有個(gè)同事跟我說:需求還是不夠多,都有時(shí)間造輪子了。。。 前言 這個(gè)輪子從18年4月22造到18年10月12日,本來(lái)就是看了一個(gè)文章講前端框架的路由實(shí)現(xiàn)原理之后,想試著擼一個(gè)路由試...

    liangzai_cool 評(píng)論0 收藏0

發(fā)表評(píng)論

0條評(píng)論

lifesimple

|高級(jí)講師

TA的文章

閱讀更多
最新活動(dòng)
閱讀需要支付1元查看
<