watermelon-clock:基于TypeScript+React的西瓜闹钟项目的实践总结

注意,本项目是基于 create-react-app 搭建,但是使用了 --typescript 选项,所以是基于 TypeScript 写的。另外,项目地址在这里,求各位大佬看到了如果喜欢还请点个小星星(拜托啦这真的对我很重要 QAQ)

项目实现 Feature

  • 小巧的用户验证

    这一部分主要是用来注册/登陆的验证用的,注册/登陆后会自动跳转至首页,所以这边也做了一个简单的路由鉴权功能

  • 西瓜闹钟

    点击开始闹钟时,会显示 25 分钟的倒计时,计时结束后若没有打断则会生成一个西瓜

  • 待办 todo 任务

    新建待办任务和完成待办任务后都会出现任务的列表,而且是不同的列表展示

  • 数据统计

    主要有 3 个

    • 月度统计:统计每个月任务总量的累计/增长率/平均值
    • 西瓜历史统计:主要是一个长达 25 分钟的西瓜量,表示自己长时间的执行完某个任务的量化显示,可编辑/删除/恢复,同时也可以手动增加西瓜记录
    • 待办 todo 历史统计:表示自己短时间内执行完的小任务的量化表示,可编辑/删除/恢复,同时可以查看已经删除的任务

项目主要技术栈

  • React 16.12
  • Antd 4.0.1
  • Axios 库
  • React-Router-DOM 5.1.2
  • React-Redux 7.1.3
  • Redux 4.0.5
  • TypeScript 3.8.3
  • History 4.10.1
  • Lodash 4.14
  • Stylus

这里需要说明的是目前的 antd 用的是比较新的版本,但是由于组件目前并不涉及到非常复杂的结构,所以可以使用其升级工具进行无痛升级

在配置 stylus 时应该在项目对应的 config-overrides.js 目录做如下的配置

...
const stylus = () => config => {
const stylusLoader = {
test: /\.styl$/,
use: [
{
loader: 'style-loader'
}, {
loader: 'css-loader',
}, {
loader: 'stylus-loader'
}
]
}
const oneOf = config.module.rules.find(rule => rule.oneOf).oneOf
oneOf.unshift(stylusLoader)
return config
}

module.exports = override(
...
stylus()
);

这里的作用相当于找出原有配置中的 oneOf 然后将 stylus 的配置加入其中,很好理解(因为我目前使用的时 cra 所以在某些配置方面能省则省了,目的是体验并上手 React 结合 TypeScript 的开发)

项目实现技术细节

部分组件使用 hooks

主要是倒计时组件这里使用了 useEffect 以及 useState 这两个 hooks

原来的倒计时组件用类组件来写的话就比较繁琐

// src/components/waterMelon/countDown.tsx
export class countDown extends Component<ICountDownProps, ICountDownState> {

constructor(props: ICountDownProps) {
super(props)
this.state = {
countDown: this.props.time,
}
}

get countDown() {
//...
}

get progressWidth() {
//...
}

componentDidMount() {
//...
}

componentWillUnmount() {
//...
}

render() {
return (...)
}
}

本质上就是使用了一个生命周期的形式来实现效果,代码分散在多处不好做统一管理,但是使用了 hooks 之后代码量减少很多,减小了很多心智负担

const CountDown:FunctionComponent<ICountDownProps> = (props: ICountDownProps) => {

const [countDown, setCountDown] = useState(props.time)

// 时间换算显示
const min = Math.floor(countDown/1000/60)
const sec = Math.floor(countDown/1000%60)
const time = `${min < 10 ? `0${min}` : min}:${sec < 10 ? `0${sec}` : sec}`

// progress bar 进度条长度计算显示
const { duration } = props
const progressWidth = (100 - (countDown - 1000) * 100 / duration).toFixed(3)

useEffect(()=> {
document.title = `${time} - 西瓜闹钟 App`
timerId = setInterval(()=> {
setCountDown(countDown - 1000)
if (countDown < 1000) {
console.log(countDown)
props.onEnd()
window.clearInterval(timerId)
document.title = '西瓜闹钟 App'
}
}, 1000)

return () => {
// 组件销毁时
window.clearInterval(timerId)
document.title = '西瓜闹钟 App'
}
})

return (...)
}

