资讯专栏INFORMATION COLUMN

一言不合造轮子--撸一个ReactTimePicker

lifesimple / 335人阅读

摘要:时间选择的表盘其实有两个,一个是小时的选择,另一个则是分钟的选择。也就是说,第一步选择小时,第二部选择分钟它是一个小时制的时间选择器。而则用于处理拖拽事件,标记着当前是否处于被拖拽状态。

本文的源码全部位于github项目仓库react-times,如果有差异请以github为准。最终线上DEMO可见react-times github page

文章记录了一次创建独立React组件并做成NPM包的过程,将会涉及到React开发、单页测试、Webpack等内容。

先看下最终的效果~

起因

因为我司的业务需求,需要有一个日期和时间的选择器。最开始我们使用的是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是一个基于momentReact的日期选择器,其插件本身就是一个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

在这样的结构下,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组件,那它的开发则和我们项目的开发相互独立。

那么问题来了:该如何搭建开发和测试环境呢?这个组件我想使用ReactES6的语法,而单元测试则使用mocha+chai和Airbnb的enzyme(再次感谢业界良心)。那么在发布之前,应该使用构建工具将其初步打包,针对于这点我选用了webpack

而在开发过程中,需要能够启动一个server,以便能在网页上渲染出组件,进行调试。因此,可以使用react-storybook这个库,它允许我们启动一个server,把自己的组件渲染在页面上,并支持webpack进行编译。具体的使用大家可以去看storybook文档,非常简单易懂,便于配置。

那么进入正题,组件的编写。

组件编写 TimePicker

对于传入组件的props

defaultTime:默认初始化时间。默认为当前时间

focused:初始化时modal是否打开。默认为false

onFocusChange:modal开/关状态变化时的回调

onHourChange:选择的小时变化时的回调,以小时作为参数

onMinuteChange:选择的分钟变化时的回调,以分钟作为参数

onTimeChange:任意时间变化时的回调,以hour:minute作为参数,参数类型是String

// src/components/TimePicker.jsx
// 省略了一些方法的具体内容和组件属性的传递
import React, {PropTypes} from "react";
import moment from "moment";

import OutsideClickHandler from "./OutsideClickHandler";
import TimePickerModal from "./TimePickerModal";

// 组件开发要养成良好的习惯:检查传入的属性,并设定默认属性值
const propTypes = {
  defaultTime: PropTypes.string,
  focused: PropTypes.bool,
  onFocusChange: PropTypes.func,
  onHourChange: PropTypes.func,
  onMinuteChange: PropTypes.func,
  onTimeChange: PropTypes.func
};

const defaultProps = {
  defaultTime: moment().format("HH:mm"),
  focused: false,
  onFocusChange: () => {},
  onHourChange: () => {},
  onMinuteChange: () => {},
  onTimeChange: () => {}
};

export default class TimePicker extends React.Component {
  constructor(props) {
    super(props);
    let {defaultTime, focused} = props;
    let [hour, minute] = initialTime(defaultTime);
    this.state = {
      hour,
      minute,
      focused
    }
    this.onFocus = this.onFocus.bind(this);
    this.onClearFocus = this.onClearFocus.bind(this);
    this.handleHourChange = this.handleHourChange.bind(this);
    this.handleMinuteChange = this.handleMinuteChange.bind(this);
  }

  // 改变state,并触发onFocusChange callback
  onFocus() {}
  onClearFocus() {}
  handleHourChange() {}
  handleMinuteChange() {}

  renderTimePickerModal() {
    let {hour, minute, focused} = this.state;
    // 给组件传入小时/分钟,以及handleHourChange,handleMinuteChange
    return (
      
    )
  }

  render() {
    let {hour, minute, focused} = this.state;
    let times = `${hour} : ${minute}`;
    return (
      
{times}
{/*OutsideClickHandler 就是上面说到了,专门用于处理modal外点击事件,来关闭modal的组件*/} {this.renderTimePickerModal()}
) } } 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: ,
  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 (
      
this.childNode = c}> {this.props.children}
) } } OutsideClickHandler.propTypes = propTypes; OutsideClickHandler.defaultProps = defaultProps;
TimePickerModal

TimePickerModal主要用来渲染PickerDargHandlerPickerPoint组件:

// src/components/TimePickerModal.jsx
// ...
// 为了简便我们在文章中忽略引入的React和一些参数类型检查

class TimePickerModal extends React.Component {
  constructor(props) {
    super(props);
    /*
    - 获取初始化时的旋转角度
    - 以step 0代表hour的选择,1代表minute的选择
    */
    let pointerRotate = this.resetHourDegree();
    this.state = {
      step: 0,
      pointerRotate
    }
  }

  handleStepChange(step) {}

