🌞

小项目:番茄闹钟 - Potado

初学 React 和 TypeScript,尝试做了一个简单的番茄闹钟,可以在点这里 在线预览,也可以进 代码仓库 去看看。

项目介绍

本项目是一个简单的番茄你闹钟,分为番茄和任务两部分:在番茄部分可以定义一个番茄闹钟,并查阅番茄历史;在任务部分可以自己新建任务并查看任务历史,此外具有登录和注册功能,可以同步番茄信息,在 PC 端和移动端均具有良好的适配性。

本项目是一个基于 React 的简单单页应用,并且使用 TypeScript 进行书写,使用 axios 发送请求、React-Router 来进行页面跳转、Redux 管理任务信息。

import alias

网上搜了一圈,感觉好像 React Create App 官方并不支持 import alias,首先想到的是直接在 tsconfig.json 里面加上这样的设置:

1
2
3
4
5
6
7
8
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*":["src/*"]
    }
  }
}

但是实际上每次 yarn start 或者 yarn build 的时候都会自动把这几句给删掉了,让你别设置 path

1
2
The following changes are being made to your tsconfig.json file:
  - compilerOptions.paths must not be set (aliased imports are not supported)

有人说可以取巧,新建一个 paths.json,在里面写上刚才那几句,然后在 tsconfig.jsonextends 他:

1
2
3
4
5
6
{
  "extends": "./paths.json",
  "compilerOptions": {
    // ...
  }
}

但是这样做是不够的,你还需要修改 webpack.config.js,因为我是使用的 Ant Design,他教我使用 react-app-rewired 来修改 Ant Design 的主题配色,所以我也正好利用 react-app-rewired 来修改配置,在 config-overrides.js 中加入:

1
2
3
4
5
6
7
8
const {override, addWebpackAlias} = require('customize-cra');
const path = require('path')

module.exports = override(
  addWebpackAlias({
    '@': path.resolve(__dirname, 'src')
  })
);

现在这样就可以,webstorm 也可以正常识别,build 的时候会提示一下,不过问题不大,目前也可以正常在线部署。

登录与注册页面及页面跳转

和 Vue 比较类似,我们要在 App.tsx 中使用 React Router:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import {
  HashRouter,
  Switch,
  Route
} from 'react-router-dom';

export default function App() {
  return (
    <HashRouter>
      <Switch>
        <Route path='/login'>
          <Login/>
        </Route>
        <Route path='/register'>
          <Register/>
        </Route>
        <Route path='/'>
          <Home/>
        </Route>
      </Switch>
    </HashRouter>
  );
}

本来我最开始是按照官方文档写的 Router,折腾了半天搞好了发布到 GH-Pages 之后发现页面并不能像我想象的那样跳转,都跳到 /hais-teatime.com/login/ 去了,跟我预期的 /hais-teatime.com/potado/login/ 不同。 因为我也是刚开始用 Router,并没有对 HashRouterBrowserRouter 理解得很好(之后有空我会专门写一篇文章来看看这个 React Router),然后我突然想起来我以前用 vue-cli 创建项目的时候,选择 Vue-Router 之后他会问我用哪种模式,其中他说有一种模式需要服务器端设置好,我一般当然就选择的是不需要服务器端设置的,后来 Google 发现这种就叫哈希路由。于是我想是不是这个 React 也需要使用哈希路由,于是就在文档中按照对应的设置好。

另外还有几个问题,一是在组件中如何来进行跳转,以达到 Vue 的 Link 或者 push 的效果?这里我采用了 useHistory 这个 Hook:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import {Link, useHistory} from 'react-router-dom';
export default function() {
  let history = useHistory();
  // 登录成功之后跳转到主页
  const onLogin = async () => {
    try {
      // await ... 发送请求
      history.push('/') 
    } catch (e) {
      //...
    }
  };
  return (
    <div>
      <button onClick={onLogin}>登录</button>
      <p>如果还没有账号,请<Link to='/register'>点击这里注册</Link></p>
    </div>
  )
}

二是如何在组件外进行跳转,我发现需要借助 history 这个库,而且还有点麻烦:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// history.ts
import {createHashHistory} from 'history';
const history = createHashHistory();
export default history;

// axios.ts
import history from './history';

// 发现未登录时跳转到登录页
if(...){
  history.push('/login')
}

主页

主页最主要的任务有以下两个:

  1. 从服务器获取并初始化数据
  2. 整体布局

其中需要获取的数据包括用户数据、todos 和 tomatoes 这三部分,todos 和 tomatoes 获取之后需要使用 Redux 存到 Store 中,方便其他组件使用。

Redux

由于有实际上我们有 todos 和 tomatoes 两个模块,所以我们可能需要按照下面的方式来使用 Redux:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
 redux
 ├── actions
 │   ├── index.ts
 │   ├── todos.ts
 │   └── tomatoes.ts
 ├── reducers
 │   ├── index.ts
 │   ├── todos.ts
 │   └── tomatoes.ts
 ├── actionTypes.ts
 ├── selectors.ts
 └── store.ts

action 主要是用来抽象行为,并且触发一个 reducer;reducer 则是处理不同 action 的 state;store 则可以理解为 state 的容器。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// actions/index.ts 用来汇总 actions
import todos from './todos';
import tomatoes from './tomatoes';
export default {
  ...todos,
  ...tomatoes
};

// actions/todos.ts 里面是 todos 的 actions
import {INIT_TODOS} from '../actionTypes';
const initTodos = (payload: any[]) => {
  return {
    type: INIT_TODOS,
    payload
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// reducers/index.ts 用来汇总 reducers
import {combineReducers} from 'redux';
import todos from './todos';
import tomatoes from './tomatoes';
export default combineReducers({todos, tomatoes})

// reducers/todos.ts 里面是 todos 的 reducers
import {INIT_TODOS} from '../actionTypes';
export default function(state: any[] = [], action: any) {
  switch (action.type) {
    case INIT_TODOS:
      return [...action.payload];
    default:
      return state;
  }
}
1
2
3
4
5
// store.ts 创建 store
import {createStore} from 'redux';
import rootReducer from './reducers'
const store = createStore(rootReducer);
export default store;

在 Home 中,我们需要获取三块数据:用户信息、todos、tomatoes,我们可以借助 React Hooks 中的 useEffect 来进行:

 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
const Home = (props: IHomeProps) => {
  React.useEffect(() => {
    // const getMe = ...
    // const getTomatoes = ...
    const getTodos = async () => {
      try {
        // 获取数据
        const response = await axios.get('todos');
        // 在 Redux 中初始化数据
        props.initTodos(todos);
      } catch (e) {
        // ...
      }
   } 
  })
  // return ... 
};
// 经过下面的代码,props 中就可访问到 state 和 actions 了
const mapStateToProps = (state: any, ownProps: any) => ({
  ...ownProps
});
const mapDispatchToProps = {
  initTodos: actions.initTodos,
  initTomatoes: actions.initTomatoes
};
export default connect(mapStateToProps, mapDispatchToProps)(Home);

这样我们就完成了从服务器获取所有 todos 的数据,然后装到 store 中的操作。

任务列表 Todos

Todos 的主要功能有两个:

  1. 添加新任务
  2. 展示未完成的任务,并可以进行编辑

因此 Todos 主要由 TodosInput 和 TodoItem 这两个构成,整体结构如下:

1
2
3
4
5
6
 Todos
 ├── TodoInput
 └── TodoList
     ├── TodoItem
     ├── TodoItem
     └── ...

我们之前就已经在主页中获取到了 todos 的全部数据,但是 TodoItem 需要展示的是未完成的 todos,为了方便今后使用,同时也要保证 reducer 的纯净性,我创建了一个 selector.ts 文件来专门处理这类对 state 进行过滤或分组的工作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// redux/selector.ts
import {TODO_FILTERS, TOMATO_FILTERS} from '../constants'

const getTodos = (store: any) => (store.todos);
const getNotDeletedTodos = (store: any) => {
  return getTodos(store).filter((todo: any) => (!todo.deleted))
};
export const getTodosByFilter = (store: any, todoFilter: string) => {
  const notDeletedTodos = getNotDeletedTodos(store);
  switch (todoFilter) {
    case TODO_FILTERS.IMCOMPLETED:
      return notDeletedTodos.filter((todo: any) => (!todo.completed));
    default:
      return notDeletedTodos;
  }
};

这样就可以在 Todos 组件中轻松拿到 incompleteTodos

1
2
3
4
5
6
7
const mapStateToProps = (state: any, ownProps: any) => {
  const incompleteTodos = getTodosByFilter(state, TODO_FILTERS.INCOMPLETE);
  return {
    incompleteTodos,
    ...ownProps
  }
}

如何切换 TodoItem 的 editing 状态?

initTodos 的时候,我们可以再加上一个 editing 属性,这样以后就可以通过 editing 来判断这个 TodoItem 是不是处于编辑状态了。同时,可以使用 classNames 这个库来帮我们动态添加 className

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import classNames from 'classnames'
const TodoItem = (props: ITodoItemProps) => {
  const todoItemClass = classNames({
    'todo-item': true,
    'editing': props.editing
  });
  return (
    <div classNames={todoItemClass}>
    {/* ... */}
    </div>
  )
}

