react 基础工程 Ⅱ(@reduxjs/toolkit & @craco/craco & vite)

书接上回《react 基础工程(react-redux & react-router)》,由于最近项目上的新实践新体验,所以记录一下。

依然,代码仓库:github

一、用 @reduxjs/toolkit 创建 reducer 和 api

其实工程里已经用到这个工具,但是当时还没完全发现它的优势,还以为不过是官方推荐的比较好用的三方库而已。才知道确实简化了处理以及还有别的封装功能。

1、简化写法

以前老式的 redux 写法:

import { cloneDeep } from 'lodash';
export const addStringAction = payload => ({
  type: 'ADD_STRING',
  payload: payload,
});
export const minusStringAction = payload => ({
  type: 'MINUS_STRING',
  payload: payload,
});
const defaultState = {
  b: 'abc',
};
export default function stringReducer(state = defaultState, action) {
  const { type, payload } = action;
  state = cloneDeep(state);
  switch (type) {
    case 'ADD_STRING':
      state.b += payload;
      return state;
    case 'MINUS_STRING':
      state.b = '';
      return state;
    default:
      return state;
  }
};

用 @reduxjs/toolkit 的写法:

import { createSlice } from '@reduxjs/toolkit';
export const counterSlice = createSlice({
  name: 'counter',
  initialState: {
    value: 0,
  },
  reducers: {
    increment: (state) => {
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    incrementByAmount: (state, action) => {
      state.value += action.payload;
    },
  },
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
  • a. 不需要再额外声明 action 了,不需要定义那些 type 了。reducers 字段下的每个方法就是个 action。用的时候直接 dispatch(increment(123)) 即可。

  • b. 而且好像可以直接改 state 上的字段,不需要返回一个完全新的 state 了?其实也是里面做了处理。看官方介绍,在注释里:

Redux Toolkit allows us to write “mutating” logic in reducers. It doesn’t actually mutate the state because it uses the Immer library, which detects changes to a “draft state” and produces a brand new immutable state based off those changes

2、用 createApi 创建接口请求

相似于 react-query,对一些请求做了封装,有一些状态参数什么的。这里有官方对比

给一段代码示例:

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
const axiosBaseQuery = ({ baseUrl }) =>
  async ({ url, method, data, params }) => {
    try {
      const result = await Promise.resolve({ a: 1, b: 2, baseUrl, url, method, data, params });
      return { data: result };
    } catch (error) {
      return {
        error: {
          status: error?.status,
          data: error?.data || error.message,
        },
      };
    }
  };
// Define a service using a base URL and expected endpoints
export const digimonApi = createApi({
  reducerPath: 'digimonApi',
   // 可以用默认的 fetchBaseQuery({ baseUrl: '' }),也可以使用自己封装的请求方法
  baseQuery: axiosBaseQuery({ baseUrl: '' }),
  endpoints: (builder) => ({
    getDigimonByName: builder.query({
      query: (name) => `digimon/${name}`,
      async onQueryStarted(args, { dispatch, queryFulfilled }) {
        try {
          const { data } = await queryFulfilled;
          // `onSuccess` side-effect
          console.info('onQueryStarted', args, data);
        } catch (error) {
          // `onError` side-effect
          console.error('onQueryStarted', error);
        }
      },
    }),
    saveDigimon: builder.mutation({
      query: (body) => ({
        url: `digimon/save`,
        method: 'POST',
        body,
      }),
      async onQueryStarted(args, { dispatch, queryFulfilled }) {
        try {
          const { data } = await queryFulfilled;
          // `onSuccess` side-effect
          console.info('onQueryStarted', args, data);
        } catch (error) {
          // `onError` side-effect
          console.error('onQueryStarted', error);
        }
      },
    }),
  }),
});
export default digimonApi;

用就是这么个用法,主要几个点介绍下:

  • a. 记得要在全局 store 注册一下
import { configureStore } from '@reduxjs/toolkit'
import { pokemonApi } from './services/pokemon'
export const store = configureStore({
  reducer: {
    // Add the generated reducer as a specific top-level slice
    [pokemonApi.reducerPath]: pokemonApi.reducer,
  },
  // Adding the api middleware enables caching, invalidation, polling,
  // and other useful features of `rtk-query`.
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(pokemonApi.middleware),
})
  • b. 可以用 query/mutation 方式,用法差别就是那个 builder 后面跟的参数。至于目的差别就是如语义所示,一个是获取值一个是修改值吧。然后以上面代码为例,会生成两个 hook,useGetDigimonByNameQueryuseSaveDigimonMutation。用法也不太一样。
const getDigimonByName = digimonApi.useGetDigimonByNameQuery('agumon', { skip: false });
const [saveDigimonFunc, saveDigimonStatus] = digimonApi.useSaveDigimonMutation();

query 的请求是直接请求,返回对象包含了返回值 data 和 各类的状态,isLoading,isSuccess 这些。当然有时不想它马上就请求,但 hook 又不能写在条件里。所以官方提供了个 skip 参数,可以用来控制是否触发请求。谓之:条件获取
mutation 返回对象是个数组,元素一是请求的触发方法,用 useEffect 什么的去触发一下。元素二就是各类的状态,isLoading,isSuccess 这些。

  • c. 请求完成钩子 onQueryStarted,无论失败与否都会触发。第一个参数是我们写的请求入参。第二是个复合对象,有两个字段(当然不只这两个):
    dispatch,就是 store.dispatch,也就是请求完毕就可以顺手触发一些修改 store 的操作。
    queryFulfilled,一个 promise 函数,执行成功会返回请求结果 data。失败了也就是请求失败,则触发 promise error 失败事件。

二、用 @craco/craco 修改 react 原生打包配置

上篇文章提到,react 把打包配置藏了起来,所以自己另起了 webpack.config.js 对其进行处理。现在知道还可以用 @craco/craco 挟持修改其默认配置的方法。用法也很简单。

  1. 把命令改成:
{
  "scripts": {
    "dev": "craco start",
    "build": "craco build",
  }
}
  1. 项目根路径配置一份 craco.config.js
const { resolve } = require('path');
const { DefinePlugin } = require('webpack');
const { getPlugin, pluginByName } = require('@craco/craco');
module.exports = {
  webpack: {
    configure: (webpackConfig) => {
      // 默认配置没有处理 PUBLIC_URL,会导致处理 index.html 模板里的变量出错
      const { isFound, match: htmlWebpackPlugin } = getPlugin(
        webpackConfig,
        pluginByName('HtmlWebpackPlugin'),
      );
      if(isFound) {
        const templateParameters = htmlWebpackPlugin.userOptions?.templateParameters || {};
        htmlWebpackPlugin.userOptions.templateParameters =
          Object.assign(templateParameters, { 'PUBLIC_URL': '' });
      }
      return webpackConfig;
    },
    alias: {
      '@': resolve(process.cwd(), 'src'),
    },
    plugins: [
      new DefinePlugin({
        'NODE_ENV': JSON.stringify(process.env.NODE_ENV),
      }),
    ],
  },
};

多半改的都是 webpack 配置吧,更多更具体可看《官网文档》。

三、用 vite 打包

vite 已经很成熟了,用于 vue 工程当仁不让,react 工程也很可以了。话不多说直接上操作。

  1. 项目根目录新建 vite.config.js
import fs from 'fs';
import path from 'path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  resolve: {
    alias: [
      {
        find: '@',
        replacement: path.resolve(process.cwd(), '/src'),
      },
    ],
  },
  define: {
    'NODE_ENV': JSON.stringify(process.env.NODE_ENV),
  },
  plugins: [
    react(),
  ],
  server: {
    port: 3000,
    open: 'http://localhost:3000', // 或 true
    // proxy: {
    //   '/api': {
    //     target: 'http://jsonplaceholder.typicode.com',
    //     changeOrigin: true,
    //     rewrite: (path) => path.replace(/^\/api/, ''),
    //   },
    // },
  },
  // 解决在 js 文件里写 jsx
  // How to use .js instead of .jsx https://github.com/vitejs/vite/discussions/3448
  esbuild: {
    loader: 'jsx',
    include: /src\/.*\.jsx?$/,
    // loader: 'tsx',
    // include: /src\/.*\.[tj]sx?$/,
    exclude: [],
  },
  optimizeDeps: {
    esbuildOptions: {
      plugins: [
        {
          name: 'load-js-files-as-jsx',
          setup(build) {
            build.onLoad({ filter: /src\/.*\.js$/ }, async (args) => ({
              loader: 'jsx',
              contents: fs.readFileSync(args.path, 'utf8'),
            }));
          },
        },
      ],
    },
  },
});
  1. 项目根目录新建 index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <link rel="icon" href="/favicon.ico">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ReactZz App</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/index.js"></script>
  </body>
</html>

项目根目录的 index.html 是 vite 的默认入口,当然也可以改,不过要自行配置了,可参考《多页面应用模式》。

这个和 @craco/craco 只是修改部分参数不同,是完全新搭建了打包配置。实际上还会有其他相关的要配置,比如,css 文件处理,自定义压缩处理,ts 配置等等。这里只是记录个基础使用。