  handleTimePointerClick(time, pointerRotate) {
    /*
    - 当表盘上某一个数字被点击时
    - 或者拖拽完指针并放下时,所调用的回调
    - 参数是该数字或指针所代表的时间和旋转角度
    */
  }

  // 在切换step的时候,根据当前的hour/minute来重新改变旋转角度
  resetHourDegree() {}
  resetMinuteDegree() {}

  /*
  + 两个方法会return PickerPoint组件
  + 之所以分两个是因为小时/分钟表盘在UI上有较多不同,因而传入的props需要不同的计算
  + 但在PickerPoint组件内部的逻辑是一样的
  */
  renderMinutePointes() {}
  renderHourPointes() {}

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

上面这样,就基本完成了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 (
        
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小时制呢:

// src/components/TwelveHoursModal.jsx
// ...
class TwelveHoursModal extends React.Component {
  render() {
    const HourPickerPointGenerator = pickerPointGenerator("hour", 12);
    const MinutePickerPointGenerator = pickerPointGenerator("minute", 12);
    return (
      ...
      
      
      ...
    )
  }
}
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 (
      
{ handleTimeChange(index, angle) }} onMouseDown={disableMouseDown}>
{index}
) } }
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 (
    
this.dragPointer = d} className={pickerPointerClass} style={getInitialPointerStyle(height, top, pointerRotate)}>
{time}
this.pickerCenter = p}>
) }

至此,我们的工作就已经完成了(才没有)。其实除了控制旋转角度以外,还有指针的坐标、长度等需要进行计算和控制。但即便完成这些,离一个合格的NPM包还有一段距离。除了基本的代码编写,我们还需要有单元测试,需要对包进行编译和发布。

测试

关于更多的React测试介绍,可以戳这两篇文章入个门:

UI Testing in React

React Unit Testing with Mocha and Enzyme

使用mocha+chaienzyme来进行React组件的单元测试:

$ npm i mocha --save-dev
$ npm i chai --save-dev
$ npm i enzyme --save-dev
$ npm i react-addons-test-utils --save-dev

# 除此之外,为了模拟React中的事件,还需要安装:
$ npm i sinon --save-dev
$ npm i sinon-sandbox --save-dev

然后配置package.json

"scripts": {
  "mocha": "./node_modules/mocha/bin/mocha --compilers js:babel-register,jsx:babel-register",
  "test": "npm run mocha test"
}

请注意,为了能够检查ES6和React,确保自己安装了需要的babel插件:

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

并在项目根目录下配置了.babelrc文件:

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

然后在项目根目录下新建test文件夹,开始编写测试。

编写TimePicker组件的测试:

// test/TimePicker_init_spec.jsx

import React from "react";
import {expect} from "chai";
import {shallow} from "enzyme";
import moment from "moment";

import OutsideClickHandler from "../../src/components/OutsideClickHandler";
import TimePickerModal from "../../src/components/TimePickerModal";

describe("TimePicker initial", () => {
  it("should be wrappered by div.time_picker_container", () => {
    // 检查组件是否被正确的渲染。期待检测到组件最外层div的class
    const wrapper = shallow();
    expect(wrapper.is(".time_picker_container")).to.equal(true);
  });

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

  it("should rendered with default time in child props", () => {
    // 提供默认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", () => {
    // 在没有默认时间的情况下,期待TimePickerModal获取的hour和minute与当前的小时和分钟相同
    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()获取组件实例
    // 并调用了它的方法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();
    wrapper.instance().handleHourChange(1);
    expect(onHourChangeStub.callCount).to.equal(1);
  });
})
编译

如同上面所说,我最后选用的是当今最火的webpack同学来编译我们的代码。相信ReactES6的webpack编译配置大家已经配烦了,其基本的loader也就是babel-loader了:

const webpack = require("webpack");

// 通过node的方法遍历src文件夹,来组成所有的webpack entry
const path = require("path");
const fs = require("fs");
const srcFolder = path.join(__dirname, "src", "components");
// 读取./src/components/文件夹下的所有文件
const components = fs.readdirSync(srcFolder);

// 把文件存在entries中,作为webpack编译的入口
const files = [];
const entries = {};
components.forEach(component => {
  const name = component.split(".")[0];
  if (name) {
    const file = `./src/components/${name}`;
    files.push(file);
    entries[name] = file;
  }
});

module.exports = {
  entry: entries,
  output: {
    filename: "[name].js",
    path: "./lib/components/",
    // 模块化风格为commonjs2
    libraryTarget: "commonjs2",
  },
  module: {
    loaders: [
      {
        test: /.jsx?$/,
        exclude: /(node_modules)/,
        include: path.join(__dirname, "src"),
        loader: ["babel-loader"],
        query: {
          presets: ["react", "es2015"]
        }
      }
    ],
  },
  resolve: {
    extensions: ["", ".js", ".jsx"],
  },
  plugins: [
    new webpack.optimize.UglifyJsPlugin({
      compress: {
          warnings: false
      }
    }),
    new webpack.optimize.OccurenceOrderPlugin(),
    new webpack.DefinePlugin({
      "process.env.NODE_ENV": ""production""
    }),
    new webpack.NoErrorsPlugin()
  ]
};

