本文的源碼全部位于github項目倉庫
react-times
,如果有差異請以github為準。最終線上DEMO可見react-times github page
文章記錄了一次創建獨立React組件并做成NPM包的過程,將會涉及到React開發、單頁測試、Webpack等內容。
先看下最終的效果~
這里可以玩線上demo
起因
因為我司的業務需求,需要有一個日期和時間的選擇器。最開始我們使用的是pickadate
,一個基于jQuery的比較老牌的時間日期選擇器。在頁面上大致長這樣:
這樣:
還有這樣:
大體上看著還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
是一個基于moment
和React
的日期選擇器,其插件本身就是一個ReactComponent,有NPM,有足夠的測試,有良好的API。于是當即下定決心要趁此干掉pickadate。可真正用到項目中才發現它居然不支持時間選擇!!!(或許因為Airbnb本身的業務就是更看重日期的?)因此才有了自己擼一個的想法。
設計與架構
UI設計
UI方面沒得說,我是妥妥的Material Design
黨。這次也是著急動手擼代碼,所以直接就參考Android6.0+系統上鬧鐘里的時間選擇好了,之后再完善并增加UI主題:
目標差不多就長這個樣子,再增加一個選擇時間的按鈕和黑白配色的選擇。
需求整理
搭配我們的“UI稿”和線框稿一起食用:
可以看到,除去上方選擇時間并展示的按鈕之外,我們把真正的時間表盤放在了下面的modal里。而modal表盤里的設計,則會模仿上圖的Android時間選擇器,是一個MD風格的擬時鐘樣式的選擇器。初步整理出一些需求:
- 點擊按鈕彈出表盤modal,再點擊其他區域關閉modal
- 表盤modal里有一個圓形的時間選擇器,時間的數字圍繞圓形環繞
- 表盤里有一個指針,可以以表盤為中心旋轉
- 點擊代表時間的數字,應該改變外層按鈕里對應的小時/分鐘,同時指針改變旋轉角度,指向點擊的時間
- 拖拽指針,可以環繞中心旋轉。當放開指針時,它應該自動指向距離最近的小時或者分鐘
- 拖拽指針并松開,指針停止之后,當前選擇的時間和外層按鈕上顯示的時間應該被改變
- 拖拽指針到兩個整數數字之間并放開時,指針應該自動旋轉到距離最近的時間上
代碼設計
有了上面的初步需求整理,我們就可以來構思組件的代碼設計了。既然是個React組件,那么就應該按照邏輯和UI,把整體盡可能的拆分成足夠小的模塊。
有幾點代碼層面的架構需要考慮:
- 考慮到“點擊按鈕彈出表盤modal,再點擊其他區域關閉modal”這個需求,或許我們應該在分離出一個
OutsideClickHandler
,專門用來處理用戶點擊了表盤以外其他區域時的modal關閉事件。 - Android時間選擇的表盤其實有兩個,一個是小時的選擇,另一個則是分鐘的選擇。用戶可以點擊modal里圓形表盤上的小時/分鐘,來切換不同的表盤。那么這意味著或許會有大量的代碼可供我們復用。
那么就先按照這個思路進行拆分:
-
TimePicker
- 按鈕
- 處理外層點擊事件的組件(
OutsideClickHandler
) - 表盤modal
- modal + 表盤(
TimePickerModal
) - 環繞的數字(
PickerPoint
) - 指針(
PickerDargHandler
)
- modal + 表盤(
在這樣的結構下,TimePicker.jsx
文件將是我們最后export
出去的組件。在TimePicker,jsx
中,包含了按鈕組件和Modal組件。而Modal組件的各個組成部分被拆分成粒度更小的組件,以便組合和復用。
這樣有哪些好處呢?舉個栗子:
- 我們在做組件的時候,先做了小時的選擇,然后做分鐘的選擇。但兩個picker的UI不同點主要集中在數字在表盤的布局上,以及一些選擇的代碼邏輯。這樣的話我們就可以保持大體框架不變,只改變表盤中心渲染的數字布局即可。
假設下圖是小時選擇器:(請原諒我可憐的繪圖)
假設下圖是分鐘選擇器:(請原諒我可憐的繪圖)
- 而我們按照這樣的架構擼完代碼之后,如果想額外做一些其他的東西,比如支持12小時制,那么小時和分鐘的選擇則應該集中在一個表盤modal上(也就是長得和正常是時鐘一樣)。在這樣的需求下,我們需要在一個表盤里同時渲染小時和分鐘的數字布局,而其他的東西,比如說modal啊,指針啊依舊保持原樣(一樣的指針組件,只不過渲染了兩個)。
下圖是24小時制,點擊modal上的小時/分鐘來切換不同表盤:
下圖是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組件,那它的開發則和我們項目的開發相互獨立。
那么問題來了:該如何搭建開發和測試環境呢?這個組件我想使用React
和ES6
的語法,而單元測試則使用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
主要用來渲染PickerDargHandler
和PickerPoint
組件:
// 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>
:
<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 之間。
因此這個計算方法直接上圖表示,清晰明了:
// 處理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測試介紹,可以戳這兩篇文章入個門:
使用mocha
+chai
和enzyme
來進行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
同學來編譯我們的代碼。相信React
加ES6
的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打包進去,因為:
- 打包React之后會讓組件文件體積增大數倍
- 打包React之后,安裝這個組件的用戶會出現“重復安裝React”的嚴重bug
因此,我們在打包的時候應該將第三方依賴獨立出去,這就需要配置webpack
的externals
:
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轉載請注明來源: