React.js入门实践:一个酷酷的日历选择器组件

之前有过一些vue.js的经验,打算学习以下React感受一下差异。看完React的基本概念,觉得react.js的官方文档还是蛮凌乱的。官方的中文文档已经有点过期了,网上的一些其他教程大多不是新的。大概看了一些英文教程后,打算用react.js写了一个万年历的小应用作为实践。
写下这篇文章,记录一下自己学习react.js的想法,也分享给想学React的朋友看看。

先上个效果图

React-Calendar

Demo 需启用Javascript

开始之前

这里我用了webpackb引入了babel,为了将ES2015(ES6)的语法转成ES5语法。如果对ES2015语法还不太熟悉,可以抽点时间看看,毕竟这是Js的规范,代表着未来,很值得学习。
如果对webpack不是很熟悉,可以先快速浏览一下webpack的概念。这一篇内容关于webpack的配置可以参考。

我想完成的功能:

  • 点击最上方的日期控件,日历选择器下拉出来
  • 可以通过左右按键无限的检索日期
  • 选中日期后,按确定折叠日历选择器
  • 提供一个简易的接口,返回所选的日期
  • 日历要足够酷炫嘿

以上功能用原生js也可以从容实现,但用react分割组件会使代码更清晰。
例子在我的github上可以download下来,可以用作参考:react-calendar

Webpack配置

var path = require('path')
var webpack = require('webpack')

module.exports = {
    entry: './src/main.js', 
    output: { 
        path: path.resolve(__dirname, './public'),
        publicPath: '/public/',
        filename: 'build.js',
    },
    resolveLoader: {
        root: path.join(__dirname, 'node_modules'),
    },
    module: {
        loaders: [
            {
                test: /\.js[x]?$/,
                exclude: /node_modules/,
                loader: 'babel-loader',
                query: {
                    presets: [
                        'es2015',
                        'react',
                        'stage-0'
                    ]
                }
            },
            {
                test: /\.(woff|svg|eot|ttf)\??.*$/,
                loader: 'url-loader?limit=50000&name=[path][name].[ext]'
            },
            {
                test: /\.scss$/
                , loader: "style!css!sass"
            },
        ]
    },
    devServer: {
        historyApiFallback: true,
        noInfo: true
    },
    devtool: '#eval-source-map'
}

if (process.env.NODE_ENV === 'production') {
    module.exports.devtool = '#source-map'
    module.exports.plugins = (module.exports.plugins || []).concat([
        new webpack.DefinePlugin({
            'process.env': {
                NODE_ENV: '"production"'
            }
        }),
        new webpack.optimize.UglifyJsPlugin({
            output: {
                comments: false,
            },
            compress: {
                warnings: false
            }
        }),
        new webpack.optimize.OccurenceOrderPlugin()
    ])
}

可以看到入口文件是在src文件夹里的main.js,然后输出文件放在public文件夹的build.js里。

主要说一下babel-loader的配置,其中presets中react使babel支持jsx语法,es2015使babel支持ES6语法,stage-0使babel支持ES7语法。

这里还使用了SASS,demo里炫酷的星空背景就是依赖SASS里的函数写出来的,是纯的css实现。在本文的末尾有实现的原理:)

分割组件

React.js很重要的一点就是组件。每一个应用可以分割成一个个独立的组件。我将这个日历分割成四个组件:

  • Calendar
  • CalendarHeader
  • CalendarMain
  • CalendarFooter

React的主流思想就是,所有的state状态和方法都是由父组件控制,然后通过props传递给子组件,形成一个单方向的数据链路,保持各组件的状态一致。于是,这其中Calendar将负责存储state和定义方法。

Calendar组件

import React from 'react'
import {render} from 'react-dom'

import CalendarHeader from './CalendarHeader'
import CalendarMain from './CalendarMain'
import CalendarFooter from './CalendarFooter'

const displayDaysPerMonth = (year)=> {

  //定义每个月的天数,如果是闰年第二月改为29天
  let daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
  if ((year % 4 === 0 && year % 100 !== 0) || year % 400 === 0) {
    daysInMonth[1] = 29
  }

  //以下为了获取一年中每一个月在日历选择器上显示的数据,
  //从上个月开始,接着是当月,最后是下个月开头的几天

  //定义一个数组,保存上一个月的天数
  let daysInPreviousMonth = [].concat(daysInMonth)
  daysInPreviousMonth.unshift(daysInPreviousMonth.pop())

  //获取每一个月显示数据中需要补足上个月的天数
  let addDaysFromPreMonth = new Array(12)
    .fill(null)
    .map((item, index)=> {
      let day = new Date(year, index, 1).getDay()
      if (day === 0) {
        return 6
      } else {
        return day - 1
      }
    })

  //已数组形式返回一年中每个月的显示数据,每个数据为6行*7天
  return new Array(12)
    .fill([])
    .map((month, monthIndex)=> {
      let addDays = addDaysFromPreMonth[monthIndex],
        daysCount = daysInMonth[monthIndex],
        daysCountPrevious = daysInPreviousMonth[monthIndex],
        monthData = []
      //补足上一个月
      for (; addDays > 0; addDays--) {
        monthData.unshift(daysCountPrevious--)
      }
      //添入当前月
      for (let i = 0; i < daysCount;) {
        monthData.push(++i)
      }
      //补足下一个月
      for (let i = 42 - monthData.length, j = 0; j < i;) {
        monthData.push(++j)
      }
      return monthData
    })
}