使用 useEffect 可以统一处理副作用以及组件销毁时的逻辑,而使用 useState 就可以统一状态的管理,这样就可以最大程度的减小代码量,可以将一些比较重要的逻辑统一起来管理,使得组件代码更加明了

axios 配置

这里的配置比较简单,主要是通过使用 axios 的拦截器去拦截验证从后端发过来的 token 有没有,配置主要有以下两点

  • 发送请求前需要在请求头中加 token 方便后端验证

    // src/config/http.ts
    instance.interceptors.request.use(
    config => {
    const xToken = localStorage.getItem('x-token')
    if (xToken) config.headers['Authorization'] = `Bearer ${xToken}`
    return config
    },
    e => {
    console.log('request error: ', e)
    return Promise.reject(e)
    }
    )
  • 接受响应之前设置下 token

    instance.interceptors.response.use(
    res => {
    if (res.headers['x-token']) localStorage.setItem('x-token', res.headers['x-token'])
    return res
    },
    e => {
    // eslint-disable-next-line
    if (e.response && e.response.status === 401 || e.response.status >= 500) {
    // 一般是如果鉴权失败,需要做重定向跳转到登录页
    console.log(history.location.pathname)
    if (history.location.pathname === '/') history.push('/login')
    }
    return Promise.reject(e)
    }
    )

这里做了一个简单的路由鉴权,不过更加详细的鉴权我写在了 redux 的 userActions 中,不过留着下面来说

redux 以及 redux-thunk 这个中间件

  • 模块划分
    说到模块划分,基本是如下的结构

    redux/
    ├── actionTypes.ts
    ├── actions
    │ ├── todosActions.ts
    │ ├── userActions.ts
    │ └── waterMelonActions.ts
    ├── reducers
    │ ├── indexReducer.ts
    │ ├── todosReducer.ts
    │ ├── userReducer.ts
    │ └── waterMelonReducer.ts
    └── store.ts

    因为这里的各种 type 都比较简单,所以这里就没有分各个模块文件了,只是就大概的 actions 以及 reducers 分了一下,store.ts 模块主要就是 creatStore 以及 applyMiddleware 中间件。这里还用了一个 redux-devtools-extension redux 的调试工具,在 debug 环境调试用的。

  • 用户登陆/注册的鉴权

    其实在用户登陆和注册之前可以先调用下 getUserInfo 获取下用户的信息,先判断是否会得到错误,如果没错那么本来没有在 / 路由的就跳转到 /,如果出错了那么在判断其是否在 / 路由,在的话就强制跳转至 /login 路由页面

    登陆和注册的逻辑基本就是如下

    try {
    await http.post(url, params)
    const response = await http.get('/me')
    const userInfo = response.data
    dispatch({
    type: VERIFY_USER_SUCCESS,
    payload: userInfo
    })
    history.push('/')
    } catch(e) {
    console.log(e.response)
    let errorInfo
    if(!e.response){
    errorInfo = '请检查网络是否正常'
    } else {
    const { errors } = e.response.data
    errorInfo = errors.account
    ? errors.account
    : errors
    }
    dispatch({
    type: VERIFY_USER_FAILURE,
    error: errorInfo
    })
    }
  • 为什么使用 redux-thunk 来 dispatch 数据

    因为用到了异步,而我们这里的异步比较简单,实际上用 redux-thunk 这个中间件就能解决问题了。那么这里的 actions 的写法也比较简单,只是需要返回一个 async 的带有 dispatch 函数参数的一个函数即可,redux-thunk 会自己帮你 enhance 这个 dispatch

    const xxx = async (dispatch: any) => {
    //...
    dispatch({
    type: 'YYY',
    payload: {...}
    })
    }
  • react-redux 中的 connect 组件

    首先我不认同只有父级组件用 connect 而其他组件就被动传 props 的这种用法。因为尽管是比较简单的一个项目,但是在某个功能模块上依然存在层级嵌套比较深的情况。有一般而言传个三级就觉得很麻烦了,这个时候子组件使用 connect 是很有必要的(虽然可能写起来依然麻烦了点,但是的确能省点心思思考我的父级组件到底给我传了个啥)