但有一个很重要很重要的问题需要说明一下:

编译过React组件的人都应该知道,React打包进代码里是比较大的(即便在Production+UglifyJsPlugin的情况下),更何况,我们这个组件作为独立的node_module包,不应该把React打包进去,因为:

打包React之后会让组件文件体积增大数倍

打包React之后,安装这个组件的用户会出现“重复安装React”的严重bug

因此,我们在打包的时候应该将第三方依赖独立出去,这就需要配置webpackexternals

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

什么意思呢?你可以看webpack externals官方文档。鉴于webpack文档一般都很烂,我来大致解释一下:

在配置externals的时候,可以把它作为一个要复写的function:

官方栗子

// request是webpack在打包过程中要处理了某一个依赖,无论是自己写的文件之间的相互引用,还是对第三方包的引用,都会将这次引用作为request参数,走这个方法
// callback接收两个参数,error和result
// 当result返回true或者一个String的时候,webpack就不会把这个request依赖编译到文件里去。而返回false则会正常编译
// 因此,我们在每次依赖调用的时候,通过这个方法来判断,某些依赖是否应该编译进文件里
function(context, request, callback) {
  // Every module prefixed with "global-" becomes external
  // "global-abc" -> abc
  if(/^global-/.test(request))
      return callback(null, "var " + request.substr(7));
  callback();
}

所以,就可以解释一下我们自己在webpack配置中的externals

externals(context, request, callback) {
  // 如果这个依赖存在于files中,也就是在./src/components/文件夹下,说明这是我们自己编写的文件,妥妥的要打包
  if (files.indexOf(request) > -1) {
    return callback(null, false);
  }
  // 否则他就是第三方依赖,独立出去不打包,而是期待使用了该组件的用户自己去打包React
  return callback(null, true);
},

至此,这个组件的编写可以告一段落了。之后要做的就是NPM包发布的事情。本来想一次性把这个也说了的,但是鉴于有更详细的文章在,大家可以参考前端扫盲-之打造一个Node命令行工具来学习Node包创建和发布的过程。

本文的源码全部位于github项目仓库react-times,如果有差异请以github为准。最终线上DEMO可见react-times github page

转载请注明来源:

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

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/90966.html

相关文章

  • 手摸手,带你封装一个vue component

    摘要:灵活性和针对性。所以我觉得大部分组件还是自己封装来的更为方便和灵活一些。动手开干接下来我们一起手摸手教改造包装一个插件,只要几分钟就可以封装一个专属于你的。 项目地址:vue-countTo配套完整后台demo地址:vue-element-admin系类文章一:手摸手,带你用vue撸后台 系列一(基础篇)系类文章二:手摸手,带你用vue撸后台 系列二(登录权限篇)系类文章三:手摸手,带...

    pkhope 评论0 收藏0
  • 徒手UI之DatePicker

    摘要:是一个组件库目前拥有的组件语法编写,无依赖原生模块化,以上支持,请开启静态服务器预览效果,静态服务器传送门采用变量配置样式辛苦造轮子,欢迎来仓库四月份找工作,求内推,坐标深圳写在前面去年年底项目中尝试着写过一个分页的组件,然后就有了写的想法 QingUI是一个UI组件库目前拥有的组件:DatePicker, TimePicker, Paginator, Tree, Cascader, ...

    zilu 评论0 收藏0
  • 徒手UI之Paginator

    摘要:是一个组件库目前拥有的组件语法编写,无依赖原生模块化,以上支持,请开启静态服务器预览效果,静态服务器传送门采用变量配置样式辛苦造轮子,欢迎来仓库四月份找工作,求内推,坐标深圳写在前面去年年底项目中尝试着写过一个分页的组件,然后就有了写的想法 QingUI是一个UI组件库目前拥有的组件:DatePicker, TimePicker, Paginator, Tree, Cascader, ...

    liuhh 评论0 收藏0
  • 用typescript个前端框架InDiv

    摘要:暂时没有指令和。当前模块内的组件可以使用来自根模块和当前模块的任何服务及组件,也可以使用被导入模块中导出的组件。作为一个前端菜鸡,还是在深知自己众多不足以及明白好记性不如烂笔头的道理下,多造轮子总归不会错的。 有个同事跟我说:需求还是不够多,都有时间造轮子了。。。 前言 这个轮子从18年4月22造到18年10月12日,本来就是看了一个文章讲前端框架的路由实现原理之后,想试着撸一个路由试...

    liangzai_cool 评论0 收藏0

发表评论

0条评论

lifesimple

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<