TS + React Hooks TodoMVC

4,045 阅读8分钟

TodoMVC 与 Redux

TodoMVC

TodoMVC是一个示例项目,它使用目前流行的不同JavaScript框架的来实现同一个Demo,来帮助你熟悉和选择最合适的前端框架。

怎样快速学习和了解一门JavaScript框架或学习使用新框架的特性? 我的经验是做一个TodoMVC应用吧

TodoMVC官网 React示例

TodoMVC 应用功能点

  • 数据的 CRUD
  • 数据模型的管理
  • MVC的理论实践
  • 组件拆分
  • 状态管理

TodoMVC 是一个功能和UI都十分完备的教学示例,实乃学习框架之必备🌰

Redux 简介

Redux是遵循 Flux 模式的一种实现,是一个状态管理库,适用于 ReactAngularVueJs 等框架或库,而不是局限于某一特定UI库。

Redux中文文档

Redux 核心概念

+----------+
|          |                +-----------+       sliceState,action        +------------+
|  Action  |dispacth(action)|           +-------------------------------->            |
|          +---------------->   store   |                                |  Reducers  |
| Creators |                |           <--------------------------------+            |
|          |                +-----+-----+    (state, action) => state    +------------+
+-----^----+                      |
      |                           |
      |                           |subscribe
      |                           |
      |                           |
      |                           |
      |              +--------+   |
      +--------------+  View  <---+
                     +--------+

更新View的方式

  • dispatch 触发 action
  • action 携带方法名 type, 和数据 payloadstore(期间可能进行异步数据处理)
  • store 接收到 typepayload 交给相应的 reducer
  • reducer 找到对应的方法更新 state

Store、Action 与 Reducer

  • Store 是整个 Redux 应用的状态容器,是一个对象
  • Action 也是一个对象,表明事件,需要有 type 字段
  • Reducer 是一个函数,会根据不同 Action 来决定返回不同的数据

目标

  • 应用TodoMVC UI
  • 全部使用函数式组件
  • 使用TypeScript
  • 学习使用Hooks(useEffect、useContext、useRef、useMemo、useState)
  • 模拟Redux

需求分析

新增Todo

  • 在输入框填写新增的值,按Enter键新增并清空输入框

删除Todo

  • 点击删除“按钮x”删除选择数据
  • 点击右下角“clear complete”清空已 complete 的数据

修改Todo

  • 双击某行数据时切换到编辑状态并自动获得焦点
  • 按回车键或光标离开可以修改数据

查询Todo

  • 当list有值时,可以正常显示数据
  • 左下角“item left”根据当前item数自动变化
  • 点击下方的all active complete可以过滤list,并且高亮自动能切换
  • 勾选item可以切换 activecomplete 样式
  • 当item全选时,新增数据框左侧的“小箭头”高亮
  • 点击新增数据框左侧的“小箭头”,可以全选或全不选item
  • 进入首页时,新增输入框自动获得焦点

安装 TodoMVC 模块

npm install todomvc-common todomvc-app-css

import 'todomvc-common/base.css';
import 'todomvc-app-css/index.css';

直接使用vue版TodoMVC模板,去掉vue相关代码,保留结构

使用 todomvc UI, 可以免去样式和dom结构编写,让我们专注于使用JS框架快速实现业务逻辑

组件拆分

页面基本结构

// App.tsx
const Input: SFC<any> = () => {...}
const TodoItem: SFC<ITodoItemProps> = {...}
const TodoList: SFC<any> = () => {...}
const Footer: SFC<any> = () => {...}

function TodoAPP() {
  return (
      <section className="todoapp">
        <header className="header">
          <h1>todos</h1>
          <Input />
        </header>
        <TodoList />
        <Footer />
      </section>
  );
}

Store

确定应用状态和事件方法

应用全局状态

const initialState: IAPPState = {
  todos: todoStore.list,
  newTodo: '',
  editTodo: '',
  visibility: ShowType.ALL,
};

这里状态的划分为全局状态和局部状态,局部状态由组件内部管理,后面会说到,所以TodoMVC 的全局状态基本就是这4个就够了,在实际项目应用中,全局状态尽量少,要做到每个状态都是全局必须的,避免在全局存放过多的状态,可能会引起组件不必要的更新。

事件方法

