Hooks & Redux

现在,有了 Hooks, 甚至可以自己编写 Reducer,通过 Proxy 对状态进行操作。

但是,为了更融入市场而且现在有了 Redux Tookit(可以减少很多代码量),所以,这里还是用 Redux。

  • Redux 在我的理解看来就是事件驱动的一个典型模式,记住这点,就可以很明白为什么有 action dispatch

  • 而 Reducer 的概念是因为使用 Proxy 代理模式,这种方式可以避免用户直接操作状态对象造成"污染"。

所以,记住以上两点,就可以很好地去使用 Redux。以及接下来要讨论的 Hooks + Redux。


开始

1. 创建 Next.js 项目

yarn create next-app --typescript

2. 安装 Redux

1
2
3
4

yarn add react-redux @types/react-redux @reduxjs/toolkit

yarn add --dev redux-devtools

以上安装了 React-ReduxTypeScript支持Redux工具包(toolkit 包含了 Redux 核心 + Thunk + Reselect)
以及用于开发测试的 redux-devtools 工具。

注意:以上的安装方式是在已有的项目中安装的(Next.js 项目中)。

3. 改变目录结构

📦src
 ├─ 📂app
 │ ├─ 📜hooks.ts
 │ └─ 📜store.ts
 ├─ 📂features
 │ └─ 📂counter
 │ │ ├─ 📜Counter.module.css
 │ │ ├─ 📜Counter.tsx
 │ │ ├─ 📜counterAPI.ts
 │ │ └─ 📜counterSlice.ts
 └─ 📂pages
 │ ├─ 📂api
 │ │ └─ 📜hello.ts
 │ ├─ 📜index.tsx
 │ └─ 📜_app.tsx

将 pages 放入 新建的 src 目录中:

4. 创建 Store

因为我喜欢 TS 和 Hooks,所以创建 Store 的时候步骤会多一点,但是这是值得的。
顺带一提的是,Next.js 是我的选择,可以没有 Redux,但是不能没有 Next.js。
所以,配置的时候我会根据 Next.js 的目录做一些配置。

在src目录中新建一个app目录

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20

// src/app/store.ts
import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit';
// import counterReducer from '../features/counter/counterSlice';

export const store = configureStore({
  reducer: {
    // 这里放置 slice 中的 reducer,例如
    // counter: counterReducer,
  },
});

export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
export type AppThunk<ReturnType = void> = ThunkAction<
  ReturnType,
  RootState,
  unknown,
  Action<string>
>;
1
2
3
4
5
6
7
8

// src/app/hooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';

// 在整个应用程序中使用,而不是简单的 `useDispatch` 和 `useSelector`
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14

// src/pages/_app.tsx
import { Provider } from 'react-redux'
import type { AppProps } from 'next/app'

import { store } from '../app/store'

export default function MyApp({ Component, pageProps }: AppProps) {
  return (
    <Provider store={store}>
      <Component {...pageProps} />
    </Provider>
  )
}

5. Counter 计数器

  • src/features/counter/Counter.tsx
import React, { useState } from 'react';

import { useAppSelector, useAppDispatch } from '../../app/hooks';
import {
  decrement,
  increment,
  incrementByAmount,
  incrementAsync,
  incrementIfOdd,
  selectCount,
} from './counterSlice';
import styles from './Counter.module.css';

export function Counter() {
  const count = useAppSelector(selectCount);
  const dispatch = useAppDispatch();
  const [incrementAmount, setIncrementAmount] = useState('2');

  const incrementValue = Number(incrementAmount) || 0;

  return (
    <div>
      <div className={styles.row}>
        <button
          className={styles.button}
          aria-label="Decrement value"
          onClick={() => dispatch(decrement())}
        >
          -
        </button>
        <span className={styles.value}>{count}</span>
        <button
          className={styles.button}
          aria-label="Increment value"
          onClick={() => dispatch(increment())}
        >
          +
        </button>
      </div>
      <div className={styles.row}>
        <input
          className={styles.textbox}
          aria-label="Set increment amount"
          value={incrementAmount}
          onChange={(e) => setIncrementAmount(e.target.value)}
        />
        <button
          className={styles.button}
          onClick={() => dispatch(incrementByAmount(incrementValue))}
        >
          添加金额
        </button>
        <button
          className={styles.asyncButton}
          onClick={() => dispatch(incrementAsync(incrementValue))}
        >
          异步添加
        </button>
        <button
          className={styles.button}
          onClick={() => dispatch(incrementIfOdd(incrementValue))}
        >
          如果奇数则添加
        </button>
      </div>
    </div>
  );
}
  • src/features/counter/Counter.module.css