封装的 history 配置

这部分的代码也并不是特别复杂,如下所示

import { createBrowserHistory } from 'history'

const ENV = process.env.NODE_ENV
let publicUrl: string = ''

if (ENV === 'development') {
publicUrl = '/'
} else if (ENV === 'production') {
publicUrl = '/watermelon-clock'
}

const history = createBrowserHistory({
basename: publicUrl
})

export default history

虽然在 App.tsx 中使用过 BrowserRouter,但是不如自己封装来的快,因为这块很多地方都要用到,比如如上所述的鉴权的位置,用于登陆验证过后的跳转

数据统计图

这次的数据统计图没有使用诸如 echart.js 和 highchart.js 的原因部分是因为觉得没有多大必要,觉得自己使用 svg 来实现或许会更加好一点,有点造轮子的意思,不过还是挺有趣的。当然这次也没有选中 canvas 的原因是 svg 有个好处就是不依赖 分辨率,因为它生成的图像是基于矢量位图 的,你放大缩小都不会失真。

折线图

折线图就是下图这玩意儿

我这边是先实现的折线图,所以先说一下大概是怎么实现的,代码如下所示

// src/components/statistics/polyline.tsx
<svg className="polyline peity" width="100%" height="60">
<polygon
fill="rgba(215,78,78,0.1)"
points={this.points()}
/>
<polygon
fill="none"
stroke="rgba(215,78,78,0.5)"
strokeWidth="1"
strokeLinecap="square"
points={this.points()}
/>
</svg>

这里有几个属性,说明一下

  • fill: 表示填充进 svg 框住的那部分,取值是颜色
  • points: 就是描线,线的坐标
  • stroke: 表示 fill 外层的那条线,取值也是颜色
  • strokeWidth: 表示线宽
  • strokeLinecap: 表示从起点到终点的线它两边的形状,可以是 butt | round | square | inherit

这里的逻辑很好理解,就是 svg 包 polygon 画线并填充颜色,然后是坐标点的计算

points = () => {
let { data, finishedCount, width } = this.props
// 首先传入的 data 是一个类似 {'时间1': {...}, '时间2': {...}} 这样的对象
// 这里首先要做的就是根据时间大小排序
// 得到一个 ['时间1', '时间2', ...] 的数组
const datesKeys = Object.keys(data).sort(
(a, b) => {
return Date.parse(a) - Date.parse(b)
}
)

// 计算起始点和终止点的坐标
const firstDay = datesKeys[0]
const startPoints = '0,60'
const lastPoints = `${width},60`
if (!firstDay) return [`${startPoints}`, `${lastPoints}`].join(' ')

const lastDay = datesKeys[datesKeys.length - 1]
// 若是同一天有很多任务,并且只有这一天的情况下,dayRange 有可能是 0
const dayRange = Date.parse(lastDay) - Date.parse(firstDay) || 1
let count = 0
let lastY
// 画折线图需要 x y 坐标
// 有断点的折线图每个 x y 都是有范围的
// 所以这里需要除一个 range
const pointsArray = datesKeys.map((datesKey: any) => {
const x = (Date.parse(datesKey) - Date.parse(firstDay)) / dayRange * (width || 320)
count += this.props.data[datesKey].length
const y = (1 - (count / finishedCount)) * 60
lastY = y
return `${x},${y}`
})
return [`${startPoints}`, ...pointsArray, `${width},${lastY}`, `${lastPoints}`].join(' ')
}

这里本质上就是求出一个多边形每个点 x 和 y 的坐标,然后求出 range 范围有个对应的关系,基本上就能搞定了

条形柱状图

条形柱状图就是下面这玩意儿

这个逻辑就是在 svg 中渲染 rect