export enum ActionType {
  // 新增Todo
  CREATE = 'create',
  // 更新Todo
  UPDATE = 'update',
  // 删除Todo
  DELETE = 'delete',
  // 删除状态是已完成的Tdo
  REMOVE_COMPLETED = 'removeCompleted',
  // 设置当前编辑的Todo
  EDIT_SET = 'setEdit',
  // 改变当前显示类型
  CHANGE_SHOW_TYPE = 'changeShowType',
  // 更新当前编辑的Todo
  UPDATE_EDIT_TODO = 'updateEditTodo',
  // 全部切换为 完成/未完成
  TOGGLE_ALL = 'toggleAll',
}

使用 createContext 创建 Context 组件

使用 useReducer 管理复杂的 state

useReducer 接收一个形如 (state, action) => newStatereducer,并返回当前的 state 以及与其配套的 dispatch 方法

使用 <Context.Provider> 做为父组件的组件,将在 state 更新时,触发更新

// Store.tsx
const initialState: IAPPState = {
  todos: todoStore.list,
  newTodo: '',
  editTodo: '',
  visibility: ShowType.ALL,
};

const reducer = (state: IAPPState, action: IAction): IAPPState => {
  const { type, payload } = action;
  return methods[type] ? methods[type](state, payload) : state;
};

const Context = React.createContext({
  state: initialState,
  dispatch: (() => 0) as React.Dispatch<IAction>,
});

const Provider: SFC<any> = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, initialState);
  return <Context.Provider value={{ state, dispatch}}>{children}</Context.Provider>;
};

export { Provider, Context };

上面的 methods 是具体更新 state 的方法对象

使用Store

Provider 的子组件可以从 Context 中获取全局的 statedispatch

// App.tsx
import { Provider, Context } from './Store'

function TodoAPP() {
  return (
    <Provider>
      <section className="todoapp">
        ...
      </section>
    </Provider>
  );
}

Input 输入组件

使用 useContext 接收 Store 中的 Context 对象,并返回 statedispatch

当组件上层最近的 <Context.Provider> 更新时,该 hook 会触发组件更新,并使用最新的 state

调用了 useContext 的组件总会在 context 值变化时重新渲染

使用 useState 创建组件内部状态 title, 该状态在组件内部使用或销毁,并不影响全局state, 接收一个初始状态的初始值和更新状态的函数 changeTitle

使用 dispatch 触发一个 action 操作,创建一个 todo

const Input: SFC<any> = () => {
  const { state, dispatch } = useContext(Context);
  // 内部状态 title
  const [title, changeTitle] = useState<string>('');
  
  // 输入时更新 title
  function onChange(e: ChangeEvent<HTMLInputElement>) { 
    changeTitle(e.target.value.trim());
  }
  
  // 回车新增一条Todo
  function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
    // 新增
    dispatch({
      type: ActionType.CREATE,
      payload: { id: utils.uuid(), title, completed: false },
    });
    // 清空
    changeTitle('');
  }

  return (
    <input
      className="new-todo"
      autoFocus
      autoComplete="off"
      placeholder="What needs to be done?"
      value={title}
      onChange={onChange}
      onKeyDown={onKeyDown}
    />
  );
};

TodoItem 组件

editing 是属于每一个 TodoItem 的内部状态

使用 useRef 来返回一个可变的 ref 引用对象,其 .current 属性被初始化为传入的参数,常用于访问 DOM对象,这里用来使用编辑输入框获取焦点

useEffect 告诉组件需要在渲染后执行某些操作。React 会保存你传递的函数(我们将它称之为 “effect”),并且在执行 DOM 更新之后调用它, 这里传入第二个参数 [editing] 做为依赖,以优化 effect 执行次数,只有当 editing 改变时,才执行