.row {
    display: flex;
    align-items: center;
    justify-content: center;
  }
  
  .row > button {
    margin-left: 4px;
    margin-right: 8px;
  }
  
  .row:not(:last-child) {
    margin-bottom: 16px;
  }
  
  .value {
    font-size: 78px;
    padding-left: 16px;
    padding-right: 16px;
    margin-top: 2px;
    font-family: 'Courier New', Courier, monospace;
  }
  
  .button {
    appearance: none;
    background: none;
    font-size: 32px;
    padding-left: 12px;
    padding-right: 12px;
    outline: none;
    border: 2px solid transparent;
    color: rgb(112, 76, 182);
    padding-bottom: 4px;
    cursor: pointer;
    background-color: rgba(112, 76, 182, 0.1);
    border-radius: 2px;
    transition: all 0.15s;
  }
  
  .textbox {
    font-size: 32px;
    padding: 2px;
    width: 64px;
    text-align: center;
    margin-right: 4px;
  }
  
  .button:hover,
  .button:focus {
    border: 2px solid rgba(112, 76, 182, 0.4);
  }
  
  .button:active {
    background-color: rgba(112, 76, 182, 0.2);
  }
  
  .asyncButton {
    composes: button;
    position: relative;
  }
  
  .asyncButton:after {
    content: '';
    background-color: rgba(112, 76, 182, 0.15);
    display: block;
    position: absolute;
    width: 100%;
    height: 100%;
    left: 0;
    top: 0;
    opacity: 0;
    transition: width 1s linear, opacity 0.5s ease 1s;
  }
  
  .asyncButton:active:after {
    width: 0%;
    opacity: 1;
    transition: 0s;
  }
  • src/features/counter/counterAPI.ts
// 模拟对数据发出异步请求的模拟函数
export function fetchCount(amount = 1) {
    return new Promise<{ data: number }>((resolve) =>
        setTimeout(() => resolve({ data: amount }), 500)
    );
}
  • src/features/counter/counterSlice.ts
 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

import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { RootState, AppThunk } from '../../app/store';
import { fetchCount } from './counterAPI';

export interface CounterState {
  value: number;
  status: 'idle' | 'loading' | 'failed';
}

const initialState: CounterState = {
  value: 0,
  status: 'idle',
};

// 下面的函数称为 thunk,它允许执行异步逻辑。 
// 它可以像常规操作一样调度:`dispatch(incrementAsync(10))`。

// 这将使用 `dispatch` 函数作为第一个参数调用 thunk。 
// 然后可以执行异步代码并可以调度其他操作。 Thunk 通常用于发出异步请求。

export const incrementAsync = createAsyncThunk(
  'counter/fetchCount',
  async (amount: number) => {
    const response = await fetchCount(amount);
    // 返回的值成为 “已完成” 操作负载(action payload)
    return response.data;
  }
);

export const counterSlice = createSlice({
  name: 'counter', //这里的 `counter` 是 store 中的 reducer 字段的 counter
  initialState,
  // `reducers` 字段可以定义 reducer 并生成相关的操作
  reducers: {
    increment: (state) => {
      // Redux Toolkit 允许在 reducer 中编写“变异”逻辑。
      // 它实际上并没有改变状态,因为它使用了 Immer 库,
      // 它检测 “draft state(草稿状态)” 的变化,并根据这些变化产生一个全新的不可变状态
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    // 使用 PayloadAction 类型声明 `action.payload` 的内容
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload;
    },
  },
  // `extraReducers` 字段让切片处理在别处(reducers 字段之外)定义的动作,包括由 createAsyncThunk 或其他切片生成的动作。
  extraReducers: (builder) => {
    builder
      .addCase(incrementAsync.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(incrementAsync.fulfilled, (state, action) => {
        state.status = 'idle';
        state.value += action.payload;
      });
  },
});

export const { increment, decrement, incrementByAmount } = counterSlice.actions;

// 下面的函数称为选择器,它允许从状态中选择一个值。 
// 也可以在使用它们的地方而不是在切片文件中内联定义选择器。 
// 例如:`useSelector((state: RootState) => state.counter.value)`
export const selectCount = (state: RootState) => state.counter.value;

// 也可以手工编写 thunk,它可能包含同步和异步逻辑。
// 这是一个基于当前状态有条件地分派动作的例子。
export const incrementIfOdd = (amount: number): AppThunk => (
  dispatch,
  getState
) => {
  const currentValue = selectCount(getState());
  if (currentValue % 2 === 1) {
    dispatch(incrementByAmount(amount));
  }
};

export default counterSlice.reducer;
  • src/pages/index.tsx
import { Counter } from '../features/counter/Counter'
import styles from '../../styles/Home.module.css'

const IndexPage: NextPage = () => {
  return (
    <div className={styles.container}>
      <Head>
        <title>Redux Toolkit</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <main className={styles.header}>
        <img src="/vercel.svg" className={styles.logo} alt="logo" />
        <Counter />
      </main>
    </div>
  )
}

export default IndexPage