React.js 初实践:一个日历选择器组件

2016.08.24

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

写下这篇文章,记录一下自己学习 react.js 的想法,也分享给想学 React 的朋友看看。

先上个效果图

Demo 在这里

开始之前

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

我想完成的功能:

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

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

Webpack 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
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 组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
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 操作符,如果我们没有用这个操作符,就要这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<CalendarMain {...props} />
// 等同于
<CalendarMain {...props}
viewData={props.viewData}
datePicked={props.datePicked} />
{% endcodeblock %}
是不是优雅多了呢
:: 是 ES7 中的语法,用来绑定this,方法需要 bind(this),不然方法内部的this 指向会不正确。
{% codeblock lang:javascript %}
prevMonth={::this.prevMonth}
// 等同于
prevMonth={this.prevMonth.bind(this)}
{% endcodeblock %}
#### CalendarHeader 组件
{% codeblock lang:javascript %}
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 组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
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 组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 个值,分别时水平偏移,竖直偏移,模糊半径,阴影厚度,颜色和内外阴影选择。

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

而且,很关键一点,一个元素可以设置多个阴影,每一个阴影用逗号隔开。所以可以这样:
1
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,用这些框架写起来真的舒服多了。相信未来前端开发应越来越愉快~