本文的源码所有位于github项目仓库
react-times
,若是有差别请以github为准。最终线上DEMO可见react-times github pagejavascript文章记录了一次建立独立React组件并作成NPM包的过程,将会涉及到React开发、单页测试、Webpack等内容。css
先看下最终的效果~html
由于我司的业务需求,须要有一个日期和时间的选择器。最开始咱们使用的是pickadate
,一个基于jQuery的比较老牌的时间日期选择器。在页面上大体长这样:前端
这样:java
还有这样:node
大致上看着还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-dates
。webpack
react-dates
是一个基于moment
和React
的日期选择器,其插件自己就是一个ReactComponent,有NPM,有足够的测试,有良好的API。因而立即下定决心要趁此干掉pickadate。可真正用到项目中才发现它竟然不支持时间选择!!!(或许由于Airbnb自己的业务就是更看重日期的?)所以才有了本身撸一个的想法。git
UI方面没得说,我是妥妥的Material Design
党。此次也是着急动手撸代码,因此直接就参考Android6.0+系统上闹钟里的时间选择好了,以后再完善并增长UI主题:github
目标差很少就长这个样子,再增长一个选择时间的按钮和黑白配色的选择。
搭配咱们的“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上的小时/分钟来切换不一样表盘:
下图是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转载请注明来源:
ecmadao,https://github.com/ecmadao/Co...