const TodoItem: SFC<ITodoItemProps> = ({ completed, title, index }) => {
  const [editing, changeEditing] = useState<boolean>(false);
  const iptRef = useRef(null);

  const { state, dispatch } = useContext(Context);
  const { editTodo } = state;

  // DOM更新,获取焦点
  useEffect(() => (editing && iptRef.current.focus()), [editing]);
  // 双击编辑
  function onDoubleClick() {...}
  // 失焦,更新Todo
  function onEditBlur() {...}
  // 回车,更新Todo
  function onEditEnter(e: KeyboardEvent<HTMLInputElement>) {...}
  // 更新当前编辑的Todo
  function onEditChange(e: ChangeEvent<HTMLInputElement>) {...}
  // 切换完成状态
  function onToggleComplete(e: ChangeEvent<HTMLInputElement>) {...}
  // 删除
  function onDestroy() {
  
  return (
    <li>
    ...
    </li>
  )
}

Footer 组件

使用 useMemo 获取类似Vue中的 计算属性,避免在每次渲染时都进行高开销的计算,传入 todos, 当 todos 更新时才重新计算

const Footer: SFC<any> = () => {
  const { state, dispatch } = useContext(Context);
  const { todos, visibility } = state;

  // 计算属性:正在进行中的数量、已完成的数量、提示文字
  const { activedNum, completedNum, activeTodoWord } = useMemo(() => {...})
  
  // 改变显示类型
  function onChangeShowType(type: ShowType) {...}
  
  // 清空已完成的Todo
  function onClearCompleted() {...}
  
  return (
    <footer className="footer">
    ...
    </footer>
  )
}

TodoList 组件

const TodoList: SFC<any> = () => {
  const { state, dispatch } = useContext(Context);
  const { todos, visibility } = state;

  const activedNum = useMemo(() => utils.filterTodos(todos, ShowType.ACTIVE).length, [todos]);
  const todoList = useMemo(() =>utils.filterTodos(todos, visibility), [todos, visibility]);

  function onToggleAll(e: ChangeEvent<HTMLInputElement>) {...}
  
  return (
    <section className="main">
      <input
        id="toggle-all"
        className="toggle-all"
        type="checkbox"
        onChange={onToggleAll}
        checked={activedNum === 0}
      />
      <label htmlFor="toggle-all">Mark all as complete</label>
      <ul className="todo-list">
        {todoList.map((v, i) => {
          return <TodoItem key={v.id} completed={v.completed} title={v.title} index={i} />;
        })}
      </ul>
    </section>
  )
}

为了防止文章过长,并没有粘贴所有代码,主要说明 React 中的一些 hooksTodoMVC 中的一些实际应用场景,以便于我们更容易更快速的理解和掌握hooks的用法

优化

在我们公司的实际应用中,并没有使用 redux 来管理应用状态,而是使用 mobx,在实际开发中我更倾向局部状态与全局状态分而治之,在后台管理系统中,全局的状态并不多,无非就是一些用户信息、菜单状态和数据、消息等等,一般以页面划分 store,复杂的组件有自己的 store, store 中包含了所有状态和操作方法。

Redux 的缺点

  • 样板代码管理比较分散,需要建立 actionTypesactionsreducers
  • 多人协作时就会出现名字冲突,相似业务的流程重复
  • dispatch 需要引用 actions 或要记忆 Action Type 等问题

优化 dispatch

在上面的 hooks 实现的伪 redux 中, dispatch 方法使用仍然比较繁琐,可以优化下面两点

  • UI逻辑处理时,不用引用 ActionsTypes
  • 直接调用 Actions, 并有方法提示
import { ActionsTypes } from './types'
import { Context } from './store'

// 现在的调用方式
const {state, dispatch} = useContext(Context)

dispatch({
  type: ActionTypes.TOGGLE_ALL,
  payload: { completed },
});

// 优化后的调用方式
const {state, actions} = useContext(Context)

actions.toggleAll({ completed })

这里的优化实现主要在 Provider 的实现中, 只要把 Actions 方法挂载到 Context 中就可以

const Provider: SFC<any> = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, initialState);

  // 生成 Actions
  type IActions = { [name in ActionType]?: (data?: Record<string, any>) => void };
  let actions: IActions = {};
  for (let i in ActionType) {
    actions[ActionType[i] as ActionType] = data =>
      dispatch({ type: ActionType[i] as ActionType, payload: data });
  }

  // Context 挂载 state、dispatch、actions
  return <Context.Provider value={{ state, dispatch, actions }}>{children}</Context.Provider>;
};

Vue 相较于 React 有一个优点就是 Vue 的包比 React 要小,在 React 中使用 setState 来管理应用状态,相比于 Vue,代码写法上稍显繁琐,引用第三方的库如 Reduxmobx 无疑又会增加React的体积,hooks 的出现给了我们以希望,在一些相对简单的应用中完全可以代替 mobxredux。但是同样也要小心使用,没有了生命周期的控制,使用不当也容易造成组件重复多次渲染产生性能瓶颈,所以在使用 hooks 时,要多思考怎样使用更合理,怎样才不会产生性能问题。