class Calendar extends React.Component {
  constructor() {
    //继承React.Component
    super()
    let now = new Date()
    this.state = {
      year: now.getFullYear(),
      month: now.getMonth(),
      day: now.getDate(),
      picked: false
    }
  }

  //切换到下一个月
  nextMonth() {
    if (this.state.month === 11) {
      this.setState({
        year: ++this.state.year,
        month: 0
      })
    } else {
      this.setState({
        month: ++this.state.month
      })
    }
  }
  //切换到上一个月
  prevMonth() {
    if (this.state.month === 0) {
      this.setState({
        year: --this.state.year,
        month: 11
      })
    } else {
      this.setState({
        month: --this.state.month
      })
    }
  }
  //选择日期
  datePick(day) {
    this.setState({day})
  }
  //切换日期选择器是否显示
  datePickerToggle() {
    this.refs.main.style.height =
      this.refs.main.style.height === '460px' ?
        '0px' : '460px'
  }
  //标记日期已经选择
  picked() {
    this.state.picked = true
  }

  render() {
    let props = {
      viewData: displayDaysPerMonth(this.state.year),
      datePicked: `${this.state.year} 年
                   ${this.state.month + 1} 月
                   ${this.state.day} 日`
    }
    return (
      <div className="output">
        <div className="star1"></div>
        <div className="star2"></div>
        <div className="star3"></div>
        <p className="datePicked"
           onClick={::this.datePickerToggle}>
          {props.datePicked}
        </p>
        <div className="main" ref="main">
          <CalendarHeader prevMonth={::this.prevMonth}
                          nextMonth={::this.nextMonth}
                          year={this.state.year}
                          month={this.state.month}
                          day={this.state.day}/>
          <CalendarMain {...props}
                        prevMonth={::this.prevMonth}
                        nextMonth={::this.nextMonth}
                        datePick={::this.datePick}
                        year={this.state.year}
                        month={this.state.month}
                        day={this.state.day}/>
          <CalendarFooter
            picked={::this.picked}
            datePickerToggle={::this.datePickerToggle}/>
        </div>
      </div>
    )
  }
}

//将calender实例添加到window上以便获取日期选择数据
window.calendar = render(
  <Calendar/>,
  document.getElementById('calendarContainer')
)

我们可以从render函数看到整个组件的结构,可以看到其实结构相当简单。className为datePicked的元素用来显示选择的日期,点击它便可下拉日期选择器。
日期选择器由CalendarHeader,CalendarMain和CalendarFooter三个组件组成,CalendarHeader用来控制月份的切换,CalendarMain用来展示日历,CalendarFooter用来提供控制台。

这其中主要的思想就是,方法在父组件定义,通过props传给需要的子组件进行调用传参,最后返回到父组件上执行函数,存储数据、改变state和重新render。

{...props}是ES6中的spread操作符,如果我们没有用这个操作符,就要这样写:

<CalendarMain {...props} />
//等同于
<CalendarMain {...props} 
    viewData={props.viewData} 
    datePicked={props.datePicked} />

是不是优雅多了呢

::是ES7中的语法,用来绑定this,方法需要bind(this),不然方法内部的this指向会不正确。

prevMonth={::this.prevMonth}
//等同于
prevMonth={this.prevMonth.bind(this)}

CalendarHeader组件

import React from 'react'

export default class CalendarHeader extends React.Component {
  render() {
    return (
      <div className="calendarHeader">
        <span className="prev"
              onClick={this.props.prevMonth}>
          《
        </span>
        <span className="next"
              onClick={this.props.nextMonth}>
          》
        </span>
        <span className="dateInfo">
          {this.props.year}年{this.props.month + 1}月
        </span>
      </div>
    )
  }
}

CalendarHeader组件接收父组件传来的日期,可以调用父组件的方法以前进到下一月和退回上一个月。

CalendarMain组件

import React from 'react'

export default class CalendarMain extends React.Component {

  //处理日期选择事件,如果是当月,触发日期选择;如果不是当月,切换月份
  handleDatePick(index, styleName) {
    switch (styleName) {
      case 'thisMonth':
        let month = this.props.viewData[this.props.month]
        this.props.datePick(month[index])
        break
      case 'prevMonth':
        this.props.prevMonth()
        break
      case 'nextMonth':
        this.props.nextMonth()
        break
    }
  }