<svg className="bar-chart" width='100%' height={height}>
{
this.points().map((point, index) => (
<rect key={index} fill="rgba(215,78,78,0.5)"
x={point[0]} y={point[1]}
width={16} height={height - point[1] || 0}
/>
))
}
</svg>
  • width: 柱子的宽度
  • height: 柱子的高度
  • fill: 颜色填充,之前说过
  • x: x 坐标值
  • y: y 坐标值
  • key: 就是 key,一个 key 值,因为要渲染多个,所以需要这个指明每个 rect 元素的 id

坐标点计算逻辑如下

// src/components/statistics/barChart.tsx
points = () => {
const { data, finishedCount } = this.props
const xRange = 10
// 利用 reduce 找出数据中7天中完成西瓜数最多的数
// data 传进来就是周一到周日的数据
// {0: [...], 1: [...], ...}
const yRange = data.reduce(
(acc, cur) => {
return acc > cur.length ? acc : cur.length
},
0
)
// 这里用的就是计数法,哪个上面 length 大说明这个柱子 y 坐标越大
return data.map((item, index) => {
const x = (index + gap) / xRange * finishedCount - 8
let y = (1 - item.length / (yRange || 1)) * height
y >= height && (y = height - 1)
return [x, y]
})
}

点线图

点线图就是下面这玩意儿

分析下,首先是背景的那个灰色的矩形框,就用 rect 绘制,然后使用 path 画线,用 circle 画圆圈,用 text 表示下面的点

<svg width='100%' height='200'>
// 灰色背景图
<rect x={0} y={0} width='100%' height={170}/>
// 描线
<path
d={
this.points().reduce(
// M,x,y,x1,y1 的形式画直线
(acc, cur) => acc.concat(`${cur.slice(0,2).join(',')},`),
'M'
)
}
/>
{
// x 坐标轴上的值显示 1 2 3 4...
this.points().map(
// 隔一个显示一个
(point, index) => (
index % 2 === 0
? <text key={index} x={point[0] - 5} y="200">
{index + 1}
</text>
: null
))
}
{
this.points().map((point, index) => (
<Tooltip
key={index} placement="top"
title={`${point[2]}`} overlayClassName='daily_tips'>
{/* 坐标点对应的⚪ */}
<circle r={this.circleRadius} cx={point[0]} cy={point[1]} />
</Tooltip>
))
}
</svg>

注意这里使用了 Tooltip 组件, 表示鼠标悬浮上去之后会有一个提示冒出来

点坐标的计算就是如下

points = () => {
const { data, xRange, width } = this.props
// data 同样是一个类似 {0: [...], 1: [...]} 的对象
// 这里用 reduce 找出 y 的最大范围值,好用来定高度
let yRange = Object.keys(data).reduce(
(acc, cur) => {
return acc > data[cur].length ? acc : data[cur].length
}
, 0)
yRange === 0 && (yRange = 5)
return Object.keys(data).map(
date => {
const x = (new Date(date).getDate() - 0.5) / xRange * (width - xBias)
const y = (1 - data[date].length / yRange) * 160 + yBias
// 这里需要返回一个 [x, y, 一个数] 是为了 Tooltip 做兼容用的
return [x, y, data[date].length]
}
)
}

项目难点

项目难点有 2

使用 svg 矢量图的大小缩放的问题

因为在项目中还稍微用媒体查询做了下小屏幕的适配,但是在小屏幕适配下图形就显得不是很好看了,为了兼容这个小屏幕,花了点心思,首先使用 css 这种自适应适配的方式是不行的,不管是 flex 还是 100% 这种方式都不能使其适应小屏幕的宽度,所以最后采用 js 来解决,怎么解决的呢,一般来说分为以下几步

  • 获取到其中一个 li 的宽度
    因为其实三个宽度都一样,只要获取到其中一个就可以了
  • 将其加入点坐标的计算
  • 完

加入点坐标计算上面已经说过了,现在说下宽度是怎么搞的

首先在 statistics 组件中引入两个变量

// src/components/statistics/statistics.tsx
this.state = {
liWidth: this.liRef.current?.offsetWidth || 0,
ulWidth: this.ulRef.current?.offsetWidth || 0
}

对应的 liRef 和 ulRef 为