番茄闹钟 Tomato

番茄闹钟分为两个模块:计时器 和 最近的历史记录。

同样也是在 Tomatoes 组件中获取到所有需要的信息,然后再以 Props 的形式传给子组件。计时器会根据是否有未完成的番茄闹钟来判断是显示按钮还是倒计时。

怎样实现倒计时

使用内置的 setInterval 即可,开始倒计时的时候运行 setInterval,每 1000 ms 将剩余时间减少 1000 ms,结束之后再 clearInterval

怎样将已完成的番茄按天分组

我将分组的函数 groupByDay 也放在了 redux/selectors.ts 中,方便日后调用,使用了 lodashdate-fns 这两个库,分别提供了分组函数和时间格式化方法。

1
2
3
4
5
6
7
8
import _ from 'lodash';
import {format, parseISO} from 'date-fns';

const groupByDay = (dataBeforeGroup: any[], keyOfTime: string) => {
  return _.groupBy(dataBeforeGroup, (item) => {
    return (format(parseISO(item[keyOfTime]), 'yyyy-MM-dd'))
  })
}

统计模块

统计模块主要由统计图和历史记录构成,可以在番茄和土豆的统计数据中进行切换

如何制作标签页的切换

React 没有 v-if 这样的指令,需要自己写条件判断,先给 Tabs 加上一个 data-index 属性,方便后续取用:

1
2
3
4
5
6
7
8
9
<div>
  <ul>
    <li data-index="1" onClick={this.onClick}></li>
    <li data-index="2" onClick={this.onClick}></li>
  </ul>
  <div>
    {Content}
  </div>
</div>

然后给 Body 做一个选择判断:

1
2
3
4
5
6
7
8
let Content: any;
switch (this.state.currentIndex) {
  case '1':
    Content = ...;
    break;
  case '2':
    Content = ...;
}

最后加上一个点击事件:

1
2
3
onClick = (e: any) => {
  this.setState({currentIndex: e.currentTarget.getAttribute('data-index')})
}

怎样画折线统计图

我是借助 polygon 标签来画的:

1
2
3
4
<svg>
<polygon fill="rgba(215,78,78,.1)" stroke="rgba(215,78,78,.5)" strokeWidth="1"
         points={genPoints()}/>
</svg>

只需要知道类似于这样的一个字符串即可:

1
[`0,${height}`, ...pointArray, `${width},0`,`${width},${height}`].join(' ')

pointArray 实际上就是我们需要的横纵坐标了。

updatedupdated2020-02-222020-02-22