  //处理选择时选中的样式效果
  //利用闭包保存上一次选择的元素,
  //在月份切换和重新选择日期时重置上一次选择的元素的样式
  changeColor() {
    let previousEl = null
    return function (event) {
      let name = event.target.nodeName.toLocaleLowerCase()
      if (previousEl && (name === 'i' || name === 'td')) {
        previousEl.style = ''
      }
      if (event.target.className === 'thisMonth') {
        event.target.style = 'background:#F8F8F8;color:#000'
        previousEl = event.target
      }
    }
  }

  //绑定颜色改变事件
  componentDidMount() {
    let changeColor = this.changeColor()
    document.getElementById('calendarContainer')
      .addEventListener('click', changeColor, false);

  }

  render() {
    //确定当前月数据中每一天所属的月份,以此赋予不同className
    let month = this.props.viewData[this.props.month],
      rowsInMonth = [],
      i = 0,
      styleOfDays = (()=> {
        let i = month.indexOf(1),
          j = month.indexOf(1, i + 1),
          arr = new Array(42)
        arr.fill('prevMonth', 0, i)
        arr.fill('thisMonth', i, j)
        arr.fill('nextMonth', j)
        return arr
      })()

    //把每一个月的显示数据以7天为一组等分
    month.forEach((day, index)=> {
      if (index % 7 === 0) {
        rowsInMonth.push(month.slice(index, index + 7))
      }
    })

    return (
      <table className="calendarMain">
        <thead>
        <tr>
          <th>日</th>
          <th>一</th>
          <th>二</th>
          <th>三</th>
          <th>四</th>
          <th>五</th>
          <th>六</th>
        </tr>
        </thead>
        <tbody>
        {
          rowsInMonth.map((row, rowIndex)=> {
            return (
              <tr key={rowIndex}>
                {
                  row.map((day)=> {
                    return (
                      <td className={styleOfDays[i]}
                          onClick={
                            this.handleDatePick.bind
                            (this, i, styleOfDays[i])}
                          key={i++}>
                        {day}
                      </td>
                    )
                  })
                }
              </tr>
            )
          })
        }
        </tbody>
      </table>
    )
  }
}

CalendarMain组件用来展示日历,是最复杂的一个组件。主体思路是通过父组件传来的长度为12的viewData数组,将数组中每一项长度为42的数组以7天为一组等分,以此来渲染表格。
由于在切换颜色时逻辑比较复杂,通过react处理事件会很麻烦,因此自己写了一个代理,通过闭包函数来控制选择日期时的样式切换。

CalendarFooter组件

import React from 'react'

export default class CalendarFooter extends React.Component {

  handlePick() {
    this.props.datePickerToggle()
    this.props.picked()
  }

  render() {
    return (
      <div className="calendarFooter">
        <button onClick={::this.handlePick}>
          确定
        </button>
      </div>
    )
  }
}

很简单的组件,在点击确定时调用日期选择器折叠和改变日期已选择属性的布尔值

关于背景的样式实现

最后,解释一下用纯css实现的的炫酷背景。
我们知道box-shadow属性接收6个值,分别时水平偏移,竖直偏移,模糊半径,阴影厚度,颜色和内外阴影选择。

box-shadow: h-shadow v-shadow blur spread color inset;

而且,很关键一点,一个元素可以设置多个阴影,每一个阴影用逗号隔开。所以可以这样:

box-shadow: h-shadow v-shadow blur spread color inset, h-shadow v-shadow blur spread color inset;

于是,你看到的每一个星星都是一个阴影,阴影形状大小与产生他的元素形状大小一致,每一个阴影有着随机的位置偏移量。所以背景里有三个div,分别是1px,2px,3px,所有的星星都是他们的投影。
另外,三个div被添加了infinite的动画,以线性速度上移,因此所有星星也随着他们上移。
还有一点很关键,在div后添加了一个after,其中也添加了同样的星星阴影,这样就能保证星星在上移的过程中下面会有新的星星补上来,造成无穷无尽的错觉。
为了完成大量的阴影,所有借助了SASS提供的函数,用来随机化阴影的位置和数量。感兴趣的同学可以看一下源码。
其实利用这些特性,还可以实现很多酷炫的css样式,留待想象了~

总结

之前有学习过vue.js,就学习难度而言,vue.js更容易一些。主要是官方文档演示的很好,而react.js有一点凌乱的感觉。如果没有接触过Angular和vue,很容易会对一些新的名词和概念产生疑惑。
个人觉得,JSX渲染函数包含逻辑比较复杂,这一点相对于vue.js,可能会使样式对照设计起来不太方便。而且数组在循环里嵌套的时候没有vue.js来的方便直观。
无论如何,相对于原生JS,用这些框架写起来真的舒服多了。相信未来前端开发应越来越愉快~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容