ulRef = React.createRef<HTMLUListElement>()
liRef = React.createRef<HTMLLIElement>()

然后需要设定一个 updateSize 函数,就是当其宽度变时就要调用的

updateSize = () => {
const liWidth = this.liRef.current?.offsetWidth || 0
let ulWidth = this.ulRef.current?.offsetWidth || 0
ulWidth < 0 && (ulWidth = 0)
this.state.liWidth !== liWidth && (
this.setState({ liWidth })
)
this.state.ulWidth !== ulWidth && (
this.setState({ ulWidth })
)
}

可以看到其实这里就是初始化的操作,利用 dom 的 current.offsetWidth 的属性确定宽度

然后在组件挂载时需要监听 resize 事件

componentDidMount() {
this.updateSize()
window.addEventListener('resize', this.updateSize)
}

在组件销毁时需要取消监听 resize 事件避免内存泄露

componentWillUnmount() {
window.removeEventListener('resize', this.updateSize)
}

然后在把这两个 ref 定位到对应的 dom 上即可

<ul className="statistics" ref={this.ulRef}>
<li className={monthlyTitleClasses} ref={this.liRef} />
...
/>

这样通过 resize 就拿到了该组件的 width,在下面计算图形的宽度时就可以代入加参数计算了,一般来说这里就是计算一个 range 即可。

有关一个组件通信的功能

就是西瓜闹钟在运行时,本来是想让 waterMelon 组件和 todos 组件有个通信的过程的,比如将正在计时中的 waterMelon 和 todos 关联起来,如果此时有完成的 todos 任务,那么在计时后,完成的 todos 几个任务的 “合体” description 就是西瓜闹钟的 description,比如我完成了 todo1 和 todo2 和 todo3,那么在 watermelon 结束计时后其 description 就是 todo1 + todo2 + todo3 这种形式做自动填充。不过这个地方有些难度,主要在于代码实现比较难看所以我没继续添加了。我想了下有几种方案

  1. 每次完成一个 todos,都向后端发送一个请求记录当前的 todos 到 waterMelon 中
  2. 将 todos 的数据用 props 传给 waterMelon 组件,只要 todos 有变化就改变 waterMelon 中的 state.description
  3. 将 description 提升到 redux 中,每次完成一个 todos,发送一个 actionType,waterMelon 接受这个并不属于它的 actionType,将 todo 的 description 合并到 waterMelon 中
  4. 搞一个 window 全局变量存 description…

首先我想要说明的是,这里我尽量想做到 高内聚低耦合,然而这里的尴尬之处在于要实现这样的功能,这两个组件在这点上居然是强耦合的,以致于说以上的方案虽然可行一点点,但是在代码上就非常的不整洁,因为

  1. 每次发请求处理相当的麻烦,而且也不是一种优化,要知道请求一多也容易降低 app 的性能
  2. 这样做只能是在 componentDidUpdate 这个生命周期里面来 setState,处理一不小心就无限循环了,这还能行???
  3. 这样也不好,耦合了,而且这个功能并不是一开始就想出来的,而是后面加的,如果要该则需要改动代码的范围大,只能是重构有时间的情况下弄弄。代码一旦庞大就不好出手了,这也是很重要的一点。
  4. 这个想都不要想,全局变量污染的问题不应该再犯了…

综上,由于这个功能十分的的蛋疼(因为点击完成一个 todo 既要更新 todos 的数据又要更新 waterMelon 的数据简单使用 actionType 来做区分是不现实的),而且目前也没有找到一个更加优雅的解决方案,故先搁置(或许如果各位感兴趣了我可以重构的((^_^)), 先别慌老铁)

总结

总之 TypeScript 是真香,少了自由度但是多了类型检查,能够不太费力的解决编程中因为静态视力导致的一些超级低级的 bug,以及 react 也真香,函数即UI这种思想很棒,而且天然支持 TypeScript,社区活跃并且轮子很多,也不乏优秀的插件,也许这就是是我转向 react 的原因之一吧哈哈。

文档参考

在 TypeScript 中使用
从 V3 到 V4