版本修订记录

这本自编的工具书《Live wtih React》(原名:《React 技术栈速查手册》)自很早就在更新了,但是之前一直仅在自己周围的小圈子里使用。现在把这本小书共享出来,希望能够服务更多的人。

作者首次发布最近更新修订版本适用 React 版本
徐涛2019 年 09 月2024 年 06 月5318.3.1

修订版本 53:

  • 重新调整组件一章内容,彻底放弃类组件的讲解,以函数组件代替。
  • Hooks 一章中的内容与组件一章合并。
  • 调整 Typescript 语言相关内容的位置。
  • 组件一章内容中增加如何使用 Typescript 类型的内容。
  • 调整部分内容的说明,以方便未来升级 React 19。

修订版本 52:

  • 增补 React 18.2.0 版本中增加的内容。
  • 增补部分新增 Hook 的使用方法。
  • 增加部分关于useMemo的使用说明。

修订版本 51:

  • 为 React Router v6 章节增加 Data API、loader方法、action方法以及相关功能的说明。

修订版本 50:

  • 迁移到线上,改版以MDBook的形式编写和发布。
  • React Query 的说明改进到 v4 版本。

修订版本 49:

  • 增加 Zustand 状态管理库的使用说明。

修订版本 48:

  • 补充使用 Redux Toolkit 时,在一个 State 片段中响应其他 State 片段定义的 Action 的说明。

修订版本 47:

本次手册内容的修订包含一次 React 大版本的更新,本次更新引入了突破性变更的内容。

  • 书名改为《Live with React》。
  • 升级手册所依赖的最高 React 版本到 18.1.0。
  • 增加基于 Redux Toolkit 和基于 Hooks 的 React Redux 使用方法说明。
  • 使用 Immer 库替换了原来的 Immutable 库作为推荐的不可变数据支持库。

修订版本 46:

  • 修正了关于使用useImperativeHandle Hook 的示例中的错误。

修订版本 45:

这是一次间隔时间比较长的跨年更新。

  • 增加了 React Router 6.x 的功能说明。
  • 增加了 React 对于组件惰性加载的使用说明。
  • 增加了一些对于使用 Typescript 中泛型的使用技巧。
  • 增加对于 Typescript 引入的工具接口的说明。

修订版本 44:

  • 增加了 React Query 库的说明。
  • 原有 React Router 一章增加针对版本的说明。

修订版本 43:

  • 所有的代码段落和提示段落样式全部重新设计。
  • 再一次彻底检查文字及语法错误。

修订版本 42:

  • 增加了关于在 Vite 中配置 React 插件的说明。
  • 增加了关于如何在 Vite 中配置 Emotion 的编译标注的说明。
  • 增加了关于如何结合使用 Emotion 和 Tailwind CSS 的说明。
  • 增加了一些可以在 Styled Components 中使用的 Stylis 语法。

修订版本 41:

  • 增加了关于在 Vite 中使用 Less 变量修改组件主题的说明。
  • 增加了关于使用 useImperativeHandle 暴露组件方法的说明。

修订版本 40:

  • 增加了关于使用 Vite 构建 React 应用的章节。
  • 增加了关于 Hook 函数依赖项的一些补充说明。

修订版本 39:

  • 增加了关于 clsx 库的一些说明。

修订版本 38:

  • 改正了 React Router 一章中存在的示例编写错误。
  • 增加了关于 useCallback Hook 的进一步说明,以方便对 useCallback 的理解。

修订版本 37:

  • 改正了一些书写错误。

修订版本 36:

  • 增加了介绍 PropTypes 类型检查的内容。

修订版本 35:

  • 增加了介绍 Emotion 库的内容。
  • 改动了 Styled Components 一章的部分描述。

修订版本 34:

  • 重新使用 Latex 排版。
  • 增加了 SWR 的部分内容。

前言

本速查手册旨在为已经具备React框架使用基础的读者进行快速语法查询使用。本手册以目前18.1.0版本的React为基础编写,在使用前请注意自己项目中React的版本。不过在本速查手册中没有附加额外的说明,那么就可以直接在低于React 18的版本中使用,对于React 18引入的突破性的新内容,本手册在介绍的时候会附加额外的说明。

手册假设读者已经完全熟悉NodeJS的基本使用,并且对使用React框架开发项目有一定的了解。

使用Create React App创建React应用

Create React App(经常简写为CRA)是一个用于创建新单页应用(SPA)的最佳方式。要使用Create React App创建一个新的项目只需要执行以下命令。

npx create-react-app project-name

其中npx命令是自npm 5.2以后的版本附带的package运行工具。如果不习惯使用npm进行包管理,还可以使用以下基于yarn的命令来创建React应用。

yarn create react-app project-name

如果需要使用Typescript来书写项目,只需要在以上命令后添加参数--template typescript即可。

如果系统中已经安装过旧版本的Create React App,那么可以使用以下命令来删除旧版本的应用,然后再安装新版。

npm uninstall -g create-react-app
yarn global remove create-react-app

执行过以上两个命令之后,就可以保证npx命令会使用最新的Create React App了。

CRA只会创建一个前端项目,不会处理后端逻辑或者操作数据库。其内部使用Babel和Webpack来进行Javascript的转译和编译。创建好的前端项目将只包括reactreact-domreact-scripts三个基础包,如果需要使用redux或者react-router,则需要自行完成后续的安装,创建的项目一般具有以下内容。

  • project-name
    • README.md
    • node_modules
    • package.json
    • .gitignore
    • public
      • robots.txt
      • manifest.json
      • logo512.png
      • logo192.png
      • index.html
      • favicon.ico
    • src
      • App.css
      • App.js
      • App.test.js
      • index.css
      • index.js
      • logo.svg
      • serviceWorker.jss

Tip

如果使用Typescript编写项目,那么生成的.js文件将会被替换为.tsx文件。

项目创建好以后,可以使用命令npm start以开发模式引动应用,此时工具将会采用热重载技术来针对项目文件的编辑进行实时显示。当项目开发完成,可以使用npm run build命令进行编译打包,编译好的可发布文件将位于dist文件夹中。

如果需要使用反向代理与后端服务整合,可以在package.json文件中增加proxy字段,指定后端服务所在位置即可。proxy字段只支持反向代理HTTP、HTTPS和WebSocket连接,并且需要调整后端服务的CORS设置。如果需要使用自定义的反向代理设置,需要在项目中添加http-proxy-middleware依赖,并在src目录中添加setupProxy.js文件来进行配置,该文件的示例内容格式如下。

const proxy = require('http-proxy-middleware');

module.exports = function (app) {
  app.use(
    '/api',
    proxy({
      target: 'http://localhost:4000',
      changeOrigin: true
    })
  );
  app.use(
    '/wsapi',
    proxy({
      target: 'http://localhost:4000',
      changeOrigin: false,
      ws: true // 使用ws选项可以开启WebSocket支持
    })
  );
};

虽然setupProxy.js文件建立在src目录下,但是其只在使用Development方式启动应用时起效,并不参与应用的编译。

Tip

反向代理的配置使用辅助配置工具直接配置Webpack Dev Server会更加便利实用,并且可以直接复用Webpack的配置。

使用react-app-rewired修改配置

使用CRA创建的应用依旧是受到限制的,如果要对CRA创建的应用进行额外的配置,一般推荐使用react-app-wired。这个功能库可以使用以下命令完成安装。

npm install react-app-rewird --save-dev
yarn add react-app-wired --dev

安装好功能库之后,需要修改package.json中的启动项,将之前的react-scripts改为react-app-rewired

{
  "scripts": {
    "start": "react-app-rewired start",
    "build": "react-app-rewired build",
    "test": "react-app-rewired test --env=jsdom"
  }
}

完成这些准备工作之后,需要在项目根目录创建一个文件config-overrides.js,其中需要导出一个函数用来修改默认配置。这个文件的基本结构如下。

module.exports = function override(config, env) {
  return config;
};

或者导出一个最多包含三个字段的对象,其中每个字段都是一个函数,这种导出方法允许自定义Jest和Webpack Dev Server的配置,此时这个文件的基本结构如下。

module.exports = {
  // 用于配置React应用的编译,本处的配置不在测试中起效
  webpack: function (config, env) {
    return config;
  },
  // 用于配置Jest的运行,本处的配置不在项目编译时起效
  jest: function (config) {
    return config;
  },
  // 当使用npm run start或者yarn start时启动的Dev Server的配置
  devServer: function (configFunction) {
    return function (proxy, allowHost) {
      const config = configFunction(proxy, allowHost);
      return config;
    };
  },
  // 为编译React应用或测试应用时提供路径覆盖
  paths: function (paths, env) {
    return paths;
  }
};

使用customize-cra进行辅助配置

customize-cra功能库是react-app-rewired功能库最好的搭档,一般在安装react-app-rewired时会连同customize-cra一起安装。

npm install react-app-rewird customize-cra --save-dev
yarn add react-app-wired customize-cra --dev

customize-cra的主要目标是帮助react-app-rewired更改其config-overrides.js配置文件。当使用customize-cra之后,config-overrides.js中导出的内容将不再是一个函数,而是导出函数override()的调用结果。这里给出一个示例配置。

const { override, disableEsLint, addBundleVisualizer } = require('customize-cra');

module.exports = override(disableEsLint(), process.env.BUNDLE_VISUALIZE == 1 && addBundleVisualizer());

函数overrides()接受若干表达式作为参数,其中所使用的参数一般都由customize-cra提供。常用的主要有以下这些。

  • addTslineLoader(loaderOptions),添加TSLint支持,需要安装tslint-loader
  • addExternalBabelPlugin(plugins),添加目录node_modules之外的 Babel 插件。
  • addBabelPlugin(plugins),添加项目中已经安装的Babel插件。
  • addBabelPresets(...presets),添加项目中已经安装的Babel Preset。
  • babelInclude(includes),重写babel-loaderinclude选项。
  • babelExclude(excludes),重写babel-loaderexclude选项。
  • fixBabelImports(libraryName, options),添加babel-plugin-import插件支持,并配置指定库。
  • addDecoratorsLegacy(),使用传统模式支持修饰器,需要安装@babel/plugin-proposal-decorators
  • disableEsLint(),关闭ESLint支持。
  • useEslintRc(configFile),指定要使用的.eslintrc文件。
  • enableEslintTypescrit(),允许eslint-loader检查Typescript文件。
  • addWebpackAlias(alias),向Webpack配置的别名区域增加别名设置。
  • addWebpackResolve(resolve),向Webpack的依赖提供区域增加依赖解析。
  • addWebpackPlugin(plugin),向Webpack增加插件。
  • addWebpackExternals(deps),增加额外的依赖,通常用来使用CDN功能。
  • addWebpackModuleRule(rule),向Webpack的module.rules部分增加规则设置。
  • addWebpackTarget(target),向Webpack增加编译目标。
  • addBundleVisualizer(options, behindFlag=false),增加Bundle Visualizer插件支持,需要安装webpack-bundle-analyzer
  • useBabelRc(),指定使用.babelrc或者.babelrc.js文件来控制Babel配置。
  • adjustWorkbox(fn),调整Wrokbox配置。
  • addLessLoader(loaderOptions),增加Less支持,需要安装lessless-loader
  • addPostcssPlugins([plugins]),增加Post-CSS插件。
  • disbaleChunk(),关闭编译文件分段功能。
  • removeModuleScopePlugin(),移除用于防止从src目录之外应用模块的CRA插件。在源码目录不是src是使用。
  • watchAll(),指示CRA监控所有项目文件,包括node_modules中的文件。与参数--watch-all功能相同。

除了可以调整Wepack的设置以外,customize-cra还可以调整Webpack DevServer的设置,这需要引入overrideDevServer。以下给出一个同时覆盖Webpack和Webpack DevServer的配置。

const { override, disableEsLint, overrideDevServer, watchAll } = require('customize-cra');

module.exports = {
  webpack: override(disableEsLint()),
  devServer: overrideDevServer(watchAll())
};

从以上示例可以看出,若要覆盖Webpack的配置,需要在覆盖配置文件中导出以webpack为键名的覆盖配置;要覆盖Webpack DevServer的配置,则需要导出以devServer为键名的覆盖配置,这与react-app-rewired中的配置方法是一致的。

Tip

事实上,customize-cra的配置方法是基于react-app-rewired的。

如果需要使用customize-cra以外的配置功能,可以自定义配置函数。配置函数需要返回一个接受目前config作为参数,并返回完成配置之后的config的函数。以下以addWebpackAlias()的源码为例来展示自定义配置函数的写法。

export const addWebpackAlias = alias => config => {
  if (!config.resolve) {
    config.resolve = {};
  }
  if (!config.resolve.alias) {
    config.resolve.alias = {};
  }
  Object.assign(config.resolve.alias, alias);
  return config;
};

使用CRACO进行辅助配置

CRACO 是除 customize-cra 以外的另一个更加优秀的辅助配置层选择。CRACO 适配于 Create React App 4.0 以上的版本,如果要在项目里使用,可以直接执行以下两个命令中的一个。

npm install @craco/craco --save-dev
yarn add @craco/craco --dev

在项目中添加 CRACO 以后,还需要手动在项目跟目录里添加一个名为 craco.config.js 的文件。并且同时还需要修改 package.jsonscripts 区段的内容,由 craco 代替之前的 react-scripts 完成项目的启动、编译等工作。更改以后的 package.json 文件的 scripts 区段至少是以下这个样子。

{
  "scripts": {
    "start": "craco start",
    "build": "craco build",
    "test": "craco test"
  }
}

CRACO 主要是通过 craco.config.js 文件来完成 React 应用的扩展配置的,而这个文件中则主要是导出了一个配置对象。以下是这个配置文件的全貌,在进行实际配置的时候可以从中选择所需要的内容进行配置。

const { when, whenDev, whenProd, whenTest, ESLINT_MODES, POSTCSS_MODES } = require('@craco/craco');

module.exports = {
  reactScriptsVersion: 'react-scripts' /* (default value) */,
  style: {
    modules: {
      localIdentName: ''
    },
    css: {
      loaderOptions: {
        /* Any css-loader configuration options: https://github.com/webpack-contrib/css-loader. */
      },
      loaderOptions: (cssLoaderOptions, { env, paths }) => {
        return cssLoaderOptions;
      }
    },
    sass: {
      loaderOptions: {
        /* Any sass-loader configuration options: https://github.com/webpack-contrib/sass-loader. */
      },
      loaderOptions: (sassLoaderOptions, { env, paths }) => {
        return sassLoaderOptions;
      }
    },
    postcss: {
      mode: 'extends' /* (default value) */ || 'file',
      plugins: [],
      env: {
        autoprefixer: {
          /* Any autoprefixer options: https://github.com/postcss/autoprefixer#options */
        },
        stage: 3,
        /* Any valid stages: https://cssdb.org/#staging-process. */ features: {
          /* Any CSS features: https://preset-env.cssdb.org/features. */
        }
      },
      loaderOptions: {
        /* Any postcss-loader configuration options: https://github.com/postcss/postcss-loader. */
      },
      loaderOptions: (postcssLoaderOptions, { env, paths }) => {
        return postcssLoaderOptions;
      }
    }
  },
  eslint: {
    enable: true /* (default value) */,
    mode: 'extends' /* (default value) */ || 'file',
    configure: {
      /* Any eslint configuration options: https://eslint.org/docs/user-guide/configuring */
    },
    configure: (eslintConfig, { env, paths }) => {
      return eslintConfig;
    },
    pluginOptions: {
      /* Any eslint plugin configuration options: https://github.com/webpack-contrib/eslint-webpack-plugin#options. */
    },
    pluginOptions: (eslintOptions, { env, paths }) => {
      return eslintOptions;
    }
  },
  babel: {
    presets: [],
    plugins: [],
    loaderOptions: {
      /* Any babel-loader configuration options: https://github.com/babel/babel-loader. */
    },
    loaderOptions: (babelLoaderOptions, { env, paths }) => {
      return babelLoaderOptions;
    }
  },
  typescript: {
    enableTypeChecking: true /* (default value)  */
  },
  webpack: {
    alias: {},
    plugins: {
      add: [] /* An array of plugins */,
      remove: []
      /* An array of plugin constructor's names (i.e. "StyleLintPlugin", "ESLintWebpackPlugin" ) */
    },
    configure: {
      /* Any webpack configuration options: https://webpack.js.org/configuration */
    },
    configure: (webpackConfig, { env, paths }) => {
      return webpackConfig;
    }
  },
  jest: {
    babel: {
      addPresets: true /* (default value) */,
      addPlugins: true /* (default value) */
    },
    configure: {
      /* Any Jest configuration options: https://jestjs.io/docs/en/configuration. */
    },
    configure: (jestConfig, { env, paths, resolve, rootDir }) => {
      return jestConfig;
    }
  },
  devServer: {
    /* Any devServer configuration options: https://webpack.js.org/configuration/dev-server/#devserver. */
  },
  devServer: (devServerConfig, { env, paths, proxy, allowedHost }) => {
    return devServerConfig;
  },
  plugins: [
    {
      plugin: {
        overrideCracoConfig: ({ cracoConfig, pluginOptions, context: { env, paths } }) => {
          return cracoConfig;
        },
        overrideWebpackConfig: ({ webpackConfig, cracoConfig, pluginOptions, context: { env, paths } }) => {
          return webpackConfig;
        },
        overrideDevServerConfig: ({
          devServerConfig,
          cracoConfig,
          pluginOptions,
          context: { env, paths, proxy, allowedHost }
        }) => {
          return devServerConfig;
        },
        overrideJestConfig: ({ jestConfig, cracoConfig, pluginOptions, context: { env, paths, resolve, rootDir } }) => {
          return jestConfig;
        }
      },
      options: {}
    }
  ]
};

在默认情况下,CRACO 将会使用内容扩展(extends)来修改配置选项,但是在一些提供了 mode 的配置项中,还可以通过将 mode 改成 file 来提供一个配置文件替换配置项的全部配置值。

为了能够让一套配置文件适应多种环境和条件,CRACO 还提供了一系列的辅助函数来对当前的运行环境和自定义条件进行判断,并在运行过程中采用不同的配置值。这套函数的调用方式比较统一,都是以下形式。

when(condition, fact, [unmetValue]);
whenDev(fact, [unmetValue]);

其中 fact 表示给定的条件为真时配置项所取得值,而 unmetValue 则表示给定条件为假时配置项所取得的值。这一系列的函数都是以 when 开头的,主要会实现以下功能。

  • when,使用自定义条件进行选择。
  • whenDev,判断应用的运行环境是否是 DEV 环境。
  • whenProd,判断应用的运行环境是否是 PROD 环境。
  • whenTest,判断应用的运行环境是否是 TEST 环境。

craco.config.js 中除了可以导出一个对象以外,还可以导出一个函数和一个 Promise 对象或者是一个异步函数,CRACO 会将其转换为所需要的内容。并且使用函数或者 Promise 对象还可以实现更加动态的配置效果。

例如我们可以这样利用 CRACO 来配置应用使用 Less 处理样式并且做一个反向代理的配置。

const CracoLessPlugin = require('craco-less');

module.exports = {
  devServer: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        pathRewrite: {
          '^/api': ''
        }
      }
    }
  },
  plugins: [
    {
      plugin: CracoLessPlugin,
      options: {
        lessLoaderOptions: {
          lessOptions: {
            javascriptEnabled: true
          }
        }
      }
    }
  ]
};

Warning

截止本文完成的时候,craco-less 插件仅能支持 5.5 版本的 CRACO,而最新版的 CRACO 已经是 6.1 版本了,所以直接使用 craco-less 会无法启动应用。这个情况目前看起来还依旧无解。

使用Vite创建React应用

Vite是一个全新的前端构建工具,其出现是为了解决JavaScript没有提供模块化开发的原生机制的问题。其中包含了一个开发服务器和一套使用Rollup完成打包的构建功能。Vite提供了开箱即用的配置,并且利用其插件API和JavaScript API提供了高度的可扩展性。借助于esbuild提供的高速预构建依赖,Vite可以提供比Webpack等传统构建工具更快的构建速度。

使用Vite创建不同的项目是通过不同的模板实现的。常用的项目模板有以下这些:

应用框架JavaScript模板TypeScript模板
原生vanillavanilla-ts
Vuevuevue-ts
Reactreactreact-ts
Preactpreactpreact-ts

要使用Vite创建一个React应用项目,可以执行以下命令。

npm init vite@latest react-app-name -- --template react

这里要注意的是,命令中间有一个--,这个--是npm 7.x以上版本中必须的,不能省略。如果使用npm 6.x那么就不需要使用这个--了。

如果使用yarn来进行包管理,那么可以使用以下命令。

yarn create vite --template react

常用命令

Vite自带了一套开发服务器,所以在开发过程中可以利用这个开发服务器完成项目的开发与调试工作,要启动Vite的开发服务器,Vite的开发服务器,只需要执行vite命令或者npm run dev命令。

另一个比较常用的命令就是进行项目的生产状态的输出。这个命令一般被称为编译命令,Vite的编译命令可以执行vite build或者npm run build

自动导入内容的配置

Vite对于一些项目中常用的组件及内容都提供了自动配置的功能。例如以下功能。

  • 对于Vue文件,Vite提供了第一优先级的支持。
  • 对于JSX文件,Vite默认通过esbuild支持React 16风格的JSX文件。
  • 对于CSS文件,Vite根据样式文件所使用的文件名后缀不同,提供了基于PostCSS和CSS Modules的支持。
    • 对于PostCSS,应用只需要在其项目根目录中提供postcss.config.js配置文件即可被Vite自动导入。
    • 而对于CSS Modules,任何文件名后缀为.module.css的文件都将被认为是一个CSS Modules文件,在导入后会自动形成一个模块。
  • 对于Less、Sass、Stylus等样式预处理文件,Vite并不需要再安装Loader来对其进行处理,只需要安装其解释器即可。
  • 对于图片等静态资源文件,Vite将会将其转译为引用URL。
  • 对于WebAssembly文件,Vite将会导入一个函数,函数的返回值为WebAssembly导出实例对象的Promise实例。
  • 对于Web Worker,可以添加一个?worker或者?sharedWorker查询串参数标记来导入,Vite会自动形成一个自定义的Worker构造器。

配置

在项目比较简单的时候,Vite是可以不需要配置的,但是比较复杂的项目通常会自定义许多内容,其中也包括前后端分离开发时反向代理服务器的配置。

对Vite进行配置是通过项目根目录中的vite.config.js文件来完成的,如果使用了其他名称的配置文件,那么在执行Vite命令的时候就需要使用--config选项来明确指定。

对于使用reactreact-ts项目模板的React项目来说,项目的vite.config.js内容是下面这样的。

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()]
});

示例中的defineConfig函数就是用来定义Vite配置的主要方法。由于Vite提供了TypeScript类型定义,所以在使用defineConfig方法的时候,将可以看到可用的代码提示。defineConfig中的常用配置项主要分为三种类型:共享配置、开发服务器配置和构建配置。其他的配置项还有依赖优化与SSR,这些属于比较不常用的配置项,在需要使用的时候可以直接参考Vite的文档。

除了可以接受一个对象来完成Vite的配置以外,defineConfig还可以接受一个函数来实现异步配置。

共享配置

常用的共享配置主要有下表中这些。

配置键默认值配置内容
rootprocess.cwd()项目index.html文件所在位置。
相对路径为相对于当前配置文件的路径。
base/公共基础路径。
modedevelopment
production
开发和构建时的模式设定。
plugins[]构建要使用的插件数组。
publicDirpublic指定静态资源所在的文件夹。
resolve.alias解析模块时所要使用的文件系统别名。
需要使用绝对路径。
css.modeules配置CSS Modules的行为。
css.postcss配置PostCSS,其内容与postcss.config.js文件中的格式相同。
如果在这里配置了PostCSS,Vite就不会寻找其他的PostCSS配置。
esbuildESBuild配置项。
assetsInclude[]指定需要包含的静态资源。
这些静态资源应是未在项目中直接引用的。
logLevelinfo调整控制台的默认输出级别。
clearScreentrue设置Vite在输出信息的时候是否允许清屏。
envDirroot设定用于加载.env文件的目录。

开发服务器配置

开发服务器配置项主要用于配置Vite自带的开发服务器,其中也包括了最常用的反向代理配置。开发服务器配置都集中在server下,在进行配置的时候需要注意。

常用的开发服务器配置主要有以下这些。

配置键默认值配置内容
host127.0.0.1指定服务器需要进行监听的IP地址。
port3000指定服务需要进行监听的端口。
如果端口被占用,那么Vite将会自动尝试下一个可用的端口。
https是否启用TLS+HTTP/2。
open是否自动在浏览器中打开应用。
proxy自定义反向代理规则。
cors设置开发服务器的CORS。
watch设置传递给chokidar的文件系统监听配置。
在WSL 2上运行Vite时,需要设置其中的usePolling配置。
origin用于定义开发调试阶段生成资产的origin。

Vite中为配置反向代理服务定义了一个专门的类型:ProxyOptions,这个类型中常用的配置项主要有以下这些。

配置键类型配置内容
targetProxyTarget?配置代理转向目标。
forwardProxyTargetUrl?配置代理转向URL。
agentany?配置代理使用的Agent。
wsboolean?配置是否支持代理WebSocket。
prependPathboolean?配置是否使用前追加路径的方式拼接代理路径。
ignorePathboolean?配置是否忽略指定的代理路径。
changeOriginboolean?是否更改Host的Origin头。
authstring?代理目标所要求使用的基本认证信息。
hostRewritestring?重写主机名称。
autoRewriteboolean?设置是否自动重写主机域名及端口。
rewrite(string) => string具体访问路径重写定义。
cookieDomain-Rewritefalse | string | {[string]: string}如何重写Cookie域。
cookiePath-Rewritefalse | string | {[string]: string}如何重写Cookie路径。
headers{[string]: string}代理过程中需要附加的额外头信息。
proxyTimeoutnumber?代理超时时间。
timeoutnumber?访问请求超时时间。
followRedirectsboolean?是否其他的重定向。

构建配置

构建配置顾名思义就是用于配置Vite如何进行项目编译打包的配置,构建配置主要根据应用运行的目标平台确定。构建配置都集中在build下,在配置的时候需要注意。

常用的构建配置选项主要有以下这些。

配置键默认值配置内容
targetmodules设置最终构建的目标。
outDirdist设置编译打包输出路径。
assetsDirassets设置生成的静态资源存放路径。
assetsInlineLimit4096编译为内联资源的阈值,单位为kb
cssCodeSplittrue设置是否启用CSS代码拆分。
cssTargetmodule设置将CSS压缩为一个指定目标浏览器,例如微信中的WebView等。
sourcemaptrue设置是否生成source map文件。
minifyesbuild设置使用何种方式进行最小化压缩与混淆。
writetrue设置是否允许将构建后的文件写入磁盘。
emptyOutDirtrue设置是否允许Vite在构建时清空输出目录。

React构建插件

对于不同类型的应用项目,Vite是通过不同的构建插件来实现对其框架的支持的。比如对于React应用项目来说,就需要在vite.config.js中引入@vitejs/plugin-react来实现对于.jsx.tsx文件的处理。

React构建插件提供了以下配置选项来支持对于React应用的自定义构建。

配置键默认值配置内容
include设置快速刷新都监听那些类型的文件。
exclude设置快速刷新不监听哪些类型的文件。
fastRefreshtrue设置是否启用快速刷新。
jsxRuntimeautomatic设置Vite使用什么方式处理JSX文件。
jsxImportSourcereact设置用于处理JSX的工厂方法。要求jsxRuntime需要为automatic
babel设置Babel的自定义配置。

其实在React构建插件中,最为常用的配置项只有两个:jsxImportSourcebabel

jsxImportSource用于设置处理JSX文件的解释器,在默认情况下,JSX文件都是由React自身提供的解释器解释的。但是在使用了一些特殊的框架以后,这些框架会要求使用其特殊的解释器,例如Emotion库就要求使用@emotion/react来对使用了Emotion提供的特殊语法的JSX进行解析。

babel用于设置Babel转译器的配置,例如Babel处理插件加载和配置。

比如当使用Emotion的时候,此时Vite的配置文件可能就是下面这个样子。

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [
    react({
      jsxImportSource: '@emotion/react',
      babel: {
        plugins: ['@emotion/babel-plugin']
      }
    })
  ]
});

构建配置示例

根据以上对于各种常用配置项的列举,下面给出一个用于前后端分离的React项目的示例配置。

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': {
        target: 'http://local.mock.server/',
        changeOrigin: true,
        secure: false,
        rewrite: path => path.replace(/^\/api/, '')
      }
    }
  }
});

对于目前基于React提供的样式框架和组件库,有相当一部分都使用了样式预处理器的变量功能来定义其主题。所以当我们在使用这些组件库的时候,一旦需要使用自定义的主题或者主题颜色,就需要按照组件库的文档,修改样式预处理器代码中定义的变量。在使用Webpack完成代码编译的项目中,这种变量值重定义的工作通常是通过样式预处理器的Loader来完成的。

以使用Less样式预处理器的组件库为例,在使用Webpack完成代码编译的情况下,通常需要在Webpack的配置文件中增加以下内容来自定义主题颜色。

module.exports = {
  rules: [
    {
      test: /\.less$/,
      use: [
        {
          loader: 'style-loader'
        },
        {
          loader: 'css-loader'
        },
        {
          loader: 'less-loader',
          options: {
            modifyVars: {
              'primary-color': '#f85959'
            },
            javascriptEnabled: true
          }
        }
      ]
    }
  ]
};

但是在使用Vite的情况下,同样是对Less样式预处理器中变量值的修改,所需要完成的动作就比较简单了。首先我们需要一个库的帮助,来把Less文件转换成JavaScript文件。

npm install -D less-vars-to-js

然后在src目录下的theme.less中定义所有需要被替换的Less变量。最后就只需要在vite.config.js配置文件文件中载入theme.less即可。这样一来, vite.config.js的内容就变成了以下样子。

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import lessToJS from 'less-vars-to-js';
import path from 'path';
import fs from 'fs';

const themeVariables = lessToJS(fs.readFileSync(path.resolve(__dirname, './src/theme.less'), 'utf8'));

export default defineConfig({
  plugins: [react()],
  css: {
    preprocessorOptions: {
      less: {
        javascriptEnabled: true,
        modifyVars: themeVariables
      }
    }
  }
});

现在,只需要修改theme.less文件中的内容,就可以改变整个应用的主题颜色以及样式配置了。

最简单的React示例

React 利用 React-DOM 库,将 React 实例插入到 HTML 中运行,并且一般 React 代码采用 JSX 格式来简化原本的 JS 格式。

以下是一个最简单的 React 示例。

import ReactDOM from 'react-dom';

ReactDOM.render(<h1>Hello world</h1>, document.getElementById('app'));

在React 17以及之前的版本中,React应用是依靠一个ReactDOM.render()函数来渲染的,但是在React 18中,React应用的渲染已经被改成了一个ReactDOM.createRoot()函数的调用。许多React 18中新引入的功能都需要使用ReactDOM.createRoot()来渲染输出应用的根节点。上面的最小React应用改用React 18编写是下面这个样子的。

import ReactDOM from 'react-dom';

ReactDOM.createRoot(document.getElementById('app')).render(<h1>Hello world</h1>);

方法ReactDOM.createRoot()会将一个HTML元素声明为React应用的根节点,在这个根节点上调用render()方法即可以将React应用渲染到根节点上。

Tip

如果直接阅读React的更新日志,那么还会发现一个名为hydrateRoot()的方法,这个方法的功用于createRoot()是基本一致的,只是hydrateRoot()方法所创建的应用根节点主要用于由ReactDOMServer渲染的动态页面上。如果React应用项目是前后端分离的形式,那么直接使用createRoot()创建应用根节点即可。

Warning

如果在项目中使用的是React 18版本,但是依旧使用了旧版的ReactDOM.render()方法来渲染输出React应用,那么你将在浏览器的控制台中收到一条警告,通知你这个render()方法已经过时了,并且此时Ract应用将运行在React 17模式下。

关于JSX

JSX 是一个 JavaScript 的语法扩展,可以很好的描述 UI 应该呈现出其应有交互的本质形式。JSX 将模板语言与 JavaScript 的全部功能进行了融合,在 JavaScript 代码中融入 UI,将会在视觉上提供辅助作用,并且使 React 显示更多错误和警告信息。

在 JSX 语法中可以在大括号中放置任何有效的 JavaScript 表达式。然而在 JSX 中的 UI 模板也是一个表达式。如以下示例。

const name = 'Jack';
const element = <h1>Hello, {name}</h1>;

引号用来在 JSX 中为属性指定字符串字面量,用大括号为属性指定一个表达式,同一属性中只能选择引号和大括号中的一种,不可同时使用。JSX 中属性使用驼峰命名法来书写,而不是像 HTML 中使用小写。

const element = <img src={user.avatar} tabIndex="0" />;

JSX 的 UI 模板元素中可以包含其他子元素,子元素可以是 HTML 标签或者其他组件。

const element = (
  <div>
    <h1>User</h1>
    <Person name="Jack" />
  </div>
);

借助 Babel,JSX 将会被转译成对于 React.createElement() 函数的调用。

Tip

现在 JSX 文件并不要求必须使用.jsx作为文件后缀,如果使用.js作为文件后缀,只需要在文件中使用import React from 'react';引入 React 支持即可。但是如果是使用 Typescript 编写项目,那么依旧要使用 .tsx 作为文件后缀。

关于ECMAScript 6中的Class

在传统的 ECMAScript 3 和 ECMAScript 5 版本中,要定义一个类,需要使用function关键字,并采用以下形式。

function Point(x, y) {
  this.x = x;
  this.y = y;
}

Point.prototype.toString = function () {
  return this.x + ',' + this.y;
};

var p = new Point(21, 32);
p.toString();

但是从 ECMAScript 6 开始,引入了class关键字来定义类。但是class关键字只是一个语法糖,定义好的类如果使用typeof显示其类型,依旧是function。对于上面示例定义的类,使用新语法就可以定义成以下样子。

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  toString() {
    return `${this.x},${this.y}`;
  }
}

let p = new Point(21, 32);
p.toString();

其中constructor()方法为整个类的构造方法,类中定义的其他方法都将自动定义在prototype上,方法之间也不需要使用逗号进行分隔。prototype属性在 ES6 中还继续存在,并可以像 ES5 版本中一样使用。如果一个类中没有定义constructor()方法,ES6 会自动向其中添加一个空白的构造方法。constructor()方法默认返回实例对象this,所以也可以根据需要返回另一个对象。

class Person {
  constructor() {
    return Object.create(null);
  }
}

除了直接使用class关键字通过结构方式直接定义类以外,还可以使用函数方式定义类,具体定义方法可以参考以下示例。

const Person = class Me {
  getClassName() {
    return Me.name;
  }
};

示例代码中出现的Me实际上是用来在类定义中指代当前类使用的,注意不是指代this而是指代类本身。因为在使用函数式定义类时,类定义本身并不知道自己要被赋予怎样的一个代号,所以需要在内部自行指定一个代号来代表自己,所以这个Me只能在类定义内部使用。如果类定义内部没有使用到自身,这个Me是可以省略的。

类中的 this

在类方法中使用this来指代自身实例是一件非常容易理解的事情,但是在 ES6 中this的使用是与 Java 等其他语言有所不同的。比如以下示例将会出现错误。

class Logger {
  printSource(source = 'nowhere') {
    this.print(`From ${source}`);
  }

  print(text) {
    console.log(text);
  }
}

const logger = new Logger();
const { printSource } = logger;
printSource();

在这个示例中,使用类实例调用printSource()时,printSource()中的this是明确指向类实例的。但是如果像示例中这样提取出来单独调用,那么this就不会再指向类实例,而是指向方法运行时所处的环境,如果这个环境上下文中没有定义一个名为print()的方法,那么就会报错。要解决这个问题,可以按照以下示例采用其中举出的两种方法。

class Logger {
  // 方法一:手动为方法绑定this
  constructor() {
    this.printSourece = this.printSource.bind(this);
  }

  printSource(source = 'nowhere') {
    this.print(`From ${source}`);
  }

  // 方法二:使用Lambda表达式自动带入this
  printSource = (source = 'nowhere') => {
    this.print(`From ${source}`);
  };

  print(text) {
    console.log(text);
  }
}

使用Typescript编写应用

JavaScript 是动态类型的脚本语言,在用于简单的网页脚本时,动态类型已经完全足够使用。但进入工程化、大型化、复杂化的中大型规模应用开发时,动态类型将成为巨大的技术债务。TypeScript 作为 JavaScript 的超集,引入的静态类型系统在一定程度上解决了这个技术债务。而且 TypeScript 引入的接口、泛型、修饰器等特性增强了代码的逻辑性,简化了书写。

由于这本手册并不是 TypeScript 的主场,所以这一部分仅对 TypeScript 进行简单的介绍,将能够使用 TypeScript 编写项目为目标。这一部分更多的是对 React 等一系列框架所使用的类型做列举介绍。

类型系统

TypeScript 的类型系统与 Java 和 C# 更加相似。TypeScript 中的变量类型是静态的,每一个变量都有一个固定的类型。变量类型在声明时指定,格式为 变量名: 类型名,例如 let a: number = 1;。如果在声明变量时没有显式指定类型,那么 TypeScript 将在第一次赋值时自动推断变量的类型。变量的类型一旦确定就不能再更改,向其赋予其他类型的值会引发错误。注意,TypeScript 中的变量是可空的,允许变量保存 null 值;如果编译时开启了 --strictNullChecks,就需要使用联合类型定义来允许变量保存空值,例如 string | null

定义函数时,除了需要定义各个参数的类型,还需要定义函数的返回值类型。函数声明中的类型定义格式为 function funcName(argName: argType): returnType。TypeScript 支持默认参数和可选参数,这两种参数都需要放置在参数表尾部,其中默认参数格式为 argName: argType = defaultValue,可选参数格式为 argName?: argType。由于 TypeScript 是静态类型语言,所以 TypeScript 中支持函数重载。由于在 JavaScript 中一切都是对象,所以 TypeScript 也不例外,函数在 TypeScript 中也有自己的类型,格式为 (argName: argType) => returnType,在平时使用中可以通过 type 关键字来定义函数类型的别名以简化函数类型的使用,格式为 type alias = (arg: argType) => returnType;

此外,TypeScript 支持泛型,格式为 <Type>。泛型可以使函数与类能够将类型作为一种可变元素来进行通用化处理。例如定义一个泛型函数格式为 function funcName<T>(arg: T): T,其中类型标记 T 就用于指代传入泛型函数的类型,在调用时可以使用 funcName<number>(1) 的格式来指定要使用的类型。在类中使用泛型,也同样需要使用 <Type>,格式为 class className<T>。在类型标记中,可以使用 extends 关键字来对泛型可以接受的类型进行限制,例如 <T extends string>。限制类型可以是内置类型、类、接口等任何合法类型。

TypeScript 中常用的类型可见下表。

类型名类型格式备注
布尔boolean
数字number
字符串string表示数字,没有整型与浮点的区别
符号symbol表示字符串
数组T[]用元素类型搭配 [] 操作符定义
数组Array<T>泛型定义数组中元素的类型
元组[T, T]用元组中各个元素的类型定义
枚举enum enumName{val, val}
任意类型any可用来存储任何值
Voidvoid用于无返回值函数的返回值类型声明
Nevernever用于函数声明,表示函数无法返回任何值,只会抛出异常
空值null通常与联合类型结合使用,表示变量为可空类型
未定义undefined
组合类型T | T表示将多个类型联合为一个,变量是多个类型的组合
联合类型T | T表示变量可以是多个类型中的一个

Tip

联合类型允许使用数字字面值、字符串字面值来定义变量可取的类型以及值。

接口

接口是 TypeScript 中比较重要的概念之一。TypeScript 中的接口不仅能够像 Java 和 C# 中的接口一样定义类模板,还提供了定义数据结构规约的功能。接口可以像一个类型一样使用,其中定义的内容均为适配该接口的对象或者类需要包含的属性及方法。以下用一个示例来说明接口中支持的所有功能及定义方法。

interface Sample<T> {
  name: string; // 定义一个必备属性
  value?: number; // 定义一个可选属性
  readonly x: number; // 定义一个只读属性
  [propName: string]: any; // 定义使用[]索引访问元素或属性的方法
  [index: number]: string; // 定义使用数字索引访问元素的方法,类似于数组
  new (arg: T); // 定义实现类需要实现的构造方法
}

// 这里定义了一个接口,其所接受的泛型类型限定必须为MessageType的子类,但是如果在使用这个接口的时候,没有声明所使用的泛型类型,那么将直接使用默认的MessageType.Message类型作为其泛型类型参数。
interface Sample<T extends MessageType = MessageType.Message>;

// 使用接口定义一个支持泛型的函数类型
interface SampleFunction {
  <T>(arg: T): boolean;
}
// 这种格式也是可以的,只是泛型类型T会对所有成员可见
interface SampleFunction<T> {
  (arg: T): boolean;
}

定义类的时候,可以使用 implements 关键字使类成为接口的一个实现。接口之间可以使用 extends 进行继承和扩展,同时接口还可以继承和扩展类。任何包含接口中所定义的成员的对象,都将被视为接口的实例,并在进行类型判断的时获得真值,这遵循“鸭子类型”的类型规则。

接口中的成员都是 public 的,不需要任何访问修饰符。但是在类定义中,可以使用 privateprotected 访问修饰符来定义私有以及保护成员。

工具接口

Typescript中还提供了一大批的工具接口,这些工具接口利用泛型对目标类型进行了修改,使其功能和特性发生了一定的变化,从而简化了接口的定义。常用的工具接口主要有以下这些。

  • Partial<Type>,将目标类型中的所有属性都变为可选的。
  • Required<Type>,将目标类型中的所有属性都变为必需的。
  • Readonly<Type>,将目标类型中的所有属性都变成只读的。
  • Record<Keys, Type>,定义一个键值对类型,键只能是Keys类型,相当于其他语言中的Map类型。
  • Pick<Type, Keys>,定义一个从Type中派生的新类型,这个新类型只能具有Keys中指定名称的属性。Keys通常使用字符串字面量联合类型定义。
  • Omit<Type, Keys>,定义一个从Type中派生的新类型,这个新类型将不具备Keys中指定名称的属性,Keys通常使用字符串字面量联合类型定义。
  • Exclude<UnionType, ExcludeMembers>,定义一个从UnionType中派生出来的新类型,这个新类型中将不包含ExcludeMembers中指定的成员类型。新类型是可以被赋值给原类型的。
  • Extract<Type, Union>,定义一个从Union中匹配Type提取出来的新类型。新类型是可以被赋值给原类型的。
  • NonNullable<Type>,将原本可空的类型改为不可空的类型,即移除其中的null | undefined
  • Parameters<Type>,构建函数类型Type所使用的参数类型,参数类型使用元组或者数组表示。
  • ConstructorParameters<Type>,从指定类型Type的构造函数中提取其参数构建新的类型,构造函数参数类型也同样使用远足或者数组表示。
  • ReturnType<Type>,构建函数类型Type所使用的返回值类型。
  • InstanceType<Type>,构建给定类型Type的构造函数构建出来的实例类型。
  • ThisParameterType<Type>,获取函数类型Typethis参数的类型。
  • OmitThisParameter<Type>,从给定的函数类型Type中移除this绑定。可以用来重新为Type类型更换this绑定。
  • ThisType<Type>,用于标记this上下文类型,并不会返回新的类型。
  • Uppercase<StringType>,用于生成字符串或者字符串联合类型StringType对应的全大写形式的类型。
  • Lowercase<StringType>,用于生成字符串或者字符串联合类型StringType对应的全小写形式的类型。
  • Capitalize<StringType>,用于生成字符串或者字符串联合类型StringType对应的首字母大写形式的类型。
  • Uncapitailize<StringType>,用于生成字符串或者字符串联合类型StringType对应的首字母小写形式的类型。

配置与编译

使用 TypeScript 编写的代码需要编译为 JavaScript 代码才能够在浏览器或者 Node.js 中使用。TypeScript 代码的编译通常使用 TypeScript 提供的 tsc 编译命令来完成。tsc 命令通常使用 tsconfig.json 文件来完成配置,该文件一般放置在项目根目录下。

tsconfig.json 文件中主要通过以下字段来定义 tsc 命令的默认配置。

  • compilerOptions,编译配置,具体内容可参考 TypeScript 文档,React 项目创建时该部分内容已经预置无需修改。
  • files,配置需要编译的文件。
  • includes,使用通配符规则配置需要编译的文件。
  • excludes,使用通配符规则配置需要排除的文件。
  • compileOnSave,当被监视文件保存时编译项目。

其中 compilerOptions 中常用的配置项有以下这些。

配置项可取值功能
moduleNone, CommonJS, AMD, System, UMD, ES6, ES2015, ESNext使用哪种模块模式
moduleResolutionNode, Classic指定如何查找模块
noImplicityAnytrue, false禁止隐式使用 any 类型
strictNullCheckstrue, false强制检查空值
alwaysStricttrue, false使用使用严格模式
experimentalDecoratorstrue, false使用修饰器功能支持
removeCommentstrue, false编译时移除注释
resolveJsonModuletrue, false允许将 JSON 文件作为独立模块导入
sourceMaptrue, false指示是否生成代码地图
typeRoots字符串列表列举库的声明文件所在目录
types字符串列表列举要包含的库声明文件
jsxreact, react-native, preserve对 JSX 文件的解析方式
outDir字符串编译输出文件的存放位置
rootDir字符串编译输入文件的存放位置
rootDirs字符串列表编译输入文件的存放位置
baseUrl字符串设定项目根路径
path对象设定模块引入路径转换映射
targetES3, ES5, ES6, ES2015, ES2017, ES2018, ES2019, ES2020, ESNext编译输出的 JavaScript 目标版本
lib字符串列表,具体见 TypeScript 文档编译时要包含在输出文件中的核心库

声明文件

TypeScript 通过声明文件来获取所有未显式在项目中定义过的内容,通常用来支持各种库和项目的外部全局变量定义等。声明文件是一个标准的 TypeScript 文件,以 .d.ts 后缀作为文件名结尾。通常使用 npm 安装的开头为 @types/ 的库都是各个功能库提供的声明文件,用来支持 IDE 进行代码提示以及编译期的合法性验证。但是需要注意的是,不是所有的库都有对应的 @types/ 提供声明文件,部分库已经自带了声明文件,或者其本身就是使用 TypeScript 编写的,这两种情况下,不需要安装 @types/ 来提供支持。

声明文件中一般只包含变量、函数、接口、类、命名空间等内容的声明,不包含实现代码。所有的声明都使用 declare 关键字引领。例如:

declare let personName: string;
declare function Hello(greeting: string): string;
declare interface Animal {
  height: number;
  averageWeight?: number;
  walk(): void;
}
declare class Mammal extends Animal {}
declare type Key = string | number;

declare namespace Application {
  function Greeting(target: string): string;
}

应用项目中的所有类型声明可以都集中到一个指定的目录中,并在tsconfig.json文件中的compilerOptions.typeRoots字段中列举指定这个目录的路径,这样就可以在使用Typescript的应用中实现对于自定义类型的代码提示,而且还不必在所有声明自定义类型的文件中将自定义类型导出出来。

组件

组件允许将 UI 拆分成独立可复用的片段,每个片段独立进行构建并独立完成相关功能。在 React 中,组件的概念类似于 JavaScript 中的函数,可以接受任意的入参(props),并返回用于描述 UI 内容的 React 元素。React 中的组件都是不可变的,组件一旦被创建,其属性和子元素就不能再发生任何变化,要理解 React 组件的这个特性,可以将其理解为 UI 界面在每一特定时刻的状态,如果 UI 要发生任何变化,那么就将是下一个时刻的状态了,而且在这个新的时刻,组件也将通过重新创建的方法更新。但是对于整个页面 DOM 来说,React 将只更新发生了改变的那些组件。

在 React 中定义组件有两种方法:使用函数定义组件和使用 ES6 中的class定义组件。但是在目前比较新的 React 版本中,使用 ES6 中的class来定义类组件已经不再推荐了,使用函数来定义组件会使得组建的定义更加轻量、灵活。

function Welcome(props) {
  return <h1>Hello, {props.name}</h1>;
}
class Welcome extends React.Component {
  constructor(props) {
    super(props);
  }

  render() {
    return <h1>Hello, {this.props.name}</h1>;
  }
}

以上两个组件的定义示例是等效的。在 16.8.0 Hook 概念出现之前,使用class来定义 React 组件是一个比较通用的方法,在 Hook 概念出现之后,使用函数来定义组件变得更加简单,代码也被大大简化。所以如果使用 16.8.0 以上的 React 版本,推荐考虑使用函数来定义组件。

Warning

在 React 中,用户自定义的组件名称都必须以大写开头。小写字母开头的组件会被视为原生 HTML 标签。

在 React 项目中,组件在使用时必须在作用域中存在。通常一个 JSX 文件中定义一个组件,在其他的组件中使用这个组件时,需要先使用import或者require将其引入自己的作用域。

组件树

一个 React 项目在正常运行的时候,其中的组件会像 HTML DOM 一样组成一个树形结构。所以在设计和实现项目中的组件的时候,也一样需要考虑到组件在这个树形结构中可能存在的位置,以及其可能处于的上下文。在 React 项目中,组件树中的每一个节点就是一个组件,组件树的根就是最开始的App,是 React 渲染的第一个组件。

函数组件代码的组织

如果接触过 React 中的类组件,那么可能会对其中各个处理方法以面向对象的方式组织在一起的代码组织形式印象比较深刻。但是在函数组件中,之前类组件中要实现的功能都被压缩到了一个函数中,这虽然使得定义一个组件所需要的代码量变少了,但是也给代码的清晰组织带来了一定的挑战。

其实在一个函数组件的定义中,对于其中代码的组织可以按照代码所执行的功能来进行分区,一般来说,根据组件中对于不同类型内容的使用顺序,可以将函数组件中的代码划分为数据区、计算数据区、事件处理定义区、副作用区、渲染区五个部分。

以下通过一个比较复杂的示例来展示一下函数组件中各个分区中内容的样子。

// 首先需要引入函数组件中所需要的功能
import { useEffect, useMemo, useState } from "react";

// 如果使用的是Typescript,那么就需要定义函数组件的props类型,
// props类型定义可以使用type,也可以使用interface
type ShelfProps = {
  books: Book[];
};

// 以下是函数组件的定义,可以使用function关键字定义,也可以使用箭头函数定义,
// 但是需要注意要导出组件
export function Shelf(props: ShelfProps) {
  // 数据区
  const [selectedBook, setSelectedBook] = useState<Book | null>(null);

  // 计算数据区
  const bookAmount = useMemo(() => props.books.length, [props.books]);

  // 事件处理定义区
  const select = (book: Book) => {
    setSelectedBook(book);
  };
  const unselectAll = () => {
    setSelectedBook(null);
  };

  // 副作用区
  useEffect(() => {
    setSelectedBook(null);
  }, []);

  // 渲染区
  return (
    <div>
      {props.books.map((book) => (
        <BookDetail book={book} onSelect={select} />
      ))}
      <p>书籍总数:{bookAmount}</p>
      <button onClick={unselectAll}>取消选择</button>
    </div>
  );
}

其实在日常的编码过程中,数据区和计算数据区中的内容常常会混在一起书写,没有特别明显的界限,在这个区域里主要要完成的任务是准备组件中所要使用的各种数据。

事件处理定义区主要用来定义组件中所要用到的各种交互功能,把它排在数据区后面是因为事件处理的落差中往往需要使用到数据区中定义的各种数据,根据先定义再使用的原则,事件处理定义区自然就需要排在数据区后面。

函数式组件的一个特点就是相同的输入应该产生相同的输出,也就是只要函数组件的入参内容是一样的,那么函数组件渲染出来的内容也应该是一样的。但是在整个组件的业务逻辑处理过程中,往往还需要做一些与输出无关或者需要配合数据完成其他动作的事情,这些操作在 React 中统统被称为副作用操作。在代码中最常见的就是使用useEffect引导的语句块。

渲染区就比较好理解了,这里就是组织和形成函数组件输出内容的位置。函数组件渲染输出的内容实际上就是函数的返回值,在函数组件中,返回的内容就是一个 React 节点。

Tip

一个组件中只能返回一个React节点,也就是只能有唯一的一个父级元素。如果需要返回若干平行元素的组合,需要使用之后章节中的Fragment概念。

props

React 元素为用户自定义组件时,它会将 JSX 中所接收的所有属性集中转换为单个对象传递给组件,这个对象就是props。例如上面示例中定义的组件Shelf在使用时就可以如下传递其中展示所需的props.booksprops所提供的主要功能就是父子组件之间的通信,允许父组件将任意合法的 Javascript 内容,例如字面量、对象、数组、函数等传递给子组件。

一个组件所接收到的props内容在组件的逻辑处理和组件渲染期间是只读的不可更改的。

在定义组件的props时,我们往往会为其定义一套类型来方便规定组件可以接收的属性。例如可以这样做。

// 使用type定义
type BookProps = {
  code: string;
  title: string;
  description?: string;
  recommended?: boolean;
  onSelect?: (code: string) => void;
};

//使用interface定义
interface BookProps {
  code: string;
  title: string;
  description?: string;
  recommended?: boolean;
  onSelect?: (code: string) => void;
}

就像上面示例中所列举的,如果使用 Typescript 来定义props参数的类型,是可以使用type定义一个类型或者使用interface定义一个接口的。这两种定义出来的props内容是没有区别的,区别仅在于在 Typescript 语言中typeinterface的区别。

Caution

在Typescript中,interface定义的接口默认是会自动拼合所有同名接口的,但type定义的类型则不支持重名,而且type还支持定义联合类型等更复杂的类型。所以在实际使用中,可以根据业务的实际需要来选择使用哪种方式,在这一点上,React和Typescript都没有做具体的规定。

在上面这个示例定义的props类型中,在使用的时候,可以像下面这样来使用。

<Book
  code="ISBN-839485"
  title="Live with React"
  recommended
  onSelect={(code) => console.debug("Selected Book: ", code)}
/>

在上面这个示例中,没有向组件Book中传递属性description,而且props类型中description定义的是可选属性,那么在组件中这个属性的取值就默认是undefined了。对于布尔类型的属性,如果其出现在了组件的属性列表中,但没有给定值,例如上面示例中的recommended,那么在组件中它的值就是true。这也算是 React 提供的一个特性之一。

给子组件传递props就跟在 HTML 中为标签元素设定属性一样。只是需要注意的是,如果传递的内容是使用""包裹的,那么传递的内容将是一个字面量,如果是使用{}包裹的,那么传递的内容将是一个对象或者数组。

在组件中接收props的时候,还常常利用 Javascript 中的解构语法来简化props中内容的调用,例如上面这个示例中,<Book>组件就可以这样定义。

function Book({
  code,
  title,
  description,
  // 可以利用赋予默认值的方式,来设置其中某个未提供属性的默认值
  recommended = false,
  onSelect,
}: BookProps) {
  return (
    <div>
      <h1>{title}</h1>
      <span>{code}</span>
    </div>
  );
}

children属性的传递

children属性在 React 中负责传递包裹在当前组件中的子元素。例如在下面这个用法中,<Child>组件就是以<Parent>组件的子组件身份出现的。

<Parent>
  <Child />
</Parent>

要在组件中获取到传递进来的children属性,也是通过props参数的。例如以下就是<Parant>组件的定义示例。

function Parent(props) {
  return <div>{props.children}</div>;
}

但是在定义props的类型的时候,却不必特意手动定义children属性,因为 React 提供了一个默认的接口PropsWithChildren来提供这个属性。其具体使用可以参考以下示例。

import { PropsWithChildren } from "react";

interface ParentProps {
  name: string;
}

export function Parent(props: PropsWithChildren<ParentProps>) {
  return (
    <div>
      <h1>{props.name}</h1>
      {props.children}
    </div>
  );
}

利用 React 中提供的这个接口可以很方便的给当前定义的props类型中增加children属性。

什么是 Hook

Hook 是 16.8.0 版本开始新增的特性,允许在函数组件中使用不同的 React 功能。常用的 Hook 主要有 State Hook 和 Effect Hook 等。Hook 实际上就是 JavaScript 函数,并且只能在函数最外层调用,不能在循环、条件、子函数中调用,而且仅能够在 React 组件中使用。

Hook 可以说是函数组件中的功能核心,通过 Hook,函数组件可以实现各式各样的功能。Hook 不仅有 React 提供的,而且也可以自定义自己需要的,几乎任何你所能想到的动作逻辑,都可以被定义成一个 Hook。在 React 中,Hook 通常都十分好认,它们都是使用use开头的,而且 React 也要求自定义的 Hook 也要遵循这个命名规则。

在一个 React 项目中,自定义 Hook 最常用的的场景就是用来在组件间共享逻辑,这在后面的章节中会逐渐体现出来。

定义 State

State 在 React 中也是一个非常重要的概念,它存储着一个组件当前的状态,也可以看作一个组件的记忆。props向组件传递的是组件的初始状态。在应用运行过程中,随着时间的推移和用户的操作,组件的状态是会发生改变的,但是由于 React 中props不可更改的规约限制,使得我们需要另寻一种形式来控制组件的状态。所以 React 提供了 State 来支持这项需求。

在函数组件中,State 的定义是通过 State Hook useState来完成的。State Hook 可以允许在函数组件中定义 State 及其变化函数。

import { useState } from "react";

function Counter(props) {
  const [count, setCount] = useState<number>(0);

  return (
    <div>
      <h1>Hello, {props.user}.</h1>
      <div>Current count: {count}.</div>
      <input type="button" onClick={() => setCount(count + 1)} />
    </div>
  );
}

函数useState()可以使函数组件中存储内部state。通过调用useState(),可以声明一个 State 变量并赋予初始值,还可以给定一个用于改变这个 State 的函数。useState()返回的 State 变量只是一个普通的变量,如果对其进行赋值操作,不会有任何效果,如果需要改变 State 变量的值,就必须使用useState()返回的setXXX()函数。在调用setXXX()的时候,实际上除了更新 State 变量的值以外,还同时通知了 React 需要重新渲染组件本身。

Tip

示例中的语句const [count, setCount] = useState<number>(0);采用 ES6 中的解构赋值语法,具体的使用可以参考相应的资料。useState()函数可以接受一个泛型参数来定义State变量的类型。

如果在函数组件中需要使用多个state变量,只需要调用多次useState()来创建变量即可,或者直接使用useState()来创建一个对象或者数组变量。需要注意的是 State Hook 返回的函数setXXX()总是采用替换的方式修改变量内容,而不是合并更新。

Tip

如果你使用useState()里声明了一个对象,那么在更新这个对象中的某一个或者某几个属性时,可以利用Javascript中的展开语法拼合新的对象,例如setPerson({...person, name: 'John'})。当然你在使用这个语法的时候,还是需要注意这个语法浅拷贝的特点的。

一定要记得,State中保存的东西始终都应该是新的,无论其中保存的是对象还是数组。

当需要惰性创建一个非常昂贵的对象时,可以向 useState() 中传递一个函数,React 只会在首次渲染时调用这个函数,并不会在组件重新渲染时发生变化。针对这一点,需要与后文介绍的 useMemo() 的功能区别开来。

为什么不使用普通变量

在函数中使用普通变量是一件非常平常的事情,但是在函数组件中,为什么不能使用普通变量来存储组件的状态而是需要使用 State 呢?这是因为在函数组件中函数的局部变量无法触发组件的渲染,也无法在组件的多次渲染中持久保存数据。

在没有做特殊声明和处理的时候,React 是无法监视一个函数中局部变量的变化的,而且在正常情况下,一个函数在执行结束以后, 其中的局部变量就因为超出作用域而被释放了,所以只靠局部变量是无法在函数组件中完成状态保持这个任务的。

构建 State 的原则

理论上来说一个组件里可以定义无数个 State,但是数量更多的 State 只会带来更加复杂的管理逻辑。所以在实际逻辑中,State 的定义应该参考借鉴以下原则。

  • 合并关联的 State,如果几个 State 总是同时更新,那么将其合并成一个 State 是一个更加合理的选择。
  • 避免定义互斥的 State,互斥的 State 往往需要成套的范式代码来维护,一旦忘记更新其中的一个 State,那么可能就会引入不必要的 Bug。
  • 避免定义冗余的 State,State 的更新操作会引起组件的重新渲染,如果一个 State 定义了但是并没有实际使用,可能会在无意中增加不必要的重新渲染动作。
  • 避免定义重复的 State,重复的 State 很难保证其中内容的同步,在这种情况下,useMemo()可能是更好的选择。
  • 避免定义深度嵌套的 State,如果你有足够的耐心来操作深度嵌套的 State,这一条你可以忽略。

如何不阻塞 UI 更新 State

State 的更新往往会带来组件的重新渲染,连续更新大量的 State 还可能会使应用的 UI 卡住。为了对这种情况进行优化,React 提供了一个useTransition Hook,可以将一些状态更新操作标记为非阻塞的。

useTransition会返回一个数组,使用时通常是这种格式:const [isPending, startTransition] = usetransition()startTransition接受一个同步函数作为参数,在这个函数中可以完成更新 State 的操作。

Caution

位于startTransition中的State更新是可以被其他的状态更新打断的,在startTransition之外的State更新拥有更高的优先级被处理。所以Transition不能被用来控制文本的输入,即配合State组建受控组件

Effect

一般说来,函数组件大多都是纯函数,而且其中的渲染部分一定都是纯粹的。这一部分的输入一般只是props和 State,而纯函数的意思就是对于相同的props和 State,函数组件产生的渲染输出都是一样的。但是一个组件在渲染之前和渲染时所要做的事情远远不止于此,例如有的组件需要通过访问服务器来获取数据。这种操作就决定了这样的函数组件不是一个纯函数,而其中也将包含很多副作用。这种副作用就是 Effect,也就是由渲染本身引起的操作而不是由事件引起的。

Tip

在之后的章节中会讨论由事件引起的副作用操作,也就是事件处理函数。

由渲染引起的 Effect 总是在屏幕更新以后的提交阶段运行,它通常用来暂时中断 React 的代码而与一些外部资源或系统进行交互。

在组件中定义一个 Effect 并不复杂,Hook useEffect()就是设计用来提供这项功能的。useEffect()的使用格式为useEffect(setup, dependencies),其中setup是 Effect 要执行的功能,React 要求其必须是同步函数,这个函数的返回值是 Effect 的清理函数;dependencies是这个 Effect 的setup函数中所引用的响应式值的列表,当这个列表里的值发生变化的时候,Effect 就会重新执行,也就是 Effect 的依赖项。

Effect 的执行过程是,当组件被添加到 DOM 的时候,React 就会执行 Effect 中的setup函数;当 Effect 的依赖项发生变化的时候,React 将使用旧值运行上一次 Effect 中setup函数返回的清理函数,然后再使用新值重新运行setup函数;当组件被从 DOM 移除的时候,React 将再执行一次从 Effect 中的setup函数返回的清理函数。

以下是一个常见的 Effect 的示例。

import React, { useState, useEffect } from 'react';

function Example() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `已点击${count}次。`;
  });

  return (
    <div>
      <p>已经点击了{count}次。</p>
      <button onClick={() => setCount(count + 1)}>Click Me</button>
    </div>
  );
}

如果需要在 Effect 中执行异步函数,那么你也必须将其视为同步函数来对待,或者利用Promise来使其组成链式操作。

例如直接像以下示例中这样使用异步操作是不会成功的。

function AsyncFetch() {
  useEffect(async () => {
    await fetch('some_url');
  });
}

位于 Effect 中的异步操作必须要同步调用才可以。

function AsyncFetch() {
  useEffect(() => {
    let fetchData = async () => {
      await fetch('some_url');
    };

    fetchData();
  });
}

清理函数

在 Effect 的setup函数可以返回一个函数,也就是上文中一直提到的清理函数。这个清理函数是十分有用的,尤其是在使用一些成对操作的情况下,例如调用document.addListener()之类的。以下是一个简单的清理函数的示例。

useEffect(() => {
    object.subscribe(...);
    return () => {
        object.unsubscribe(...);
    };
);

所有需要在组件中 DOM 中移除时完成的操作,实际上都可以利用setup函数返回的清理函数来完成。

什么时候需要使用 Effect

要想知道什么时候需要使用 Effect,那么可以了解什么时候不需要使用 Effect。如果你计划的 Effect 只是用来调整其他的 State,那么你可能不会需要 Effect。

Effect 不应该被用来处理props和 State 数据的转换,也不应该用来处理用产生的事件。props和 State 中俄数据转换通常都比较昂贵,会消耗较多的资源,所以这种工作使用useMemo()会更加适合一些。

除此以外的情况,你可以尝试选择使用 Effect 来达到你的设计目标,但是在 Effect 并不想你设想的那样工作的时候,应该首先积极的考虑是否有其他可以替代 Effect 的方法。

同一个 Effect 会执行两次

这种现象主要出现在开发环境中,在开啊环境中,React 会执行两次 Effect 以确保在离开和返回页面时不会导致代码的运行出现问题。但是如果在 Effect 中需要完成一些额外的启动时只需要执行一次的操作,那么可能就会出现设想中应该执行一次的操作,结果却执行了两次的错觉。

出现这种情况时,所需要解决的问题不是如何确保 Effect 只执行一次,而是如何确保 Effect 在重新挂载以后可以正常工作。

如果确实需要在应用加载的时候只执行一次,那么可以参考下面这个示例使用一个顶级变量来记录是否执行过来解决。

let initialized = false;

function App() {
  useEffect(() => {
    if (!initilized) {
      initialized = true;
      // 以下执行需要仅执行一次的功能。
    }
  }, []);
}

依赖一个空数组和没有依赖项的区别

  • 在 Effect 的dependencies中传递一个依赖数组可以使 Effect 监控一些值的变化,并在这些值发生变化的时候重新执行 Effect。如果依赖项中的内容没有发生变化,那么 Effect 就不会执行了。
  • 如果传递一个空数组作为 Effect 的依赖项时,就表示 Effect 没有任何依赖项,这个 Effect 只在初始渲染之后执行。
  • 但如果省略 Effect 的依赖项,那么这个 Effect 就会在每次组件重新渲染的时候都执行一遍。

Tip

注意,在开发模式下,即便是传递空数组作为Effect的依赖项,Effect也会执行两次。

Effect 出现无限循环的执行

在 React 项目开发的过程中,Effect 出现无限循环执行的情况还是非常常见的,要造成这种效果,需要满足两个条件:

  1. Effect 的setup函数中更新了一些 State。
  2. 这些被 Effect 更新的 State 导致了组件的重新渲染,致使 Effect 再次执行。

了解了这个会造成 Effect 无限循环执行的原因,那么你就可以尝试看看如何来解决这个问题了。

两个扩展的 Effect Hook

useEffect Hook 总是在组件渲染完毕即将提交屏幕之前执行,但是有一些操作可能并不需要这个运行时机,所以 React 提供了另外两个 Effect Hook 来扩展 Effect 函数执行的时机。

useLayoutEffect

React 会在运行 Effect 之前先让浏览器绘制出更新后的屏幕,所以如果 Effect 中操作了一些涉及视觉的操作,例如定位组件之类,那么可能会造成屏幕的闪烁,此时可以使用useLayoutEffect来代替useEffect

useLayoutEffect的触发时机在浏览器重新绘制屏幕之前,所以常常用来计算布局。例如:

function Component() {
  const ref = useRef(null);
  const [componentHeight, setComponentHeight] = useState(0);

  useLayoutEffect(() => {
    const { height } = ref.current?.getBoundingClientRect() ?? 0;
    setComponentHeight(height);
  }, []);
}

useInsertionEffect

useInsertionEffect主要用于 CSS-in-JS 库的开发,允许在布局副作用触发之前将元素插入到 DOM,常常用来在 CSS-in-JS 库中注入动态样式。

条件渲染

在函数组件的渲染部分是整个组件索要渲染输出的最终整合部分,在实际 React 项目中,一个组件也常常会遇到需要根据一定条件输出不同的渲染输出的要求,这就需要使用到条件渲染的功能。

条件渲染的主要目标就是在组装渲染输出的时候,即时决定内容是否需要保留。虽然可以使用比较复杂的方式实现,例如提前使用if来对条件进行判断,获取所需要输出的组件片段。

function ConditionRenderer(props) {
  let button;
  if (props.isLoggedIn) {
    button = <button>登出</button>;
  } else {
    button = <button>登入</button>;
  }

  return <div>{button}</div>;
}

上面示例中的这种组装形式还是十分复杂的,尤其是在组件中存在大量需要判断是否输出的组件片段的时候。所以常用的方式还是使用三元操作符在return部分做即时的判断和输出。例如上面这个示例可以精简成以下这个样子。

function ConditionRenderer(props) {
  return (
    <div>
      {props.isLoggedIn ? <button>登出</button> : <button>登入</button>}
    </div>
  );
}

如果组件中不需要这么复杂的判断,只需要根据指定条件决定一个元素是否输出,那么可以直接借助 Javascript 中的&&操作符来完成。例如:

function ConditionRenderer(props) {
  return <div>{props.isLoggedIn && <button>登出</button>}</div>;
}

如果需要阻止整个组件的输出,只需要让return语句返回null即可。

重新渲染

一个组件在显示到屏幕上之前,需要经过 React 的渲染,这在之前的章节中已经多次提到了。React 将一个组件显示到屏幕上主要会经过三个步骤:触发渲染、渲染组件、将渲染后的组件提交到 DOM。

一旦组件完成了初次渲染,接下来的渲染就全部都是重新渲染的范畴了。那么 React 在何时会重新渲染组件,我们又可以如何利用重新渲染的机制来增加一些手动控制呢?

首先要明确的一点,React 只在必要的时候才会重新渲染组件,换句话说只在两次渲染之间组件必须确实发生了一些变化。而能代表这些变化的就是props和 State。

props和 State 组成了组件中的一张快照,代表了组件当前的状态。所以最浅显的一个操作:调用 State 的set函数就可以触发组件的重新渲染。在徐建正在被 React 渲染的时候,组件的props、事件处理函数、局部变量等都是根据组件当前的 State 计算出来的。所以组件会首先返回一个 JSX 快照,然后由 React 更新渲染以匹配返回的这个 JSX 快照。

在 React 处理过程中,State 实际上是位于函数组件之外的,因为我们所定义的组件实际上是 View,这个 View 加上 React 管理的 State,才是最终屏幕上展示出来的结果。

所以要手动完成组件重新渲染控制的核心,还是利用useState来定义能够被 React 管理的 State,然后利用 State 提供的set函数来触发 State 更新,使组件进入重新渲染。

创建和使用 Context

Context(上下文)为应用提供了一个无需为组件指定props就可以在组件树中传递数据的方法。在典型的 React 应用中,数据都是通过props由父及子进行传递的。如果一些属性是许多组件都需要使用的,例如用户信息,那么使用props就会十分复杂。Context 就是为了解决这类数据的共享而存在的,其中保存的是相对组件树来说的全局数据。

Context 使用React.createContext(defaultValue)方法完成创建,并使用Context.Provider组件对组件树的根组件进行包裹。在其后的组件中,任何订阅了这个 Context 的组件,都会从距离自己最近的 Provider 处获取到当前 Context 的值。如果需要多个 Context,可以使用嵌套的方式包裹组件树的根组件。

在组件树中的子组件需要指定本组件的contextType,来将 Context 绑定到相应的 Provider 上,并从 Provider 中获得值。

import { createContext } from "react";

export type OnlineStates = {
  users: User[];
  updateUsers: (users: User[]) => void;
};

export const OnlineContext = createContext<OnlineStates>({
  users: [],
  updateUsers: () => {},
});

function App() {
  const [users, setUsers] = useState<User[]>([]);

  return (
    <OnlineContext.Provider value={{ users, updateUsers: setUsers }}>
      // 这里放置其他需要使用Context的组件。
    </OnlineContext.Provider>
  );
}

Tip

在定义Context的时候,一定要注意导出所定义的Context,这在未来使用这个Context的组件中要使用。

上面这个示例中的 Context 可以在其下的组件树部分中像下面这个示例中一样使用。

import { useContext } from "react";
import { OnlineContext, OnlineStates } from "./context-provider";

function States() {
  const { users, updateUsers } = useContext<OnlineStates>(OnlineContext);

  // 这里可以直接使用从Context中获取到的users和updateUsers。
}

从上面这个示例中可以看到,Context 中实际上是可以传递任何内容的。这个示例中就传递了一个useState返回的set函数,这在其下的组件中调用 Context 中提供的方法updateUsers()时,就可以直接更新 Context 中的users内容了,也就变相的实现了一个基于组件树 Context 的组件间通信。

Memoized 值的定义和使用

Hook useMemo() 提供了一个创建 Memoized 值的方法,允许对依赖项数组进行监控,仅当依赖项数组发生改变时才重新使用创建函数计算 memoized 值。Memoized 值有助于避免在每次渲染时都进行高开销的计算。useMemo() 的使用格式为 useMemo(fn, deps)。具体使用可参考以下示例。

const value = useMemo(() => computeValue(a, b), [a, b]);

在示例中,当依赖数组 [a, b] 中的任何一个值发生变化,React 都将重新计算新的值。如果不提供依赖数组,那么 React 在每次渲染时都会计算新的值。传入 useMemo() 的函数会在渲染期执行,请记住不要在这里执行任何副作用操作。useMemo() 中的依赖数组并不会被传入创建函数作为参数,所以就目前的 React 版本,不要尝试在创建函数中利用参数来访问依赖数组。

React 并不保证所有的 Memoized 值都能在依赖数组不发生变化时不被计算,例如在离屏组件中,React 会将组件状态从内存中丢弃,并在需要时重新计算,在这种情况下,Memoized 值就不会被记住。所以在编写应用时,可以先不使用 useMemo(),待代码可执行后,再使用 useMemo() 进行优化。

useMemo() 中的创建函数可以返回各式各样的对象,甚至可以被用来跳过子节点的重新渲染,这只需要在创建函数中返回一个组件即可。但是需要注意的是,Hook 调用不能被放置在循环中,如果需要在列表中使用,可以将列表项抽取成一个独立的组件来处理。

Warning

不是什么时候都必须要使用useMemo来优化组件中对于数据的处理,一般来说只有创建或者循环了成千上万个对象才会出现需要优化的问题。在不确定一段代码是否需要优化之前可以使用console.timeconsole.timeEnd两个方法来对一段处理所消耗的时间进行测量。useMemo不会使渲染变快,它的作用只是可以帮助组件跳过不必要的更新。

事件处理

React 中事件命名采用驼峰式书写,并且需要传入一个函数作为事件处理函数。在函数组件中,一个事件的处理函数常常作为组件内的函数存在,在实际定义时可以使用关键字function也可以直接定义一个匿名剪头函数。例如以下是一个最简单的响应<button>元素点击事件的例子。

function Button(props) {
  function handleClick() {
    alert("Clicked!");
  }

  return <button onClick={handleClick}>{props.children}</button>;
}

但是这种function中套function的写法看起来属实比较怪,所以在实际使用中,常常都是使用匿名剪头函数来定义,例如:

function Button(props) {
  const handleClick = () => {
    alert("Clicked!");
  };

  return <button onClick={handleClick}>{props.children}</button>;
}

甚至还可以直接在onClick的位置定义匿名函数。

function Button(props) {
  return <button onClick={() => alert("Clicked!")}>{props.children}</button>;
}

优化事件处理函数定义

useMemo一样,在事件处理函数中如果访问了组件的 State 等内容,实际上也是一种开销。一种比较好的解决方法依旧是将事件处理函数缓存起来,等到它内部所使用的值发生变化的时候再重新渲染。

于是 React 中提供的另一个 Hook useCallback就派上用场了,useCallback可以在组件的多次渲染中缓存其所定义的函数。useCallback的使用方法与useMemo基本上一致,其接受的参数也是一个需要缓存的函数和一个dependencies依赖项列表。

Tip

在大部分情况下,React是不会丢弃函数的缓存的,也就是说在应用刚开始开发的时候是没有必要直接使用useCallback的,只有在某些计算过程变慢需要优化的时候才需要使用。如果应用中使用了虚拟列表等需要React丢弃函数缓存的功能,那么useCallback就更没有用武之地了。

事件冒泡

事件冒泡通常是指事件在触发以后,沿着组件树向组件树的根节点传播。在传统 HTML DOM 编程中,我们常常使用e.stopPropagation()来阻止事件继续冒泡。

但是如果需要在事件被阻止冒泡以后继续被捕获,那么就可以在组件树上使用事件带有Capture后缀的版本来对其进行捕获。例如:

function Compo() {
  return (
    <div
      onClickCapture={() => {
        /* 将先于子元素的onClick处理执行 */
      }}
    >
      <button onClick={() => e.stopPropagation()}>Click</button>
    </div>
  );
}

事件默认行为

在 HTML DOM 中,浏览器对一些事件是有默认处理行为的,例如<form>onSubmit,会导致页面的刷新。要阻止这种默认行为,在 React 中的处理与 HTML DOM 中的处理一样,都是通过调用e.preventDefault()来实现的。

事件类型

React 对于常见的 HTML 元素标签都提供了封装,同时也提供了相应事件的封装。在定义事件处理函数时,可以使用 React 提供的类型来接收和处理元素产生的事件。在 React 中经过包装的事件被称为合成事件,即SyntheticEvent<E>类型对象。React 中的绝大多数事件类型都是从SyntheticEvent<E>类型派生而来。

例如可以这样捕获input产生的输入事件。

function Input() {
  const [value, setValue] = useState<string | null>(null);
  const handleInput = (e: InputEvent) => {
    setValue(e.data);
  };

  return <input value={value} onChange={handleInput} />;
}

React 将 HTML DOM 的原生事件包装进了SyntheticEventnativeEvent属性中,在必要的时候可以通过这个属性来访问浏览器的原生事件。除了nativeEvent以外,SyntheticEvent类型还提供了以下标准属性可供使用。

  • bubbles:布尔值,用于表示事件是否会冒泡传播。
  • cancelable:布尔值,用于表示事件是否可以被取消。
  • currentTarget:DOM 节点,用来获取当前节点在 React DOM 树中的位置。
  • defaultPrevented:布尔值,用来表示是否调用了preventDefault()方法。
  • eventPhase:数字值,用来表示当前事件所处的阶段。
  • isTrusted:布尔值,用来返回事件是否由用户发起。
  • target:DOM 节点,用来返回事件发生的节点。
  • timestamp:数字值,用来返回事件发生的时间。

常用事件及其对应类型

以下事件监听属性适用于所有内置组件

事件监听属性事件类型触发时间
onAnimationEndAnimationEvent在 CSS 动画完成时
onAnimationIterationAnimationEvent在 CSS 动画的一次迭代结束时
onAnimationStartAnimationEvent在 CSS 动画开始时
onAuxClickAnimationEvent当指针设备上非主要指针按钮被点击时
onBeforeInputInputEvent在可编辑元素的值被修改之前
onBlurFocusEvent在元素失去焦点时
onClickMouseEvent当指针设备上的主按钮被点击时
onCompositionStartCompositionEvent当输入法编辑器开始新的组合会话时
onCompositionEndCompositionEvent当输入法编辑器完成或者取消组合会话时
onCompositionUpdateCompositionEvent在输入法编辑器收到一个新的字符时
onContextMenuMouseEvent在用户尝试打开上下文菜单时
onCopyClipboardEvent在用户尝试将一些内容复制到剪贴板时
onCutClipboardEvent在用户尝试将一些内容剪切到剪贴板时
onDoubleClickMouseEvent在用户双击时
onDragDragEvent在用户拖拽某些元素时
onDragEndDragEvent在用户停止拖拽某些元素时
onDragOverDragEvent在被拖动的元素进入指定放置目标时
onDragStartDragEvent在用户开始拖拽元素时
onDropDragEvent在元素被拖放到有效的目的区域时
onFocusFocusEvent在元素获得焦点时
onGotPointerCapturePointerEvent当元素以变成方式捕获指针时
onKeyDownKeyboardEvent当按键被按下时
onKeyUpKeyboardEvent当按键被释放时
onLostPointerCapturePointerEvent当元素停止捕获指针时
onMouseDownMouseEvent当指针被按下时
onMouseEnterMouseEvent当指针在元素内移动时
onMouseLeaveMouseEvent当指针移动到元素外部时
onMouseMoveMouseEvent当指针改变坐标时
onMouseOutMouseEvent当指针移动到元素外部或者移动到子元素时
onMouseUpMouseEvent当指针释放时
onPointerDownPointerEvent当浏览器取消指针交互时
onPointerEnterPointerEvent当指针在元素内移动时
onPointerLeavePointerEvent当指针移动到元素外部时
onPointerMovePointerEvent当指针改变坐标时
onPointerOutPointerEvent当指针移动到元素外部时
onPointerUpPointerEvent当指针不再活动时
onPasteClipboardEvent当用户尝试从剪贴板粘贴内容时
onScrollEvent当元素被滚动时
onSelectEvent当可编辑元素内部的选择更改后
onTouchCancelTouchEvent当浏览器取消触摸交互时
onTouchEndTouchEvent当一个或者多个触摸点被移除时
onTouchMoveTouchEvent当一个或者多个触摸点发生移动时
onTouchStartTouchEvent当一个或者多个触摸点被放置时
onTransitionEndTransitionEvent当 CSS 过度完成时
onWheelWheelEvent当用户旋转滚轮时

以下事件监听属性适用于<form>元素

事件监听属性事件类型触发时间
onResetEvent当表单被重置时
onSubmitEvent当表单被提交时

以下事件监听属性适用于<dialog>元素

事件监听属性事件类型触发时间
onCancelEvent当用户尝试关闭对话框时
onCloseEvent当对话框已经被关闭时

以下事件监听属性适用于<details>元素

事件监听属性事件类型触发时间
onToggleEvent当用户切换详细信息时

以下事件监听属性适用于<img><iframe><object><embed><link>、SVG 中的<image>元素

事件监听属性事件类型触发时间
onLoadEvent当资源开始加载时
onErrorEvent当资源无法加载时

以下事件监听属性适用于<audio><video>元素

事件监听属性事件类型触发时间
onAbortEvent当资源没有完全加载时
onCanPlayEvent当有足够的数据可以开始播放但没有缓冲到结束时
onCanPlayThroughEvent当有足够的数据且可以播放到结束时
onDurationChangeEvent当媒体持续事件更新时
onEmptiedEvent当媒体变为空时
onEncryptedEvent当浏览器遇到加密媒体时
onEndedEvent当因为没有剩余内容可以播放而停止时
onErrorEvent当资源无法播放时
onLoadedDataEvent在当前播放帧已被加载时
onLoadedMetadataEvent当元数据加载完成时
onLoadStartEvent当浏览器开始加载资源时
onPauseEvent当媒体暂停时
onPlayEvent当媒体不再暂停时
onPlayingEvent当媒体开始或者重新开始播放时
onProgressEvent在资源加载时定期触发
onRateChangeEvent当播放帧率改变时
onResizeEvent当视频大小发生改变时
onSeekedEvent当搜索操作完成时
onSeekingEvent当搜索操作开始时
onStalledEvent当浏览器等待数据时
onSuspendEvent当资源加载被暂停时
onTimeUpdateEvent当播放时间更新时
onVolumeChangeEvent当音量发生变化时
onWaitingEvent当临时缺少数据而播放停止时

受控组件与非受控组件

React 中的受控组件和非受控组件都是指的表单组件,其区别主要是表单值是由 React 处理还是由浏览器处理。

受控组件

表单元素,如inputtextareaselect等,通常都是自行维护 State 并根据用户的输入进行更新。通过与 React 组件中的 State 进行组合,可以使 React 组件中的 State 成为唯一数据源。渲染表单的 React 组件在控制组件 State 的同时,还控制用户输入过程中的表单操作,还可以更方便的对用户输入的内容进行验证,这就形成了受控组件。

所以在项目中,一般可以通过以下方式来获取用户的输入。

function UserForm(props) {
  const [formState, setFormState] = useState({
    username: "",
    password: "",
  });
  const handleChange = (event) => {
    event.preventDefault();
    setFormState({ ...formState, [event.target.name]: event.target.value });
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        用户名:
        <input
          type="text"
          name="username"
          value={formState.username}
          onChange={handleChange}
        />
      </label>
      <label>
        密码:
        <input
          type="password"
          name="password"
          value={formState.password}
          onChange={handleChange}
        />
      </label>
    </form>
  );
}

Caution

在使用受控组件的时候,因为组建的State已经成为了表单组件展示数据的数据源,如果不手动响应表单组件的onChange等方法,表单组件中展示的内容是不会发生改变的。

非受控组件

受控组件的 State 数据都保存在 React 组件的 State 中,这就需要在 React 组件中编写大量用于控制 DOM 节点的逻辑。虽然在大部分情况下推荐使用受控组件来处理表单数据,但是在表单规模很大或者使用文件上传时,可以使用非受控组件来替代处理。

非受控组件可以使用ref来从 DOM 节点中获取表单数据。以下是上例的替代示例。

function UserForm() {
  const user = useRef();
  const pass = useRef();

  const handleSubmit = (event) => {
    const formData = {
      user: user.current.value,
      pass: pass.current.value,
    };
    event.preventDefault();
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        用户名:
        <input type="text" ref={user} />
      </label>
      <label>
        密码:
        <input type="password" ref={pass} />
      </label>
    </form>
  );
}

在使用ref来获取非受控组件的值时,通常需要给定组件一个初始值,这个初始值可以在组件上通过属性defaultValuedefaultChecked来赋予。

Fragment

在一个 React 组件中返回多个元素是一件很常见的事情。但是例如下面这个例子,可能就比较难以达到目的。

function Table(props) {
  return (
    <table>
      <tr>
        <Columns />
      </tr>
    </table>
  );
}

function Columns(props) {
  return (
    <div>
      <td>Column 1</td>
      <td>Column 2</td>
    </div>
  );
}

在这个示例中,Table 组件的渲染就将出现问题,因为 <table>标签的结构中是不能出现 <div> 标签的。但是又必须在组件中返回一个根标签结构,这种情况下,Fragment 就派上用场了。

Fragment 不会输出任何 UI,只是用来对需要返回的同级标签做分组。所以上面的这个示例就可以改成以下这个样子。

function Columns(props) {
  return (
    <React.Fragment>
      <td>Column 1</td>
      <td>Column 2</td>
    </React.Fragment>
  );
}

除了显式使用 <React.Fragment> 标签以外,还可以使用以下这样的简短模式。

function Columns(props) {
  return (
    <>
      <td>Column 1</td>
      <td>Column 2</td>
    </>
  );
}

如果用在循环里,每个子元素都需要使用一个 key 属性来进行唯一化标记,这时就必须使用显式的 Fragment 标签,例如 <React.Fragment key={item.id>}。

Portal

默认情况下,所有的组件都是会被渲染到 React DOM 树中,但是在一些特别需求的情况下,组件就会需要脱离 DOM 树,独立位于页面的某一个部分。为了打到这个目的,React 提供了 Portal。Portal 提供了一种将子节点渲染到父节点以外的 DOM 节点的解决方案。一个最经典的例子是渲染一个全局的 Modal 对话框,这种对话框通常会被插入到 body 中来完成复用。

用来创建 Portal 的函数是React.createPortal(children, domNode, key?)。其中children代表了一个要被放置在domNode位置的 React 组件,domNode则是用来指定一个用来存放 Portal 内容的 HTML DOM 节点,通常会使用document.body

例如可以这样来创建一个放置在document.body里的节点。

import { createPortal } from "react";

function Modal() {
  return (
    <div>
      <p>以下节点在document.body中。</p>
      {createPortal(<p>Portal节点</p>, document.body)}
    </div>
  );
}

Portal 只会改变 DOM 节点所在的位置,并不会改变节点的渲染和逻辑处理行为。而且 Portal 节点依旧可以访问 React DOM 树中父级节点提供的 Context 对象,其产生的事件也会沿着 React DOM 树进行冒泡。

自定义 Hook

自定义 Hook 就是一个函数,名称以use开头,函数内部可以调用其他 Hook,包括但不限于useState()useEffect()。自定义 Hook 可以返回任何类型的值,这个值可以被其他 Hook 或者组件使用。

在两个或更多组件中使用相同的自定义 Hook,不会使 Hook 中的 State 共享。但由于自定义 Hook 本身就是函数,所以不同的 Hook 之间可以进行通讯,传递信息。自定义 Hook 中是可以使用 Hook 函数的,所以可以仿照以下示例制作一个自定义的 Hook 用于在组件之间进行通讯。

import { useState, useEffect } from 'react';

function useStatus(zoneId) {
  const [isActivated, setActivated] = useState(false);

  useEffect(() => {
    const subscription = ZoneControlApi.subscribeZoneStatus(zoneId, status => setActivated(status.isActivated));

    // 以下代码会在 React 开发者工具中显示调试信息
    useDebugValue(isActivated ? 'Activated' : 'Deactivated');

    return () => {
      subscription.unsubscribe();
    };
  });

  return isActivated;
}

// 以上自定义 Hook 可以在组件中这样使用
function BattleFieldZone(props) {
  const isActivated = useStatus(props.id);

  // 实现组件中其他的逻辑功能。
}

这里需要注意的是,在上面的示例中,虽然已经定义了自定义 Hook useStatus ,但是这个自定义 Hook 并不会在组件之间共享 State,也就是说每次调用这个 Hook,都会创建一个独立的 State。

Warning

虽然使用自定义 Hook 可以非常方便的在组件之间共享处理逻辑,但是盲目过早的提取 Hook 可能会导致代码量激增,使项目的管理难度加大。所以在拆分逻辑功能的时候,还需要稍微保守一些。

Ref 的妙用

Ref 在组建中可以用来帮助引用一个不需要渲染的内容,包括值或者 React 节点。Ref 是通过 Hook useRef来定义的。组件可以通过 Ref 记住一些信息,但是这些信息可以不触发重新渲染。

在组件中创建一个 Ref 可以直接使用语句:const ref = useRef<T>(initialValue)。要使用 Ref 中保存的值,需要使用 Ref 的.current属性。当 Ref 的.current属性值被改变时,React 不会重新渲染组件。

以下是一个使用 Ref 存放计数器值的简单示例。

import { useRef } from "react";

function Counter() {
  const ref = useRef<number>(0);
  const handleClick = () => {
    ref.current++;
    console.log(`点击了 ${ref.current} 次`);
  };

  return <button onClick={handleClick}>点击</button>;
}

Caution

不要在组件的渲染期间读写Ref的值,这可能会让组件的行为变得难以预测。读写Ref的位置应该是在Effect或者事件处理中。

操作 DOM

使用 Ref 来操作 DOM 也是非常常见的行为,这也是 React 内置支持的功能。要操作 DOM,需要首先声明一个初始值为null的 Ref。

const domRef = useRef(null);

Tip

所有的DOM元素在React中也是有对应的类型的,可以用来声明Ref对象所存放的内容类型。具体可以在@types/react库中寻找名称为HTML*ElementSVG*Elemenet的类型定义,如果不知道具体所要引用的DOM节点类型,可以直接使用HTMLElement或者SVGElement

之后就可以在组件的渲染部分将 Ref 绑定到所要引用的 DOM 节点上。

return <input ref={domRef} />;

接下来就可以通过定义的 Ref 来操作 DOM 节点的一些功能了。以下是这个示例的完整版。

import { useRef } from "react";

function ManuelFocusInput() {
  const inputRef = useRef<HTMLInputElement | null>(null);
  const handleFocusClick = () => {
    inputRef.current?.focus();
  };

  return (
    <div>
      <input ref={inputRef} />
      <button onClick={handleFocusClick}>Focus Input</button>
    </div>
  );
}

获取自定义组件的 Ref

在默认情况下,自定义组件不会暴露他们内部 DOM 节点的 Ref,所以如果需要获取自定义组件的 Ref,就需要使用forwardRef对其进行包装。例如以下示例。

import { forwardRef } from "react";

const CustomInput = forwardRef(({ value, onChange }, ref) => {
  return <input ref={ref} value={value} onChange={onChange} />;
});

在 Typescript 中书写forwarfRef的类型是一个比较苦恼的事情,因为forwardRef所使用到的类型比较复杂,这里提供一个简短的示例来展示一下如何声明forwardRef所使用的类型。

// 首先声明一个自定义组件所接受的props参数的类型
type CustomComponentProps = {};

const CustomComponent: ForwardRefExoticComponent<
  CustomComponentProps & RefAttributes<HTMLDivElement>
> = forwardRef((props: CustomComponentProps, ref: Ref<HTMDivElement>) => {
  return <div ref={ref}></div>;
});

Tip

在未来React 19版本中,forwardRef将会发生更改,引用自定义组件会变得更加简单,所以不要纠结在forwardRef的类型声明上。

暴露组件的可操作接口

一般来说,一个组件是封闭的,是不能允许从外部操作其内部数据的。在父组件中调用其中子组件中的方法,一般会被认为是不推荐行为。这是因为这种调用需要父组件明确知晓子组件内部的具体方法以及调用形式,而这种知晓往往是侵入式的,并且破坏了面向对象变成原则中对于密封的要求。

但是在一些情况下,我们又需要组件能够暴露出一些功能,允许我们进行操作。这种调用往往存在非常实在的意义,比如进行父子组件间的通讯。这单单仅使用 Ref 是不容易做到的,还需要useImperativeHandle Hook 配合一起使用。

useImperativeHandle这个 Hook 提供了这样一个能力,允许在不破坏组件密封性的前提下,让组件对外暴露一定的方法,来允许其他的组件调用。这种形式就相当于组件对外暴露了一套接口,所以这种形式是保持了面向对象的基本原则的。

以下通过一个示例来说明useImperativeHandle对于组件密封性的保护意义。假设有一个组件提供了一个列表界面,这个列表界面上允许用户进行内容的选择,但是选项的清空功能需要从组件外部触发,所以组件需要向外提供一个方法来清空列表当前的已选项。

根据实例中对于列表组件的功能需求,首先定义一个列表组件。

type ListProps = {
  items: string[];
};

const ListComponent = (props: ListProps) => {
  const [selected, setSelected] = useState<number>(-1);
  const listItems = props.items.map((item: string, index: number) => (
    <li className={ 'selected': index === selected } 
        key={index} 
        onClick={() => setSelected(index)}
    >{item}</li>
  ));

  return (
    <ul>
      {listItems}
    </ul>
  );
};

export default ListComponent;

在这个列表组件中,对于列表项只提供了选择功能,而没有提供清除选择功能,这就导致这个组件在使用的时候,其中的列表项一旦被选择,就不能再被取消选择。而本着保持组件只包含最简单的展示逻辑的原则,这个列表组件并不适合再向其中添加其他的控制按钮或者连接来清除列表项的选择状态。

那么一个比较可行的方法就是让这个列表组件提供一个清除选择状态的方法来提供其他的组件调用。为了实现这个目标,就需要再定义一个接口,并配合useImperativeHandle来将其开放出去。这个列表组件经过改造以后,就变成了下面这个样子。

type ListProps = {
  items: string[];
};

export interface ClearSelection {
  clear: () => void;
}

const ListComponent = forwardRef((
  props: ListProps,
  ref: Ref<ClearSelection>
) => {
  const [selected, setSelected] = useState<number>(-1);
  const listItems = props.items.map((item: string, index: number) => (
    <li className={ 'selected': index === selected }
      key={index}
      onClick={() => setSelected(index)}
    >{item}</li>
  ));

  useImperativeHandle(
    ref,
    () => ({
      clear: () => {
        setSelected(-1);
      }
    }),
    []
  );

  return (
    <ul>
      {listItems}
    </ul>
  );
});

export default ListComponent;

forwardRef在 React 中提供的是将ref自动通过组件传递到子组件的方法,也就是ref转发。这里使用forwardRef的原因主要是普通的组件定义是不包含ref参数的,但是在这里我们需要使用ref参数来携带组件开放出去的方法。在这个示例中,需要注意的是,ref参数的类型被定义为了Ref<ClearSelection>,这就说明ref传递的内容是一个符合ClearSelection接口的对象,不能像使用 React 组件一样来使用它,只能按照接口的定义来使用。

所以,根据组件ListComponent的定义,在其父组件中可以这样来使用它。

const ListHost = ({ items }) => {
  const listRef = useRef<ClearSelection>(null);
  const clearSelection = () => {
    listRef.current?.clear();
  };

  return (
    <>
      <button onClick={clearSelection}>Clear</button>
      <ListComponent items={items} ref={listRef} />
    </>
  );
};

在这个示例中需要注意的是,listRef在使用的时候需要做空值判断,在示例中只是利用了 Typescript 的安全访问语法免除了空值判断过程。

组件间通讯

组件间的通讯有很多种,常见的主要是父子组件之间、同级组件之间和任意组件之间。

父子组件通讯

父子组件通讯主要依靠props。父组件可以通过props将信息传递给子组件,子组件还可以通过props中父组件指定的回调函数来将信息传递回父组件。具体实现可参考以下示例。

// 父组件
function ParentElement() {
  const [info, setInfo] = useState(0);

  const handleChange = (e) => {
    setInfo(e);
  };

  return <ChildElement value={info} onValueChange={handleChange} />;
}

// 子组件
function ChildElement(props) {
  const handleChange = (e) => {
    props.onValueChange(e.target.value);
  };

  return <input value={props.value} onChange={handleChange} />;
}

同级组件通讯

同级组件之间的通讯无论使用哪种方案解决,都还是需要其共同的父级组件来介导一下的。除了可以使用上面提到的父子组件通讯方法,还可以使用 Context 来形成一个 API 来传递通讯。

const SharedStateContext = createContext();

const ParentComponent = () => {
  const [sharedState, setSharedState] = useState("");

  return (
    <SharedStateContext.Provider value={{ sharedState, setSharedState }}>
      <ChildComponentA />
      <ChildComponentB />
    </SharedStateContext.Provider>
  );
};

const ChildComponentA = () => {
  const { sharedState, setSharedState } = useContext(SharedStateContext);

  return (
    <div>
      <input
        type="text"
        value={sharedState}
        onChange={(e) => setSharedState(e.target.value)}
        placeholder="Type in A"
      />
    </div>
  );
};

const ChildComponentB = () => {
  const { sharedState } = useContext(SharedStateContext);

  return (
    <div>
      <p>Shared state in B: {sharedState}</p>
    </div>
  );
};

任意组件间通讯

任意组件之间的通讯就要复杂一些了,首先的困难就是无法确定组件之间的共同祖先,其次如果将大量的 Context API 放置在应用的根节点上,可能会给应用带来不必要的开销。所以在这种情况下,可以利用现有的事件系统来实现,或者字型构建一个事件系统。这里借用events库来完成任意组件之间的通讯。

import EventEmitter from "events";
import { useEffect, useState } from "react";

const eventEmitter = new EventEmitter();

const ChildComponentA = () => {
  const [value, setValue] = useState("");

  const handleChange = (e) => {
    setValue(e.target.value);
    eventEmitter.emit("update", e.target.value);
  };

  return (
    <div>
      <input
        type="text"
        value={value}
        onChange={handleChange}
        placeholder="Type in A"
      />
    </div>
  );
};

const ChildComponentB = () => {
  const [sharedState, setSharedState] = useState("");

  useEffect(() => {
    eventEmitter.on("update", setSharedState);
    return () => {
      eventEmitter.off("update", setSharedState);
    };
  }, []);

  return (
    <div>
      <p>Shared state in B: {sharedState}</p>
    </div>
  );
};

const ParentComponent = () => (
  <div>
    <ChildComponentA />
    <ChildComponentB />
  </div>
);

惰性加载

在一般的应用编写中,对于组件的加载一般都是直接使用import Component from 'component_path';来完成的,这种加载方式拥有最快的组件加载速度和应用运行速度,但是在编译打包以后输出的 bundle 的体积会比较大,这在通过网络加载应用的时候用户体验会比较差。所以一般都会采用惰性加载来延迟一部分不常用组件的加载,来综合提升用户使用体验和网络加载速度。

React 提供了一个函数和一个组件来实现对于组件的惰性加载。

  • lazy,这个函数主要用于惰性加载组件,它接受一个函数作为参数,这个函数中需要动态的调用import并返回一个 Promise 类型对象,这个 Promise 对象的resolve结果需要返回一个组件。
  • Suspend,这个组件用于包裹惰性加载的组件,在组件正在被加载的时候提供比较优雅的降级措施。

lazy函数是比较容易理解和使用的,最常用的方法就是如以下示例中这样替代原来的import用法。

import { lazy } from 'react';

const FormComponent = lazy(() => import('./components/Form'));

Suspend组件的使用要更加简单一些,它只接受一个fallback属性,Suspend组件在其包裹的惰性加载组件尚未加载完毕的时候,会渲染fallback属性指定的组件来作为降级代替,在大部分应用中会选择加载提示来作为降级组件。例如像以下这样使用。

const TaskForm = lazy(() => import("./components/TaskForm"));
const TaskInfo = lazy(() => import("./components/TaskInfo"));

function TaskList(props) {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <TaskInfo />
        <TaskForm />
      </Suspense>
    </div>
  );
}

如示例中所示,Suspend组件不仅可以只包裹单一的一个惰性加载的组件,还可以包裹多个惰性加载的组件。在包裹多个惰性加载的组件时,Suspend组件回等待其包裹的所有惰性加载组件都加载完毕以后才进行正常内容的渲染,否则都将一直渲染降级组件。

Suspend组件还可以与 React Router v6 组合使用,只要包裹住用于定义路由结构的Routes组件,就可以支持整个路由结构中的所有惰性加载路由组件的降级渲染。

Render Props

render prop 是 React 中的一个术语,是指在 React 组件之间使用一个值为函数的 prop 共享代码的技术。说白了就是给组件的 prop 传入一个函数,来提供一个可以自定义的视图渲染逻辑。

例如下面这个组件使用示例。

<DataProvider render={data => <div>{data.msg}</div>} />;

Render Props 通常被用来解决横切关注点的问题。在组件开发的时候,偶尔会遇到需要将组件的封装状态或者行为共享给其他需要相同状态的组件的需求,这时就会面临一个问题:如何在另一个组件中复用这个行为?或者说将这个行为封装以后可以与其他的组件共享?

在上面这个简短的示例中,Render Props 就是一个用于通知组件渲染什么内容的函数 prop。以下是一个可以接受 Render Props 的组件示例。

function Mouse(props) {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const handleMouseMove = (event) => {
    setPosition({ x: event.clientX, y: event.clientY });
  };

  return <div onMouseMove={handleMouseMove}>{props.render(position)}</div>;
}

还可以基于这个示例创建一个高阶组件。

function withMouse(Component) {
  return () => {
    return <Mouse render={(mouse) => <Component {...props} mouse={mouse} />} />;
  };
}

如果在函数组件中返回一个函数,那么这个组件就不再是纯粹的了,因为其可能不会因为传入相同的参数产生相同的结果了,所以 Render Props 的使用,可能会在应用中埋入一些潜在的问题,在使用时需要多加留意。

Tip

其实Render Props在大部分情况下可以使用自定义Hook来代替。

获取异步数据

在 React 18 及以前版本中,异步获取数据的操作一般都是通过 Axios 或者 fetch 功能来完成的。但是在 Canary 和 Experimental 发布渠道中,React 新提供了一个use函数来从 Promise 和 Context 中获取内容。

使用 fetch 搭配自定义 Hook 完成异步

fetch 和 Axios 都是用来完成对服务端进行数据异步访问的,它们之间仅有一些使用上的不同。由于 React 里很多 Hook 都要求同步操作,尤其是useEffect中,所以在使用 fetch 或者 Axios 访问服务端数据时,一般都需要自定义一个 Hook 来简化组件中对于异步数据的访问。

以下是一个基于 fetch 使用 GET 方式访问 RESTful API 服务的自定义 Hook 示例。

import { useEffect, useState } from "react";

export function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true);
        const response = await fetch(url);
        if (!response.ok) {
          throw new Error("Network response was not ok");
        }
        const result = await response.json();
        setData(result);
      } catch (error) {
        setError(error);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [url]);

  return { data, loading, error };
}

在这个示例中,需要注意的是,useEffect Hook 中只能使用同步函数,所以即便是使用async/await定义了异步函数,也需要使用其同步调用方式。

使用use获取异步数据 🧪

Tip

本章节的内容在当前React 18版本中是不可用的,如果需要尝试本章节介绍的内容需要使用Canary或者Experimental发布渠道。预计在未来React 19版本中,本章的内容将被发布至正式版。

use不是一个 Hook,而是一个普通的函数,所以她可以在循环语句和条件判断语句中调用,但是调用use的函数依旧需要是一个组件或者 Hook。

使用use读取 Context 中的内容时十分简单,基本上与useContext一样。参见以下示例。

import { use } from "react";

function ThemedPage() {
  const theme = use(ThemeContext);
  // ...
}

但是当use与 Promise 结合使用的时候,效果就不一样了。use可以在调用异步过程的时候暂停组建的渲染过程,这也就是说use可以与<Suspense>组件一起搭配使用。当调用use的组件被<Suspense>包裹的时候,组件被use挂起时,将展示fallback指定的内容,直到use参数中的 Promise 被解决。如果use中的 Promise 出现错误,那么最近的错误边界中的后备 UI 将被展示。

以下示例展示的是利用 Promise 从服务端获取数据的用法。

import { Suspense, use } from "react";

function Message({ messagePromise }) {
  const messageContent = use(messagePromise);

  return <div>Message: {messageContent}</div>;
}

function App() {
  const messagePromise = async () => {
    const response = await fetch("/message");
    return response.text();
  };

  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Message messagePromise={messagePromise} />
    </Suspense>
  );
}

现在对比一下上面传统使用自定义 Hook 实现的异步操作,是不是已经简单了很多。

在有async/await加持的条件下,Promise 被 reject 时,往往可以使用try/catch来处理,但是在使用use捕获 Promise 返回的值时,是不可以使用try/catch的。作为替代方案,需要使用错误便边界。

错误边界的定义不在 React 中提供,函数式组件中目前也没有提供处理错误的错误边界功能。所以错误边界功能目前还是由传统的使用类编写组件的方式实现的,一般情况下,可以使用react-error-boundary库来支持错误边界,其中提供的<ErrorBoundary>组件的使用与 React 中提供的<Suspense>组件的使用基本上是一致的。

严格模式

React 的严格模式是一个用于突出显示应用中潜在问题的工具,StrictMode 组件不会渲染任何 UI,只会对它所包裹的所有子代元素进行额外的检查和警告。要对组件启用严格模式,只需要将其用标签 <React.StrictMode></React.StrictMode> 包裹即可。严格模式仅在开发模式下运行,不会影响生产模式的构建。

严格模式目前可以用于检测以下潜在的问题:

  • 组件将额外渲染一次,用来查找由于非纯渲染引起的错误。
  • 使用了过时字符串 ref API。
  • 使用了已经废弃的 findDOMNode
  • 意外的 Effect。组件将额外运行 Effect 一次,用来查找由于 Effect 清理过程缺失引起的问题。
  • 使用了过时的 Context API。

高阶组件

高阶组件不是 React 的组成部分,而是一种基于 React 特性形成的设计模式,是一种复用组件逻辑的技巧。所谓高阶组件,就是以组件为参数,返回一个新组件的函数。高阶组件在 React 的第三方库中十分常见,在实际应用中经常使用高阶组件来解决横切关注点的问题。

在中大型应用中,通常会出现多个组件共用一种显示及处理风格,但行为不同的一系列组件。对于这种组件,就可以利用高阶组件对其进行抽象。高阶组件通过定义一个通用的组件,并根据需要注入不同的数据和功能来建立新的组件,所以常用的格式如同下例所示。

function Post(props) {
  // 通用组件定义
}

function filteredPost(WrappedComponent, selectedData) {
  return (props) => {
    // 专用组件定义
    return <WrappedComponent data={selectedData} />;
  };
}

const CommentPost = filteredPost(Post, (Data) => data.getComments());

示例中的函数 filteredPost 通过返回一个匿名类建立了一个新的组件,这个组件将传入的组件进行了包装,并做了外围的数据处理。这就使得原始的通用组件只需要关心通用数据和行为即可,包装后的组件负责个性化后的数据和行为的提供,以此达到了组件复用的目的。

高阶组件不对原组件做任何修改,只是在外围进行包装或者多个组件的组合。对原始组件的干涉越少,代码的抽象级别就越高。

Tip

在React引入了Hooks以后,高阶组件的使用变得不那么频繁了,因为高阶组件主要解决的是关注点分离的问题,而用来解决这个问题所使用的复用逻辑的问题,很多情况下都可以通过自定义Hooks来解决。所以现在高价组件大多仅用在语义化表达以及更高的复用程度上。

闭包陷阱

在使用 React Hook 的时候最常遇到的问题就是闭包陷阱。要理清这个闭包陷阱,就需要先从两个简单的示例开始。

首先看一个无论何时都只会输出 5 的示例。

for (var i = 0; i < 5; i++) {
  setTimeout(() => {
    console.log(i);
  }, 0);
}

示例中使用了 setTimeout(f, 0) 的形式来让延时过程立刻执行,但是这段代码的输出依旧全部都是 5 。出现这种情况,首先需要明确在 Javascript 中什么是闭包。闭包就是一个函数与对其周围词法状态的引用捆绑在一起的组合。闭包可以在内层函数中访问到外层函数的作用域。

追究上例中问题出现的原因,就是因为闭包是在循环过程中定义的,这时的闭包捕获的是外层函数的作用域,但是这个作用域是所有闭包共享的,所以当这个作用域中的变量发生了变化,所有闭包能够取得的值也就发生了变化。

要避免这个问题,最简单的方法是在闭包外面再套上一层函数。

for (var i = 0; i < 5; i++) {
  (function (i) {
    setTimeout(() => {
      console.log(i);
    }, 0);
  })(i);
}

上面这个改进以后的示例,就是通过使用函数作用域,“固化”了闭包捕获的作用域,使所有的闭包不再共享作用域,就使问题得到了解决。

其实在使用 React Hook 过程中常常遇到的“闭包陷阱”,其实就是 Javascript 中这种闭包特性的体现。例如下面这个 React 组件,在使用的时候就会出现“闭包陷阱”。

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(function () {
    setInterval(function () {
      console.log(`Count: [${count}]`);
    }, 1000);
  }, []);

  return (
    <div>
      {count}
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

这个组件在运行的时候,无论怎样点击界面中的按钮,控制台中的输出始终是 Count: [0] 。这是因为在第一次渲染的时候,setInterval 中的闭包捕获的是 count0 时候的状态,而当 count 发生变化以后,setInterval 中的闭包所捕获的内容就变成了过时的内容,而 setInterval 中的闭包也就成了一个过时的闭包。

那么知道了这个“闭包陷阱”形成的原因,要解决它就比较容易了,一个比较简单可行的思路就是重新创建这个闭包。重新创建闭包听起来似乎不容易,但是 React 已经提供了针对这个问题的解决方法。例如把上面这个错误的组件修改一下,就成了下面可以正常工作的样子。

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(
    function () {
      const id = setInterval(function () {
        console.log(`Count: [${count}]`);
      }, 1000);
      return function () {
        clearInterval(id);
      };
    },
    [count]
  );

  return (
    <div>
      {count}
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

仔细与前面的示例对比一下,就可以发现,useEffect 里多了清理计时器的操作,而且 useEffect 的执行也加入了条件。这样一来,在 count 发生变化的时候,setInterval 中的闭包就会得到重建,组件也就会按照期望来执行了。

相同的情况也会发生在 useState 上,例如下面这个组件,它的运行效果也是十分诡异的。

function Counter() {
  const [count, setCount] = useState(0);

  function increaseAsync() {
    setTimeout(function () {
      setCount(count + 1);
    }, 2000);
  }

  function increaseSync() {
    setCount(count + 1);
  }

  return (
    <div>
      {count}
      <button onClick={increaseAsync}>延时递增</button>
      <button onClick={increaseSync}>立刻递增</button>
    </div>
  );
}

在这个组件中,交替点击两个按钮,会发现界面上所显示的数字在一定时间之后总会变成 1。这是因为 increaseAsync() 函数中的闭包捕获了旧的 count 状态,所以要解决这个问题,就不能像 useEffect 中那样重新创建闭包了,但是可以利用给 useState 返回的 set 方法一个闭包来使其捕获最新的状态值。

那么上面这个组件就可以修改成以下样子。

function Counter() {
  const [count, setCount] = useState(0);

  function increaseAsync() {
    setTimeout(function () {
      setCount(count => count + 1);
    }, 2000);
  }

  function increaseSync() {
    setCount(count + 1);
  }

  return (
    <div>
      {count}
      <button onClick={increaseAsync}>延时递增</button>
      <button onClick={increaseSync}>立刻递增</button>
    </div>
  );
}

所以,解决 React Hook 中的“闭包陷阱”所使用的方法就是判断闭包是因为什么情况捕获的过时变量。对于整个闭包都过时的,可以通过设置闭包的依赖(即 useEffect 的刷新条件)来解决;对于捕获的状态过时的,可以通过函数式方法来更新状态。

常用 Hook

随着 React 中函数式组件的发展,React 也提供了众多的 Hook。在之前的介绍中,许多 Hooks 都混杂在各个章节中不易寻找,这里将一些常用的 Hook 列举一下,简要的说明其功能。

  • useState(initial),允许向组件中增加一个可以触发渲染的状态变量(State)。
  • useEffect(fn, deps),允许组件在deps内容发生变更的时候在fn中与外部系统进行同步操作。
  • useContext(),接收一个使用React.createContext()创建的上下文返回值作为参数并返回这个上下文。
  • useReducer(reducer, initial, init),useState 的替代方案,返回[state, dispatch],工作方案与 Redux 相似。其中 reducer 的形制为 (state, action) => newState 的函数,action 是一个带有载荷的对象,而不是一个方法。如果打算采用惰性初始化 Reducer ,可以使用第三个参数 initinit 参数接受一个返回初始值的函数。
  • useCallback(fn, deps),仅在某个依赖项改变时才更新回调函数。这里提供的依赖项不会被传入回调函数中。相当于useMemo(() => fn, deps),即指定回调函数的 memorized 优化版本。
  • useRef(initial),返回一个可变 ref 对象,同样不能用于函数组件。但是 ref 对象不仅可以用于 DOM 对象,还可以借助其 .current 可变属性容纳任何值,从而在 useEffect() 或其他函数中提供对于域外内容的访问。当 ref 对象内容发生变化的时候,useRef 并不会发出任何通知,而且变更 .current 属性值不会引发组件的重新渲染。
  • useImperativeHandle(ref, createHandle, deps),允许在使用 ref 时自定义暴露给父组件的值。
  • useLayoutEffect(fn, deps),功能与 useEffect() 相同,但是会在所有的 DOM 变更之后同步调用 effect。所以常用来读取 DOM 布局并同步触发重新渲染。
  • useInsertionEffect(fn, deps),可以用来在布局 Effect 触发之前,将元素插入到 DOM 中。
  • useDebugValue(value),在 React 开发者工具中显示自定义 Hook 标签。
  • useDeferredValue(value),这是一个在 React 18 中新引入的 Hook,其主要功能是接受并返回一个值,但是这个值的更新将被推迟到组件的紧急更新之后,例如响应用户输入。在组件发生紧急更新之前尝试获取值将得到之前被缓存的值。与useMemo搭配使用可以达到类似防抖(Debounce)和节流(Throttle)的效果。
  • useTransaction(),这是一个在 React 18 中新引入的 Hook,主要用于返回一个状态的待更新中间态,这可以将状态的更新标记为不紧急。其调用返回一个数组,数组的第一个元素用于表示当前的更新状态(isPending),第二个元素是一个函数,用于标记和启动状态更新过程(startTransaction())。非紧急状态的更新将会被紧急状态的更新打断,这可以在组件处理一些比较耗时或者可能会导致组件不响应用户操作的事物时,保持用户与组件的交互。
  • useId(),这是一个在 React 18 中新引入的 Hook,主要功能是用来在组件中生成一个随机且唯一的 ID。并且这个 ID 可以在浏览器端渲染和服务器端渲染两种条件下保持一致。
  • 🧪useOptimistic(value, fn),可以用来帮助乐观的更新 UI,允许在进行异步操作期间展示value的值,并在fn执行结束后展示fn返回的新的value
  • useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?),在 React 18 中引入的新 Hook,主要功能是用来订阅一个外部 Store,或者是订阅浏览器暴露出来的 API。

Tip

带有🧪标记的Hook是需要使用Canary或者Experimental发布渠道的,因为这个功能尚在实验中,没有最终确定。正式版(Latest)里这个功能是不可用的。

接下来将拣选其中几个比较常用和可以解决项目中实际问题的 Hook 做进一步的详细说明。


useReducer

Reducer 可能是使用 React 的时候最常见到的一个词了。Reducer 通常用在处理复杂的数据逻辑并提供一个 State 的场景中。与useState可以直接提供一个改变 State 的功能不同的是,useReducer可以更加灵活的改变 State。

首先看一下useReducer在组件中的使用格式:

const [state, dispatch] = useReducer(reducer, initialArg, init?);

useReducer的使用格式中,state就是useReducer经过运算处理最终返回的 State,dispatch则是用来改变 State 的关键函数,对于调用了dispatch以后的具体处理逻辑,则是在reducer参数中定义的。结合后面 Flux、Redux 等章节的内容实际上就可以发现,useReducer就是完成了一个单向数据流 State 的处理过程。

Info

useReducer的参数initialArg表示用于初始化state的值,而init参数则是利用initialArg参数计算state初始值的函数。但是通常在使用中,只设定initialArg就已经足够使用了。

reducer函数的定义

useReducer Hook 中的第一个参数reducer是一个函数,它需要接受两个参数:stateactionreducer函数的返回值则是更新以后的state

React 没有对useReducerreducer函数的参数做任何限制,它们可以是任意合法的内容。例如以下利用switch实现一个根据action中携带的指令完成state内容更新的reducer函数。

function reducer(state, action) {
  switch (action.type) {
    case "increase":
      return {
        conter: state.counter + action.amount,
      };
    case "decrease":
      return {
        conter: state.counter - action.amount,
      };
  }
  throw new Error("Unknown action: " + action.type);
}

在这个示例中可以看到,action可以通过携带各种所需的内容,使reducer函数可以对state做出任何的改变。但是需要注意的是,reducer函数所返回的state,始终都应该是一个完整的state对象结构,而不是只是发生了改变的state不分结构,useReducer Hook 不会对新旧state自动进行拼合的。

Info

虽然useReducer不会对state进行贼床上拼合,但是可以结合其他的工具库来完成state对象的拼合操作,例如Ramda库提供的merge系列函数,亦或者Javascript中提供的Object.assign()方法。使用{ ...state }的格式来复制已有state中的属性也不失为一种好办法。

dispatch函数

dispatch函数是调用useReducer以后返回的内容之一,也是在项目代码中调用reducer函数更改state值的唯一途径。dispatch函数的唯一作用就是更新state值并触发一次组件的重新渲染,它所接受的参数就是reducer函数的action参数内容。

从以上的描述实际上可以看出来,dispatch函数就是把提供给它的action参数转发给了reducer函数,然后再触发一次组件的重新渲染。

例如结合前一节的示例,就可以这样实现一个组件。

function Amount() {
  const [state, dispatch] = useReducer(reducer, { counter: 1 });
  const handleIncrease = useCallback(() => dispatch({ type: "increase" }), []);
  const handleDecrease = useCallback(() => dispatch({ type: "decrease" }), []);

  return (
    <div>
      <div>
        <span>Current Amount: {state.amount}</span>
        <button onClick={handleDecrease}>Decrease</button>
        <button onClick={handleIncrease}>Increase</button>
      </div>
    </div>
  );
}

Warning

在使用dispatch函数的时候,有以下几点是需要注意的:

  1. dispatch函数在调用以后不会立即更新state,所以在调用dispatch函数以后立刻读取state的值,依旧还是更新之前的值,新的state值只能在组件重新渲染之后才可以获得。
  2. 如果新的state值与旧的state值相同,也就是使用Object.is方法进行比较获得的结果是true,那么React就会跳过组件的重新渲染,这是一种非常重要的优化手段。
  3. 组件中的state并不是即时更新的,而是批量更新的。React会在所有的视见函数调用完毕以后对state进行更新,这也是React为了减少组件的渲染次数做出的优化。

init函数

init函数在usereducer的定义中不是必备的,在不提供init函数的时候,useReducer会默认使用其第二个参数initialArg的值作为state的初始值,但是如果state的初始值是通过一定的逻辑处理生成的,那么就需要使用init参数了。init函数接受一个参数,就是useReducer的第二个参数initialArg,其返回值是state的初始值。

可能有的读者可能觉得既然是通过initialArg来生成state的初始值,那么可以直接使用init(initialArg)作为useReducer的第二个参数,也就是如同下面示例中的样子。

function produceInitValue(initialArg) {
  // 计算产生state初始值的逻辑
}

function RepeatInit() {
  const [state, dispatch] = useReducer(reducer, produceInitValue(initialArg));

  // 组件渲染逻辑
}

这个示例在实际运行的时候酒就会发现,produceInitValue(initialArg)返回的值虽然仅在初次渲染的时候被使用了,但是在组件每一次渲染的时候都会重复执行一次,如果这个初始化过程比较费时,那么就会严重影响项目的性能。所以在这种情况下就体现出了useReducer第三个参数的用途了。

useReducer的第三个参数接受的是一个函数的名称,而不是函数的调用,它可以保证被指定的初始化函数仅在useReducer首次使用的时候被调用一次,之后就不再运行。所以上面的示例可以优化成以下样子。

function produceInitValue(initialArg) {
  // 计算产生state初始值的逻辑
}

function RepeatInit() {
  // 注意这里只是使用了产生初始值的函数名,而不是调用。
  const [state, dispatch] = useReducer(reducer, initialArg, produceInitValue);

  // 组件渲染逻辑
}

一些常见的问题

reducerinit函数都执行了两次

在严格模式下,React 默认会调用两次reducerinit函数,这个特性主要是为了帮助开发者保持组件的纯粹。如果组件、reducer函数和init函数都是纯函数,那么 React 的这个特性并不会影响项目的逻辑。

例如以下代码中的reducer函数就不是纯函数,在严格模式下就会向state.items中增加两条数据。

function unpureReducer(state, action) {
  switch (action.type) {
    case "add_item":
      state.items.push({
        id: max(pluck("id", state.items)) + 1,
        text: action.text,
      });
      return state;
    // 以下其他分支代码省略
  }
}

这种在reducer函数中操作数组的方法正确的应该是每次都返回新的数组实例。例如可以修改成以下形式。

function unpureReducer(state, action) {
  switch (action.type) {
    case "add_item":
      return {
        ...state,
        items: [
          ...state.items,
          { id: max(pluck("id", state.items)) + 1, text: action.text },
        ],
      };
    // 以下其他分支代码省略
  }
}

Tip

一个比较好而且省事的推荐是使用一些函数式编程支持库,利用函数式编程中纯函数的特性来完成state的修改操作,可以大大简化state的控制。

调用dispatch以后出现了Too many re-renders的错误

出现Toomany re-renders的错误通常意味着组件渲染进入了死循环,也就是在徐建重新渲染过程中又发生了组件state的改变,触发了新的重新渲染请求。在使用useReducer的过程中出现这个错误,一般是在渲染期间再一次dispatch了一个能够触发重新渲染的action。大多数这样的错误都是存在于事件处理函数中。

例如以下组件中因为错误的书写了事件处理函数,就会造成dispatch调用的死循环。

function FaultyReducer() {
  const [state, dispatch] = useReducer(reducer, { count: 1 });
  const handleClick = useCallback(() => dispatch({ type: "increase" }), []);

  return (
    <div>
      <div>Current count: {state.count}</div>
      <button onClick={handleClick()}>Increase</button>
    </div>
  );
}

useDebugValue

useDebugValue的唯一功能就是在 React 调试工具中给自定义 Hook 增加标签。useDebugValue的调用格式为useDebugValue(value, format?),在自定义 Hook 的顶部设置value参数以后,就可以在 React 调试工具中看到这个 Hook 名下增加了对应值的标签了。

format参数则比较好理解,它主要是用来给value值做格式化转换处理。format参数接受一个函数,函数需要可以接受一个参数,在实际执行时,参数的值即为useDebugValuevalue值。

useDeferredValue

useDeferredValue的实际功能可能跟它的字面意思不是很一样,它提供的并不是一种 State,而是用来延迟 UI 的更新的。在组件中使用useDeferredValue可以获取指定值的延迟版本,通常与useState组合使用。useDeferredvalue打的返回值在组件初始渲染时与原始值相同,但是在组件更新的时候,React 会首先尝试使用旧值做组件的重新渲染,然后在后台使用新值做另一个重新渲染。

React 在后台会使用Object.is来比较旧值和新值,如果两者不同,那么 React 将会安排一个后台渲染,但这个后台渲染是可以被中断的。如果在 React 进行后台渲染期间,useDeferredValue接收到了另一个新值,那么之前的后台渲染就将会被中断。这个操作十分类似于常见的数据防抖操作,但是useDeferredValue的防抖时间阈值非常的小,所以不要将其做作为防抖方法使用。

useDeferredValue的主要功能还是用来在新内容加载期间展示用于替代的旧内容,改善 UI 的显示效果。需要注意的是,useDeferredValue的后台渲染不会触发任何useEffect定义的副作用,所有的副作用会在 UI 更新后执行。

useDeferredValue最典型的使用案例就是延迟更新数据列表,例如以下示例。

function DeferredList() {
  const [quest, setQuery] = useState([]);
  const deferredQuery = useDeferredValue(query);

  return (
    <>
      <div>
        <label>Search Keyword: </label>
        <input value={query} onChange={(e) => setQuery(e.target.value)} />
      </div>
      <Suspense fallback={<span>Loading...</span>}>
        <SearchResults query={deferredQuery} />
      </Suspense>
    </>
  );
}

以上示例将useDeferredValue<Suspense>组件搭配使用了,这可以达到在<SearchResults>组件未完成数据加载的时候,先展示旧数据和加载提示的效果。

如果需要判断当前展示的数据是新值还是旧值,在以上示例中可以使用query !== deferredQuery来判断。如果它们两个不相等,那么当前展示的就依旧是旧值,否则就是新值了。

useSyncExternalStore

useSyncExternalStore允许应用订阅一个外部的 Store,但是这个 Store 需要是同步的。useSyncExternalStore的使用格式如下:

const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?);

useSyncExternalStore在使用的时候,最常用的用法是提供其中前两个函数,其中subscribe用来订阅 Store,并返回一个取消订阅的函数,getSnapshot用来从 Store 中读取数据。

Tip

第三个参数getServerSnapshot是在使用服务端渲染以及在客户端水合服务端渲染内容的时候用到。

以下实现一个可以被useSyncExternalStore订阅的 Store。

const itemsStore = {
  addItem(info) {
    items = [...items, { id: max(pluck("id", items)) + 1, text: info }];
    emitChange();
  },
  subscribe(listener) {
    listeners = [...listeners, listener];
    return () => {
      listeners = listener.filter((l) => l !== listener);
    };
  },
  getSnapshot() {
    return items;
  },
};

function emitChange() {
  for (let listener of listeners) {
    listener();
  }
}

然后就可以使用useSyncExternalStore来订阅这个 Store 了。

function ItemList() {
  const items = useSyncExternalStore(
    itemsStore.subscribe,
    itemsStore.getSnapshot
  );

  return (
    <>
      <div>
        <button onClick={() => itemsStore.addItem(`${Math.random()}`)}>
          Add
        </button>
      </div>
      <ul>
        {items.map((item) => (
          <li key={item.id}>{items.text}</li>
        ))}
      </ul>
    </>
  );
}

在使用useSyncExternalStore的时候需要注意以下几点:

  1. getSnapshot返回的 Store 快照内容必须是不可变的,而且在 Store 没有发生变化的时候,getSnapshot返回的内容应该始终是一致的。
  2. React 会使用Object.is来对比getSnapshot返回的内容,以决定是否需要重新渲染组件。
  3. 如果在重新渲染的时候传入了一个不同的subscribe函数,那么 React 会使用新传入的subscribe函数重新订阅 Store。所以为了避免subscribe函数发生改变,应该像上面示例中一样在组件外部定义订阅函数。
  4. 如果在非阻塞 transition 更新过程中更新了 Store,那么 React 将回退并视此次更新为阻塞更新。换句话说,在每次 transition 更新的时候,React 将在更改应用到 DOM 之前第二次调用getSnapshot,如果此次的返回值与之前的返回值不同,那么 React 将重新开始更新过程。
  5. 不要使用useSyncExternalStore返回的 Store 内容决定渲染状态。

例如根据上面最后一条的内容,下面的用法是不建议使用的。

function IllegalStoreSwitch() {
  const selectedItemId = useSyncExternalStore(
    store.subscribe,
    store.getSnapshot
  );

  return selectedItemId != null ? <ItemDetailPage /> : <FeaturedItemPage />;
}

React 中使用的类型

React 的声明文件定义了名为 React 的命名空间,所以在使用 React 中声明的类型时,都是需要使用 React. 来引用的。

  • 所有泛型类型中的 P 均指代传递给组件的 props 的类型,通常根据需要自行使用接口定义,默认为 {}
  • 所有泛型类型中的 S 均指代组件自身的 states 的类型,通常也需要使用接口自行定义,默认为 {}
  • Component<P, S>,组件基类。
  • PureComponent<P, S>,纯组件类。
  • FunctionComponent<P>,函数式组件类,可简写为 React.FC<P>
  • ReactNode,React 组件实例。
  • RefObject<T>ref 引用对象。
  • MutableRefObject<T>,可变 ref 引用对象。
  • Context<T>,上下文对象。
  • Provider<T>,上下文供应器对象。
  • Consumer<T>,上下文消费者对象。
  • NamedExoticComponent<P>,Memoized 对象。
  • SetStateAction<S>setState() 函数对象
  • Dispatch<A>dispatch 函数对象。
  • Reducer<S, A>,Reducer 对象,其中 A 为 Action。
  • EffectCallback,副作用函数类型。

常用的函数的声明格式如下,在调用时一般需要指定函数的泛型参数,以返回指定类型的内容。

  • createContext<T>(defaultValue: T),返回 Context<T> 类型值。
  • createRef<T>(),返回 RefObject<T> 类型值。
  • useContext<T>(context: Context<T>),返回 T 类型值。
  • useState<S>(initial: S | (() => S)),返回 [S, Dispatch<SetStateAction<S>>] 类型值。
  • useReducer<R, I>(reducer: R, initial: I),返回 [ReducerState<R>, Dispatch<ReducerAction<R>>] 类型值。
  • useRef<T>(initial: T),返回 MutableRefObject<T> 类型值。
  • useEffect(effect: EffectCallback, deps?: DependencyList),返回 void
  • useMemo<T>(factory: () => T, deps: DependencyList | undefined),返回 T 类型值。

这里给出一个简单的函数式组件的编写示例。

interface CounterProps {
  user: string;
}

const Counter: React.FC<CounterProps> = (props: CounterProps) => {
  const [count, setCount]: [count: number, setCount: Dispatch<SetStateAction<number>>] = useState<number>(0);
  return (
    <div>
      <h1>Hello, {props.user}.</h1>
      <div>Current count: {count}.</div>
      <input type="button" onClick={() => setCount(count + 1)} />
    </div>
  );
};

使用PropTypes进行类型检查

在React 15.5之前的版本中,PropTypes是React内置的类型检查功能,但目前这个功能已经被独立了出来,形成了一个新的名为prop-types的库。这个库虽然功能不及Flow或者Typescript可以这整个应用进行强制的类型约束,但是prop-types还可以可以完成对于组件的props属性的类型检查的。

要在应用中使用prop-types十分容易,只需要安装这个库即可。

npm install prop-types
yarn add prop-types

PropTypes主要是通过在组件的.propTypes属性上添加一系列的验证器来确保组件所收到的数据类型是有效的。例如以下使用示例。

import PropTypes from 'prop-types';

class Greeting extends React.Component {
  render() {
    return <div>Nice day, {this.props.name}.</div>;
  }
}

Greeting.propTypes = {
  name: PropTypes.string
};

示例中虽然是使用的基于class的组件,但是抽动实际使用中,PropTypes并没有限定组件类型,函数组件以及高阶组件等都可以使用PropTypes来验证内容类型。但是在使用函数组件或者高阶组件的时候,不能直接使用export default导出组件,而是需要在定义.propTypes属性之后再导出组件。例如以下示例。

import PropTypes from 'prop-types';

function Greeting({ name }) {
  retrun(<div>Nice day, {name}.</div>);
}

Greeting.propTypes = {
  name: PropTypes.string
};

export default Greeting;

验证器

在使用PropTypes的时候,主要是使用其中定义的各种验证器,这些验证器主要是声明props中属性的类型以及值组成规则。

其中主要的验证器有以下这些:

Tip

所有以.开头的验证器都定义在PropTypes下面。

验证器功能
.array数组类型
.bool布尔类型
.func函数类型
.number数字类型
.object对象类型
.string字符串类型
.symbol标记类型
.any任意类型
.node任何可以被渲染的元素,例如数字、字符串、数组、Fragment等
.elementReact元素
.elementTypeReact元素的类型
.instanceOf()某个指定类的实例
.oneOf([])枚举多个具体值
.oneOfType([])枚举多个PropTypes验证器
.arrayOf()指定元素类型的数组,可以接受函数作为元素的自定义验证
.objectOf()指定元素类型的对象,可以接受函数作为元组的自定义验证
.shape({)}使用PropTypes定义的指定必需组成的对象
.exact({)}使用PropTypes定义的精确组成的对象
.isRequired附加在其他验证器后表示必须提供的值
function(props, propName, componentNamr)自定义验证器,验证失败时需要返回Error对象实例

例如可以这样来使用验证器确保传递给组件的某个属性只包含一个元素。

import PropTypes from 'prop-types';

class CompositeGreeting extends React.Component {
  render() {
    // 这里必须只有一个元素存在
    const children = this.props.children;

    return <div>{children}</div>;
  }
}

CompositeGreeting.propTypes = {
  children: PropTypes.element.isRequired
};

默认值

组建的props是可以设定默认值的,这个默认值的设定方式与设定.propTypes类似,但是设定的是.defaultProps属性。具体可参考以下示例。

import PropTypes from 'prop-types';

class Greeting extends React.Component {
  render() {
    return <div>Nice day, {this.props.name}</div>;
  }
}

Greeting.propTypes = {
  name: PropTypes.string
};

Greeting.defaultProps = {
  name: 'Stranger'
};

Flow

Flow 是针对 JavaScript 代码的静态类型检查器,经常与 React 一起使用。Flow 通过特殊的类型语法为变量、函数以及 React 组件提供注解,以便于及时发现错误。

要在项目用使用 Flow,首先需要完成安装。Flow 一般安装到package.jsondevDependencies中。

npm install flow-bin --save-dev
yarn add flow-bin --dev

之后需要在项目的package.json中添加命令来支持 Flow。

{
  "scripts": {
    "flow": "flow"
  }
}

最后,在项目目录中执行以下命令来初始化 Flow 环境。

npm run flow init
yarn run flow init

运行以下命令可以启动对项目文件的检查。

npm run flow
yarn run flow

如果项目是使用 Create React App 建立和管理的,那么在编译时 Flow 注解会被自动去除。如果是手动使用 Babel 等库搭建,则需要安装相应的 Preset。

启用 Flow

要使一个文件纳入 Flow 的监控,只需要在文件中增加注释// @flow或者/* @flow */即可。如果不打算添加这个注释而强制对所有文件进行监控,可以执行命令flow check --all

类型标记

与 Typescript 类似,Flow 采用后置类型标记,例如格式为function f(a: string): string {}

Flow 中可以使用以下基本类型,这些类型名称的首字母可以为大写。

  • boolean,布尔型。
  • number,数字型。
  • string,字符串型。
  • null,空。
  • voidundefined 类型。
  • ?type,在类型名称前加前缀?,表示可空类型,变量可以是null或者void
  • propertyName?,在对象语法的属性字段名后添加后缀?,表示该属性在对象中可能不会出现。例如{ foo?: string }。用在函数定义中表示可选参数。
  • 字面量,表示变量或者函数参数只能取值指定字面量,常搭配联合类型使用。
  • mixed,可能是任何类型。
  • any,可能是任何类型,类型检查器将不对其进行强制检查。
  • Array<T>,数组类型。
  • [T, T, T],元组类型。
  • T | T | T,联合类型,变量可以是联合类型中的一种。
  • T & T & T,合集类型,变量是所有类型的合集。
  • interface typeName {},接口类型,其中需定义接口中要包含的方法。
  • <T>,泛型类型,用法与 Java、Typescript 中的泛型一致。

类型别名

对于 Object 类型和联合类型、函数类型等内容书写较长,所以 Flow 提供了类型别名来简化这些类型的书写。类型别名使用type定义,如果在type之前加入opaque,可以为已有类型定义新的类型别名,即透明别名。

type User = {
  name: string;
  age: number;
};

let person: User;

在 React 中的应用

Flow 在 React 中主要是标记propsstate的类型,这些可以通过使用type定义类型,并使用React.Component<Props, State>来定义组件中使用的propsstate类型。

对于 React 中事件的类型,主要是使用SyntheticEvent<T>进行标记,并且根据响应事件来源的不同,还可以细分为更多的事件类型,例如SyntheticMouseEvent<Th>SyntheticAnimationEvent<Ti>等。React 组件的子组件类型为React.Node

Flow 中所支持的其他 React 元素的类型,可直接参考 Flow文档

样式

在 React 中使用样式并不是由 React 控制的,而是由打包工具 Webpack 负责管理的。只用于组件的样式文件可以在组件定义文件中使用import直接载入,Webpack 在打包时会自动进行提取。

或者还可以使用react-scripts中提供的 CSS 模块功能来为组件指定 CSS 文件,这只需要遵守组件 CSS 文件的命名规约即可。例如有一个组件名为Button.js,那么其 CSS 模块文件的名称就为Button.module.js。即 CSS 模块文件的命名规则为[name].module.css。React Scripts 会自动处理模块中的 CSS 样式类名称。

使用 Sass

如果要使用 Sass 来书写 CSS 样式,只需要在项目中安装dart-sass,并将所有.css文件重命名为.scss即可。

如果使用了 Flow 来进行检查,可以将module.file_ext=.scssmodule.file_ext=.sass加入到.flowconfig[options]中来使 Flow 识别 Sass 文件。

使用 Less

由于 Create React App 中没有支持使用 Less 来编写 CSS Module,所以如果要在应用中使用 Less,例如使用 Ant Design 并使用自定义主题,就必须使用 react-app-rewired 搭配 customize-cra 来对 Less Loader 进行配置。

以下是配置 Less Loader 并定义 Ant Design 主题色的示例。

const { override, addLessLoader } = require('customize-cra');

module.exports = {
  webpack: override(
    addLessLoader({
      lessOptions: {
        loader: 'css-loader',
        javascriptEnabled: true,
        modifyVars: {
          'primary-color': '#FF7A45'
        },
        cssModule: {
          localIdentName: '[path][name]__[local]--[hash:base64:5]' // if you use CSS Modules, and custom `localIdentName`, default is '[local]--[hash:base64:5]'.
        }
      },
      sourceMap: true
    })
  )
};

拼合样式类名

如果在 React 组件中只使用 CSS Module 或者只使用普通样式类名,那么一般直接书写是没有问题的。但是如果要是混用 CSS Module 和通用样式类名,那么就需要对样式类名进行拼合了。

下面这种拼合是错误的,不能通过编译或者编译不出所需要的效果。

// 下面这样是不能通过编译的
<Button className={styles.primary} className="mt-1">Test Button</Button>

// 下面这样是没有效果的
<Button className="{styles.primary} mt-1">Test Button</Button>

如果的确需要这样使用,那么可以选择以下方法。

<Button className={[styles.primary, 'mt-1'].join(' ')}>Test Button</Button>

<Button className={`${styles.primary} mt-1`}>Test Button</Button>

如果觉得这种写法也不是那么爽,还可以利用 classnames 库来拼合。使用 classnames 库以后,上面的示例就会变成下面的样子。

<Button className={classnames(styles.primary, 'mt-1')}>Test Button</Button>;

要在项目中使用 classnames 可以直接使用 npm 安装。

npm install classnames
yarn add classnames

之后只需要在需要的文件中引入即可。

import classnames from 'classnames';

相对于classnames,还有一个更加简单的库可以使用:clsx。要在项目中使用clsx,可以直接使用npm安装。

npm install clsx
yarn add clsx

clsx支持更多种样式类名的组合方法,包括字符串、对象、数组等。具体可参考以下示例。

import clsx from 'clsx';

// 使用字符串拼接,每个字符串参数可以使用布尔表达式搭配 && 来确定其是否会被拼入
clsx('class-A', true && 'class-B', false && 'class-C');
// 将产生 'class-A class-B' 的字符串。

// 使用对象来描述,如果对象中字段的值是 true,那么这个字段的名称将被拼入
clsx({ classA: true, classB: isTrue() });
// 将产生 'classA classB' 的字符串,对于其中的函数将会自动求值。

// 使用多个对象进行拼合
clsx({ classA: true }, { classB: isTrue() });
// 同样会产生 'classA classB' 的字符串。

// 使用数组进行拼合,数组中的所有true值将会被拼入
clsx(['classA'], [1 && 'classB', { classC: true, classD: false }]);
// 会产生 'classA classB classC' 的字符串

总起来说,所有传入的false值都将不会被拼入最终的字符串,而且有用复杂嵌套结构的数组也会被展平。

Styled Components

Styled Components 是用来增强 React 组件系统对 CSS 的支持的。Styled Components 可以自动为组件生成局域样式 Class 名称,避免因为书写导致的样式错误。并且还能便利的支持动态样式。

要在项目中加入 Styled Components 支持,只需要使用 npm 或者 yarn 安装即可。

npm install styled-components
yarn add styled-components

要在 create-react-app 工具中使用 Styled Components 需要使用 Babel 插件:babel-plugin-styled-components。Styled Components 的 Babel 插件不仅可以支持样式提取、压缩等,还支持服务端渲染。加载 Babel 插件可以使用 customize-craaddBabelPlugin()

基本使用方法

Styled Components 的基本使用方法是引入 styled 并使用其定义标签样式,将带样式的标签包装成组件来使用,以下是一个最简单的示例。

import styled from 'styled-components';

const Title = styled.h1`
  font-size: 1.5em;
  text-align: center;
`;

function Welcome(props) {
  return <Title>Welcome</Title>;
}

Styled Components 在这里使用了 ES6 的一个新语法——带标记的模板字符串。例如 fn 是一个函数,那么以下两种调用是等价的。

fn`some string`;
fn(['some string']);

如果模板字符串中使用了其他的变量,那么对于 fn 的调用就会变成以下这种等价形式。

const aVar = 'foo';

fn`the value is ${aVar} .`;
fn(['the value is ', ' .'], aVar);

示例中将 HTML 标签 h1 通过加入样式包裹成了一个新的标签 Title,然后在其他组件中直接进行了使用。Styled Components 将 ES6 中的模板字符串进化为了定义样式化组件的语法,模板字符串中的样式遵循样式预处理库 stylis 的规范。

Tip

样式预处理库 stylis 采用类似于 SCSS 的语法结构。

定义 HTML 标签样式的格式为 styled.tag,而定义 React 组件样式的格式为 styled(Component)。无论对哪种标签或者组件进行包装,都会返回一个样式化的组件(StyledComponent)。

Styled Components 中还可以直接使用 props 来动态变换组件样式,例如以下示例。

const Button = styled.button`
  background: ${props => (props.primary ? 'pink' : 'white')};
`;

function Buttons(props) {
  return (
    <div>
      <Button>Normal Button</Button>
      <Button primary>Primary Button</Button>
    </div>
  );
}

Styled Components 可以根据传入组件的 props 中的内容动态修改组件的样式。如果被包装的组件是简单的 HTML 标签,那么 Styled Components 将传递任何已知的 HTML 属性给 DOM。如果被包装的组件是 React 组件,那么 Styled Components 会将全部 props 内容传递给被包装的组件。所以包装好的组件也同样可以像一般组件那样使用React提供的各种事件。

同样的,对Styled Components组件使用ref,也同样会将组件的底层元素传递给父组件。如果底层元素是一个HTML元素,那么父组件中的Ref获得的就是这个HTML DOM元素,如果底层元素是一个React组件,那么父组件中的Ref获得的就是这个组件。所以在对Styled Components组件使用Ref时,需要仔细注意其底层元素的类型。

样式扩展

除了可以直接定义已包装样式的组件以外,Styled Components 还支持对已经包装了样式的组件进行扩展,建立新的组件。

const Button = styled.button`
  color: pick;
  padding: 0.25em 1em;
  border-radius: 4px;
`;

const RedButton = styled(Button)`
  color: red;
`;

Styled Components 还可以样式化任意组件,例如已经定义好样式的组件。

const Link = ({ className, children }) => <a className={className}>{children}</a>;

const StyledLink = styled(Link)`
  color: palevioletred;
`;

Styled Components 还提供了一个 css 方法,可以用来创建样式片段。样式片段可以作为 Mixin 混入其他的样式中。以下给出一个示例供参考。

import styled, { css } from 'styled-components';

const mixin = css`
  color: ${props => (props.whiteColor ? 'white' : 'black')};
`;

const Comp = styled.div`
  ${props => (props.complex ? mixin : 'color: blue;')};
`;

修改 props

在定义 Styled Component 时,可以利用 .attrs() 方法对要使用的 props 进行一些修改。.attrs() 接受一个 Lambda 表达式,表达式需要返回一个 JavaScript 对象,Styled Components 会自动将其与 props 对象合并。

const Input = styled.input.attrs(props => ({
  type: 'password',
  size: props.size || '1em'
}))`
  margin: ${props => props.size};
  padding: ${props => props.size};
`;

props 中可以直接携带 className 来使样式化组件直接使用已有的样式类,这样可以方便使用现有的 CSS 框架。

动画定义

在 CSS3 中对动画的支持主要通过 @keyframes 来完成,Styled Components 提供了一个 keyframes 方法来支持局部 @keyframes 的定义。例如以下示例做了一个 360 度旋转的动画。

const rotate = keyframes`
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
`;

const Rotate = styled.div`
  display: inline-block;
  animation: ${rotate} 3s linear infinite;
`;

使用 keyframes 定义的关键帧只能使用在后续组件定义的样式中,而不是独立成为一个组件。

主题支持

Styled Components 提供了一个名为 ThemeProvider 的组件,其中可以通过 theme 属性设定一个对象作为主题描述。被 ThemeProvider 包裹的组件树中的组件的 props 中都会被注入一个名为 theme 的属性,其中内容为 ThemeProvidertheme 属性提供的内容,并且会随着 ThemeProvider 提供的内容变换而幻变化,从而达到更改主题的目的。

对于主题的使用可以参考以下示例。

const theme = {
  main: 'mediumseagreen'
};

const Button = styled.button`
  color: ${props => props.theme.main};
`;

function Form(props) {
  return (
    <div>
      <Button>No Theme</Button>
      <ThemeProvider theme={theme}>
        <Button>Themed</Button>
      </ThemeProvider>
    </div>
  );
}

所有已经样式化的组件的 props 都会被自动注入 theme 以获得主题的支持,但是没经过样式化的组件要想得到主题的支持,需要使用 Styled Components 提供的 withTheme() 方法进行包装。

Warning

包装语法在 React 中非常常见,请注意其使用位置和使用方法以及原理。

stylis 增强的 CSS 语法

Styled Components 采用 stylis 样式预处理库来处理样式定义。stylis 在处理样式时,基本上采用与 SCSS 相同的语法结构,但是添加了一些特殊的语法特性。

与 SCSS 一样,stylis 支持嵌套定义样式。嵌套的选择器会形成层级选择器。操作符 & 可以用来在嵌套时指代父级,从而实现平级选择器以及父级选择器的定义。

stylis 支持大部分 CSS3 伪类,例如 ::before::after:not():hover等,并且还支持 @media() 媒体查询。stylis 定义了一个特殊的选择器 :global() 可以用来覆盖定义全局样式,例如:global(h1)

.parent {
  .child {
    /* 这会定义组件中的具有指定class的子组件的样式。 */
    padding: 0 8px;
  }
  &:hover {
    /* 这会定义组件在鼠标悬浮状态时的样式。 */
    color: red;
  }
  & ~ & {
    /* 这会定义当组件作为另一个同名组件的非比邻兄弟组件出现时的样式。 */
    background: green;
  }
  & + & {
    /* 这会定义组件作为另一合同名组件比邻兄弟组件出现时的样式。 */
    background: lime;
  }
  &.actived {
    /* 这会定义组件具有指定class名称时的样式。 */
    padding: 0 16px;
  }
  .disabled & {
    /* 这会定义组件出现在具有指定class名称的组件中时的样式。 */
    background-color: gray;
  }
}

除此之外还可以使用&&来在样式定义中指代当前组件。这可以在根据条件定义组件样式的时候,不使样式被应用在所有同类组件上,而仅应用在当前组件上。

Emotion

Emotion是一个与Styled Components十分类似的CSS-in-JS库,它提供了比较强大的可预测样式组合,并且同时支持类似于Styled Components采用的字符串的样式定义和基于对象的样式定义。Emotion有三种使用方法,一种是框架无关的用法,一种是与React搭配使用的用法,最后一种则是与Styled Components相同的用法。这两种用法所需要安装的依赖包是不同的。

框架无关的使用方式主要是依赖css``字符串模版标签来生成样式类名称,采用这种写法的时候,需要安装以下依赖。

npm install @emotion/css
yarn add @emotion/css

这样在项目中就可以以以下的方式来使用了。

import { css, cx } from '@emotion/css';

function App() {
  const color = '#c8c8c8';

  return (
    <div
      className={css`
        padding: 32px;
        &:hover {
          color: ${color};
        }
      `}>
      Some text.
    </div>
  );
}

而使用与React固定搭配的方式则需要安装以下依赖。

npm install @emotion/react
yarn add @emotion/react

这时就可以在组件中使用css属性来设定样式了。

/** @jsx jsx */
import { css, jsx } from '@emotion/react';

function App() {
  const color = '#c8c8c8';

  return (
    <div
      css={css`
        padding: 32px;
        &:hover {
          color: ${color};
        }
      `}>
      Some text.
    </div>
  );
}

Warning

在以上两种用法中,虽然引入的cxjsx都没有被使用,但是在进行样式拼合的时候是会需要到他们的。其中cx可以拼合所有用到的样式类名,例如className={cx(css``, 'logo')}。而jsx则是用于提供jsx babel plugin指定JSX文件的解析方式,这样jsx函数就会替代React.createElement方法使css属性生效了。

如果使用Styled Components的样式组件方式定义样式,那么就需要安装以下依赖库。

npm install @emotion/styled @emotion/react
yarn add @emotion/styled @emotion/react

这时,上面的示例就可以改为以下这种书写方式了。

import styled from '@emotion/styled';

const Container = styled.div`
  padding: 32px;
  &:hover {
    color: '#c8c8c8';
  }
`;

function App() {
  return <Container>Some text.</Container>;
}

在这种用法下,Emotion的使用方法与Styled Components完全相同。

不同使用方法的优势

与框架无关的方式是使用Emotion最简单的方法,这种方法主要具备以下特点。

  • 不需要任何额外的配置步骤,包括Babel插件配置、配置文件配置等。
  • 支持媒体查询、嵌套选择器等。
  • 使用css生成的样式在cx的辅助下可以与既有的任何样式类组合。

但是与框架无关的使用方式也有其不足之处,例如在使用服务端渲染时,就需要进行额外的配置。

与React最搭配的方式是使用@emotion/react的方式,也是Emotion最推荐使用的方式。这种方式主要有以下特点。

  • 可以使用可配置式的生成环境。
  • css属性可以支持以下特性:
    • 使用方式与style属性类似,但是可以支持嵌套选择器、媒体查询等。
    • 允许开发人员直接对组件和元素设置样式。
    • css属性可以接受一个可以与主题一起使用的函数。
    • 减少编写样式时的样板文件。
  • 在使用服务端渲染的时候无需任何配置。
  • 主题功能开箱即用。
  • 拥有比较完善的ESLint插件支持。

JSX编译标注

在前面的示例中可以看到,使用与React搭配的方式时,需要在源文件的首行添加一行/** @jsx jsx */。这行编译标注是用来利用React 17新加入的JSX转换来用Emotion提供的JSX转换函数替代React自身的createElement转换的。

React 17在React的包中引入了两个接口,这些接口只会被Babel和Typescript等编译器使用,在这种情况下,JSX将不会被直接转换成React.createElement,而是会从这两个新接口中获取函数调用。

Emotion要求源文件首行添加的/** @jsx jsx */,可以将jsx babel plugin配置为使用Emotion提供的jsx函数。这个编译标注实际上就是@babel/plugin-transform-react-jsx插件提供的Runtime更换功能,其中@jsx就是用于指示更换JSX转译函数的。比如/** @jsx Preact.h */可以将JSX的转换交由Preact提供的h方法来完成。

Warning

如果项目使用的是Create React App 4.0以上的版本,那么就可以直接在源文件的首行添加编译标注就可以了。但是如果已经添加的编译标注不起作用,那么就需要将编译标注更换为/** @jsxImportSource @emotion/react */

如果项目使用Vite来作为构建工具,那么在JSX源文件中就不需要再添加编译标注了,你可以参考之前介绍Vite的章节来配置Emotion的编译。

在添加了编译标注以后,还是需要使用import { jsx } from '@emotion/react'来指定编译标注中所指示使用的JSX转换函数的。

另外,在搭配Typescript使用Emotion的时候,IDE和编辑器常常会提示无法找到和验证css函数。这个问题可以在Typescript配置的compilerOptions中添加配置项'jsxImportSource': '@emotion/react'来解决。

使用对象定义样式

使用对象来定义样式是Emotion与Styled Components不同的一点,对象可以使样式的定义更具有表现力,也便于Lint工具检查。使用对象来定义样式的时候,只需要将使用连词符-分隔单词的kebab-case样式属性改为camelCase形式书写即可。

例如以下就是一个使用对象来定义样式的示例。

import { jsx } from '@emotion/react';

function App() {
  return (
    <div
      css={{
        color: 'blue',
        backgroundColor: 'gray'
      }}>
      Some text.
    </div>
  );
}

对象样式也可以与styled结合使用,用于样式组件的定义。

import styled from '@emotion/styled';

const Button = styled.button(
  {
    color: 'darkorchid'
  },
  props => ({
    fontSize: props.fontSize
  })
);

function App() {
  return <Button fontSize={16}>Action!</Button>;
}

在对象样式中可以直接书写数字,数字值一般都会采用px作为单位。对于类似于fontWeight之类的属性,数字将会自动选择其对应的单位。

有些样式属性是不被一些浏览器支持的,这时可以使用数组来定义回退值。例如以下示例。

function App() {
  return (
    <div
      css={{
        background: ['red', 'linear-gradient(#000, #fff)'],
        lineHeight: 30
      }}>
      Some text.
    </div>
  );
}

借助于css函数,还可以把对象样式定义成可以在多个位置直接复用的形式,而且还可以支持样式的组合。具体使用可以参考以下示例。

import { jsx, css } from '@emotion/react';

const redText = css({
  color: 'red'
});
const redOnHover = css({
  '&.hover,&.focus': redText
});
const redTextOnBlack = css(
  {
    backgroundColor: 'black',
    color: 'green'
  },
  redText
);

function App() {
  return (
    <div>
      <p css={redText}>Red text on white.</p>
      <button css={redOnHover}>Color changes on hover</button>
      <p css={redTextOnBlack}>Red Ttext on black.</p>
    </div>
  );
}

定义关键帧动画

关键帧动画是由Emotion中提供的keyframes函数提供的,keyframes的使用与css函数一样,即可以作为字符串模板标签使用,也可以作为函数使用。

使用keyframes函数定义关键帧动画,也同样可以与css一样使用字符串或者对象。以下是一个使用字符串定义弹跳的示例。

import { jsx, css, keyframes } from '@emotion/react';

const bouncing = keyframes`
  from, 20%, 53%, 80%, to {
    transform: translate3d(0, 0, 0);
  }
  40%, 30% {
    transform: translate3d(0, -30px, 0);
  }
  70% {
    transform: translate3d(0, -15px, 0);
  }
  90% {
    transform: translate3d(0, -40px, 0);
  }
`;

function App() {
  return (
    <div
      css={css`
        animation: ${bouncing} 1s ease infinite;
      `}>
      Bouncing text.
    </div>
  );
}

使用主题

Emotion的主题功能是在@emotion/react库中提供的,跟React的Context一样,主题功能也是需要使用<ThemeProvider>在全局提供目前要使用的主题,然后在组件树的枝叶部分就可以使用propswithTheme()或者useTheme()来使用主题,并在主题发生变化的时候使组件发生重新渲染。

<ThemeProvider>使用theme属性接受一个对象来作为主题。这个用于定义主题的对象,其中都是由样式属性组成,这些样式属性的值将构成所有组件中对应样式属性的默认值。一个应用中<ThemeProvider>可以使用多个,而且不同主题之间定义的相同样式属性之间会根据<ThemeProvider>之间的层级关系产生覆盖。

Tip

在定义主题的对象中使用样式属性可以比较明确的说明属性值定义的目的,但是Emotion对采用什么样的对象属性没有限制,一个普通的对象也可以作为主题对象使用。

例如以下示例。

import { ThemeProvider } from '@emotion/react';

const theme = {
  backgroundColor: 'gray',
  color: 'red'
};

const adjustTheme = ancestorTheme => ({ ...ancestorTheme, color: 'blue' });

function Container() {
  return (
    <ThemeProvider theme={theme}>
      <ThemeProvider theme={adjustTheme}>
        <p>Some text.</p>
      </ThemeProvider>
    </ThemeProvider>
  );
}

在组件中应用主题可以通过两个途径,一个是使用withTheme函数定义高阶组件,一个是使用useTheme Hook。withTheme可以将主题的配置注入到组件的this.props.theme中。例如以下示例。

import React from 'react';
import { jsx, ithTheme } from '@emotion/react';

class Container extends React.Component {
  render() {
    return (
      <div
        css={{
          color: this.props.theme.color
        }}>
        Some text.
      </div>
    );
  }
}

const ContainerWithTheme = withTheme(Container);

在使用React新的Hook语法的时候,useTheme就可以用来把主题设置引入组件。例如可以把上面这个示例改成以下样子。

import { jsx, ThemeProvider, useTheme } from '@emotion/react';

const theme = {
  colors: {
    primary: 'red'
  }
};

function TextWithTheme(props) {
  const theme = useTheme();
  return <div css={{ color: theme.colors.primary }}>Some text.</div>;
}

function App() {
  return (
    <ThemeProvider theme={theme}>
      <TextWithTheme />
    </ThemeProvider>
  );
}

样式类名后缀

Emotion在CSS中增加了一个名为label的属性,这个属性的值将会被附加到模块化的样式类名末尾。这样一来所形成的实际样式类名就比一般的散列值更加具有可读性了。label的具体使用效果可以观察以下示例。

import { jsx, css } from '@emotion/react';

const style = css`
  color: red;
  label: some-style;
`;

const ShowClassName = ({ className }) => <div className={className}>{className}</div>;

function App() {
  return (
    <div>
      <ShowClassName css={style} />
    </div>
  );
}

嵌套选择器

嵌套选择器是几乎所有的CSS预处理必备的功能,Emotion也不例外。在Emotion中,嵌套选择器可以仿照以下示例来使用。

import { jsx, css } from '@emotion/react';

const paragraph = css`
  color: blue;

  a {
    border-bottom: 1px solid red;
    color: red;
    &:hover {
      border-bottom: 1px solid yellow;
      color: yellow;
    }
  }
`;

function App() {
  return (
    <p css={paragraph}>
      Something:
      <a href="#">links</a>
    </p>
  );
}

Tip

符号&在Emotion中是用来代表本级选择器的。

媒体查询

媒体查询在Emotion中可以直接使用,例如以下示例。

import { jsx, css } from '@emotion/react';

function App() {
  return (
    <div
      css={css`
        font-size: 15px;
        @media (min-width: 400px) {
          font-size: 25px;
        }
      `}>
      Some text.
    </div>
  );
}

借助于Javascript提供的计算键,还可以便捷的重复使用固定的几个媒体查询条件。

import { jsx, css } from '@emotion/react';

const breakpoints = [320, 576, 768, 992, 1200, 1400];

const mq = breakpoints.map(bp => `@media (min-width: ${bp}px`);

function App() {
  return (
    <div>
      <div
        css={{
          color: 'red',
          [mq[0]]: {
            color: 'gray'
          },
          [mq[1]]: {
            color: 'blue'
          }
        }}>
        Some text.
      </div>
      // 既然媒体查询已经被转换成了字符串,那么也可以直接在字符串模版中使用
      <div
        css={css`
          color: red;
          ${mq[0]} {
            color: gray;
          }
          ${mq[1]} {
            color: blue;
          }
        `}>
        Some text.
      </div>
    </div>
  );
}

像上例这样定义一组媒体查询的断点虽然十分可行,但是带来的结果是冗长的代码。在一般情况下,媒体查询所控制的样式属性都是相同的一个或者一组,所以这里可以借助一些其他的库来简化媒体查询的编写。这里所使用的辅助库名称为facepaint,可以使用以下命令在项目中安装。

npm install facepaint
yarn add facepaint

使用facepaint就可以把上面这个示例简化成以下这样。

import { jsx } from '@emotion/react';
import facepaint from 'facepaint';

const breakpoints = [320, 576, 768, 992, 1200, 1400];
const mq = facepaint(breakpoints.map(bp => `@media (min-width: ${bp}px)`));

function App() {
  return (
    <div>
      <div
        css={mq({
          color: ['red', 'gray', 'blue']
        })}>
        Some text.
      </div>
    </div>
  );
}

Warning

facepaint只能使用在对象样式中,不能在字符串模板中使用。

全局样式

Emotion中的全局样式是通过<Global>组件来设置的,<Global>组件使用styles属性来接收要设定的样式,可以将css生成的样式直接赋予styles,或者将对象样式赋予styles<Global>在使用的时候,可以仿照以下示例。

import { Global, jsx } from '@emotion/react';

function App() {
  return (
    <div>
      <Global
        styles={css`
          h1 {
            font-weight: 700;
          }
        `}
      />
      <Global
        styles={{
          '.some-class': {
            fontWeight: 700
          }
        }}
      />
      <div className="some-class">Some text</div>
    </div>
  );
}

Warning

<Global>组件只是在全局插入样式,当样式更改或者<Global>组件被卸载的时候,全局样式也会被删除。

组合使用Tailwind CSS框架

Tailwind CSS框架是一个功能类优先的CSS框架,可以支持开发人员通过组合其提供的大量样式类来完成样式的定义。Tailwind CSS在与Emotion结合使用的时候,如果采用Emotion的框架无关的使用方式,那么可以直接借助@emotion/css提供的cx函数来组合所需要的Tailwind CSS工具类。

但是如果应用项目采用的是与React固定搭配的形式,那么就需要借助一些其他的工具库来辅助一下了。要达到在应用项目中组合使用Tailwind CSS框架的目的,我们所需要的主要辅助工具库是twin.macro。在应用项目中引入twin.macro以后,还需要在项目中配置babel-plugin-macros来支持twin.macro的样式转换。

在项目中添加所有的相关支持,只需要运行以下命令。

npm install twin.macro tailwindcss -D
yarn add twin.macro tailwindcss -D

Warning

不需要在项目中再添加babel-plugin-macros,因为twin.macro库已经安装了这个依赖,所以在应用项目中可以直接使用。

接下来就是对应用项目进行配置,首先需要配置babel-plugin-macros使用Emotion。这个配置在package.json中,需要添加一个名为babelMacros的键。

{
  "babelMacros": {
    "twin": {
      "preset": "emotion"
    }
  }
}

Warning

只要是在项目中使用twin.macro,不管是使用Create React App还是使用Vite,都需要在package.json中增加这条配置。

在配置好Tailwind CSS的配置文件tailwind.config.js以后,就可以继续配置babel-plugin-macros了。tailwind.config.js文件可以使用以下命令生成。

npx tailwind init --full

以Vite为例,在vite.config.js中需要编写以下内容来配置 babel-plugin-macros

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: [
          'babel-plugin-macros',
          [
            '@emotion/babel-plugin-jsx-pragmatic',
            {
              export: 'jsx',
              import: '__cssprop',
              module: '@emotion/react'
            }
          ],
          ['@babel/plugin-transform-react-jsx', { pragma: '__cssprop' }, 'twin.macro']
        ]
      }
    })
  ]
});

Tip

如果使用的是Create React App,那么CRA使用的Webpack会自动从 package.json中加载babel-plugin-macros的配置,所以也就不需要在添加其他的配置。

完成twin.macro的配置以后,就可以仿照以下示例编写React组件了。

// 直接使用twin.macro提供的tw属性引入工具样式
import 'twin.macro';

const One = () => <div tw="text-blue-300 w-full">One</div>;
// 使用Style Components风格语法定义组件,注意此时不需要引入@emotion/styled。
import tw, { styled } from 'twin.macro';

const Four = tw.div`border border-solid border-blue-300 hover:border-black`;
const Five = tw(Four)`border-purple-500`;

// 还可以直接使用闭包函数来定义条件样式。
const StyledSix = styled.div(({ hasBorder }) => [
  `color: black;`,
  hasBorder && tw`border-purple-500 border-solid border rounded-sm`
]);
const Six = () => <StyledSix hasBorder>Six</StyledSix>;

// 使用字符串模板的形式也可以。
const StyledSeven = styled.div`
  color: black;
  ${({ hasBorder }) => hasBorder && tw`border border-solid border-purple-500`}
`;
const Seven = () => <StyledSeven hasBorder>Seven</StyledSeven>;

// 或者还可以混用多种样式定义形式。
const StyledEight = styled.div`
  ${tw`text-sm`};
  ${({ hasBorder }) => hasBorder && tw`border-purple-500`};
  ${({ color }) =>
    color &&
    `
        color: ${color};
    `};
  font-weight: 500;
`;
const Eight = () => (
  <StyledEight hasBorder color="blue">
    Eight
  </StyledEight>
);
// 在结合Emotion使用的时候,Emotion的css函数是从twin.macro中引入的。
// twin.macro提供的tw字符串模板函数是用来解析工具样式的,不要与tw属性混淆了。
import tw, { css } from 'twin.macro';

const hoverStyles = css`
  &:hover {
    border-color: black;
    ${tw`text-black border-solid`}
  }
`;
const Three = ({ hasHover, text = 'Three' }) => <div css={[tw`border`, hasHover && hoverStyles]}>{text}</div>;

React Router v5

React Router 在 React Web 应用或者 React Native 应用中提供了导航功能。根据所使用环境的不同,分为了 React Router DOM 和 React Router Native 两个版本,分别用于搭配 React DOM 和 React Native 开发。

Warning

本章节中所讲述的React Router是5.x版本,主要为提供目前依旧在大量使用和运行的应用参考。新版的6.x版本React Router的整体使用已经有了巨大的变革,且与5.x版本基本不兼容,所以如果应用中安装的React Router是6.x版本,需要去查看和参考后续的React Router v6章节。

React Router DOM

React Router DOM 可以使用 npm 或者 yarn 进行安装,命令为:

npm install react-router-dom
yarn add react-router-dom

在 React Router 中,主要提供了三种元素组件来完成导航功能定义:Router(路由器组件)、Route(路由组件)和 Link(导航组件)。

Router

Router 是应用中进行路由导航的基础,React Router DOM 中提供了四种 Router 组件供使用:BrowserRouterHashRouterMemoryRouterStaticRouter

其中BrowserRouter主要应用于有可以响应动态请求的服务器使用,HashRouter主要用于静态文件服务器,MemoryRouter会将 URL 都保存在内存里,主要应用于非浏览器环境下,StaticRouter主要应用于服务器渲染环境中。Router 一般套在应用组件之外。

import { BrowserRouter } from 'react-router-dom';

ReactDOM.render(
  <BrowserRouter>
    <App />
  </BrowserRouter>,
  holder
);

Route

Route 在整个路由导航中,既定义了路由组件与 URL 路径的关联关系,又定义了路由组件渲染的位置。路由组件在定义时需要指定要渲染的组件和要响应的 URL 路径,其中要渲染的组件可以通过以下三个属性来定义。

  • component,接受一个组件。
  • render,接受一个返回组件的函数。
  • children,接受一个返回组件的函数,与render不同的是,可以根据当前地址是否匹配来渲染不同的内容。

在 Route 中,对于当前地址的匹配是通过exactstrictsensitivepath四个属性来决定的。其中exact属性表示路由需要完全匹配。例如有给定路由路径/one,如果设定exact,则路由对于当前地址/one/two就不会产生匹配,否则就会匹配。exact属性通常用于对嵌套路由中的父路由进行标记,防止子路由在渲染时,父路由也一并被渲染出来。strict则是要求当前地址与给定的路由路径必须完全匹配,例如路由路径中带有末尾的/,若设定strict,则只有当前地址也带有末尾的/时才能匹配。sensitive则是表示对于当前地址的匹配是否大小写敏感。

属性path用来设定对当前地址的匹配条件(路由路径)以及路由能够捕获的参数。path给定的匹配条件可以是正则表达式,但在其中设定命名参数,则必须使用:字符名称的格式。例如匹配条件/:foo/:bar中,将拥有两个命名参数:foobar,而匹配条件/(apple-)?icon-:res($\backslash$d+).png中,则只具有一个命名参数:res。命名参数后可以添加一些修饰字符来表示参数是否必需等特性,在参数后添加?表示参数可选,例如/:foo?;在参数后添加*表示参数可以为 0 个或者多个,例如/:foo*;在参数后添加+表示参数至少为 1 个,例如/:foo+;在参数后使用圆括号并搭配正则表达式,可以使参数仅捕获匹配正则表达式的值,例如/:foo($\backslash$d+)可以匹配/123,但是对于/abc则不会匹配。

命名参数可以通过组件的props对象访问,所有的命名参数都保存在props.match.params下。

除命名参数外,Route 还可以匹配未命名参数。未命名参数可以使用两种方式进行匹配,一是使用圆括号括起的正则表达式,而是使用*进行宽泛匹配。无论使用哪种未命名参数,参数的捕获都将以数字索引编排提供。

Route 的path参数可以接受一个字符串匹配条件或者一个匹配条件列表。没有指定path属性的 Route 将会对所有地址完成匹配,通常用来展示页面不存在等错误提示页面。

除了使用path进行导航目标匹配以外,还可以使用location属性来使用一个对象进行更加详细的匹配。如果使用location属性进行导航目标匹配,则在 Link 导航组件和 Switch 分组组件中,也需要相应的使用location导航描述对象来进行导航匹配。当前组件的location属性可以通过组件的props对象访问。

Switch

Switch 组件主要功能是用来对 Route 进行分组。Switch 组件将会仅渲染其子组件中首个匹配的 Route。

<Switch>
  <Route exact path="/" component={Home} />
  <Route path="/about" component={About} />
  <Route path="/:user" component={Profile} />
</Switch>;

在以上示例中,当目前地址为/about时,将只会渲染组件About,并不会渲染Profile,虽然/:user也可以完成匹配。

Switch 组件还可以使用location属性接受一个导航描述对象,可以用来代替当前地址来进行更加详细匹配。

Redirect

Redirect 组件主要完成重定向功能,除了与 Route 一样具有控制匹配的exactstrictsensitive以外,主要还是通过属性tofrompush来完成重定向功能。

  • to,接受一个字符串或者一个导航描述对象,来表达重定向到的位置。
  • push,表示重定向到的路径是推入历史栈顶还是替换整个历史栈,默认为替换。
  • from,用于指定匹配什么路径时才产生重定向。
<Switch>
  <Route exact path="/old">
    <Redirect push to="/new" />
  </Route>
  <Route path="/new" component={Place} />
</Switch>;

Link

Link 组件是在 UI 界面上提供导航功能的组件。Link 组件可以通过以下属性来控制其 UI 显示和特性。

  • to,可以接受一个字符串、路径描述对象或者返回目标路径的函数作为属性值,指示导航的目标位置。如果使用导航描述对象,则可以使用以下字段进行设置。
    • pathname,导航目标路径,这是导航描述对象进行匹配的主要字段。
    • search,导航的查询参数串(Query String)。
    • hash,导航前部增加的 Hash 前缀。
    • state,向location中保存的状态。
  • replace,指示是否替换历史栈,默认为推送。
  • titleidclassName等,与 HTML 中的<a>标签功能一致。

NavLink

导航菜单中使用的 Link 组件,在 Link 组件的基础上增加了根据当前地址渲染组件的功能。相比 Link 组件增加了以下属性。

  • activeClassName,当显示为激活状态时要附加的 CSS 样式类名称。
  • activeStyle,接受一个对象,用于设定当显示为激活状态时要附加的行内 CSS。
  • isActive,接受一个函数,返回值为布尔型,用于为判断当前组件是否处于激活状态增加额外逻辑。

History

从程序中控制路由是依靠 React Router DOM 提供的 History 对象。每个路由组件的props中都会注入一个键值为history的 History 对象,用于提供路由的控制。History 对象主要提供了以下方法来供使用。

  • .length,表示 History 栈的长度。
  • .action,表示导航到当前路由的操作,取值为PUSHREPLACE或者POP等。
  • .location,获取用于描述当前位置的对象。由于 History 对象是可变的,所以.location获取的当前位置描述并不可靠,可以直接访问props中的location对象代替。location对象提供了以下功能供使用:
    • .pathname,当前位置的路径。
    • .search,当前位置的查询串。
    • .hash,当前位置的 Hash 前缀。
    • .state,当前位置的状态量。可以通过 History 对象的.push(path, state)设置。
  • .push(path, [state]),导航到指定位置,并向 History 栈中推入一条记录。
  • .replace(path, [state]),导航到指定位置,清空 History 栈并推入一条记录。
  • .go(n),将 History 栈指针向栈顶移动 n 位,n 为负值为向栈底移动。
  • .goBack(),将 History 栈指针向栈底移动一位,即后退一步。
  • .goForward(),将 History 栈指针向栈顶移动一位,即前进一步。
  • .block(prompt),阻止导航并弹出提示,返回一个函数供解除阻止使用。
class CargoComponent extends React.Component {
  constructor(props) {
    super(props);
  }

  render() {
    return <button onClick={() => this.props.history.push('/login')}>Click Me</button>;
  }
}

注意,要从组件的props中访问 History 对象或者 Location 描述对象,组件必须是在 Route 的component中指定的路由组件。如果需要在其他组件中使用 History 对象或者 Location 描述对象,需要使用下一节中的withRouter

withRouter

React Router DOM 提供了一个withRouter()方法,用于向普通组件中注入 History 对象、Location 描述对象和路由路径 Match 描述对象。withRouter()会从距离被包装组件最近的路由组件中取得上述值供使用。使用withRouter()函数包装的组件会返回一个新的组件(高阶组件)。

import React from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router';

class ShowTheLocation extends React.Component {
  render() {
    const { match, location, history } = this.props;

    return <div>You are now at {location.pathname}</div>;
  }
}

const ShowTheLocationWithRouter = withRouter(ShowTheLocation);

使用 Hook 替代 withRouter

在 React 16.8 版本引入 Hook 之后,React Router DOM 就提供了通过 Hook 函数向组件中注入 History 对象、Location 描述对象等用于访问和控制路由的对象的方法。React Router DOM 提供的 Hook 方法主要有以下四个。

  • useHistory(),不接受任何参数,直接返回当前路由的 History 对象,可用于控制路由导航。
  • useLocation(),不接受任何参数,直接返回当前匹配路由的 Location 描述对象,可用于获取当前的 URL 地址。
  • useParams(),不接受任何参数,直接返回一个键值对对象,其中保存了所有的路径参数,允许直接访问 match.params,通常会采用解构赋值的方式从其中提取参数内容。
  • useRouteMatch(),用于提供类似于 <Route> 组件的功能,尤其适用于在不渲染任何路由组件的情况下使用路由路径 Match 描述对象。

以下给出一个使用 useHistory()useParams() 的示例。

function ContentPost() {
  let { author } = useParams();
  let history = useHistory();

  return (
    <div>
      <p>{author}</p>
      <button type="button" onClick={() => history.push('/')}>
        Click to return
      </button>
    </div>
  );
}

使用 useRouteMatch() 可以将以下示例简化。

function ContentPost() {
  return <Route path="/post/:id" render={({ match }) => <div />} />;
}

使用 useRouteMatch() 之后:

function ContentPost() {
  let match = useRouteMatch('/post/:id');

  return <div />;
}

路由守卫

React Router 没有提供专门的路由守卫功能,但不代表不能完成进入路由前的判断和离开路由之前的处理。

对于离开路由之前的处理,可以通过组件的componentWillUnmount()生命周期方法或者useEffect()返回的函数进行处理。

而对于进入路由之前的判断,例如先判断用户是否登录再决定进入路由,可以通过对 Route 组件进行包装,结合 Route 组件的render属性来完成。例如可参考以下示例。

const AuthRoute = ({ component: Component, ...rest }) => {
  if (user.isLoggedIn) {
    return <Route {...rest} component={component} />;
  } else {
    return <Redirect to="/login" />;
  }
};

<Router>
  <AuthRoute path="/glance" component={Glance} />
</Router>;

HTTP 服务对 SPA 导航的处理

后端 HTTP 服务如果仅向外提供了网站根路径/到文件index.html的映射,而没有对单页应用中其他路由路径进行处理,则会在没有经过根路由直接访问指定子路由时被报告 404 NOT FOUND 错误。

出现这种错误的原因是当跳转到 SPA 的根路由时,HTTP 服务提供的是针对index.html的映射,并且当访问其下详细路由路径(如/order/123)时都是由 SPA 的路由系统进行处理,所以不会出现任何问题。但是在直接访问指定路由而非根路由时,HTTP 服务将不能对这个详细路由路径进行识别,所以会报出 404 NOT FOUND 错误。

要解决这个问题,可以让 HTTP 服务将对于其他路径的访问都重定向至index.html,交由 SPA 的路由系统来进行处理。

以下给出一个 Express 服务的示例,需要注意的是示例中对于路由路径*的捕获,应该在其他功能路径(如/api)之后定义,否则将影响其他功能的访问。

const express = require('express');
const path = require('path');
const port = process.env.PORT || 3000;
const app = express();

app.use(express.static(__dirname + '/public'));

app.get('*', function (request, response) {
  response.sendFile(path.resolve(__dirname, 'public', 'index.html'));
});

app.listen(port);

如果 HTTP 服务使用 Nginx 进行负载,那么配置就简单许多了,只需要使用try_files指令即可。

server {
  location / {
    try_files $url /index.html;
  }
}

在 TypeScript 中的类型定义

React Router DOM 中的组件都继承自 React.Component,并且分别根据其所接受的 props 定义了专用的类型。由于 props 类型已经内置,在一般使用时,可以不必显式声明 props 类型。常用的类型、函数及其声明主要有以下这些。

  • History<LocationState>
  • BrowserRouter
  • HashRouter
  • Link<History.LocationState>
  • NavLink<History.LocationState>
  • RouteProps
  • Route<T extends RouteProps>
  • Switch
  • useHistory(): History<HistoryLocationState>
  • useLocation(): History.Location<History.LocationState>
  • useParams(): { [p in keyof Params]: string }
  • useRouteMatch(path?: string | string[] | RouteProps): match<Params> | null

React Router v6

React Router 6.x版本相比React Router 5.x版本提供了更多强大的功能,并且在与最新版的React结合方面也做出了更多的优化。但是在我们日常所接触到的应用项目中,可能既有基于React Router 5.x的项目,也有基于React Router 6.x的项目。所以这也是同时保留React Router 5.x和React Router 6.x两个版本说明的原因。

要在项目中使用React Router 6.x版本,需要保证项目所使用的React至少为16.8版本。

Tip

注意,React Router 6.x版本依旧分为DOM版本和Native版本,分别用于React Web应用和React Native应用。由于这本小书暂时不涉及React Native开发的内容,所以这里所有使用到的Router库都是React Router DOM库。

安装与最小应用

React Router安装方式没有变化,依旧可以使用NPM或者Yarn来完成,只是安装的库的名称会有不同。

npm install react-router-dom@6
yarn add react-router-dom@6

在应用中完成React Router的安装以后,就可以使用其来编写应用了,以下是一个React Router最小应用的示例。

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Home from './components/Home';
import About from './component/About';

ReactDOM.render(
  <BrowserRouter>
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="about" element={<About />} />
    </Routes>
  </BrowserRouter>,
  document.getElementById('root')
);

当然在这个最小的应用示例中,还需要使用Link组件来生成导航,其具体使用将在后续的章节中详细说明。

常用组件

React Router 6.x提供的组件与React Router 5.x相比,有了比较大的变化。而且有一些路由定义组件的使用方法已经发生了比较彻底的变化。

BrowserRouter

BrowserRouter组件是用来包裹路由表定义的,通常用在Web浏览器中。BrowserRouter组件在前面的最简示例中已经有过使用。BrowserRouter组件的主要作用是对目前应用中产生的浏览动作栈进行管理,并利用浏览器的地址栏作为路由导航依据。

BrowserRouter组件在使用的时候一般不必使用任何属性进行配置,但是其可以支持使用以下两个配置属性。

  • basename,用来指定URL中的固定部分。
  • window,指定React Router所要监控的窗口,默认是当前document所在的视图,除此之外还可以指定监控页面中的iframe。

HashRouter

HashRouter组件跟BrowserRouter组件一样都是用来包裹路由表定义,控制浏览动作栈的。但是其特性不同的是,HashRouter并不是直接以URL地址作为导航依据,而是会利用URL尾部#标记以后的内容作为导航依据。HashRouter还具备BrowserRouter可以使用的所有配置属性,而且其配置属性的功能也是保持一致的。

MemoryRouter

MemoryRouter组件的主要功能与BrowserRouterHashRouter是一样的,但是MemoryRouter会将浏览动作栈保存在内存里。MemoryRouter组件通常会在应用需要完全控制浏览动作栈的时候使用。

RouterProvider

Tip

RouterProvider是React Router v6.4版本引入的新功能,在使用的时候需要主机自己项目中所使用的React Router的版本。

从 React Router v6.4 版本开始,React Router 引入了一套新的路由定义方法:Data API。这种方法所创建出来的直接就是 Router。根据应用中所要使用的不同种类的 Router,目前总共有三个函数可供使用。

  • createBrowserRouter(),用于创建一个BrowserRouter,对应使用<BrowserRouter>组件创建的路由。
  • createMemoryRouter(),用于创建一个MemoryRouter,对应使用<MemoryRouter>组件创建的路由。
  • createHashRouter(),用于创建一个HashRouter,对应使用<HashRouter>组件创建的路由。

Warning

传统使用<Router>系列组件和<Routes>系列组件创建路由的形式不适用Data API。并且这两种API的使用形式不能在应用中同时存在。

Data API 接受一个数组来完成路由的定义,例如使用createBrowserRouter()来创建之前的最小应用,就是以下样子。

const router = createBrowserRouter([
  {
    path: "/",
    element: <Home />,
  },
  {
    path: "/about",
    element: <About />,
  },
]);

ReactDOM.createRoot(document.getElementById("root")).render(
  <RouterProvider router={router} />
);

这样创建好的路由就需要使用<RouterProvider>注入到应用中,就如同上例中所示的那样。

同样以createBrowserRouter()为例,这个函数所接受的参数主要有以下这些。

  • routesRouteObject[]类型,用于定义整套路由。
  • opts,路由组件的配置,主要可以配置以下两项内容。
    • basename,自动在所有路由路径前添加的基础路径名称。例如设置为/app,那么在访问/的时候,实际上会自动指向/app
    • window,设定是否要导航到一个新的窗口中。

在 Data API 中最常用的就是RouteObject[]了,这是在 Data API 中用来定义路由的核心。其实RouteObject的结构也十分简单,基本上跟<Route>组件所接受的参数相同。这里给出RouteObject的类型定义。

interface RouteObject {
  /** 路由路径 */
  path?: string;
  /** 路由是否是默认的根路由 */
  index?: boolean;
  /** 子路由配置 */
  children?: React.ReactNode | RouteObject[];
  /** 是否要使用大小写敏感的匹配 */
  caseSensitive?: boolean;
  /** 用于在组件树上下文中标记loader方法加载到的数据 */
  id?: string;
  /** 用于在路由加载之前提前加载一些数据的方法 */
  loader?: LoaderFunction;
  /** 用于响应form提交的事件 */
  action?: ActionFunction;
  /** 当前路由需要渲染的组件 */
  element?: React.ReactNode | null;
  /** 当`loader`或者`action`中出现错误的时候需要渲染的组件,
   * 可以作为React中的错误边界(Error Boundary)使用。
   */
  errorElement?: React.ReactNode | null;
  handle?: RouteObject["handle"];
  /**  确定什么情况下需要重新执行`loader`来重新刷新数据。 */
  shoudeRevalidate?: ShouldRevalidateFunction;
}

为了使用传统<Routes>系列组件的应用能够更快的转换到使用 Data API,React Router 还提供了一套函数来将<Routes>系列组件定义的路由转换成 Data API 中使用的RouteObject[]数组对象。这个函数就是createRoutesFromElements(),使用的时候直接将<Routes>组件传入即可。

嵌套路由定义

RouteObject中定义嵌套路由也是非常容易的,跟使用<Routes>系列组件一样。以下是一个嵌套路由的定义示例。

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    loader: rootDataLoader,
    children: [
      {
        index: true,
        element: <Glance />,
      },
      {
        path: "about",
        element: <About />,
      },
      {
        path: "user",
        element: <UserList />,
      },
      {
        path: "user/:uid",
        element: <UserDetail />,
        loader: userDetailLoader,
      },
    ],
  },
]);

React Router 中的子路由可以嵌套多层,每一层children中定义的子路由都将渲染到这一层element中组件的<Outlet>组件中,所以在定义的时候可以根据实际应用中布局来进行定义。

loader方法

路由上定义的loader方法的主要功能就是在路由组件渲染之前加载路由组件中需要展示的数据。例如以下示例中可以在路由加载之前获取需要展示的用户信息。

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    children: [
      {
        path: "/user/:uid",
        element: <UserDetail />,
        loader: async ({ params }) => {
          return await fetch.get(`/api/user/${params.uid}`);
        },
      },
    ],
  },
]);

路由定义中的loader方法获取到的内容,在组件中需要通过useLoaderData() Hook 来获取。loader方法接受一个对象作为参数。这个对象中的字段有以下两个。

  • params,React Router 中定义路由路径中出现的路径参数。
  • request,应用中发出的请求对象,其中可以通过以下几个常用字段来访问业务相关的功能。
    • url,使用URL类解析后可以从中获取查询串(URLSearchParams)。

loader方法中除了可以直接返回数据以外,还可以直接返回一个 Web 响应(Response)。例如可以将fetch的结果直接返回。这样在组件中使用useLoaderData() Hook 获取到的将是响应中响应体携带的数据。

Tip

如果在loader方法中抛出一个携带着异常HTTP状态码的响应,那么React Router将会导航到errorElement指定的组件中。

自定义shouldRevalidate

在以下几个默认的情况下,loader方法都会被自动调用来刷新组件中的数据。

  • 当使用<form>提交后调用了action方法。这种情况包括采用任何形式的表单提交行为。
  • 当 URL 中的参数发生了变化。包括 URL 中的路径参数和查询参数发生变化的情况。
  • 当重新路由到当前路由的时候。

但是如果想要增加手动控制loader方法重新运行,就需要在路由定义中定义shouldRevalidate方法。当这个方法返回true的时候,React Router 就会重新指定loader方法来刷新组件中的数据。shouldRevalidate方法同样也是只接受一个对象作为参数,其中常用的字段有以下这些。

  • currentUrl,当前页面的 URL。
  • currentParams,当前页面中的路径参数。
  • nextUrl,即将导航向的 URL。
  • nextParams,即将导航向的页面中的路径参数。
  • formMethod,表单提交方法。
  • formAction,表单提交动作。
  • formEncType,表单数据封装方法。
  • formData,表单提交的数据。
  • actionResultaction方法处理的结果。
  • defaultShouldRevalidate,React Router 中默认shouldRevalidate行为的判断结果。

redirect

React Router 中还提供了一个redirect工具函数,可以用来在loader方法和action方法中进行导航转向。使用起来也是非常的简单。如下例所示。

const loader = async () => {
  const data = await getData();

  if (isEmpty(data)) {
    return reditect("/not-found-data");
  }

  return defer({
    data,
  });
};

redirect函数实际上构建了一个 HTTP 状态码为 302 的响应,在loader方法中返回一个 HTTP 响应实际上会导致 React Router 堆响应的解析,从而就实现了导航转向。实际上 React Router 也推荐在loader方法和action方法中使用redirect来代替使用useNavigate()来进行导航转向。

Tip

除了redirect函数以外,React Router还提供了一个json函数,可以用来构建一个响应体内容为JSON数据的响应。

action方法

Caution

action方法目前仅适用于Browser Router。

action方法是与loader方法相对的,根据 React Router 文档中的描述,loader方法是向组件中读入数据的方法,而action方法则是一种从组件中写出数据的方法。action方法所能够接受的参数与loader方法是一致的。

action方法中返回的数据可以在组件中使用useActionData() Hook 来获取。

Note

因为标记为index的路由是没有其实际路径的,如果想要将表单提交到标记为index的路由组件的action方法上,那么就需要将表单的提交目标action属性设置为?index的查询形式,例如/projects?index

errorElement

在 React 引入函数组件以后,如何处理函数组件中抛出的错误就不像类组件中那样方便定义了,所以在应用开发过程中经常会被 React 报出缺少错误边界的警告。

React Router 中路由定义中的errorElement就提供了这样一个错误边界的功能。React Router 会在loader方法、action方法、组件渲染失败等情况下转而去渲染errorElement定义的组件,并且可以在errorElememt组件中使用useRouteError() Hook 来获取组件和各种方法中抛出的错误。

Tip

想要手动抛出错误可以直接使用throw,抛出的错误会被errorElement指定的组件捕获。

在实际应用开发中,推荐在根路由中定义一个可以适用于全局的errorElement,以保证整个路由系统中抛出的错误都能够被有效的捕获。

RoutesRoute

Routes组件是在一个应用中定义路由表的位置,其中对应用中所使用到的所有路由进行了包裹。Routes组件负责了在根据浏览动作栈顶的内容,在路由表中寻找匹配路由的职责。所以Routes组件在使用的时候经常是以包裹着一大片的Route组件的形式出现,并且会作为所有Route组件的父级组件。

Routes组件的直接Route子组件的内容将会直接渲染在路由表定义的位置上,渲染的内容由被匹配上的Route组件中的element属性定义。

Route组件的功能就非常的专一了,其目标就是定义路由路径与路由对应的组件的之间的关系。位于Routes组件下的所有Route组件的集合构成了一个完整的路由表。在应用产生浏览动作的时候,Routes组件会在其下定义的路由表中进行匹配。与 React Router 5.x 中需要精确的控制Route组件的顺序不同的是,React Router 6.x 中的Routes组件会更加智能的在所有的Route组件中选择最佳的路由进行匹配。所以在使用 React Router 6.x 的应用中,Route组件的定义先后顺序已经不再是开发人员需要关注的重点了。

在之前 React Router 5.x 中,如果需要定义嵌套路由,那么子路由需要在路由页面中需要渲染内容的位置上定义被嵌套的子路由。这样一来就整个路由表的定义就变得分散了,几乎是分部在了整个应用的各个页面组件上。React Router 6.x 中的Route组件改变了这一特性,现在的Route组件支持使用Route组件作为其子组件来定义嵌套路由了。在这个特性的支持下,原本分散的路由表被重新集中了起来。子路由对应的组件会被渲染在路由组件中的Outlet组件中。

Tip

如果父级路由没有指定element属性,那么匹配到的子路由就会直接渲染在父级路由所在的位置上。也就是说此时的嵌套路由的渲染行为就像是一个不嵌套的路由一样。

以下是一个应用中嵌套路由的定义示例。

function AppRoutes() {
  return (
    <Routes>
      <Route index element={<Home />} />
      <Route path="/things" element={<Things />}>
        <Route path=":id" element={<Thing />} />
        <Route path="new" element={<NewThing />} />
      </Route>
      <Route path="/tasks" element={<Tasks />}>
        <Route path=":id" element={<Task />} />
        <Route path="new" element={<NewTask />} />
        <Route path="delete/confirm" element={<ConfirmDeleteTask />} />
      </Route>
    </Routes>
  );
}

在这个示例中Route组件最经常出现的两个属性分别是pathelement。这两个属性分别定义路由所可以匹配的路径和当路径被匹配上以后所需要渲染的组件。这两个属性的用法相比 React Router 5.x 中的相似属性区别不大。但是对于嵌套路由来说,子Route组件的path属性在匹配的时候会自动的组合父Route组件的path属性值。也就是说,对于示例中的NewThing组件来说,其对应的路由路径就是/things/new。如果路由在定义的设置了index属性,那么这个路由将不需要定义path属性,其匹配路由路径将于其父路由相同,也就是一般常说的/

在默认情况下,Route组件就是一个Outlet组件,如果没有在父路由组件中显式指定Outlet,那么将会在Route出现的位置上,渲染子组件的内容。这种用法通常仅在父路由组件中不包含任何其他 UI,只需要在其中渲染不同的子路由的情况下使用。

Route组件可以使用的其他属性还有以下这些,主要功能都是用于定义和控制路由路径的匹配的。

  • caseSensitive,采用大小写敏感的形式匹配,默认为false

除此以外,Route组件还可以使用之前RouteProvider一节中RouteObject定义的全部字段,例如loaderaction等。

Outlet

Outlet组件是用来在父路由组件中标定其嵌套子路由组件的渲染位置的。Outlet组件的使用非常简单,只需要书写到父路由组件中所需要的位置即可,不需要其他任何属性配置。

Tip

现在React Router还不支持命名路由,即在一个父级组件中通过对Outlet组件命名来同时渲染多个子路由组件,所以在设计嵌套路由的时候,需要注意路由表的编排。

以下是一个Outlet组件的使用示例。

function FuncLayout() {
  return (
    <div>
      <h1>Main Functions</h1>
      <Outlet />
      <ul>
        <li>
          <Link to="/func/1">Function 1</Link>
        </li>
      </ul>
    </div>
  );
}

function App() {
  return (
    <Routes>
      <Route path="/func" element={<FuncLayout />}>
        <Route path=":id" element={<FuncItem />} />
      </Route>
    </Routes>
  );
}

Outlet组件目前不支持接受任何属性,所以只需要在需要渲染嵌套子路由的位置放置这个组件就可以了。

Link

Link组件是在应用中用于放置路由导航链接的,相当于HTML中的a标签。Link组件的使用在上一节的示例中已经出现过了,其最简单的用法就是使用to属性指定链接要导航到的路由,组件标签所包裹的内容就是需要在应用页面上渲染输出的代表链接的文字内容。所以这样看来,Link组件的整体表现形式与a标签的形式基本相同。

但是Link组件与普通的a标签还是有非常巨大的不同的。在这方面最大的表现是Link组件还具有更多的路由控制属性。Link组件常用的属性主要有以下这些。

  • to,用于指示当前链接的导航目标路由位置。to属性可以接受一个字符串或者一个Partial<To>类型的对象作为其属性值。这个To类型的对象实际上并不难理解,其中提供了pathnamesearchhash三个字符串类型字段来分别存储一个路由导航URL的路径、查询串和Hash串三个部分。但是在大部分情况下,为了使用便利,还是以字符串表示导航目标的形式为主要使用方法。
  • replace,布尔类型属性,用于指示React Router在执行导航的时候是否需要清空导航历史栈。如果给定了true值,那么React Router将会先清除当前的导航历史栈,然后再把当前的导航目标压入栈中,此时应用也就不能再执行后退等操作,因为历史栈已经被清空了。
  • state,可以是任意类型的对象或者值,用于传递给目标路由组件的数据。注意,这里的属性名虽然也叫做state但是所传递的内容并不同于React应用中所使用的State。

NavLink

NavLink是一个增强以后的Link组件。相比Link组件,NavLink组件增加了导航菜单上常用的激活状态的控制。其实在实现逻辑上,NavLinkLink组件中的classNamestylechildren三个属性进行了重定义,这三个属性现在都可以接受一个函数作为其属性值了。这样一来,NavLink组件就可以根据当前路由状态的匹配是否来动态的展示路由导航链接。以下是这三个属性的简要使用说明。

  • className,可以接受一个签名为(props: { isActive: boolean }) => string | undefined的函数。从这个函数的签名可以看出来,NavLink会传递一个名为isActive的参数属性来使函数知晓当前导航链接与路由位置的匹配状态。
  • style,可以接受一个签名为(props: { isActive: boolean }) => React.CSSProperties的函数。这个函数的签名与className可以接受的函数签名十分相似,只是需要返回一个React中常用的样式定义对象。
  • children,可以接受一个签名为(props: { isActive: boolean }) => React.ReactNode的函数。因为children属性都是表示组件所包裹的内容,所以这就表示NavLink可以直接包裹一个函数,而不是只能包裹一个传统的React组件实例。

除此之外,NavLink组件还有以下两个属性可供使用。

  • caseSensitive,可以接受一个布尔类型的值,用于指示在匹配路由的时候是否使用大小写区分的精确匹配方法。
  • end,同样可以接受一个布尔类型的值,如果给定了true的值,那么当其嵌套的子路由匹配的时候,那么这个父路由就不会被匹配了。

Navigate

Navigate组件是一个重定向组件,当其被渲染的时候,会使React Router自动执行导航动作,导航向Navigate组件指向的位置。

因为Navigate组件代表的是无条件转向,所以其可以接受的属性其实很少,基本上与Link组件是相同的,只有toreplacestate

重定向在应用中进行路由控制和权限管理的时候十分有用,这一点可以在后面路由守卫一节得到比较详细的说明。

Form

Form组件是对 HTML 中form标签的一个封装,它的功能只是模拟了 HTML 中表单的各种操作,直接使用Form组件进行表单的提交一样会引起页面的导航。需要注意的是这个Form组件并不支持表单验证等表单支持库中提供的功能。

Tip

如果不想引起页面的导航操作,需要使用useFetcher() Hook。

跟 HTML 中的form标签一样,Form组件中常用的属性也是methodaction,其中method依旧是用来定义表单的提交方法,而action也是用来定义表单的提交路径的。如果Form组件在使用的时候没有定义action属性,那么Form组件将默认提交到当前组件的action方法或者是在组件树中拥有action方法的距离当前组件最近的父级组件。

表单在提交并完成action方法的调用后,会调用loader方法来刷新页面上的数据。

get方法

如果将Form组件的method属性定义为get,即采用GET方法提交表单,那么整个表单将产生一个导航到action属性的查询,所有的表单内容将形成一套查询串(SearchParams)。例如以下这个搜索表单的例子。

function SearchForm() {
  return (
    <Form method="get" action="/products">
      <input type="text" name="k" placeholder="请输入检索关键字" />
      <button type="submit">查询</button>
    </Form>
  );
}

在这个示例中提交表单将导航到/prodcuts?k=...的 URL 上。提交methodget的表单会调用路由中定义的loader方法。

replace

replace属性主要是用来清除和重置浏览历史堆栈的,但是根据Form组件上其他属性的不同取值,会有不同的默认行为。根据 React Router 文档中的列举,这些默认行为大致有以下这些。

  • method取值为get时,replacefalse
  • 根据action属性的不同,replace存在不同的默认取值。
    • action方法抛出异常,replacefalse
    • action导航至当前页面,replacetrue
    • action导航到其他位置,replacefalse
    • 其他情况下,replace都为false

reloadDocument

Form组件设置reloadDocument属性,将会使Form组件跳过 React Router 的表单提交操作,直接使用浏览器中的原生表单提交操作,并使页面产生刷新。

preventScrollReset

这个属性是需要搭配<ScrollRestoration>组件使用的,<ScrollRestoration>组件可以在表单提交并导航到新页面时重置页面的滚动条到之前的位置。但是如果设置Form组件上的preventScrollReset属性为true,将会阻止页面重置滚动条的动作。

useSubmit

useSubmit() Hook 可以返回一个方法用于提供开发者采用代码方式提交表单。其返回的函数接受两种参数,第一种是任意的表单或者表单控件的ref,第二种是一个FormData类型的表单数据以及配置表单提交目标和方法的配置项。

其使用方法十分简单,如以下示例所示。

/* 示例一:用在Form组件上 */
function SearchForm() {
  const submit = useSubmit();

  return (
    <Form onChange={(event) => submit(event.currentTarget)}>
      {/* 实际表单内容 */}
    </Form>
  );
}

/* 示例二:用在提交按钮上 */
function SearchForm() {
  const ref = useRef(null);
  const submit = useSubmit();

  const handleAction = useCallback(() => {
    submit(ref.current);
  }, [submit, ref]);

  return (
    <Form>
      <button type="submit" ref={ref}>
        提交
      </button>
      {/* 其他代码 */}
    </Form>
  );
}

/* 示例三:直接提交表单内容 */
const formData = new FormData();
formData.append("field", someVlaue);
submit(formData);

Await

Await组件是用来搭配 React 中Suspense组件使用的异步处理组件。其主要功能是用来在异步任务处理过程中,保证页面展示使用的。

默认情况下,所有的路由都是立刻加载完毕的,但是如果路由使用了耗时比较长的loader方法,那就会在等待数据加载的时候,始终保持空白的页面展示,这对于保持良好的用户体验是十分不利的。React Router 借助 React 中提供的Suspense组件,定义了Await组件用于根据异步方法执行的结果,动态的决定页面中实际组件的渲染。

React 提供的Suspense组件通过fallback属性可以在其内部的组件不能渲染的时候,先渲染一个临时的组件作为代替。这样就可以给内部的Await组件一个加载界面。这样Await组件就可以重点关注loader方法中异步过程执行成功或者执行失败的处理了。

Await组件在 React Router 中的定义如下。

declare function Await(props: AwaitProps): React.ReactElement;

interface AwaitProps {
  children: RecordingState.ReactNode | AwaitResolveRenderFunction;
  errorElement?: RecordingState.ReactNode;
  resolve: TrackedPromise | any;
}

interface AwaitResolveRenderFunction {
  (data: Awaited<any>): React.ReactElement;
}

Await组件中的children属性既可以渲染一个 React 组件,也可以执行一个异步过程,并将异步过程的执行结果作为渲染内容。例如以下两个示例。

// 使用一个函数作为Await组件的children。
<Await resolve={loadingPromise}>
  {(resolvedValue) => <SomeView items={resolvedValue} />}
</Await>;

// 使用一个组件作为Await组件的children
function ResolvedView() {
  const resolvedValue = useAsyncValue();

  return <div></div>;
}

<Await resolve={loadingPromise}>
  <ResolvedView />
</Await>;

resolve属性

Await组件中的resolve属性看起来并不容易理解,实际上这个属性是用来从loader方法中获取需要提取到Await组件的children中的异步处理结果的。简而言之就是路由组件的loader方法会返回一个使用defer()函数组装的异步结果,之后路由组件可以使用useLoadData()获取这个使用defer()组装的异步结果内容,位于路由组件中的Await组件就可以使用resolve属性指定异步结果中的部分内容,用以传递给Await组件中的children组件。

以下是一个可以清楚展现这个处理过程的示例。

<Route
  loader={async () => {
    let users = await getUsers();
    let privileges = getPrivileges();

    return defer({
      users,
      privileges,
    });
  }}
  element={<Users />}
/>;

function Users() {
  const { users, privileges } = useLoadData();

  return (
    <div>
      <h1>Users</h1>
      <React.Suspense fallback={<LoadingSkeleton />}>
        <Await resolve={privileges}>
          <Privileges />
        </Await>
      </React.Suspense>
    </div>
  );
}

function Privileges() {
  const privilges = useAsyncValue();

  return <div>{/* 处理实际的输出 */}</div>;
}

在这个示例中出现了defer()useAsyncValue()两个函数和 Hook,这两个内容在Await组件的正常工作中都是十分重要的。

defer

defer()函数是 React Router 提供的用在loader方法中组装异步结果使用的。defer()函数可以在loader方法中将异步执行结果和同步执行结果组装成一个返回值对象。这种操作比较像Promise.all(),但是Promise.all()需要内部所有内容都是Promise,但是defer()中组装的内容是可以混合的。

useAsyncValue

useAsyncValue()这个 Hook 的功能就是从组件父级Await组件中获取异步结果。它所获得的内容就是Awaitresolve属性所指定的内容。

errorElement属性

errorElement属性接受的是当异步出错时候需要渲染的组件,它实际上可以理解为异步执行出错时的children

useAsyncError

useAsyncValue()功能近似,useAsyncError() Hook 是用来在errorElement组件中获取异步处理过程中抛出的错误的。

可用的Hook

随着在React中Hook用法的大范围普及,React Router也通过Hook提供了大量的常用功能供应用使用。这些Hook有一部分功能已经在React Router 5中出现了,但是从整体来说,React Router6完全强化了Hook在React Router中的份量。

Hook的主要目标是在React组件功能之外,为应用提供更多更丰富的通过程序手段控制路由的方法。

useHref

useHref(to: To): string主要来获取给定的to属性对应的URL路径,useHref即便是在Router作用范围以外也是可以使用的。useHref的使用场景主要是在自定义类似与Link组件功能的组件的时候,用于确定组件自身所处位置的URL以及to属性的匹配。

useLinkClickHandler

useLinkClickHandler用于获取一个用于处理导航组件被点击的事件的事件处理函数,这通常用于在自定义Link组件的时候执行导航功能的。以下是useLinkClickHandler的函数签名,可以在使用的时候参考。

declare function useLinkClickHandler<E extends Element = HTMLAnchorElement>(
  to: To,
  options?: {
    target?: React.HTMLAttributeAnchorTarget;
    replace?: boolean;
    state?: any;
  }
): (event: React.MouseEvent<E, MouseEvent>) => void;

以下示例将结合useHrefuseLinkClickHandler两个Hook来定义一个自定义的Link组件。

const StyledLink = styled.a`
  color: red;
`;

const Link = React.forwardRef(({ onCLick, replace = false, state, target, to, ...rest }, ref) => {
  let href = useHref(to);
  let handleClick = useLinkClickHandler(to, {
    replace,
    state,
    target
  });
  let compositeClickHandle = event => {
    onClick?.(event);
    if (!event.defaultPrevented) {
      handleClick(event);
    }
  };

  return <StyledLink {...rest} href={href} ref={ref} target={target} onClick={compositeClickHandle} />;
});

useInRouterContext

useInRouterContext是一个非常简单的Hook,它只会返回一个布尔型值,用来表示当前组件是否处于Router组件环境里。

useLocation

useLocation用来获取当前的路由地址信息,这通常在需要根据路由地址执行一些副作用方法的时候十分有用。以下是useLocation的函数签名,可以在使用的时候参考。

此外,在导航的时候传入的state内容,也是通过useLocation函数返回的Location对象中的state属性获取的。

interface Location extends Path {
  state: unknown;
  key: Key;
}

declare function useLocation(): Location;

useNavigationType

useNavigationType是用来获取用户是通过哪种方法导航到当前组件中的。用户在进行导航的时候虽然只是简单的点击和后退操作,但是在React Router中对应的操作却不尽相同。

从以下useNavigationType的函数签名中可以看出来React Router在操作用户导航历史栈的时候,都有哪些操作。

type NavigationType = 'POP' | 'PUSH' | 'REPLACE';

declare function useNavigationType(): NavigationType;

由于导航历史栈是一个FILO(先入后出)的数据结构,所以NavigationType的三个取值含义可以按照以下方式理解。

  • POP,表示栈弹出操作,这个操作是将一个值从栈顶移除,可以理解为用户或者应用进行了后退操作。
  • PUSH,表示栈压入操作,这个操作是将一个值放入栈顶,可以理解为用户导航进入了一个新的页面。
  • REPLACE,表示栈清空操作,这个操作是将整个栈都清空了,然后再执行一次压入操作,可以理解为用户执行了刷新操作,删除了之前的全部导航历史。

useMatch

useMatch是用来获取当前的路由组件与路由路径之间的匹配情况的,主要用于从路由路径中拆出所需要的内容部分。这个Hook在实际应用中使用的不多,大部分的使用场景都是需要比较底层的操作,通常出现在其他Hook不能满足操作需求的情况下。

useMatch的函数签名如下,可以在使用的时候参考。

interface PathPattern {
  path: string;
  caseSensitive?: boolean;
  end?: boolean;
}

interface PathMatch<ParamKey extends string = string> {
  params: Params<ParamKey>;
  pathname: string;
  pattern: PathPattern;
}

declare function useMatch<ParamKey extends string = string>(pattern: PathPattern | string): PathMatch<ParamKey> | null;

useNavigate

useNavigate是一个使用比较频繁的Hook,它可以返回一个导航函数,用于在代码中执行程序导航。毕竟程序导航操作也是应用导航动作除用户点击触发导航以外的另一大动作触发器。useNavigate的函数签名如下。

interface NavigateFunction {
  (to: To, options?: { replace: boolean; state?: any }): void;
  (delta: number): void;
}

declare function useNavigate(): NAvigateFunction;

例如在应用的登录表单中就经常会使用到这种程序导航功能。

function LoginForm() {
  let navigate = useNavigate();

  let handleSubmit = async function (event) {
    event.preventDefault();
    await submitForm(event.target);
    navigate('../home', { replace: true });
  };

  return <form obSubmit={handle}></form>;
}

useNavigation

useNavigation() Hook 用于获取当前全局导航的状态,比如加载状态等。useNavigation()在调用以后返回的对象中提供了以下这些属性字段供使用。

  • state,当前全局的路由导航状态,取值有idle(空闲)、submitting(提交数据,执行action)和loading(正在加载数据,执行loader)。
  • formData,当前执行action时,表单提交的数据。
  • location,当前的导航目标 URL。

useParams

useParams在路由组件中用于从动态路由路径中获取以键值对组织起来的动态参数。能够被useParams获取到的参数都是在Route组件的path属性中定义的。动态参数的定义方法在React Router 6中没有改变,还是使用:paramName的形式定义,其中paramName就将成为存放动态参数键值对中用于保存参数值的键。

useParams的函数签名如下,其实是比较简单的一个Hook函数。

type Params<Key extends string = string> = {
  readonly [key in Key]: string | undefined;
};

declare function useParams<K extends string = string>(): ReadOnly<Params<K>>;

这里需要注意的是如果在嵌套路由的子路由中使用这个Hook,那么它也会获取到其父路由定义的动态参数。以下是一个具有两层嵌套路由而且使用了动态参数的参数值获取示例。

function UserOrders() {
  let { userId, startDate, endDate } = useParams();

  // 这里以下可以放置具体的应用业务逻辑。
}

function AppRoutes() {
  return (
    <Routes>
      <Route path="user">
        <Route path=":userId" element={<UserProfile />}>
          <Route path=":startDate/:endDate" element={<UserOrders />} />
        </Route>
      </Route>
    </Routes>
  );
}

useResolvedPath

useResolvePath一般是在React Router内部用来解析当前路由页面所对应的to属性的。在应用中,这个Hook常用来构建路由路径。

useRoutes

useRoutes是组件RoutesRoute对应的程序化实现。useRoutes提供了通过JSON格式数据进行路由结构的程序化定义方法。它在调用之后回返回一个React.ReactElement,跟直接使用RoutesRoute组件定义出来的React元素是一样的,但是相比使用JSX语法定义路由结构,程序化的定义方式可以更加自由和动态。

跟之前的章节一样,先来看一下useRoutes的函数签名。

interface RouteObject {
  caseSensitive?: boolean;
  children?: RouteObject[];
  element?: React.ReactNode;
  index?: boolean;
  path?: string;
}

declare function useRoutes(routes: RouteObject[], locaiton?: Partial<Location> | string): React.ReactElement | null;

这样就可以在应用中使用程序化的方式重新定义一遍之前章节中出现过的嵌套路由结构。

function AppRoutes() {
  let routes = useRoutes([
    { path: '/', element: <Home /> },
    {
      path: '/things',
      element: <Things />,
      children: [
        { path: ':id', element: <Thing /> },
        { path: 'new', element: <NewThing /> }
      ]
    },
    {
      path: '/tasks',
      element: <Tasks />,
      children: [
        { path: ':id', element: <Task /> },
        { path: 'new', element: <NewTask /> },
        {path: 'delete/confirm' element: <ConfirmDeleteTask />}
      ]
    }
  ]);

  return routes;
}

读者可以将这个示例与之前章节中的嵌套路由定义进行一下定义形式的对比。

Tip

这两种路由结构的定义方法是等价的,没有哪种更好,性能更优的区别。在应用项目中选择的时候应该主要考虑应用的实际需求,从应用项目的实际需求出发选择所需要使用的方法。

useSearchParams

useSearchParams在应用中主要的功能是提供对于当前页面位置URL中的查询串的读取和修改。这个Hook会返回当前页面URL中的查询串内容,以及修改查询串内容的方法。以下是useSearchParams的函数签名。

type ParamKeyValuePair = [string, string];

type URLSearchParamsInit =
    | string
    | ParamKeyValuePair[]
    | Record<string, string | string[]>
    | URLSearchParams;

type SetURLSearchParams = (
    nextInit?: URLSearchParamsInit,
    navigateOpts?: : { replace?: boolean, state?: any}
) => void;

declare function useSearchParams(
    defaultInit?: URLSearchParamsInit
): [URLSearchParams, SetURLSearchParams];

useSearchParams在应用中可以像以下示例中一样使用。

function TaskForm() {
  let [searchParams, setSearchParams] = useSearchParams();

  let handleSubmit = event => {
    event.preventDefault();
    let params = serializeQuery(event.target);
    setSearchParams(params);
  };

  return <form onSubmite={handleSubmit}></form>;
}

Tip

这里需要注意的是,useSearchParams的执行效果与useNavigate返回的navigate函数十分相像,但是useSearchParams影响的仅仅是URL中的查询串。

useFetcher

路由定义中的loader方法和action方法一般都是路由在加载之前和路由中的表单提交时被调用的,这种调用一般伴随着页面的重新刷新。但是有的时候可能需要在应用中手动调用loader方法和action方法,而且并不需要引起页面的重新刷新。此时就需要用到 React Router 中提供的一个新 Hook:useFetcher()

useFetcher()提供了内容供使用,例如有const fetcher = useFetcher();

  • fetcher.submit(data, options),用于调用action方法,提交表单数据。其中data中为表单提交的内容,options可以使用一下配置项目。
    • method,表单的提交方法。
    • action,表单的提交目标路径。
  • fetcher.load(href),用于调用指定路径对应的路由的loader方法。但是需要注意,.load()方法只能调用其上下级路由的loader方法。
  • fetcher.Form,这是一个会调用fetcher.submit()Form组件,它的提交动作不会引起页面刷新。
  • fetcher.state,当前fetcher的执行状态。
  • fetcher.data,用于访问fetcher.load()获取到的数据。
  • fetcher.formData,用于访问fetcher.submit()提交的数据。
  • fetcher.formAction,用于访问fetcher.Form配置的action目标。
  • fetcher.formMethod,用于访问fetcher.Form配置的表单提交方法。

useRouteLoaderData

useRouteLoaderData() Hook 用来在当前已经渲染的组件中获取组件树中任何组件的loader方法获取到的数据,不过通常都用来获取其祖辈层级的数据。这个 Hook 接受一个参数,用于指定要获取的loader方法数据所在的组件的id

例如可以像以下示例中使用。

const router = createBrowserRouter([
  {
    path: "/",
    loader: () => fetchData(),
    element: <Root />,
    id: "root",
    children: [
      {
        path: "task/:tid",
        loader: loadTask,
        element: <TaskDetail />,
      },
    ],
  },
]);

function TaskDetail() {
  const tasks = useRouteLoaderData("root");

  // 其他业务代码
}

useRevalidator

useRevalidator() Hook 在调用以后会返回一个对象,提供在组件代码中无条件重新加载loader和获取当前重新执行loader方法的状态。

其中主要内容有:

  • .state,当前 revalidate 操作的状态,取值有idle(无操作)、loading(正在加载中)。
  • .revalidate(),执行当前路由的loader方法。

Caution

revalidator是单例的,不管调用了.revalidate()多少次,只要第一次调用.revalidate(),所有位置的.state都将变为loading状态。

useRouteError

useRouteError() Hook 主要用在errorElement指定的组件中,用来捕获其他组件或者loader方法、action方法中抛出的异常。这个 Hook 的使用非常简单,调用以后就直接返回抛出的异常本身。

例如课传说下例一样定义错误边界。

function ErrorCatcher() {
  const err = useRouteError();

  console.error(err);

  return <div>{err.message}</div>;
}

const router = createBrowserRoute([
  {
    path: "/",
    errorElement: <ErrorCatcher />,
    loader: async () => await getData(),
    element: <Root />,
  },
]);

模拟路由守卫功能

就像之前React Router v5一章中所介绍的,React Router中没有提供路由守卫功能。路由守卫是Angular框架引入的,用于在一个路由的导航前和导航后进行额外的操作处理的机制。虽然React Router中没有直接提供路由守卫功能,但是在实际使用的时候是可以借助React的高阶组件功能来实现的。

路由守卫功能在应用中最经常用到的地方就是页面权限控制,所以为了方便在高阶组件中进行权限判断,这里先定义一个用于权限判断的Hook供高阶组件使用。这个Hook使用了来自Ramda函数式编程库中提供的工具函数,具体使用到的函数可以在顶部的import语句中查找。

import { difference, equals, length, lt, or } from 'ramda';

export function useAuthenticated() {
  const state = useContext<UserStore>(UserContext);

  return state.isLoggedIn && !state.isTokenExpired;
}

export function useAuthorization() {
  const state = useContext<PrivilegeStore>(PrivelegeContext);

  function hasAll(...privileges: string[]): boolean {
    const differ = difference(privileges, state.privileges);
    return or(state.isAdmin, equals(length(differ), 0));
  }

  function hasAny(...privileges: string[]): boolean {
    const originalPrivilegesSize = length(privileges);
    const differ = difference(privileges, state.privileges);
    return or(state.isAdmin, lt(length(differ), originalPrivilegesSize));
  }

  return { hasAll, hasAny };
}

接下来就是要定义一个用于包装其他组件的高阶组件了。

import { defaultTo, isEmpty, isNil } from 'ramda';
import { Component } from 'react';
import { useLocation } from 'react-router';
import { Navigate, Route } from 'react-router-dom';

export interface AuthorizeOptions {
  all: string[];
  any: string[];
}

export const requireAuthorize = (Comp: Component, options?: Partial<AuthorizeOptions>): Component => {
  const altedOptions = defaultTo({ any: [], all: [] })(options);
  return props => {
    const isAuthenticated = useAuthenticated();
    const { hasAll, hasAny } = useAuthorization();
    const location = useLocation();

    let requiredAllPrivileges: string[] = defaultTo([])(altedOptions.all);
    let requiredAnyPrivileges: string[] = defaultTo([])(altedOptions.any);

    const isUserMatchNeeds =
      (isEmpty(requiredAllPrivileges) && isEmpty(requiredAnyPrivileges)) ||
      (isEmpty(requiredAllPrivileges) ? hasAny(...requiredAnyPrivileges) : hasAll(...requiredAllPrivileges));

    if (isAuthenticated && isUserMatchNeeds) {
      return <Comp {...props} />;
    } else {
      return <Navigate to="/login" state={{ from: location }} replace={true} />;
    }
  };
};

然后就可以像下面这样在导出组件的时候声明当前要导出的组件所需要的用户权限了。

export default requireAuthorize(Task, { any: ['manager'] });

Flux

Flux 是由 Facebook 提出来的一个单向数据流架构。与其说 Flux 是一个框架,倒不如说更像是一种模式,或者叫概念原型。Flux 拥有非常少的代码实现,但是却可以完成整套全局 State 管理和单向数据流功能。

核心概念

Flux 的整体架构还是从传统的 MVC 架构衍生而来,根据不同模块所负责功能的不同,Flux 设计了 Action、Dispatcher、Store 和 View 这几个概念,数据在这几个模块之间进行单向流动,所以 Flux 被称为单向数据流架构。

核心概念数据流

Action 是携带操作与数据的对象,通过 Disptacher 的处理,更改 Store 中保存的数据。View 可以获取 Store 中保存的数据,或者捕获 Store 中数据的更改,并将其渲染到界面上。当用户进行操作时,View 会再次触发 Action 启动下一轮的数据流。

由于 Flux 更多的只是像是一种模式,所以在进行技术选型时,还会有许多采用 Flux 模式概念产生的框架可供选择。

Action

Flux 中的 Action 通常只是一个普通 JavaScript 对象。Action 中一般会包括操作类型和用于更新 Store 的新数据。例如以下形式。

const add_content = {
  type: 'ADD_CONTENT',
  payload: {
    content: 'some text'
  }
};

为了可以更加便捷的创建 Action,在应用中一般会使用名为 Action Creator 的函数来创建相应的 Action。Action Creator 可以用来接受数据或者进行某些操作,最终返回一个 Action 对象即可。

Dispatcher

Dispatcher 是进行 Action 分发和改变 Store 内容的主要模块。Flux 提供了一个Dispatcher类来提供服务。每个采用 Flux 的应用都需要至少一个Dispatcher实例。Dispatcher类提供了以下五个方法来完成 Action 分发。

  • register(callback),向 Dispatcher 注册一个回调函数,这个回调函数会在每次分发 Action 之后调用。回调函数注册后会返回一个分配给这个回调函数的 ID,可供后文的waitFor()等方法使用。
  • unregister(id),将使用register()注册的回调函数解除注册,其中id参数使用注册函数的返回值。
  • waitFor(ids),在执行后续的操作之前等待所有指定的回调函数执行完成,参数ids为所有回调函数的 ID 列表。
  • dispatch(payload),将指定的载荷(或者 Action)分发到所有的回调函数中。
  • isDispatching(),查询当前 Dispatcher 是否处于分发状态。

以下给出一个使用 Flux 进行 Store 管理的示例。

const dispatcher = new Dispatcher();
let CargoStore = {
  cargos: []
};

dispatcher.register(function (payload) {
  if (payload.actionType === 'add_cargo') {
    CargoStore.cargos.push(payload.cargo);
  }
});

dispatcher.dispatch({
  actionType: 'add_cargo',
  cargo: 'ProductName'
});

这个示例中没有使用不可变数据,这在实际项目中并不是优选方案,实际项目中还是要优先选择不可变数据来实现 State。

Store

Flux 通过其工具库flux/utils提供了三个工具类以方便编写比较基础的 State 数据管理逻辑。Store就是其中之一。Flux 中的 Store 主要需要提供数据缓存、数据存取、响应分发的 Action 等功能,并且需要在响应分发时发送数据更改事件以使使用 Store 中数据的模块能够及时进行数据刷新。

Store类在实际项目中一般作为自定义 Store 类的基类使用。Store类提供了以下方法来完成 Store 功能。

  • constructor(dispatcher),创建 Store 实例并将自身绑定至 Dispatcher。
  • addListener(callback),向自身绑定一个监听回调函数,当 Store 中的数据发生改变时会调用这个回调函数。该方法会返回一个函数用来移除监听回调。
  • getDispatcher(),返回自身绑定到的 Dispatcher。
  • getDispatchToken(),返回自身注册在 Dispatcher 上可用于 Dispatcher 上的waitFor()方法的 ID。
  • hasChanged(),在响应 Action 分发时用来判断其中内容是否发生了改变。
  • __emitChange(),用于通知所有监听回调数据已经发生了改变。这个方法不需要手动调用。
  • onDispatch(payload),用于响应 Dispatcher 的 Action 分发,在实现自定义 Store 时必须重写这个方法以实现数据更新逻辑。

在日常使用中,并不会直接使用Store类作为基类来实现自定义 Store,而是会使用Store类的派生类ReduceStore类作为自定义 Store 的基类。ReduceStore类在Store类基础上增加了以下方法来方便 State 的管理。

  • getState(),返回当前 Store 中保存的 State。如果自定义 Store 中保存的不是不可变数据,那么最好重写这个方法。
  • getInitialState(),创建 State 的初始状态,一般在自定义 Store 进行构建时调用。
  • reduce(state, action),响应 Dispatcher 分发 Action,并且对内部保存的 State 进行变更。自定义 Store 类需要重写这个方法,并且保证这个方法是纯函数,不产生任何副作用。这个方法需要返回新的 State。
  • areEqual(one, two),判断两个版本的 State 是否相同。如果使用不可变数据来保存 State 则不需要重写这个方法。

在使用ReduceStore类作为基类时,不需要手动发送数据发生变更的事件。如果需要手动控制数据发生变更事件的发送,可以通过重写areEqual()方法来实现。

以下给出一个使用ReduceStore类实现自定义 Store 的示例。

class CargoStore extends ReduceStore {
  getInitialState() {
    return {
      cargos: []
    };
  }

  reduce(state, action) {
    switch (action.type) {
      case 'add_cargo':
        state.cargos.push(action.payload);
        return state;
      default:
        return state;
    }
  }
}

这个示例中依旧没有使用不可变数据来控制 State,在实际项目中依旧建议使用 Immutable 等功能库来保证 State 中的内容是完全确定的。

Container

Container类也是 Flux 工具库中提供的一个工具类。其主要功能是将 Flux 的 Store 与 React 组件进行连接,使 React 组件能够访问 Store 中的 State。按照 Flux 的建议,Container 应该是一个不能访问props并且没有 UI 逻辑的类。Container类不需要手动建立,Flux 提供了可以直接对 React 组件类进行包裹的Container.create()方法,该方法要求 React 组件类中定义两个静态方法,分别是getStores()calculateState()

Container.create()方法可以接受两个参数,第一个参数是原始的 React 组件类,第二个参数是可选的包装参数。调用.create()方法后会返回一个新的 React 组件类。在默认情况下,Container 组件类是不能访问props的,如果需要访问props,可以通过在包装参数中使用{ withProps: true }来声明。并且在默认情况下,Container 组件类中的propsstate如果没有发生任何改变,组件类是不会重新渲染的,要改变这个默认行为,可以在包装参数中使用{ pure: false }来改变。

以下给出一个创建 Container 组件类的示例。

import { Component } from 'react';
import { Container } from 'flex/utils';

class CargoContainer extends Component {
  static getStores() {
    return [CargoStore];
  }

  static calculateState(prevState) {
    return {
      cargo: CargoStore.getState()
    };
  }

  render() {
    return <CargoUI cargo={this.state.cargo} />;
  }
}

const container = Container.create(CargoContainer);

使用 Hook 简化 Flux

在 React 16.8 版本引入 Hook 之后,在 React 中使用 Flux 概念变得比较简单了,这主要是通过useContext()useReducer()两个 Hook 函数。

借助于useContext(),全局 State 可以被注入到 Context 中,通过 Provider 向组件树提供。useReducer()可以直接将ReduceStore中的reduce()方法映射进组件类,并且直接向组件类提供dispatch()函数以分发 Action。原则上,由于useReducer()返回的 State 和dispatch()方法是成对的,所以在使用 Provider 向组件树提供 Context 时,要将两者都提供出去。以下给出一个参考示例。

const LoginInitialState = { loggedIn: false, user: '' };
const LoginContext = React.createContext(null);

function LoginReducer(state, action) {
  // 这里完成State变化操作
}

function App() {
  const [LoginState, dispatch] = useReducer(LoginReducer, LoginInitialState);
  return (
    <LoginContext.Provider value={{ LoginState, dispatch }}>
      <div>Application UI</div>
    </LoginContext.Provider>
  );
}

export { App as default, LoginContext };

在子组件上可以这样来使用。

import { LoginContext } from './App';

function LoginPage() {
  const loginCtx = useContext(LoginContext);
  const loginAction = () => {
    loginCtx.dispatch(loginActionCreator());
  };

  return <button onClick={loginAction}>登录</button>;
}

这样,在不引入 Flux 的情况下,只需要定义若干 Action Creator 和 Reduce 函数,搭配上不可变对象,即可直接完成全部全局 State 管理的功能。

Redux

Redux 是一个全局 State 管理器,并不是只应用于 React。Redux 采用单向数据流管理,但比概念原型 Flux 要更加简便。要在 React 项目中使用 Redux,只需要执行以下命令完成安装即可。

# 安装Redux以及React绑定
npm install redux react-redux
yarn add redux react-redux
# 安装开发工具
npm install redux-devtools --save-dev
yarn add redux-devtools --dev

与 Flux 有何不同

在 Redux 中主要有三种内容参与全局 State 的管理:Store、Action 和 Reducer。简而言之,应用中所有的 State 都以一个对象树的形式保存在单一 Store 中,想要改变 State 只能通过触发用于描述发生何种事件的 Action 对象,而 Reducer 则是描述 Action 事件如何去改变 State 树。State 的更新遵循(state, action) => state原则。

Redux 与 Flux 的主要区别就是 Redux 是假设你永远不会改变你的数据,即便是使用 Reducer,也只是返回一个新的对象,并不是在原有对象的基础上进行修改。如果搭配 Immutable 等支持数据不可变性的功能库会对这个特性产生更加深刻的理解。此外,Redux 还可以搭配 RxJS 来实现响应式的 State 处理。

Action

在 Redux 中,Action 是把数据从应用传到 Store 的有效载荷,是 Store 数据的唯一来源。Action 本质上是一个普通 JavaScript 对象,按照 Redux 约定,Action 对象内必须使用一个字符串类型的type来表示 Action 将要执行的动作。

一般建议 Action 使用以下模板来设计。

const ADD_CONTENT = {
  type: 'ADD_CONTENT', // Action操作的类型,用于Reducer进行分支判断
  payload: {}, // Action的载荷,可以是任何类型,用于交给Reducer处理State
  error: false, // 布尔类型,表示Action是否代表一个错误
  meta: {} // 用于承载不隶属于payload的数据。
};

Action 一般会使用 Action 创建函数来完成创建,而不是手动书写。在 Redux 中,Action 创建函数只是简单的返回一个 Action,而不是像 Flux 中需要调用dispatch()

const ADD_CONTENT = {
  type: 'ADD_CONTENT', // Action操作的类型,用于Reducer进行分支判断
  payload: {}, // Action的载荷,可以是任何类型,用于交给Reducer处理State
  error: false, // 布尔类型,表示Action是否代表一个错误
  meta: {} // 用于承载不隶属于payload的数据。
};

对于 Action 的type,其所有取值可以预先在一个文件中集中定义,这也防止 Reducer 在处理时出现书写错误,也便于书写。例如上面的 Action 书写到文件中可能是下面这个样子。

export const ADD_CONTENT = 'ADD_CONTENT';

export function addContent(content) {
  return {
    type: ADD_CONTENT,
    payload: content
  };
}

Reducer

Reducer 是一个纯函数,接收旧 State 和 Action,返回新的 State,即(previousState, action) => newState。纯函数的意义在于相同的输入只会得到相同的输出,并且不会产生任何可观察的副作用,所以以下操作永远不要在 Reducer 中进行:

  • 修改传入的参数;
  • 执行有副作用的操作,例如执行 API 请求或者路由跳转。
  • 调用其他非纯函数,例如即Date.now()或者即Math.random()

Redux 在首次执行时,State 为undefined,此时可以返回应用的初始 State。以下是一个 Reducer 的示例。

function contentApp(state = initialState, action) {
  switch (action.type) {
    case ADD_CONTENT:
      return Object.assign({}, state, { content: action.payload.content });
    case SET_FILTER:
      return Object.assign({}, state, { filter: action.payload.filter });
    default:
      return state;
  }
}

示例中使用Object.assign()创建了一个 State 的副本,并且在遇到未知的 Action 时一定要返回旧的 State。按照示例中的写法,当 Action 逐渐增多,整个 Reducer 将会越变越大,这时就需要考虑拆分 Reducer。Reducer 的拆分就必然意味着 Reducer 的合并。使用一个函数来作为主 Reducer,调用若干子 Reducer 来分别处理 State 中的一部分数据,然后再将这些数据合并为一个大的单一对象,这就是开发 Redux 应用最基础的模式。在这种模式下,主 Reducer 不需要设置初始化时完整的 State,各个子 Reducer 会返回各自的初始默认值。

Tip

在 ES7 标准中,可以使用对象展开运算符来替代Object.assign()。上例中的Object.assign()可以改写为return { ...state, content: action.payload.content }

将以上示例改为拆分的写法如下。

function filter(state = initialState, action) {
  switch (action.type) {
    case SET_FILTER:
      return Object.assign({}, state, { filter: action.payload.filter });
    default:
      return state;
  }
}

function add(state = [], action) {
  switch (action.type) {
    case ADD_CONTENT:
      return Object.assign({}, state, { content: action.payload.content });
    default:
      return state;
  }
}

// 主Reducer
function contentApp(state = {}, action) {
  return {
    filter: filter(state.filter, action),
    add: add(state.content, action)
  };
}

在这种模式下,随着应用规模的膨胀,拆分后的 Reducer 只需要放置在不同的文件中,保持其独立性即可。针对主 Reducer,Redux 提供了combineReducers()工具来完成示例中主 Reducer 的事情。所以上例中主 Reducer 可以改写为以下形式。

import { combineReducers } from 'redux';

const contentApp = combineReducers({
  filter,
  content: add
});

或者还可以为其中不同的 Reducer 设置不同的键值,以定义其要更改的 State。

Tip

在使用 ES6 语法时,可以将所有的顶级 Reducer 都放置在一个独立文件中,并使用export暴露出来,在合成 Reducer 的文件中使用import * as reducers from './fileName'以 Object 方式导入全部 Reducer,所获得的这个 Object 可以直接给予combineReducers()来合成 Reducer。

Store

Store 是将 Action 和 Reducer 联系到一起的对象。Store 能够维持应用的 State,并且能够提供getState()来获取 State,提供dispatch(action)来更新 State,通过subscribe(listener)及其返回的函数注册和注销监听器。每个 Redux 应用有且只有一个 Store,当需要拆分数据逻辑时,应该使用 Reducer 组合而不是创建多个 Store。

Store 可以通过之前combineReducers()返回的主 Reducer 创建,这是通过 Redux 提供的createStore()来完成的。所以基于之前的示例创建一个 Store 就是通过以下代码。

import { createStore } from 'redux';
import { contentApp } from './reducers';

let store = createStore(contentApp);

createStore()还可以接受第二个参数,其内容为 State 的初始状态。

定义好的 Store 可以使用以下方式发起 Action。

store.dispatch(addContent('some content'));

store.subscribe()可以在 Store 上注册一个监听器,每当 Dispatch Action 的时候就会执行,在监听器函数中可以使用store.getState()来获取当前的 State。

let unsubscribe = store.subscribe(() => {
  let currentValue = store.getState();
});

// 注销监听器
unsubscribe();

在监听器中可以进行 Dispatch 操作,但是需要注意这样的操作会导致 Store 进入无限循环。并且store.subscribe()是一个低层级 API,在一般情况下并不建议直接使用。

Tip

建议可以结合 RxJS 将 State 的变化转换为一个 Observable 进行监听。

与 React 整合

Redux 与 React 的整合主要通过react-redux这个绑定库。并且主要的实现目的就是将 React 组件与 Redux 关联起来。但是需要注意的是,在 React 中使用 Redux,React-Redux 这个绑定库并不是必需的,而且使用这个库需要掌握额外的 API,并要遵循其组件拆分规范。

Tip

根据 Redux 作者的建议,只有遇到 React 解决不了的问题,才会需要 Redux。在大多数情况下,React 已经足够,所以对于在项目中使用 Redux,要根据项目需要仔细甄别。按照一般经验来说,在以下场景内会用到 Redux。

  • 用户的使用方式复杂。
  • 不同的身份的用户有不同的使用方式,例如普通用户与管理员。
  • 多个用户之间需要协作。
  • 与服务器有大量的交互,或者使用了 WebSocket。
  • UI 视图需要从多个来源获取数据。

简而言之,在组件的状态需要共享,并且需要操作全局状态甚至更改其他组件的状态的情况下,可以考虑使用 Redux。但是需要牢记的是,Redux 只是一种架构解决方案,不是唯一的。

React-Redux 中将 React 组件分为两大类:容器组件和 UI 组件。其中 UI 组件只负责 UI 的呈现,不带有任何业务逻辑,不使用this.state保存本地 State,所有数据都由props提供,并且不使用任何 Redux API。所以 UI 组件与之前提到的纯函数一样,给定相同的参数,产生相同的 UI 显示。而容器组件则完全相反,只处理数据和业务逻辑,不负责 UI 呈现,并且带有内部状态,也是用 Redux API。

从原理上说,就是在 React 中创建容器组件,通过store.subscribe()从 State 树中读取数据,并通过props提供给 UI 组件。但是 React-Redux 规定,所有的 UI 组件都由用户提供,容器组件都由 React-Redux 自动生成。所以 React-Redux 提供了connect()方法,用于从 UI 组件中生成容器组件。

connect()方法接受两个参数,两个参数均为函数,其中第一个参数用于将 State 映射到 Props,第二个参数用于将 Dispatch 映射到 Props。即第一个参数负责输入逻辑,第二个参数负责输出逻辑。

connect()的具体用法可参考以下示例。

const mapStateToProps = state => {
  return {
    contents: state.contents.filter(c => t.onShow)
  };
};

// 其中ownProps代表容器自身的props
// bindActionCreator可以将Action Creator直接放入组件供生成Action使用
const mapDispatchToProps = (dispatch, ownProps) => {
  return {
    onClick: () => {
      dispatch(addContent(ownProps.newContent));
    },
    actions: bindActionCreators({ addContent }, dispatch)
  };
};

// Contents为UI组件
const OnShowContents = connect(mapStateToProps, mapDispatchToProps)(Contents);

注意上例中mapDispatchToProps()中返回的对象,定义了 UI 组件的参数如何发出 Action。

React-Redux 中还提供了了一个 Provider 组件,用于包裹组件树的根,使整个组件树都能拿到 State,这个原理与 React 中的 Context 一致。一般用法如下。

const store = createStore(reducers, initialState);

// Provider包裹上例中使用connect建立的容器组件
ReactDOM.render(
  <Provider store={store}>
    <OnShowContents />
  </Provider>,
  document.getElementById('app')
);

Warning

不建议直接向组件中注入全局 State,这样会导致任何一个 Action 都触发整个应用的重新渲染。最好的使用习惯是每个组件只监听它所关联的部分 State。组件可以使用以下方式注入部分关注的 State 和 dispatch

export default connect(state => {
  related: state.related;
})(SomeElement);

如果使用 Hook 语法编写 React 组件,可以在 React 组件中使用useReducer()来返回当前的 State 和配套 Dispatch 方法。useReducer()接受一个形如(state, action) => newState的函数和 State 初始状态作为参数。

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <div>
      Count: {state.count}
      <button onClick={() => dispatch({ type: 'increment' })}>Add</button>
    </div>
  );
}

React-Redux v7.1 及以后的版本还提供了若干 Hook 来方便使用 Hook 方式编写的 React 组件。

  • useSelector(selector, equalityFn),用于从 Store 中提取数据。其中 selector 是一个函数,用来选择要返回的 State,equalityFn 函数通过指定一个相等规则来判断 State 是否发生了变化。
  • useDispatch(),向组件中注入 Dispatch 方法。
  • useStore(),向组件中注入整个 Store。

对于前面使用 useReducer() 编写的示例,可以使用 React-Redux 提供的功能重新改写如下。

function Counter() {
  const count = useSelector(state => state.count);
  const dispatch = useDispatch();
  return (
    <div>
      Count: {count}
      <button onClick={() => dispatch({ type: 'increment' })}>Add</button>
    </div>
  );
}

与 React-Router 整合

对于使用 React-Router 的项目,要完成与 Redux 的整合,也只是像前一节一样,使用 Provider 将 Router 包裹一层,建立一个新的组件即可。

Warning

除非需要回溯操作和使用 Action 来触发 URL 的改变,否则建议将 React-Router 与 Redux 分开使用。

异步数据流

严格的单向数据流是 Redux 架构的核心。这样的设计可以使应用变得更加可预测和更容易理解。所以 Redux 中的数据一般遵循以下 4 个生命周期。

  • 调用store.dispatch(action),启动一个 State 变更操作。
  • Redux 调用传入的 Reducer 函数,返回新的 State。
  • 主 Reducer 将各个子 Reducer 的输出合并为新的 State 树。
  • Redux 将新的 State 树保存在 Store 中。

在默认情况下 Redux 创建的 Store 并没有使用中间件,所以只支持同步数据流。如果要使 Redux 支持异步数据流,必须加入 redux-thunk 或者 redux-promise 等中间件。这些中间件可以使 Store dispatch 除了 Action 以外的其他内容,例如函数和 Promise,从而达到实现异步数据流的目的。但是无论中间使用了那些中间件,整条中间件链中最后一次 dispatch 的 Action 一定是一个普通对象,并使用同步操作。

根据 Redux 的设计理念,Reducer、Action 等位置上都不适合使用中间件,唯一适合使用中间件的位置为store.dispatch()。所以目前的中间件,都是对store.dispatch()进行了改造,在发送 Action 和执行 Reducer 之间添加了其他的功能。

中间件通过 Redux 提供的applyMiddleware()加入到 Store 中的,一般作为createStore()方法的最后一个参数。applyMiddleware()方法可以接受若干中间件作为参数,中间件的出现顺序代表中间件的调用顺序,并且有些中间件是存在次序要求的。

常用中间件 Redux-Thunk 可以支持 Dispatch 函数,并且可以在处理过程中多次 Dispatch。而使用 Redux-Promise 中间件则可以在 Action 中直接返回一个 Promise。

以下示例使用了 Redux-Thunk 和 Redux-Promise 中间件。

import { createStore, applyMiddleware } from 'redux';
import thunkMiddleware from 'redux-thunk';
import reducers from './reducers';

const store = createStore(reducers, applyMiddleware(thunkMiddleware));

// 定义一个Action Creator
const fetchContents = title => (dispatch, getState) => {
  dispatch(fetchContents(title));
  return fetch('url')
    .then(response => response.json())
    .then(json =>
      dispatch({
        type: 'FETCH_CONTENTS',
        payload: json
      })
    );
};

// 直接dispatch即可
store.dispatch(fetchContents('something'));
import { createStore, applyMiddleware } from 'redux';
import promiseMiddleware from 'redux-promise';
import reducers from './reducers';

const store = createStore(reducers, applyMiddleware(promiseMiddleware));

// 定义一个Action Creator
const fetchContents = (dispatch, title) =>
  new Promise((resolve, reject) => {
    dispatch(requestContents(title));
    return fetch('url').then(response => ({
      type: 'FETCH_CONTENTS',
      payload: response.json()
    }));
  });

从以上两个示例中可以看出,无论采用哪种异步方式,最后 Dispatch 的都是非常明确的 Action,并且执行同步操作。

Redux-Saga

Redux-Saga 是 Redux 的一个中间件,用于管理应用程序的所有副作用(Side Effect,如异步获取数据、访问浏览器缓存等)。Redux-Saga 使用了 ES6 的生成器功能来优化整个异步的流程。在实际应用中,可以将 Redux-Saga 想象为一个专门用于处理副作用的线程,可以从主程序启动,接受主程序控制,可以访问 Redux 的 State,并且可以分发 Action。

Redux-Saga 可以使用以下命令完成安装。

npm install redux-saga
yarn add redux-saga

Redux-Saga 已经内置了用于 TypeScript 的声明,所以如果使用 TypeScript 编写项目,不必再安装任何其他的声明库。

在 Redux-Saga 中将每个执行副作用操作的生成器称为一个 Saga,所有的 Saga 合并在一起形成一个中间件,加入到 Redux 的 Store 中。以下给出一个示例来说明其基本用法。

import { createStore, applyMiddleware } from 'redux';
import createSagaMiddleware, { delay } from 'redux-saga';
import { put, takeEvery, all } from 'redux-saga/effects';

// function* 表示创建一个生成器函数。
// 其中使用yield暂停函数执行并返回当前值,并可以捕获.next()传入的参数。
function* doSomeDelayAction() {
  yield delay(1000);
  // put 辅助函数用于通知中间件发起指定 Action。
  yield put({ type: 'ACTION' });
}

// 使用 takeEvery 辅助函数监听指定的 Action,并在匹配时执行指定任务。
function* watchActions() {
  yield takeEvery('ACTION_ASYNC', doSomeDelayAction);
}

function* logSaga() {
  console.log('Log something.');
}

// 负责启动全部抛出的 Sagas
function* rootSaga() {
  yield all([logSaga(), watchActions()]);
}

const sagaMiddleware = createSagaMiddleware();
const store = createStore(reducer, applyMiddleware(sagaMiddleware));
// 启动 rootSaga 抛出的全部 Sagas
sagaMiddleware.run(rootSaga);

一般为了运行所有被创建的 Sagas,需要首先创建一个 Saga Middleware,并将这个 Saga Middleware 连接至 Redux Store。Sagas 会抛出一个可以被中间件解释执行的指令对象,当中间件取得一个被抛出的 Promise 是,Saga 将会被暂停直到 Promise 完成运行。示例中所使用的一些辅助方法其含义将逐步说明,但其功能都是用于执行某些副作用。所有的副作用都是简单 JavaScript 对象,其中包含需要中间件去执行的指令,这些副作用 Action 可以参照 Redux 的 Action 编写标准来书写。

middleware.run() 启动根 Saga 必须在 applyMiddleware() 之后,根 Saga 中可以包含所有要相应的 Action。所以建议在根 Saga 中使用 all() 来启动所有的子 Saga。

Effects

前一节提到所有的副作用都是简单的 JavaScript 对象,所以 Redux-Saga 提供的 Effects 函数(副作用函数)都是返回纯 JavaScript 对象的,即 Effect。Effect 用于提供给中间件解释执行的操作指令。Effect 函数都在 redux-saga/effects 包中提供。在一个 Saga 中通常使用 yield 来向中间件发出 Effect,其中最简单的方式是 yield 一个 Promise 对象。

在 Saga 中,yield 关键字右侧的表达式将被求值,值的结果将作为 yield 表达式的值返回。对于 yield 一个 Promise 对象时,Promise 对象的 resolve 结果将作为 yield 表达式的值。例如以下示例。

function* fetchContent() {
  const content = yield fetch('url');
  // 或者使用 call 副作用函数来书写
  // 使用 call 时,中间件将会确保被调用函数的执行和 resolve 结果的响应
  const content = yield call(fetch, 'url');
}

所以,在编写 Saga 时,就是通过 yield 来抛出不同的 Effect 来实现各种操作。Redux-Saga 提供的常用 Effect 函数主要有以下这些。

  • take(pattern),命令中间件等待指定的 Action,在发起与 pattern 匹配的 Action 之前,Saga 将暂停。当捕获到特殊的 END Action 时,所有被 take 阻塞的 Saga 都会被终止,如果有正在运行的 fork 子任务,则会等待子任务终止。pattern 可以是以下类型的值。
    • 空值或者 *,表示匹配所有发起的 Action。
    • 函数,匹配 pattern(action) 返回值为 true 的 Action。
    • 字符串,相等匹配。
    • 数组,以上匹配规则混合使用。
  • take(channel),命令中间件从指定的通道中等待一条特定信息。
  • take.maybe(pattern),与 take 相同,但响应 END Action 时不自动终止 Saga。
  • actionChannel(pattern, [buffer]),命令中间件通过一个事件通道对匹配 pattern 的 Action 进行排序,返回一个通道对象。通道对象可以干替代 patterntakeput 等函数中使用。
  • flush(channel),命令中间件清除通道中所有被缓存的数据,被清除的数据会被返回至 Saga。
  • put(action),创建一个 Effect 描述信息,命令中间件向 Store 发起一个 Action。非阻塞型 Effect,向下游抛出的错误不会回到 Saga 中。
  • put(channel, action),向指定通道中放入一个 Action。
  • put.resolve(action),与 put 相同,但是阻塞型 Effect,会返回 dispatch 的结果并冒泡下游抛出的错误。
  • call(fn, ...args),命令中间件以参数 args 调用函数 fn。其中 fn 可以是普通函数也可以是生成器函数,中间件将会检查函数的调用结果并返回。当函数返回 Promise 时,其 reject 的值可以被包裹 calltry...catch 语句捕获。call 常用格式有以下这些。
    • call([context, fn], ...args),支持将上下文传递给 fn
    • call([context, fnName], ...args),支持使用字符串调用 fn,常用于调用对象中的方法。
    • apply(context, fn, [args]),同 call([context, fn], ...args)
  • cps(fn, ...args),命令中间件以 Node 风格函数调用 fn。Node 风格函数在其执行后会调用一个回调函数,回调函数的第一个参数用于报告错误,第二个参数用于报告函数执行结果。还可以使用 cps([context, fn], ...args) 的形式传递上下文对象给函数。
  • fork(fn, ...args),命令中间件以非阻塞调用的方式执行 fn,即派生子任务。还可以使用 fork([context, fn], ...args) 的形式传递上下文对象给函数。yield fork() 将返回一个 Task 对象,可供 join 使用。父任务在终止时,会等待所有子任务终止。
  • join(task),命令中间件等待 task 子任务的结果。
  • cancel(task),命令中间件取消子任务执行。或者可以使用 cancel(...tasks) 批量取消,cancel() 取消自身。
  • spawn(fn, ...args),与 fork 类似,但创建一个分离的子任务,子任务与父级任务保持独立。同样可以使用 spawn([context, fn], ..args)的形式传递上下文对象给函数。
  • select(selector, ...args),命令中间件在当前 Store 的 State 上调用指定的选择器,selector 为选择器函数。
  • cancelled(),用来判断当前 Saga 是否已经被取消。通常在 finally 块中使用来完成取消逻辑。
  • setContext(props),命令中间件更新其自身上下文。
  • getContext(prop),命令中间件返回 Saga 上下文中的特定属性。

通过 fork()spawn()middleware.run() 或者 runSaga() 建立的子任务提供了以下方法来进行控制和交互。

  • isRunning(): boolean,检查子任务是否在运行。
  • isCancelled(): boolean,检查子任务是否已被取消。
  • result<T = any>(): T undefined},获取子任务的运行结果。子任务依旧在运行时会返回 undefined
  • error(): any undefined},获取子任务抛出的错误。子任务依旧在运行时会返回 undefined。
  • toPromise<T = any>(): Promise<T>,获取一个 Promise 对象,用于获取子任务的运行结果或者错误信息。
  • cancel(): void,取消子任务运行。
  • setContext<C extends object>(props: Partial<C>): void,设置子任务的环境上下文。

Saga 还可以被组合在一起,来并行的启动一个或者多个子任务。组合后的 Sagas 在使用 yield 执行时,与其他的 Saga 没有任何不同。Redux-Saga 提供了以下函数来对 Saga 进行组合。

  • race(effects),多个 Saga 竞赛执行,effects 参数为一个 { label: saga() } 格式的字典对象。race 返回一个赢得竞赛的 { label: result } 对象。当一个 Saga 赢得竞赛时,其他的 Saga 将被终止。race 还可以接受一个 Saga 数组作为参数,即 race([effects])
  • all([effects]),并行运行多个 Saga。当 Saga 并发执行时,生成器将被暂停,直到所有 Saga 都完成运行或者任意一个 Saga 抛出错误。

除此之外,在一个 Saga 中,还可以使用 yield* 来对多个 Saga 进行排序。yield* 主要用在在生成器函数中,重新抛出另一个生成器函数,所以可以用来对 Saga 的执行进行排序。

Channels

Redux-Saga 中的 Channel 通常用于对 Action 进行排序和与外部事件源进行通信。

使用 actionChannel(pattern) 可以创建一个容纳匹配 pattern 的 Action 的队列。这个队列可以缓存所有未执行的 Action。以下是一个缓存所有 Action 并按照顺序处理的示例。

function* watchRequest() {
  // 创建一个队列
  const requestQueue = yield actionChannel('RQUEST');
  while (true) {
    // 从队列中取出一个 Action
    const { payload } = yield take(requestQueue);
    // 调用 Action 对应的处理方法,并阻塞当前 Saga
    yield call(handleRequest, payload);
  }
}

默认情况下 actionChannel 会无限制的缓存所有传入的 Action,但是可以通过提供第二个参数来为 Channel 指定一个缓存。Redux-Sage 提供了以下这些内置的缓存实现可供使用。

  • buffers.none(),不缓存,未被处理的 Action 将会被丢弃。
  • buffers.fixed(limit),容量为 limit 的缓存,溢出时将会报错。
  • buffers.expanding(initialSize),与 fixed 相似,但溢出时会动态扩展。
  • buffers.dropping(limit),容量为 limit 的缓存,溢出时会丢弃最新的 Action。
  • buffers.sliding(limit),容量为 limit 的缓存,溢出时会丢弃最古老的 Action。

与 actionChannel 类似,eventChannel 可以为外来事件源创建一个 Channel。eventChannel 接受一个函数作为参数,即 subscriber ,其主要任务是初始化外部事件来源,eventChannel 会为 subscriber 提供一个 emitter 来将事件源传入的所有事件路由到 Channel。以下示例创建了一个可以倒数计数的 Channel。

function* countdown(secs) {
  return eventChannel(emitter => {
    const interval = setInterval(() => {
      secs -= 1;
      if (secs > 0) {
        emitter(secs);
      } else {
        // 发送 END 将导致 Channel 关闭。
        emitter(END);
      }
    }, 1000);
    // 回传一个 unsubscribe 函数。
    return () => {
      clearInterval(interval);
    };
  });
}

除此之外,还可以通过创建一个独立的 Channel 来在不同的 Saga 之间进行通信。Channel 可以使用 put 手动推送,还可以通过 take 手动取出,并以此来完成 Saga 之间的通信。

自定义 Buffer

Redux-Saga 中可以通过自定义 Buffer 类型来实现更加自由的通道控制。由于 Buffer 在 TypeScript 中是一个接口,所以只需要按照接口定义完成 Buffer 类型的实现即可自定义 Buffer。这里列出 Buffer 接口的定义,在自定义 Buffer 时只需要按照以下接口编写代码即可。

interface Buffer<T> {
  // 判断缓存是否为空
  isEmpty(): boolean;
  // 向缓存推送一条消息
  put(message: T): void;
  // 从缓存中取出一条消息
  take(): T | undefined;
  // 清空缓存
  flush(): T[];
}

常用辅助函数

辅助函数用于监听特定的被发起到 Store 的 Action,并在这些 Action 被发起时执行指定任务。根据 Saga 发起方式的不同,Redux-Saga 提供了以下辅助函数。

  • takeEvery(pattern, saga, ...args),对每一个匹配的 pattern 都派生一个 saga,并将 args 传递给 saga,当前的 Action 将被追加到 args 末尾传递给 saga
  • takeLatest(pattern, saga, ...args),对匹配 pattern 的 Action 派生一个 saga 并取消之前所有已启动的匹配的执行。
  • takeLeading(pattern, saga, ...args),对匹配 pattern 的 Action 派生一个 saga,并阻塞后续的匹配直到派生的 saga 执行完毕。
  • throttle(ms, pattern, saga, ...args),对匹配 pattern 的 Action 派生一个 saga,但仍将新传入的 Action 接收到缓存中,但只保留最新的一个,并在指定 ms 毫秒的时间内暂停 saga 的派生。也就是说 throttle 会在 saga 派生后的指定时间内忽略新传入的 Action。

takeEverytakeLastesttakeLeading 除了可以接受 pattern 之外,还可以接受 channel 作为参数。

作为 Effect 的高级实现,在底层,辅助函数都是由 takefork 联合实现的。例如 takeEvery 的实现实际上是以下形式。

const takeEvery = (patterOrChannel, saga, ...args) =>
  fork(function* () {
    while (true) {
      const action = yield take(patternOrChannel);
      yield fork(saga, ...args.concat(action));
    }
  });

使用在 TypeScript 中的类型定义

Redux-Saga 自带了用于 TypeScript 的类型声明文件,在使用 TypeScript 时,了解 Redux-Saga 所定义的类型有助于在编写代码时书写有效的类型标注。这里对使用 Redux-Saga 时常用的类型进行列举。

  • ActionType,定义为 string | number | symbol,用于标记 Action 类型的类型。
  • Saga<Args extends any[] = any[]>,定义为 (...args: Args) => Iterator<any>,Saga 是一个接受一个数组作为参数,并返回一个迭代器的函数。
  • Predicate<T>,定义为 (arg: T) => boolean,用于使用在 Pattern 中的判定函数。
  • SubPattern<T>,定义为 Predicate<T> | StringableActionCreator | ActionType,用于标记每一个 Pattern 单元的类型。其中 StringableActionCreator 为可以生成 Action 并带有 toString() 方法的函数。
  • Pattern<T>,定义为 SubPattern<T> | SubPattern<T>[],用于 take 等函数的 Pattern 参数的类型。
  • Buffer<T>,能够存放指定内容类型的缓存类型。
  • Channel<T>,通道类型。
  • END,通道终止类型。
  • Task,派生 Saga 得到的任务类型。

一些需要注意的关键点

  1. 直接调用 take() 将会使程序卡死,必须要搭配 yield 将控制权交给 Redux-Saga 中间件才能够保证程序的正常运行流程。
  2. 当 Saga 正在等待 Effect resolve 时,它不能再 take 其他的 Action,也就是 Saga 在等待时是被阻塞的。如果需要 Saga 在等待 Effect resolve 时依旧可以响应其他 Action,可以使用 fork 来代替 call 实现无阻塞调用。
  3. 通过 yield 不同内容,可以轻松实现 AJAX 重试。例如以下示例。
function* updateApi(data) {
  let retryCounter = 0;
  while (retryCounter < 4) {
    retryCounter++;
    try {
      const response = yield call(apiRequest, { data });
      return response;
    } catch (err) {
      yield delay(2000);
    }
  }
  throw new Error('API Request failed.');
}

React Toolkit与Hooks

自从React 16.8引入了Hooks语法特性以后,主流的中间件库都开始了升级改造以支持Hooks方式的使用。Redux也不例外,但是最新版的Redux除了引入了Hooks以外,还通过引入Redux Toolkit简化了之前饱受诟病的复杂的状态构建及控制方式。

Tip

虽然Redux提供了新的基于Redux Toolkit的使用方法,但是其核心理念并没有发生变化,之前一章中所介绍的内容在本章中依旧十分有用。

新版本提供的Redux Toolkit可以通过以下命令安装到项目中。

# 安装Redux Toolkit
npm i @reduxjs/toolkit
yarn add @reduxjs/toolkit
# 安装Redux React以及调试工具
npm i react-redux
npm i -D @redux-devtools/core
yarn add react-redux
yarn add @redux-devtools/core --dev

相比之前版本中直接安装Redux(现在被称为Redux Core),安装使用Redux Toolkit可以允许在项目中使用更多的快捷工具方法来简化Redux状态Store和处理过程的编码。

Tip

如果不打算在项目中使用Redux Toolkit,那么在项目中使用Redux的方法就跟前面一章中几乎完全相同,除了使用\texttt{Provider}在项目中提供Store上下文以外。

Toolkit提供的新API

Redux Toolkit主要解决的问题是Redux在使用过程中配置复杂、模板代码过多等问题。所以Redux开发团队就问题比较集中的几项,推出了Redux Toolkit。其中主要通过以下几个API来简化Redux的配置和使用。

  • configureStore(),用于简化createStore,可以支持使用简单的配置语法来创建新的Store。
  • createReducer(),支持采用查找表的方式定义Reducer,而不是采用编写大量switch语句的方式。
  • createAction(),支持快速构建{ type: 'action_name', payload: object }类型Action的函数。
  • createSlice(),支持快速构建一个可以接受Reduce函数的对象(也即是State片段),以及这个对象的默认值、名称等。
  • createAsyncThunk(),支持构建一个接受Action并返回一个Promise的Thunk,并且这个Thunk会根据Promise的最终值产生新的Action。
  • createEntityAdapter(),用于支持在Store中生成一组Reducer和Selector。
  • createSelector工具集,用于从State中选择一部分子State。

除了以上功能以外,Redux Toolkit还包括了一组用于数据获取和数据缓存的的工具集RTK Query,如果要想在项目中使用这套工具集,需要额外单独引入,其并不直接包含在Redux Toolkit包中。

构建Store

要使用Redux,还是要从构建Store开始。使用Redux Toolkit构建Store的时候可以直接使用configureStore函数。

我们可以像以下示例一样构建一个简单的空白Store。

import { configureStore } from '@reduxjs/toolkit';

export const store = configureStore({
  reducer: {}
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

在上面这个示例中,configureStore函数接受了一个对象作为参数,而这个对象中也只有一个名为reducer的空字段。但实际上configureStore函数所能够接受的配置对象的解构还是比较复杂的。以下是其可以接受的配置对象的类型定义,从这个类型定义可以看出configureStore函数斗能够配置哪些内容。

interface ConfigureStoreOptions<S = any, A extends Action = AnyAction, M extends Middlewares<S> = Middlewares<S>> {
  // reducer属性可以接受一个函数或者一个对象。
  // 当给定的值是一个函数的时候,这个函数的返回结果将直接被作为Store的初始值和结构使用。
  // 或者还可以使用一个由命名的State片段组成的对象作为属性值,此时Store即是这些片段的集合,这也是configreStore常用的配置方法。
  // 给定一个由片段组成的对象的方法将会直接将其传递给combineReducers方法。
  reducer: Reducer<S, A> | ReducersMapObject<S, A>;

  // 用于指定一组用于附加在Store上的中间件。会自动将这里设置的所有中间件都传递给applyMiddleware方法。
  // 如果没有配置,confugreStore将会调用getDefaultMiddleware函数使用默认配置的中间件。
  middleware?: M | ((getDefaultMiddleware: CurrieGetDefaultMiddleware<S>) => M);

  devTools?: boolean | DevToolsOptions;

  // 用于指定一个初始的state,如果设置了,将会传递给createStrore函数使用。
  preloadedState?: DeepPartial<S extends any ? S : S>;

  // 用于为Store指定一组enhancer,或者通过一个生成函数来自定义和生成一组enhancer。
  // 此处定义的enhancer将会被传递给createStore函数使用。
  enhancers?: StoreEnhancer[] | ((defaultEnhancers: StoreEnhancer[]) => StoreEnhancer[]);
}

这个空白的Store在于React应用结合的时候是通过React的Context(上下文)注入的。但是这个上下文的注入不是通过React的Context.Provider,而是直接通过Redux React提供的Provider组件。例如上面的这个空白Store可以如下例一样注入到应用中。

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import App from './App';
import './index.css';
import { store } from './store/root_store';

ReactDOM.createRoot(document.getElementById('root')).render(
  <Provider store={store}>
    <App />
  </Provider>
);

快速创建Action

在Redux中Action实际上就是一个有着特定结构的普通Javascript对象。在习惯上,Action的基本类型就是{ type: string, payload: any }

在Redux使用到项目开发中以后,经历了一个首先定义大量Action Type字符串,然后过渡到利用Action构建函数根据需要动态的创建Action。例如以下示例所示。

const INCREASE = 'counter/increase';

function counterIncrease(amount: number) {
  return {
    type: INCREASE,
    payload: amount
  };
}

// 定义Action构建函数以后,就可以使用以下形式创建Action
const action = counterIncrease(1);

因为这种Action的结构形式和创建方法已经基本上得到了绝大多数Redux使用者的认可和习惯,所以Redux Toolkit根据这个Action的结构形式和创建方法,引入了一个能够快速创建Action的辅助函数createAction

利用createAction这个辅助函数来重写上面的示例,就会使代码变得十分简练。

import { createAction } from '@reduxjs/toolkit';

// createAction创建的是一个生成器函数,返回的函数在执行以后才会返回一个Action生成函数。
const counterIncreaseAction = createAction<number | undefined>('counter/increase');

// 调用执行生成器函数可以生成一个Action,如果提供任何参数生成的Action就不会携带payload属性。
// 在这个示例中生成的action内容是 \{ type: 'counter/increase' \}
const counterIncrease = counterIncreaseAction();

// 传递一个参数来调用生成器函数可以生成一个带有payload属性的Action对象。
// 在这个示例中,action的内容是 \{ type: 'counter/increase', payload: 1 \}
const action = counterIncrease(1);

createAction函数的函数签名是createAction(type, preparedAction?),所以除了可以直接指定一个type参数来快速定义一个Action生成器函数以外,还可以通过传递一个自定义的Action准备函数来为默认生成的Action携带的内容。以下示例在之前示例的基础上为Action自定义了要携带的内容。

import { createAction } from '@reduxjs/toolkit';

const counterIncreaseAction = createAction('counter/increase', (amount: number, text: string) => ({
  payload: {
    amount,
    message: text,
    createdAt: new Date().getTime()
  }
}));

// 调用这个生成器函数可以直接生成一个Action对象。
// 在这个示例中,action的内容是 \{ type: 'counter/increase', payload: \{ amount: 1, message: 'hello', createdAt: 1652523758732 \} \}
const increaseAction = counterIncreaseAction(1, 'Hello');

Warning

注意,createAction所构建的Action只能使用字符串作为Action类型,不能创建非字符串类型的Action。任何传入的非字符串类型的Action类型值,都将被转换成字符串。

当具备了一些Action以后,就可以前进到定义Reducer的步骤了。

创建Reducer

对于Reducer的创建,同样需要与传统Redux中的Reducer创建方法进行对比。传统定义Reducer都是通过构建一个复杂的大型switch表达式来完成的。

例如延续上一节示例中的Action对应的Reducer,就是以下示例中的样子。

const initialState = { value: 0 };

function counterReducer(state = initialState, action) {
  switch (action.type) {
    case 'counter/increase':
      return { value: state.value + action.payload ?? 1 };
    case 'counter/decrease':
      return { value: state.value - action.payload ?? 1 };
    default:
      return state;
  }
}

使用这种传统Reducer的创建方法最大的缺点就是在Reducer要处理Action类型繁多的Action时,switch表达式的结构将变得非常庞大,并且难于管理。所以就更不用说每种Action类型再携带不同形式的载荷的情况了。

Redux Tookit针对Reducer定义时的这种特点,使用构造者模式对其进行了优化。虽然不能明显的减少Reducer所使用的代码量,但是可以让Reducer的定义变得更加清晰。

以下是采用Redux Toolkit提供的createReducer函数完成上面示例中的Reducer创建的示例。

import { createAction, createReducer } from '@reduxjs/toolkit';

interface CounterState {
  value: number;
}

const counterIncreaseBy1 = createAction('counter/increase');
const counterDecreaseBy1 = createAction('counter/decrease');
const counterIncreaseByAmount = createAction<number>('counter/increase');
const counterDecreaseByAmount = createAction<number>('counter/decrease');

// createReducer所接受的第一个参数是State的初始状态,第二个参数是处理传入的不同Action的函数。
// addCase方法主要接受使用createAction创建的Action创建函数作为其匹配条件。
// 除了addCase之外,createReducer还支持addMatcher方法,可以用于使用自定义函数进行匹配。
// addDefaultCase方法通常是用作最后的匹配处理,其不需要任何匹配条件,在之前的所有条件都不匹配时匹配,相当于switch表达式中的default。
const counterReducer = createReducer<CounterState>({ value: 0 }, builder => {
  builder
    .addCase(counterIncreaseBy1, (state, action) => {
      state.value += 1;
    })
    .addCase(counterDecreaseBy1, (state, action) => {
      state.value -= 1;
    })
    .addCase(counterIncreaseByAmount, (state, action) => {
      state.value += action.payload;
    })
    .addCase(counterDecreaseByAmount, (state, action) => {
      state.value -= action.payload;
    })
    .addDefaultCase((state, action) => state));
});

在使用createReducer函数的时候需要注意addCaseaddMatcheraddDefaultCase之间的排列顺序。由于addMatcher能够处理包括action.type在内的附加了其他内容的Action载荷,所以需要放置在仅使用action.type进行判断的addCase之后,但是要放置在不需要任何判断条件的addDefaultCase之前。

另外需要说明的一点是,因为createReducer内部使用了Immer库对数据进行不可变处理,所以在匹配条件的回调函数中可以对State直接进行操作,createReducer内部会将其转换为使用Immer库的操作。

Tip

对于Immer库的使用,本书会在后续章节中进行介绍。

定义State片段

在具备Action和Reducer以后就可以把它们组合起来创建Store了。在上一节的示例中,Action和Reducer都是分别定义的,所以实际上一个State片段的定义还是有进一步优化的空间的。为了简化定义Reducer的过程,Redux Toolkit也提供了创建State片段的快捷方法createSlice

使用createSlice实际上就是结合使用createActioncreateRedcuer两个函数。所以在绝大部分情况下斗建议直接使用createSlice函数来直接创建State片段,而不是分别创建Action和Reducer。

现在把上一节中的示例再使用createSlice重写一下就是以下的样子。

import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CounterState {
  value: number;
}

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    // 将会创建一个能够处理action type为 'counter/increaseBy1' 的reducer。
    increaseBy1(state) {
      state.value += 1;
    },
    // 将会创建一个能够处理action type为 'counter/decreaseBy1' 的reducer。
    decreaseBy1(state) {
      state.value -= 1;
    },
    // 将会创建一个能够处理action type为 'counter/increaseByAmount' 的reducer。
    increaseByAmount(state, action: PayloadAction<number>) {
      state.value += action.payload;
    },
    // 将会创建一个能够处理action type为 'counter/decreaseByAmount' 的reducer。
    decreaseByAmount(state, action: PayloadAction<number>) {
      state.value -= action.payload;
    }
  }
});

export const { increaseBy1, decreaseBy1, increaseByAmount, decreaseByAmount } = counterSlice.actions;
export default counterSlice.reducer;

从上面这个示例可以看出,使用createSlice创建State片段就不再需要详细定义Action了,只需要定义Reducer即可。而且Reducer的syyi也被简化成了普通的函数。createSlice在其实现中使用name作为action.type的前缀,Reducer的名称作为实际action.type的名称,来逐个定义各个Action。不过最推荐的办法还是如示例中所示的,直接从定义好的State片段的action属性中导出即可。

在之前的示例中,createAction函数提供了能够自由构建Action的方法,但是在上面的示例中,Action被简化成了一个函数名称,看起来并不能自由的定义构建Action的方法。事实上,createSlice方法已经提供了可以自由定义构建Action的途径。例如这里整合一下前面需要自定义Action的过程。

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increaseWithTime: {
      reducer: (state, action) => {
        state.value += action.payload.amount;
      },
      prepare: (amount: number) => ({ payload: { amount, createAt: new Date() } })
    }
  }
});

在之前介绍的configureStore函数中,要形成一个Store需要在其配置对象的reducers属性中列举所甩开使用的Reducer。这里做需要使用的Reducer即是从State片段中导出的reducer属性。例如上面构建的State片段放入之前使用configureStore创建Store的示例中,就是以下的样子。

import { configureStore } from '@reduxjs/toolkit';
import CounterReducer from './counter_reducer';

export const store = configureStore({
  reducer: {
    counter: CounterReducer
  }
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

除了可以直接在createSlice函数配置对象的reducers属性中定义Reducer以外,还可以在其extraReducers属性中使用更高级的Reducer定义方法,例如使用createReducer中的构造者模式。以下是createSlice的函数签名定义,通过这个函数签名可以看出createSlice都可以接受那些配置内容。

// CaseReducer代表一个Reducer Action,接受一个State对象和一个处理函数作为参数。
// 其中Draft<T>类型来自Immer库。
type CaseReducer<S = any, A extends Action = AnyAction> = (
  state: Draft<S>,
  action: A
) => S | void | Draft<S>;

function createSlice<
  State,
  CaseReducers extends SliceCaseReducers<State>,
  Name extends string = string
>({
  // name属性用于定义action type的前缀。
  name: Name,
  // 用于定义redecer的初始状态。
  initialState: State | (() => State),
  // 用于定义匹配分支条件的Reducer方法,其中Reducer中的键值将作为action type名称使用。
  reducers: Object<string, CaseReducer | ReducerAndPrepareObject>,
  // 可以通过构造者模式来定义具有自定义分支条件的Reducer方法,这里也同样建议使用Reducer对应的键值参与action type名称的匹配。
  extraReducers?: Object<string, CaseReducer>
    | ((builder: ActionReducerMapBuilder<State>) => void)
})

另一种比较复杂的情况是需要在一个State片段中响应另一个State片段中定义的Action,这种使用方法在处理复杂数据结构的时候十分有用。这种情况也需要在extraReducers中定义,但是需要引用另一个State片段中定义的Action。这种用法可以参考以下示例。

const personStore = createSlice({
  name: 'person',
  initialState: { name: '', age: 18 },
  reducers: {
    setName: (state, action) => {
      state.name = action.payload;
    },
    incrementAge: state => {
      state.age++;
    }
  }
});

const yearCounterStore = createSlice({
  name: 'year',
  initialState: { year: 0 },
  reducers: {},
  extraReducers: {
    // 在extraReducers中只需要直接定义响应其他State片段导出的Action即可。
    [personStore.actions.incrementAge]: state => {
      state.year++;
    }
  }
});

其实在属性extraReducers中还可以定义异步Action,这种使用方法可以参考下一章节。

在State片段中使用异步函数

因为在Redux中,所有的Action都必须是同步的,所以不能直接在Reducer或者State片段中定义返回Promise的异步Action。在传统Redux中,对于异步数据的支持也是通过redux-thunk或者redux-promise等中间件实现的。

其实要将异步过程加入到Redux的Action处理流程中,只需要将异步Action拆分成两个处理步骤即可,即启动异步Action的调用和处理Action的返回值。这样就可以将一个异步Action转换为两个同步的Action。

为了简化这个处理的过程,Redux Toolkit提供了一个名为createAsyncThunk的函数来构建便于State片段处理的异步Action。

createAsyncThunk函数可以接受三个参数来创建异步Action,以下是其函数签名。

// 用于向异步Action提供可供使用的额外功能与属性
// 从这个类型定义可以看出,异步处理函数中可以使用state、extra、dispatch函数等内容。
// 例如调用getState()可以获取state,调用dispatch()可以调用dispatch函数,
// 调用fulfillWithValue(value, meta)可以代替return从异步Action中返回处理成功的值,
// 调用rejectWithValue(value, meta)可以代替return或者thorw从异步Action中返回处理失败的值。
type GetThunkAPI<ThunkApiConfig> = BaseThunkAPI<
  GetState<ThunkApiConfig>,
  GetExtra<ThunkApiConfig>,
  GetDispatch<ThunkApiConfig>,
  GetRejectValue<ThunkApiConfig>,
  GetRejectedMeta<ThunkApiConfig>,
  GetFulfilledMeta<ThunkApiConfig>
>;

// 此类型定义了异步Action实际上是如何工作的,最终确认或者拒绝什么结果。这个类型在实际应用中实际上是一个返回了Promise类型值的异步函数。
// 这个异步函数只能接受一个参数,所以如果想要向函数传递多个参数,可以使用对象来组织。
type AsyncThunkPayloadCreator<Returned, ThunkArg = void, ThunkApiConfig extends AsyncThunkConfig = {}> = (
  arg: ThunkArg,
  thunkAPI: GetThunkAPI<ThunkApiConfig>
) => AsyncThunkPayloadCreatorReturnValue<Returned, ThunkApiConfig>;

// createAsyncThunk定义了一个异步Action本身,其第一个参数type用于定义这个action的基础action type。
// createAsyncThunk会使用给定的action type作为前缀创建三个用于第二步action处理的action type,分别是:
// pending,fulfilled,rejected,分别代表执行中、执行成功和执行失败。
function createAsyncThunk<Returned, ThunkArg = void>(
  typePrefix: string,
  payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, {}>,
  options?: AsyncThunkOptions<ThunkArg, {}>
): AsyncThunk<Returned, ThunkArg, {}>;

这个函数签名看起来十分的复杂,但是createAsyncThunk在使用的时候并不复杂。createAsyncThunk定义的异步Action是以独立Action出现的,并不需要定义在State片段内部,但是在State片段内部还是需要对createAsyncThunk创建的异步Action产生的结果Action进行处理。所需要处理的结果Action有以下几个。

  • .pending,会根据createAsyncThunk中提供的Action Type名称自动创建一个附加了/pending的新Action Type,用于表示异步过程正在执行过程中。
  • .fulfilled,同样会自动创建一个附加了/fulfilled的新Action Type,用于表示异步过程已经成功执行,并返回了成功执行的结果。
  • .rejected,会自动创建一个附加了/rejected的新Action Type,用于表示异步过程出现错误,并返回了执行错误的信息。

对于异步Action的处理不能直接在State片段的reducers属性中定义,只能在extraReducers中定义。以下是延续上一节中的示例,并在其中增加了异步请求的Action。

import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';

interface CounterState {
  value: number;
}

// 这个Action可以在应用中使用 dispatch(increaseByRemoteAmount(10)) 的形式来触发。
export const increaseByRemoteAmount = createAsyncThunk(
  'counter/increateByRemoteAmount',
  async (amount: number, thunkAPI) => {
    const response = await fetch('/api/increase');
    // 这里可以使用thunkAPI参数中提供的fulfillWithValue()方法,也可以直接使用return返回值。
    thunkAPI.fulfillWithValue(response.amount);
  }
);

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    // 这里延续上一节中定义的Reducer。
  },
  extraeReducers: builder => {
    // builder中需要响应处理的是异步Action的fulfilled、rejected和pending这几个属性,
    // 它们实际上就代表了异步Action在完成处理以后触发的Action的Action Type。
    builder.addCase(increaseByRemoteAmount.fulfilled, (state, action) => {
      // 从异步Action中返回的值,会保存在action的payload属性中传入。
      state.value += action.payload;
    });
  }
});

Tip

Redux Toolkit还提供了一套用于执行异步请求的RTK Query功能库,这个异步请求库可以提供客户端的数据缓存和数据访问控制,并且可以很好的通过Redux Toolkit与Redux结合使用。如果在应用中使用了Redux Toolkit,可以优先尝试使用RTK Query作为异步数据访问库。

从技术选型角度说,RTK Query与后续章节中介绍的React Query的功能比较类似,基本上可以在使用Redux的应用中替代React Query使用。但是RTK Query的细节功能和对于异步数据访问控制的精细度不如React Query,这需要根据自身应用的需求甄别使用。

在组件中利用Hook控制Store

自从React引入Hooks语法以来,大多数的React库都在积极的拥抱这一变化,Redux也不例外,但是Redux中使用Hooks的位置也主要集中在组件中。

在之前的章节中定义完毕的Store最终还是要在组件中操作使用的。前面已经提到过,定义好的Store是通过React Redux提供的Provider组件以Context的形式注入进入应用中的。而在组件中引用Store和使用Store提供的dispatch函数也是由React Redux通过相应的Hook提供的。

用于完成这两项工作的Hook主要是useSelectoruseDispatch。另外React Redux还提供了一个useStore的Hook用于在应用中获取完整的Store,但是能够使用到这个Hook的场景一般并不多见。

useSelector

useSelector用于从完整的Store中选取出一部分State,并在选出的这一部分State发生变化的时候通知React重新渲染组件。使用useSelector的组件必须被包含在Provider中,否则useSelector将找不到能够提供Store的上下文。

根据之前的示例,如果在应用中的某一个组件需要使用定义的counterSlice这个State,那么就可以如同以下实例中一样实现。

import { useSelector } from 'react-redux';

export const CounterComponent = () => {
  const counter = useSelector(state => state.counter);
  return <div>{counter}</div>;
};

在默认情况下,使用useSelector创建的选择器是不会保存任何内部状态的,每次调用useSelector都会在组件的实例中创建一个全新的选择器。但是如果需要在多个组件实例之间共享状态,也就是让选择器拥有内部状态,可以使用Redux Toolkit从reselect库中重新到导出的createSelector函数来在组件外部创建State选择方法。

以下是createSelector函数的签名。

createSelector(...inputSelectors | [inputSelectors], resultFunc, selectorOptions?)

这里借用官网的一个示例来展示createSelector的使用方法。

import { createSelector } from '@reduxjs/toolkit';
import { useSelector } from 'react-redux';

// createSelector这里接受了三个参数,其中前两个是用来从State中选出所需要的State的选择器,分别用于从State中选出不同的部分。
// 其中第二个选择器参数比较特殊,它忽略了传入的State,并且支持了一个额外的参数,这个参数将直接对应到createSelector函数的第二个参数。
// 所有的选择器选出的State部分,都将映射到最后一个函数参数的参数列表中,并且最后一个函数参数的运算经过将成为整个选择器的返回值。
// createSelector返回的是一个函数,这个函数可以在之后的useSelector中调用。
const selectCompletedTodosCount = createSelector(
  (state) => state.todos,
  (_, completed) => completed,
  (todos, completed) => todos.filter((todo) => todo.completed === completed).length
);

// 通过调用createSelector生成的选择器函数,useSelector获取到的内容就不止是State的一部分,而可以是一个经过计算处理的State部分。
export const CompletedTodosCount = ({ completed }) => {
  // 此处获取到的不是State的一部分,而是一个基于State计算出的一个数字。
  const matchingCount = useSelector((state) => selectCompletedTodosCount(state, completed));

  return <div>{matchingCount}</div>;
});

这种用法可以保证每个组件实例都有一个基于props的独立选择器实例。但是如果需要在多个组件实例之间保证每个组件实例都有自己独立的选择器实例的话,就需要使用一些额外的操作。

以下同样借用官网上增强后的示例来说明。

import { createSelector } from '@reduxjs/toolkit';
import { useMemo } from 'react';
import { useSelector } from 'react-redux';

// 这个进行复杂计算的State选择器的定义没有任何变化。
const selectCompletedTodosCount = createSelector(
  state => state.todos,
  (_, completed) => completed,
  (todos, completed) => todos.filter(todo => todo.completed === completed).length
);

export const CompletedTodosCount = ({ completed }) => {
  // 这里利用useMemo对每一个组件实例创建一个独立的selector函数实例,这样就可以打断同一个组件在不同实例之间共享selector状态的情况了。
  const selectTodosCount = useMemo(selectCompletedTodosCount, []);

  const matchingCount = useSelector(state => selectTodosCount(state, completed));

  return <div>{matchingCount}</div>;
};

useDispatch

选择器只是能够从Store中选出所需要使用的部分State,或者是利用上一节中介绍的复杂选择器构建方法对State进行派生。但是在几乎所有的应用中,除了需要选取State并进行展示以外,更重要的是要进行State的变更操作。

在传统Redux中,要想变更State中保存的内容,必须要使用Store提供的dispatch方法触发一个Action。但是在目前使用Provider以Context方式提供Store的使用Hooks访问和操作State的情况下,从Store中获取dispatch方法变得更加简单了。

React Redux库提供的useDispatch这个Hook可以提供组件从Store上下文中获取dispatch函数的功能。所以在使用的时候,只需要搭配从State片段中导出的Action生成函数以及额外定义的异步Action,就可以非常方便的更改Store中保存的内容了。

以下是一个结合选择器输出State,并进行State修改的简单综合示例。

import { useDispatch, useSelector } from 'react-redux';
import { decreaseBy1, increaseBy1 } from './counter_state';

export const CounterComponent = () => {
  const counter = useSelector(state => state.counter);
  const dispatch = useDispatch();

  return (
    <div>
      <div>当前计数:{counter}</div>
      <button onClick={() => dispatch(increaseBy1())}>加1</button>
      <button onClick={() => dispatch(decreaseBy1())}>减1</button>
    </div>
  );
};

使用独立的局部Store

Redux的使用哲学是在一个应用中仅使用一个全局Store来存放应用中所有的State。这其实对于State的优化是一件非常不错的选择,但是在实际的应用开发中,常常会遇到有一些State只在组件树的一部分使用,并不需要或者并不能够将其提升到应用全局管理,亦或者有一些State需要同时存在若干个相同结构的不同实例。

这些额外的设计需求虽然违背了Redux的设计哲学,但是的确是应用的实际设计需要。Redux针对这种需要也设计了解决方案,而且实现过程也并不难以理解。

熟悉Angular或者后端开发的可能对依赖注入比较熟悉,实际上局部Store的使用与依赖注入是比较类似的,都是在需要的位置注入所需要的内容。要建立局部Store,主要是构建局部Store以及供其他组件使用的一系列Hooks。

构建局部Store并将局部Store注入到组件树与构建全局Store的方法并没有什么区别。只是Provider组件出现的位置不同,在注入局部Store的时候,Provider组件只需要包裹需要从其中获取State的组件树分支部分即可,并且还需要提供一个自定义的Context。

以下借用官网上提供的一个示例来说明局部Store的使用。

import React from 'react';
import { createDispatchHook, createSelectorHook, createStoreHook, Provider } from 'react-redux';

// 这个自定义的Context是用于携带Store的Context,必须与全局Context区分开。
const MyContext = React.createContext(null);

// 以下这些Hooks都是需要从自定义Store的文件中导出的。
// 虽然它们的名字与React Redux库中提供的标准Hooks相同,但是它们所获取的Context不同。
export const useStore = createStoreHook(MyContext);
export const useDispatch = createDispatchHook(MyContext);
export const useSelector = createSelectorHook(MyContext);

const myStore = createStore(rootReducer);

// 在需要注入局部Store的组件树分支根部,使用Provider组件和自定义的Context注入所需要注入的Store实例。
// 这样就可以在组件树的子代组件中使用上面自定义的Hooks操作局部Store了。
export function MyProvider({ children }) {
  return (
    <Provider context={MyContext} store={myStore}>
      {children}
    </Provider>
  );
}

MobX

MobX 是一个通过运用透明函数式响应编程(Transparent Functional Reactive Programming,TFRP)使状态管理变得更加自动化。MobX 信奉的一个原则是“任何可以从应用状态中派生出来的值都应该被自动派生出来”,这就是的使用 MobX 构建应用状态变得非常简单直接。

在应用上,MobX 没有使用过多的限定规则,甚至 MobX 可以和任意 UI 框架结合使用。MobX 采用注解标注的方式使其能够在运行过程中追踪所有数据的变更和使用,并且 MobX 会将这些被监视追踪的数据形成一个图,并据此来确定那些依赖于状态的计算是否需要进行。

要在 React 项目中使用 MobX,只需要安装以下依赖即可。

npm install mobx mobx-react-lite
yarn add mobx react-mobx-lite

这里出现的 mobx-react-lite 是 MobX 与 React 集成的库,其中提供了 MobX 监控 React 组件渲染等功能。

Observable

在 MobX 中任何属性、对象、数组、Map、Set 等都可以被转化为可观察对象(Observable),要完成这一转化,需要使用 makeObservable() 为对象中的属性和方法等指定注解。makeObservable() 中所使用的注解主要有以下几个。

  • observable,标记一个用于存储 State 的可被追踪的字段。
    • observable.ref,仅当被重新赋值时才进行追踪。
    • observable.shallow,不跟踪集合中元素的值。
    • observable.struct,仅跟踪值的结构。
  • action,标记一个可以修改 State 的方法。
  • computed,标记一个可以由 State 派生出新值的 Getter。
    • computed.bound,仅关注计算结果的结构不同。
  • flow,标记一个生成器函数成员,主要用于管理异步进程。
  • override,用于子类覆盖继承标记。
  • true,自动推断最佳注解。
  • false,不为成员指定注解。

对于 Map 、Set、数组这样的集合将被自动转化为可观察对象。

构建可观察对象的函数有以下三个。

  • makeObservable(target, annotations, options),用于手动对类进行标记。
  • makeAutoObservable(target, overrides, options),用于利用自动推断对类或者工厂类进行标记。
  • observable(source, overrides, options),用于对对象进行转换。

以下是这三个函数的使用方法示例。

import { makeObservable, observable, computed, action } from 'mobx';

class GearBox {
  currentGear;

  constructor(currentGear) {
    makeObservable(this, {
      currentGear: observable,
      gear: computed,
      shiftUp: action,
      shiftDown: action
    });
    this.currentGear = currentGear;
  }

  get gear() {
    return this.currentGear;
  }

  shiftUp() {
    this.currentGear++;
  }

  shiftDown() {
    this.currentGear--;
  }
}

如果使用 makeAutoObservable 上面这个示例就可以简化成下面的样子。

import { makeAutoObservable } from 'mobx';

class GearBox {
  currentGear;

  constructor(currentGear) {
    makeAutoObservable(this);
    this.currentGear = currentGear;
  }

  get gear() {
    return this.currentGear;
  }

  shiftUp() {
    this.currentGear++;
  }

  shiftDown() {
    this.currentGear--;
  }
}

至于 observable 的使用就比较简单了。

import { observable } from 'mobx';

const worksList = observable([
  {
    work_id: '123',
    title: 'Something to do.',
    done: false
  }
]);

一旦使用 observable 将一个对象转化为可观察对象,那么对这个对象进行任何操作,都可以被 MobX 所追踪到。

Tip

observable 创建的是对象的一个代理,而不是将对象本身变成了可观察对象,所以只有对这个代理对象进行操作,才可以被追踪到。

Actions

在 MobX 中,Action 就是任意一段可以修改 State 的代码。Action 所作的事情,一般就是对一个事件进行响应,然后再改变 State 的内容。

一个函数被标记为 Action 的原则是这个函数仅会修改 State,但是并不会派生出其他任何信息。使用 makeAutoObservable() 会自动检查并标记一部分 Action,但是比较复杂的 Action 还是需要手工标记的。Action 都是在 MobX 的内部的事务中运行,这就保证任何可悲观察的对象在最外层的 Action 完成之前都不会被更新,而且任何 Action 执行期间产生的中间值也都不会被泄露给应用。

将一个类函数标记为 Action 的示例可以参考上一节的示例。为了尽可能利用 MobX 的事务,Action 应该被尽可能的传递到外层。一个没有被标记的、会连续调用两个 Action 的事件处理函数仍然会生成两个事务,所以将事件处理函数也标记为 Action 就成了一个不错的选择,因为事件处理函数是处于最外层的。要标记事件处理函数,就需要借助 action 函数。

Tip

没有错,action 函数就是用来标记 Action 的那个注解标记。

action 作为函数来使用的时候,可以返回一个与原函数拥有相同签名的被包装过的新函数。例如可以这样包装一个事件处理函数。

function ResetButton({ formState }) {
  return (
    <button
      onClick={action(e => {
        formState.resetValues();
        e.stopPropagation();
      })}>
      Reset Form
    </button>
  );
}

在使用 makeAutoObservable() 的时候,使用配置 { autoBind: true } 可以使方法被绑定到正确的实例,确保 this 的正确。

action 功能类似的包装函数是 runInAction() 这个包装函数可以创建一个会被立刻调用的临时 Action,经常会被用在异步进程中。

对于异步 Action 来说,MobX 通常不需要做任何特殊处理,因为可观察对象是可变的,在 Action 中保持对可观察对象的引用一般都是安全的。但是依旧有一点需要注意,异步 Action 中更新可观察对象的每一个步骤都应该被标记为 Action。对于这个概念,最典型的应用案例就是经常要使用到的 async/await 函数。

import { runInAction, makeAutoObservable } from 'mobx';

class Store {
  data = [];
  state = 'pending';

  constructor() {
    makeAutoObservable(this);
  }

  async fetchData() {
    this.data = [];
    this.state = 'pending';
    try {
      const data = await fetchFromServer();
      const filteredData = filterRecently(data);
      runInAction(() => {
        this.data = filteredData;
        this.state = 'done';
      });
    } catch (e) {
      runInAction(() => {
        this.state = 'error';
      });
    }
  }
}

在上面这个示例中,await 之后的任何操作都不跟它在一个步骤中,所以对可观察对象做出的任何变更都需要包裹在 Action 中。如果使用的不是 async/await 语法,而是使用的生成器语法,即使用 yield 代替 await ,则需要在 makeAutoObservable 中将方法标记为 flow

Computed

Computed 是用来从其他可观察对象中派生新的信息的,一般都会采用惰性求值和缓存输出等方法来处理,而且 Computed 也只有在其依赖的可观察对象发生改变的时候才会重新计算。Computed 可以有效的减少需要存储的 State 数量,所以在任何情况下,请尽可能的优先使用 Computed。

在使用 makeAutoObservable 对对象进行标记的时候,它会自动的将所有的 Getter 都标记为 computed。以下是一个官方的示例,可以直接运行观察一下效果。

import { makeObservable, observable, computed, autorun } from 'mobx';

class OrderRecord {
  price = 0;
  amount = 1;

  constructor(price) {
    makeObservable(this, {
      price: observable,
      amount: observable,
      total: computed
    });
    this.price = price;
  }

  get total() {
    console.log('Computing...');
    return this.price * this.amount;
  }
}

const record = new OrderRecord(0);
const stop = autorun(() => {
  console.log(`Total: ${record.total}`);
});

console.log(record.total);
record.amount = 5; // 不会重新计算,因为 total 依旧是 0
record.price = 2; // 会重新计算,因为 total 发生了变化
stop();
order.price = 3; // 不再计算,监控过程已经结束

在使用 Computed 的时候,需要遵守以下三个原则:

  1. Computed 不应该有任何副作用。
  2. Computed 不能更新任何可观察对象。
  3. 不能创建和返回新的可观察对象。

因为 Getter 一般都作为 Computed 出现,所以与 Getter 相对应的 Setter 都作为 Action 出现。

Reactions

Reaction 在 MobX 中是用来执行副作用内容的。Reaction 的目标是模拟哪些需要自动发生的副作用,它们可以为 Observable 创建消费者并在相关内容发生变化时自动运行预定义的副作用内容。Reaction 有两种方式可以创建,一种是使用 autorun(),一种是使用 reaction()

  • autorun(effect: (reaction) => void) ,创建一个实时监控 Observable 变化的 Reaction,只要被监控的 Observable 发生了变化,Reaction 就会被执行。
  • reaction(() => value, (value, previous, reaction) => { sideEffect }, options?),监控指定 Observable 或 Computed 的变化,执行指定的副作用过程。

reaction() 可以创建更加灵活控制的 Reaction,不仅可以降低 Reaction 的执行频次,还可以对 Observable 做到更加精细的控制。

autorun()reaction() 在执行以后会返回一个取消函数,这个取消函数在调用以后会取消掉之后 Reaction 的自动执行。所以需要注意的是,在不再需要 Reaction 的时候,一定要及时的使用取消函数将其释放,否则将会造成内存泄露,影响性能。

另一个用来监控 Observable 并执行一些处理过程的方法是 when(),这个函数有两种用法。

  • when(predicate: () => boolean, effect?: () => void, options?),在这种用法中,when() 会一直监控第一个参数直到它返回 true 的结果,然后就会去执行第二个参数定义的副作用过程。
  • when(predicate: () => boolean, options?): Promise,没有指定第二个参数的 when() 会返回一个 Promise 实例。如果搭配 async/await 使用,可以让处理过程停下来,直到 when() 的条件变成 true 才会继续执行。

以下示例是 when() 的常见用法。

import { when, makeAutoObservable } from 'mobx';

class Resource {
  constructor() {
    makeAutoObservable(this, { dispose: false });
    when(
      () => !this.isVisible,
      () => this.dispose()
    );
  }

  get isVisible() {
    // 按照逻辑返回 Boolean 类型的值
  }

  async processWhenVisible() {
    await when(() => this.isVisible);
    // 继续完成当 isVisible 为真的时候需要做的处理
  }

  dispose() {
    // 这里做释放资源的动作
  }
}

与 React 集成

MobX 与 React 继承的时候可以选择使用 mobx-react-lite 或者使用 mobx-react 库。现在一般推荐采用 mobx-react-lite 库,因为 mobx-react 库主要适用于旧版本的 MobX 和 React,而 mobx-react-lite 更加轻量一些。

与 React 集成的时候,主要是利用 observer() 函数生成 React 高阶组件。observer() 创建的高阶组件将自动订阅 React 组件在任何渲染期间使用的 Observable 对象。observer() 会确保当 Observable 发生变化的时候组件才会自动进行重新渲染,并且在更新 Observable 对象中的不可见对象的时候,重新渲染的操作也不会进行。这样一来,使用 MobX 的应用性能就会得到提升。

observer() 并不关心 Observable 对象是如何传递到组件的,这些对象可以通过任意渠道传递给对象,例如 propsContext 等,即便是在 State 中深层嵌套存放也不影响其正常使用。

以下是一个简单的与 React 集成的示例。

import React from 'react';
import ReactDom from 'react-dom';
import { makeAutoObservable } from 'mobx';
import { observer } from 'mobx-react-lite';

class Timer {
  seconds = 0;

  constructor() {
    makeAutoObservable(this);
  }

  increase() {
    this.seconds++;
  }
}

const timer = new Timer();

const TimerView = observer(({ timer }) => <div>Time passed: {timer.seconds}</div>);

ReactDom.render(<TimerView timer={timer} />, document.body);

setInterval(() => timer.increase(), 1000);

或者还可以使用 Context 来实现 Observable 的传递。

import { createContext, useContext } from 'react';

const TimerContext = createContext<Timer>();

const TimerView = observer(() => {
  const timer = useContext<Timer>(TimerContext);

  return <div>Time passed: {timer.seconds}</div>;
});

ReactDom.render(
  <TimerContext.Provider value={new Timer()}>
    <TimerView />
  </TimerContext.Provider>
);

这里并不推荐把每一个 Observable 都使用一个 Context Provider 来提供,可以把该级别所需要的 Observable 集合起来一并在 Context 中提供,具体示例可见后面“定义数据存储”一节。

observable() 可以监控的 Observable 可以来自任何地方,甚至是组件的本地 State,在这种用法下,useState() 函数的返回值就可以少用一个了。

const LocalTimerView = observer(() => {
  const [timer] = useState(() => new Timer());

  useEffect(() => {
    const handle = setInterval(() => {
      timer.increase();
    }, 1000);
    return () => {
      clearInterval(handle);
    };
  }, [timer]);

  return <div>Time passed: {timer.seconds}</div>;
});

示例中的这种 useState() 的使用形式似乎不是太 React,所以 MobX 提供了一个专用的自定义 Hook。

import { observer, useLocalObservable } from 'mobx-react-lite';

const LocalTimerView = observer(() => {
  const timer = useLocalObservable(() => ({
    seconds: 0,
    increase() {
      this.seconds++;
    }
  }));
  // 执行其他副作用函数或者渲染过程
});

这里有一点需要注意一下,我们在构建列表时常常用到的回调组件通常也是组件渲染周期的一部分,所以通常这些回调组件也必须是一个 observer 组件。这个时候就需要使用 <Observer /> 创建一个匿名观察者。

const DataView = observer(({ item }: { item: data }) => {
  return <DateRow onRender={() => <Observer>{() => <td>{data.content}</td>}</Observer>} />;
});

另外,有些时候使用 props 接受到的参数并不是 Observable,这就需要在使用的时候用 useLocalObservable 包装一下,但是对于这样传递过来的 props 依旧不会在其变化时得到响应。在这种情况下,就需要定义一个 useEffect 手动更新一下。

定义数据存储

基于 Flux 定义的概念,Store 是用来将逻辑和 State 从组件中移出的可独立测试单元。所以在定义 Store 的时候,通常可以将其分为两种:领域 Store 和 UI Store。

应用中可能会包含一个或者多个领域 Store,这些 Store 保存着应用的所有数据,例如用户、订单等。领域 Store 一般与应用中的业务概念一一对应,一个 Store 通常都会被组织成一个树状结构,每个树状结构中会包含多个领域对象。不同领域对象组合成 Store 的经验是:如果两个事物之间是包含关系,那么它们通常应该存放在一个 Store 里。从这条经验出发,Store 实际上是在管理 领域对象。

领域 Store 一般都需要具备以下职责:

  • 实例化领域对象,确保领域对象知道其归属 Store。
  • 确保每个领域对象只有一个实例。
  • 提供服务后端集成,用以存储数据和更新领域对象实例。
  • 控制领域 Store 自身为单例。

每个领域对象都需要声明自己的类,而且没有必要把 State 当作是数据库来使用。领域对象可以直接引用其他 Store 中的领域对象,我们只需要保证操作和视图足够简单即可。

Tip

领域对象中不应该包含通信方法,所有的通信方法可以抽象出去形成独立的通信层。

领域对象还需要持有一个 Store 实例的引用,这样可以方便与 Store 中的其他领域对象进行通信,在标注 Store 引用时,可以将其标记为 false 以使 MobX不关注 Store 引用的变化。

UI Store 相对于领域 Store 来说就非常具体了,而且 UI Store 一般没有多少逻辑,只会存储大量关于 UI 的松散信息。例如以下这些内容通常都是放置在 UI Store 中的。

  • Session 信息。
  • 应用加载阶段的信息。
  • 影响全局 UI 的信息,例如窗口尺寸、可访问性、语言、主题等。
  • 会影响多个组件的界面 State,例如当前选中项、通用组件可见性、向导、全局遮罩层等。

在构建 Store 的时候,一般需要解决的问题是多个 Store 要如何组合在一起?Store 之间如何相互通信?为了解决这个问题,一般可以通过创建一个 RootStore 来解决,也就是把所有的 Store 都实例化,并共享引用在一个 RootStore 中。这种模式易于设置,而且全局只存在一个 RootStore 的实例,实例控制和单元测试都会比较简单一些。RootStore 一般会通过 Context 插入到组件树的根上,来使得应用全局都可以访问。

mobx-state-tree

MobX State Tree (MST)是 MobX 开发者基于 MobX 开发的一个状态管理工具。MST 在保留 MobX 的设计理念的同时结合了 Redux 中只有一个 Store 和不可变数据的特点。

如果要在应用项目中使用 MST,需要同时安装 MST 和 MobX。

npm install mobx mobx-react-lite mobx-state-tree
yarn add mobx mobx-react-lite mobx-state-tree

MST 的核心部分就是一个动态树,它由严格保护的可变对象和运行时信息组成。每一棵树都是有一个类型结构信息和一个状态数据组成。

类型结构信息通常使用 types 对象提供的方法来定义。类型结构的元信息的定义过程一般是使用 types.Model() 定义一个对象结构,然后在其中使用 types 中的类型标记声明其中的具体字段类型(节点类型),并以此来形成 MST 树结构。Model 中的节点不仅可以是基础类型,还可以是其他的 Model,这样 MST 就可以通过层层嵌套形成一棵 MST 状态管理树。

以下是一棵简单的 MST 示例。

import { types } from 'mobx-state-tree';

const Todo = types.model('Todo', {
  title: types.optional(types.string, ''),
  done: types.optional(types.boolean, false)
});

const User = types.model('user').props({
  name: types.optional(types.string, '')
});

const RootStore = types.Model({
  users: types.map(User),
  todos: types.optional(types.map(Todo), {})
});

MST 中常用的类型定义有以下这些:

  • types.model() ,定义一个 Model,其中包含属性和 Actions。
  • types.array(type) ,定义一个包含指定类型的数组。
  • types.map(type) ,定义一个包含指定类型的 Map。
  • types.string ,字符串。
  • types.number ,数字类型。
  • types.boolean ,布尔类型。
  • types.Date ,日期类型。
  • types.union() ,多类型的联合类型。
  • types.optional() ,可选值类型。
  • types.literal() ,字面量类型。
  • types.enumeration(name, options) ,枚举类型。
  • types.refinement() ,基于基础类型的增强类型。
  • types.maybe() ,可选或者可空类型。
  • types.null ,空值类型。
  • types.undefined ,未定义类型。
  • types.late() ,延迟定义的递归类型。
  • types.frozen ,可串行化类型。
  • types.compose() ,组合类型。
  • types.identifier() ,标识符类型。
  • types.reference(type) ,引用类型。

定义了 MST 的初始值和类型以后,可以通过定义 Action 来更改 MST 中的值。Action 的定义可以利用 model.action() 方法来定义。

const Todo = types.model({
  id: types.number,
  title: types.string
})
.action(self => ({
  setTitle(val: string) {
    self.title = val;
  }
  const fetchTitles = flow(async function() {
    // 使用 await 完成一系列异步操作
  })
}));

// 创建一个树并初始化数据
const todo = Todo.create({ title: "Something" });

// 验证一下数据的变化
console.log(todo.title);
Todo.setTitle("Another thing");
console.log(todo.title);

在使用 Action 的时候需要注意以下几点:

  • 节点内容的修改必须在 Action 中进行。
  • 每次实例化的时候,初始化函数都会执行,所以 self 始终指向当前实例。
  • Action 内部不要使用 this ,因为 this 的指向性不够明确。
  • 在 MST 中使用异步 Action 的时候,可以通过引入 flow 来包装。

相对应于 MobX 中数据的派生,在 MST 中是通过 View 来定义的。MST 中的 View 有两种形式,有参数的 View 和无参数的 View。其中没有参数的 View 实际上就是 Computed。这两种 View 的区别就是 Computed 有明确的缓存点,其派生的值会被缓存到它依赖的数据发生变化。而有参数的 View 则不会对派生的值进行缓存。

View 的定义与 Action 类似,是使用 model.view() 完成的。

const Todos = types
  .model({
    children: types.array(Todo)
  })
  .view(self => ({
    get amount() {
      return self.children.length;
    }
  }));

MobX 中的数据都是可变数据,MST 中的数据也都是会经常变化的。但是在 UI 渲染的时候,不可变的数据的可预测可回溯特性不会出现数据无法渲染的问题。从 MST 中获取不可变数据,就需要用到快照功能。

MST 中快照相关的方法主要有三个:

  • getSnapshot(model) ,返回一个 MST 的当前状态。
  • onSnapshot(model, callback) ,创建一个当新快照可用的时调用回调函数的监听器。
  • applySnapshot(model, snapshot) ,使用快照更新 MST 及其所有后代的状态。

当 MST 与 React 结合的时候,可以通过类似与前面示例中的 RootStore.create() 来获取 Store 实例。

Warning

虽然大部分情况下 MST 均提供了针对性能的优化,但是如果应用中存在大量会频繁发生变化的数据时,原生 MobX 的性能会更好。

Zustand

你可能是一路按顺序读过来的,也可能是根据自己的需要找到这一章的,但是无论如何,如果你在寻找一个非常简单易用的状态管理中间件,但是又苦于 Redux 和 MobX 有些复杂了,那么这一章所介绍的 Zustand 将会满足你的需求。

Zustand 是一款非常精炼小巧的状态管理中间件,它是基于不可变数据设计的,采用了 React 中的 Hook 作为其与组件整合的方法。虽然 Zustand 的体积很小,但是它基本上避免 React 状态管理中常见的几乎绝大部分陷阱,所以整体来说 Zustand 的稳定性还是非常不错的。

在 React 项目中使用 Zustand 只需要安装以下依赖即可。

npm install zustand
yarn add zustand

根据 Zustand 文档中的介绍,其相比 Redux 有以下优点。

  • 比 Redux 更加简单,而且不用过度思考状态的控制。
  • 充分采用 Hook 来控制和消费状态。
  • 不需要使用 Provider 或者 Context 来向项目中注入必需的内容。
  • 可以在不引起组件重新渲染的情况下更新状态。

而相比 React 原生的 Context,也有以下的优点。

  • 会减少更多的样板代码。
  • 可以只在状态更改发生的时候重新渲染组件。
  • 相比 Context,状态可以被更加集中的管理。
  • 所有状态的管理是基于动作的。

创建和使用Store

Zustand依旧采用了Flux的单向数据流的概念,也采用了其中所设计的结构。所以在使用Zustand的时候并不需要特别的去学习新内容,我们所要关心的只有Store和Action。在Zustand中,Store的组织是以Hook的形式出现的,所以定义一个Store,实际上就是创建了一个Hook。

这里我们依旧采用之前在MobX中使用过的实例。

import create from 'zustand';

const useGearBox = create(set => ({
  currentGear: 0,
  ecoMode: false,
  sportMode: false,
  shiftUp: () => set(state => ({ currentGear: state.currentGear + 1 })),
  shiftDown: () => set(state => ({ currentGear: state.currentGear - 1 })),
  switchEcoMode: () => set(state => ({ ecoMode: !state.ecoMode })),
  switchSportMode: () => set(state => ({ sportMode: !state.sportMode }))
}));

在上面这个示例中,创建了一个只携带有一个State和两个Action的Store。这里有两个内容需要额外说明一下。Zustand的Store中,需要使用create函数提供的参数set来执行State的合并。在没有执行set的时候,State是不会发生改变的。我们不需要在调用set的时候更改整个的State,只需要列出需要发生更改的部分即可,Zustand会将更改和原有的State进行合并。

定义Store中要持有的State就更加简单了,只需要将所需要使用的State列出来,赋上默认值即可。在Zustand的Store中,你可以使用任意类型的和任意复杂度的内容来作为State。因为在默认条件下,Zustand都是以绝对相等(===)的比较来判定对象是否发生了变化的,所以即便是使用了比较复杂的数据结构,依旧不会影响Zustand对于数据是否发生变化的响应。

在Zustand中,Action的定义也是比较简单的,从上面的示例中可以看出来,Zustand中的Action实际上就是一个普通的实例方法。Zustand没有像Redux和MobX那样对Action提出多少定义形式和内容上的要求,你可以根据实际的需要定义符合你的需求的Action。

定义好的Store在React组件中的使用就更简单了,只需要使用定义好的Store Hook从定义的Store中选出所要使用的部分即可。例如以下示例。

export const GearDisplay = () => {
  const gear = useGearBox(state => state.currentGear);

  return <div>Current Gear: {gear}</div>;
};

export const GearOperator = () => {
  const shiftUp = useGearBox(state => state.shiftUp);
  const shiftDown = useGearBox(state => state.shiftDown);

  return (
    <div>
      <button onClick={shiftUp}>Shift Up</button>
      <buton onClick={shiftDown}>Shift Down</buton>
    </div>
  );
};

在上面这个实例中定义了两个组件,其中第一个组件从Store中选取了Store持有的一个State,并将其渲染到了页面上。在这种情况下,如果Zustand中持有的State发生了变化,Zustand就会使组件重新渲染。

在另一个组件中则是直接选择了Store中的两个Action。这里可以看到,在组件中选取Action的操作跟选取State的操作是一样的。从Store中选取出来的Action可以直接在组件中调用执行,也可以直接将其赋予组件上的事件,Zustand对于这一点依旧没有给定任何限制。

自定义选择器

除了可以利用Store Hook从Store中精确地选择所需要的State和Action以外,直接使用没有任何参数形式的Hook还可以直接获取Store实例本身。例如以下示例中所示。

export const GearAssembly = () => {
  const store = useGearBox();

  return (
    <div>
      <div>Current Gear: {store.currentGear}</div>
      <button onClick={store.shiftUp}>Shift Up</button>
      <buton onClick={store.shiftDown}>Shift Down</buton>
    </div>
  );
};

这种用法虽然十分的方便,但是其缺点就是如果Store中持有的任何State发生了变化,那么组件就会发生重新渲染,这对于只是用到了Store中一部分内容的组件来说是十分不经济的。

Zustand生成的Hook还支持返回State的组合,但是如果使用Zustand的这项特性,就必须向Hook提供第二个参数。Zustand生成的Store Hook在使用的时候其第二个参数用来判断其所要返回的内容是否已经发生了变化。

例如一般可以从Store中组合返回以下形式的State。

export const GearAssembly = () => {
  // 从Store中以对象的形式组合返回两个State。
  // 当这两个State中的任何一个发生改变的时候,组件都会重新渲染。
  const { currentGear, ecoMode } = useGearBox(state => ({ state.currentGear, state.ecoMode }), shallow);

  // 从Store中以数组的形式组合返回两个State。
  // 与以对象的形式一样,组合中的任何一个对象的改变都会使组件重新渲染。
  const [gear, eco] = useGearBox(state => [state.currentGear, state.ecoMode], shallow);
};

组合返回State只是生成的Store Hook的一种用法,实际上这种用法并不是Zustand特意支持的,Zustand实际上支持的是对State进行变形,因为组合State也是State变形的一部分。例如还可以使用map等方法来使获取到的State先进行一个计算。

在上面的示例中,Store Hook的第二个参数都是使用了Zustand提供的shallow比较函数。Zustand提供的这个比较函数可以支持仅对Store Hook生成的对象中的第一层级内容进行比较,而不深入的比较各个字段内容的组成。除了使用Zustand提供的shallow函数以外,在使用过程中还可以自定义比较函数来提供Store Hook判定所获取到的内容是否发生了更改。自定义的比较函数需要接受两个参数,分别代表所返回值的上一个状态和当前状态的值,你需要根据Zustand提供的这两个值来提供一个表示值是否发生了变化的true或者false的返回值。

例如以下这个简单的判断。

export const StupidGear = () => {
  const currentGear = useGearBox(
    state => state.currentGear,
    (lastGear, newGear) => lastGear !== newGear
  );
};

自动生成选择器

从上面的使用和示例也许可以得出一个结论:每个组件中都要调用Store Hook反复选取其中的State产生的样板代码太多了,Zustand应该提供一种更加简单的方法来选取State。

Zustand提供了这样的方法,但是并没有集成在Zustand功能中,你可以使用Zustand提供的以下代码来创建一个工具函数。这个工具函数可以向Store Hook中注入自动生成的State选择器。

import { State, UseStore } from 'zustand';

// 这里定义自动生成的State选择的类型,所有自动生成的State选择器都将被注入到Store Hook中的use字段下。
interface Selectors<StoreType> {
  use: {
    [key in keyof StoreType]: () => StoreType[key];
  };
}

function createSelector<StoreType extends State>(store: UseStore<StoreType>) {
  (store as any).use = {};

  Object.keys(store.getState()).forEach(key => {
    const selector = (state: StoreType) => state[key as keyof StoreType];
    (store as any).use[key] = store(selector);
  };

  return store as UseStore<StoreType> & Selectors<StoreType>;
}

这样一来,使用这个工具函数就可以让我们用更加方便的方法选择所需要的State了,例如用这个工具函数改造一下之前的示例。

const useGearBoxBase = create({
  currentGear: 0,
  ecoMode: false,
  sportMode: false
});

const useGearBox = createSelectors(useGearBoxBase);

const GearAssembly = () => {
  // 之前使用Store Hook选取State的写法,现在就变成了一个预置的函数。
  const gear = useGearBox.use.currentGear();

  // ...
};

除了可以自定义这个用于自动生成State选择器的工具函数以外,还可以通过安装auto-zustand-selectors-hook工具库,来使用其中提供的createSelectorFunctions工具函数来实现相同的功能。

监控状态变化

在使用状态存储的时候,往往会出现监控某一个或者某几个状态的变化然后自动执行对应的方法的需求。在Zustand中,这种需求可以借助subscribeWithSelector中间件来实现。subscribeWithSelector中间件是Zustand内置的中间件,在定义的时候直接使用即可。

例如以下示例中所示。

import create from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import shallow from 'zustand/shallow';

// 需要使用监控功能的Store,在定义的时候需要需要特别指出所需要使用的中间件。
const useGearBox = create(
  subscribeWithSelector(set => ({
    currentGear: 0,
    ecoMode: false,
    sportMode: false,
    shiftUp: () => set(state => ({ currentGear: state.currentGear + 1 })),
    shiftDown: () => set(state => ({ currentGear: state.currentGear - 1 })),
    toggleEcoMode: () => set(state => ({ ecoMode: !state.ecoMode })),
    toggleSportMode: () => set(state => ({ sportMode: !state.sportMode }))
  }))
);

// subscribe的第一个参数是选取所要监控的状态。
const unsubGear = useGearBox.subscribe(
  state => state.currentGear,
  gear => console.log(`Current Gear: ${gear}`)
);

// subscribe的第二个参数所接收的函数实际上是可以接受两个参数的,
// 其中第二个参数是所选择状态在发生变化时之前的旧值。
const unsubGearHistory = useGearBox.subscribe(
  state => state.currentGear,
  (gear, previousGear) => console.log(`Shift from ${previousGear} to ${gear}`)
);

// 同样subscribe也可以同时监控多个状态,但是此时需要使用subscribe的第三个参数来配置所监控内容发生变化的条件。
const unsubMode = useGearBox.subscribe(
  state => [state.ecoMode, state.sportMode],
  ([ecoMode, sportMode]) => console.log(`Eco Mode: ${ecoMode}, Sport Mode: ${sportMode}`),
  { equalityFn: shallow }
);

// subscribe默认情况下是只在状态发生变化的时候才会触发,在subscribe的第三个参数中配置fireImmediately可以在监控创建的时候立刻触发。
const ubsubImmediately = useGearBox.subscribe(state => state.currentGear, console.log, { fireImmediately: true });

引入subscribeWithSelector中间件以后,就给创建的Store Hook中增加了一个subscribe方法,允许通过这个方法来在Store中的状态发生变化的时候执行一些响应操作。

在监控被创建的时候,会返回一个用于注销监控的方法,如上面示例中返回的unsub开头的函数。这些用于注销的函数在实际使用中是非常有用的,可以避免内存泄漏的情况出现。

在监控的响应函数中同样可以对Store中的状态进行更改,要完成这个操作只需要调用Store中的Action即可。但是需要注意的是,在响应函数中一定不要修改当前被监控的状态,否则将引起循环触发造成死循环出现。

瞬态更新

Store中所保存的状态在更新的时候通常是会引起React组件重新渲染的,但是有一些时候我们可能并不希望组件响应状态的更新。在这种需求下,可以采用类似于以下示例中的用法来获取Store中的状态,但不用重新渲染组件。

const TransientGearBox = () => {
  const gearRef = useRef(useGearBox.getState().scratches);

  useEffect(() => {
    const unsub = useGearBox.subscribe(state => (gearRef.current = state.scratches));

    return () => unsub();
  }, []);

  // 组件剩余部分。
};

如同示例中所使用的方法,在组件中创建一个Store的瞬态引用,可以避免在Store中的状态发生变化的时候使React重新渲染组件,因为到Store瞬态的引用是始终保持不变的。组件中所需要做的就是在useEffect中为这个引用赋予实际的内容。

操作State

在Zustand中,对于State的操作都是通过Action来完成的,这与Flux所提倡的机制是一致的。Zustand中的Action与Redux和MobX中略微有些不同,其中最主要的不同就是Zustand中的Action在定义的时候限制更少。

在之前的实例中,已经出现过了Action的身影,在Store定义中,Action就是一个非常普通的方法。但是在之前的示例中,定义的Action函数式没有接受任何参数的,实际上,因为Zustand对于Action没有太多的限制,所以Action方法是可以接受任意形式和数量的参数的。

例如以下这个简单的示例。

const useGearBox = create(set => ({
  currentGear: 0,
  jumpShiftUp: amount => set(state => ({ currentGear: state.currentGear + amount }))
}));

除了可以在Action中接受参数以外,还可以定义异步Action。定义和使用异步Action在Redux中是不被允许的,因为Redux中的数据变化必须都要是明确的,但是因为Zustand中的数据变化是由set函数实现的,所以在何时变更状态,对于Zustand也是确定的事情。

Action中的异步可以用在任何位置上,甚至可以用在set函数内部,例如以下这个示例。

const useGearBox = create(set => ({
  currentGear: 0,
  ecoMode: false,
  switchEcoMode: async () => {
    const status = await fetchVehicleStatus();
    set(async state => ({ ecoMode: await switchEcoMode(status.engine) }));
  }
}));

在Action中同样也是可以访问当前Store中的状态的,但是必须要通过已鞥专门的函数来为Store生成一个快照,而不能直接访问Store中的状态。要在Action中访问当前Store中持有的状态,可以仿照以下示例进行。

const useGearBox = create((set, get) => ({
  currentGear: 0,
  shiftDown() {
    const current = get().currentGear;
    if (current > 0) {
      set(state => ({ currentGear: current - 1 }));
    }
  }
}));

从上面的示例中可以看到,不论是同步更改状态还是异步更改状态,Zustand都是通过create函数提供的set函数来实现的。其实set函数的使用远远不止上面示例中出现过的这些形式。在之前的示例中,传递给set函数的都是一个接受一个代表Store的参数的函数,对于状态的更新也是由这个函数来完成的。在默认情况下,set函数是采用合并的方法来更新状态的,也就是Store中所保存的状态只有在set函数中操作了的那一部分会发生变化,其余的状态都会维持原样。但是在一些情况下,我们可能会期望完全变更Store中持有的状态,这时就需要用到set函数的第二个参数了。set函数的第二个参数可以接受一个布尔类型的值,用于控制当前的set操作是采用拼合(Merge)还是替换(Replace),这个参数的默认值是false,就是采用拼合(Merge)来更新状态。

通过set函数的第二个参数,可以实现很多非常有用的用法。例如以下示例中所列举的操作。

import { omit } from 'ramda';

const useGearBox = create(set => ({
  currentGear: 0,
  ecoMode: false,
  sportMode: false,
  deleteSportMode: () => {
    set(state => omit(['sportMode'], state), true);
  },
  deleteEverything: () => {
    set({}, true);
  }
}));

在上面这个示例中,我们可以通过设置set函数的第二个参数为true来整体替换Store中持有的状态,但是需要注意的是,如示例中的deleteEverything这个Action所示,set函数所替换的内容不止是Store中保存的State,还包括了全部的Action。所以类似于示例中的操作实际上是会影响应用后续的运行的,在实际应用中并不应该使用。

示例中的deleteEverything这个Action原本的意图是重置Store中所保存的状态,但是因为set函数会简单的替换Store中的状态,所以如果要实现状态的重置,还需要一些额外的操作。以下示例所示的是如何正确的在Zustand中实现状态重置。

type GearBoxState = {
  currentGear: number;
  ecoMode: boolean;
  sportMode: boolean;
};

type GearBoxAction = {
  shiftUp: () => void;
  shiftDown: () => void;
  switchEcoMode: () => void;
  switchSportMode: () => void;
  reset: () => void;
};

const initialState: GearBoxState = {
  currentGear: 0,
  ecoMode: false,
  sportMode: false
};

const useGearBox = create<GearBoxState & GearBoxAction>(set => ({
  ...initialState,
  shiftUp: () => set(state => ({ currentGeat: state.currentGear + 1 })),
  shiftDown: () => set(state => ({ currentGeat: state.currentGear - 1 })),
  switchEcoMode: () => set(state => ({ ecoMode: !state.ecoMode })),
  switchSportMode: () => set(state => ({ sportMode: !state.sportMode })),
  reset: () => set({...initialState})
});

从上面这个示例中可以看出,其实要完成状态的重置,实际上最关键的步骤就是如何获取和保存Store中状态的原始状态,并且还要保证不会影响Store中的Action。上面示例中的这个实现方式通过分离定义的方式比较方便的实现了这个重置状态的需求。

但是这种重置的方法也会带来一定的模板代码,如果想要继续优化这些模板代码,那就需要自己重写一个create方法了。例如可以如同下面示例中一样,自动的为Store注入重置方法。

import actualCreate, { GetState, SetState, State, StoreApi, UseBoundStore } from 'zustand';

type WithRestter = {
  reset: () => void;
};

export const create: typeof actualCreate = <TState extends State>(
  createState: StateCreate<TState, SetState<TState>, GetState<TState>, any> | StoreApi<TState>
): UseBoundStore<TState & WithRestter, StoreApi<TState & WithRestter>> => {
  const slice = actualCreate(createState);
  const initialState = slice.getState();

  slice.reset = () => {
    slice.setState(initialState, true);
  };

  return slice;
};

利用上面这种重写create的方法,还可以很方便的实现同时重置应用中所有Store保存的状态。

在React之外使用

除了可以在React组件中使用Store以外,Zustand还支持在React组件以外以原生Javascript的形式使用Store。此时对于Store操作就会变得更加底层了一些。具体一些常用的操作可以参考以下示例。

// 利用getState()方法可以立刻获取调用时刻的Store内容的快照。
// 但是使用这种方法不会是返回的内容随Store中持有的Stae的变化而发生变化。
const currentGear = useGearBox.getState().currentGear;

// subscribe()方法可以接受一个函数作为参数,这个函数所接收到的是整个Store发生变化以后的快照。
// 在调用的时候会创建一个监听器,会自动在Store中的内容发生变化的时候调用。
// subscribe()方法会返回一个用于注销监听器的函数,需要在清理监听现场的时候使用。
const unsubscribe = useGearBox.subscribe(console.log);

// 使用setState()方法可以更新Store中持有的State,并且可以触发所有相关的监听器。
useGearBox.setState({ ecoMode: true });

// destory()方法可以用来注销Store中的注册的全部监听器,并同时销毁Store中持有的全部内容。
useGeaBox.destory();

实际上在之前的章节中已经多次使用过上面这些方法了,但是使用Zustand默认导出的create方法会创建一个Hook,而使用原生Javascript的时候,却用不到这个,所以在原生Javascript中使用Zustand的时候,需要创建一个非Hook形式的Store。

创建一个用于原生Javascript的Store也是通过create函数,但是是从Zustand的另一个模块中导出的。例如以下示例。

import create from 'zustand/vanilla';

const gearBoxStore = create(set => ({
  currentGear: 0
}));

const { getState, setState, subscribe, destory } = gearBoxStore;

从这个示例中可以看到,用于原生Javascript使用的Store提供了一组用于访问和控制状态的工具函数。

使用原生模式创建的Store也可以被还容易的转换回提供React组件使用的Hook形式的Store。要实现这个转换只需要对已经建立的用于原生Javascript的Store再使用Zustand默认导出的create函数处理一遍即可。

import create from 'zustand';

const useGearBox = create(gearBoxStore);

Warning

如果在中间件中修改了create提供的getset两个函数的实现,那么在调用getStatesetState的时候,是不会调用修改以后的实现的。

其他常用中间件

Zustand支持中间件,编写自定义的中间件也十分的方便,只需要仿照以下示例中的样式编写即可。

const logTrace = config => (set, get, api) =>
  config(
    (...args) => {
      console.log('Before applying: ', args);
      set(...args);
      console.log('After applying: ', get());
    },
    get,
    api
  );
const useGearBox = create(
  logDebug(set => ({
    currentGear: 0
  }))
);

根据以上示例中所示的中间件的书写以及定义方法可以看出,Zustand在创建中间件的时候,可以同时嵌套使用多个中间件函数,这些中间件函数将根据调用的次序不同,形成类似于洋葱皮一样顺序包裹的先入后出的结构。

Zustand默认提供了一些用于满足常见应用操作需求的中间件,以下将列举一些常用的。

持久化中间件

持久化中间件可以用于将状态保存到指定的任意类型的存储设备中,例如Local Storage或是Session Storage。

以下是将用户登录信息保存到Local Storage中的示例。

import create from 'zustand';
import { persist } from 'zustand/middleware';

const useUser = create(
  persist(
    (set, get) => ({
      id: null,
      token: null,
      expires: null
    }),
    {
      name: 'user-session',
      getStorage: () => localStorage
    }
  )
);

持久化中间件在使用的时候需要提供第二个参数,用来配置存储状态所用到的一些特性。常用的配置项有以下这些。

  • name,为当前定义的Store在持久化存储中制定一个唯一的名字用于标识。
  • getStorage,以一个函数的形式指定持久化存储所要使用的存储位置。默认保存在Local Storage中。
  • serialize,指定保存状态时所要使用的序列化方法,默认采用JSON.stringify
  • deserialize,指定读取已经保存的状态时所需要使用的反序列化方法,默认采用JSON.parse
  • version,标识当前所保存的Store的内容的版本号,主要用于记录Store中状态记录结构的变化情况。
  • migrate,用于在持久化存储中的版本号与应用中的版本号不一致的情况下进行状态迁移操作的函数。

Immer中间件

Immer在之前Redux Toolkit一章中已经有所介绍,而且也会在后面的章节中进行更加详细的介绍。在状态管理中使用Immer的主要目的就是利用不可变数据的特性,对应用状态变化检测过程进行优化。毕竟对于不可变数据来说,其中任何内容的变化,都会直接创建一个全新的对象。

Zustand也同样内置了整合Immer来实现其底层数据管理的中间件,而且Immer中间件可以直接使用,不必做任何配置。以下示例中展示了Immer中间件的使用。

import create from 'zustand';
import { immer } from 'zustand/middleware';

const useGearBox = create(
  immer(set => ({
    currentGear: 0,
    shiftUp: () =>
      set(state => {
        state.currentGear += 1;
      }),
    shiftDown: () =>
      set(state => {
        state.currentGear -= 1;
      })
  }))
);

从示例中可以看出,在使用了Immer中间件以后,set函数已经从需要返回发生了变化的值,变成了对状态字段的操作,充满了浓浓的Immer的操作特点。

Redux中间件

Redux比较优越的一点是Redux拥有一款功能比较全面的浏览器调试插件。而Zustand可以通过其提供的devtools中间件直接使用Redux的调试工具。

devtools插件除了可以接受用于创建Store的函数以外,还可以接受一个对象参数,其中可以通过name属性为当前的Store命名以在调试工具中创建一个新的实例。

如果使用了devtools中间件,那么就可以在set函数中传递第三个参数,来给当前的这个操作赋予一个Action Type名称,这个Action Type名称可以在调试工具中用作显示当前所发生的改变发送的Action名称。

Tip

当然,Zustand还同样提供了以Redux的方式定义Store的中间件,这个中间件可以与devtools中间件嵌套使用,并且效果会比较不错。但是这里并不打算进一步对这个中间件进行说明,因为如果打算使用Redux形式的状态管理,为什么不直接使用Redux Toolkit呢?

类型声明

在之前的章节中,已经出现了许多类型,这些类型在使用Typescript编写应用的时候十分有用。但是在一般情况下,编辑器和Typescript都是有自动类型推断功能的,所以并不需要吧每一个部分的类型都书写的十分清楚。

最简单的声明Store所使用的类型的方法就是如同以下示例中一样,利用类型参数化的create函数。

import create from 'zustand';
import { immer } from 'zustand/middleware';

interface GearBoxState {
  currentGear: number;
  shiftUp: () => void;
  shiftDown: () => void;
}

const useGearBox = create<GearBoxState>()(
  immer(set => ({
    currentGear: 0,
    shiftUp: () =>
      set(state => {
        state.currentGear += 1;
      }),
    shiftDown: () =>
      set(state => {
        state.currentGear -= 1;
      })
  }))
);

Warning

在使用类型参数化的create时,不是直接使用create<T>(set => ({}))的Store定义形式,而是create<T>()(set => ({})),不要丢了泛型参数之后的那一对括号。

以下提供一些在自定义Zustand中间件或者扩展功能的时候可能会用到的类型的定义。

// 所有声明中的State类型都是一个普通的对象。
type State = object;

// State片段就是实际上就是指定State类型的一部分,或者是返回一部分State类型内容的函数。
type ParitalState<T extends State> = Partial<T> | (state: T) => Partial<T>;

// 用于定义从State中选择指定内容的函数,可以是返回一个指定的State,也可以是返回使用指定的一组State组成的新数据结构,还可以是通过计算返回新的内容。
type StateSelector<T extends State, U> = (state: T) => U;

// 用于定义State是否相等的判断函数,主要用于控制State是否更改的判断。
type EqualityChecker<T> = (a: T, b: T) => boolean;

// 用于定义对State更变的监听器函数。
type StateListener<T> = (state: T, previousState: T) => void;

// 用于定义创建对指定Store监听的函数,以及监听注销的函数类型。
interface Subscribe<T extends State> {
  (listener: StateListener<T>): () => void;
}

// 定义set函数的类型,用于更新State。
type SetState<T extends State> = (
  partial: T | Partial<T> | ((state: T) => T | Partial<T>),
  replace?: boolean | undefined
) => void;

// 定义get函数的类型,用于从State中获取指定内容。
type GetState<T extends State> = () => T;

// 定义销毁Store的函数类型。
type Destory = () => void;

// 定义在原生Javascript中定义出的Store类型与组成。这也是一个Store中比较基本的底层功能。
interface StoreApi<T extends State> {
  setState: SetState<T>;
  getState: GetState<T>;
  subscribe: Subscribe<T>;
  destory: Destory;
}

// 定义Store中状态的名称,其中StoreMutator就是一个普通的对象,
type StoreMutatorIdentifier = keyof StoreMutator<unknown, unknown>;

// 定义Store中的可变对象,也就是状态。
type Mutate<S, Ms> = Ms extends []
  ? S
  : Ms extends [[infer Mi, infer Ma], ...infer Ms]
  ? Mutator<StoreMutators<S, Ma>[Mi & StoreMutatorIdentifier], Ms>
  : never;

type Get<T, K, F = never> = K extends keyof T ? T[K] : F;

// 定义用于创建Store的函数类型。也就是create函数中所使用的函数类型。
type StateCreator<
  T extends State,
  Mis extends [StoreMutatorIdentifier, unknown][] = [],
  Mos extends [StoreMutatorIdentifier, unknown][] = [],
  U = T
> = ((
  setState: Get<Mutate<StoreApi<T>, Mis>, 'setState', undefined>,
  getState: Get<Mutate<StoreApi<T>, Mos>, 'getState', undefined>,
  store: Mutate<StoreApi<T>, Mis>,
) => U);

// 定义create函数的类型声明,可以看到create函数是可以接受一个StateCreator类型的函数作为Store的初始化函数的。
// 同时,用于定义中间件的函数类型也是采用的这个类型。
interface CreateStore {
  <T extends State, Mos extends [StoreMutatorIdentifier, unknown][] = []>(
    initializer: StateCreator<T, [], Mos>
  ): Mutate<StoreApi<T>, Mos>;

  <T extends State>(): <Mos extends [StoreMutatorIdentifier, unknown][] = []>(
    initializer: StateCreator<T, [], Mos>
  ) => Mutate<StoreApi<T>, Mos>;
}

// 工具类型,用于定义允许提取状态的类型限定。
type ExtractState<S> = S extends { getState: () => infer T } ? T : never;
//工具类型,用于定义适用于React的内容类型。
type WithReact<S extends StoreApi<State>> = S & {
  getServerState?: () => ExtractState<S>;
}

// 定义Store Hook的类型,可以看到Store Hook可以直接返回State,或者使用选择器返回要选择的内容。
type UseBoundStore<S extends WithReact<StoreApi<State>>> = {
  (): ExtractState<S>;
  <U>(
    selector: StateSelector<ExtractState<S>, U>,
    equals: EqualityChecker<U>
  ): U
} & S;

上面这些类型定义中有一部分是Zustand中所使用到的工具类型,为了保证能够顺利阅读所有常用的类型,故一并摘抄了进来。在实际项目使用中,可以从其中选择所需要的类型使用。

SWR

SWR (stale-while-revalidate) 是一种由 HTTP RFC 5861 推广的 HTTP缓存失效策略。这种策略会优先从缓存中获取数据,同时发出 AJAX 重新验证请求,最后得到最新的数据。在 React 中使用 SWR 可以使组件不断地、自动的获得最新的数据流,而 UI 也会一直保持快速响应。

根据 SWR 官网的列举,SWR 能够在以下功能领域提供优秀的体验。

  • 快速页面导航。
  • 定时轮询。
  • 数据依赖。
  • 在组件被 Focus 时的重新验证。
  • 网络恢复时的重新验证。
  • 本地缓存更新。
  • 智能出错重试。
  • 分页和滚动位置恢复。

SWR 在 React DOM 和 React Native 中都可以使用,并且提供了 Typescript 的支持,在对于进行 AJAX 访问所使用的第三方库并不限制,可以自由选择 Axios 或者 fetch 。

要在项目中使用 SWR ,可以通过以下命令来安装。

npm install swr
yarn add swr

基本使用

SWR 是基于 React Hook 的,所以在使用的时候也就是一句话搞定。

import useSWR from 'swr';

function Order({ orderId }) {
  const { data, error } = useSWR(`/api/order/${orderId}`, fetcher);

  if (error) return <div>加载错误!</div>;
  if (!data) return <div>加载中...</div>;

  return <div>{data.customer}</div>;
}

从上面这个示例可以看出,SWR 的核心就是 useSWR 这个 Hook,而获取数据的核心函数则是示例中出现的 fetcher 。这个 fetcher 实际上是十分简单的,只需要返回一个 Promise 实例就可以了,对于 fetcher 如何从服务端获取数据,SWR 并不关心。

以下是一个使用原生 fetchfetcher 函数示例,这个 fetcher 函数可以直接被应用于任意 useSWR Hook 中。

const fetcher = (...args) => fetch(...args).then(res => res.json());

通常一个请求会有3种可能的状态:loadingreadyerror 。在使用 SWR 时,可以使用 dataerror 的值来确定当前的请求状态。

组件复用

在构建Web页面上的组件的时候,在UI的很多地方都需要重用数据。使用SWR在不同的组件间复用数据是非常简单的,只需要定义一个自定义Hook即可。

function useUser(id) {
  const { data, error } = useSWR(`/api/user/${id}`, fetcher);

  return {
    user: data,
    isLoading: !error && !data,
    isError: error
  };
}

这样的Hook就可以在组件中使用了,这样使用自定义Hook获取的数据可以使代码变得更加声明性,使用数据的组件中只需要声明自己使用哪些数据即可。

function UserDetail({ id }) {
  const { user, isLoading, isError } = useUser(id);

  if (isLoading) return <Spinner />;
  if (isError) return <Error />;
  return <div>{user.name}</div>;
}

在这种模式下,所有的数据都将会被绑定到需要这些数据的组件上,并且还能够保证组件之间的独立性,父组件也不需要关心数据的组织和传递,从而使代码更加简单。

配置

useSWR()的完整调用格式如下:

const { data, error, isValidating, mutate } = useSWR(key, fetcher, options);

在之前的示例中,我们只从useSWR中获取了dataerror两个返回值,在大部分情况下仅使用这两个返回值就已经能够满足数据获取的需要了。useSWR的所有返回值的功能如下:

  • data,通过fetcher用给定的key获取的数据,如果数据尚未完全加载,那么此项将返回undefined
  • error,保存从fetcher中抛出的错误,如果没有错误抛出,那么这一项将是undefined
  • isValidating,标识请求中是否有请求或者重新验证。
  • mutate(data?, shouldRevalidate?),用于更改缓存数据的函数。

从以上说明中可以看出,前面示例中使用dataerror判断数据是否加载完成是非常便利的方式。而在使用useSWR时,一般只需要传递前两个参数即可。useSWR的三个参数含义如下:

  • key,标识请求用的唯一字符串,也可以是function或者null。如果使用函数或者null作为key,那么当fetcher函数抛出错误或者返回false值的时候,SWR将不会再次发起请求。
  • fetcher,一个用于请求数据的返回Promise的函数。
  • options,用于配置SWR行为的配置对象。

SWR的很多高级功能都是通过options中的各种配置来实现的,以下是一些常用的配置项及其功能。

选项名默认值功能
suspensefalse启用React的Suspend功能。
fetcher提供一个可以供所有的数据访问使用的fetcher函数。
initiData设定要返回的初始数据。
revalidateOnMount设定在组件挂载时是否启动自动验证。在没有设置initialData的时候,将会重新验证。
revalidateOnFucustrue当窗口被聚焦的时候是否进行重新验证。
revalidateOnReconnecttrue当网络恢复连接的时候是否进行重新验证。
refreshInterval0设置轮询间隔,0表示关闭轮询。
refreshWhenHiddenfalse当窗口不可见的时候是否继续保持轮询。
refreshWhenOfflinefalse当网络离线的时候是否继续保持轮询。
shouldRetryOnErrortruefetcher函数抛出错误的时候是否自动进行重试。
dedupingInterval2000在指定时间中使用相同key获取数据时,自动避免数据请求的时间限度。
focusThrottleInterval5000重新验证的时间间隔。
loadingTimeout3000超时触发onLoadingSlow事件的时间限度。
errorRetryInterval5000fetcher函数出现错误以后重试的时间间隔。
errorRetryCountfetcher函数出现错误以后最大重试次数。
onLoadingSlow(key, config)请求加载时间过长以后的回调函数。
onSuccess(data, key, config)请求加载成功完成时执行的回调函数。
onError(err, key, config)请求返回错误时执行的回调函数。
onErrorRetry(err, key, config, revalidate, revalidateOptions)确定错误重试策略的处理函数。
compare(a, b)用于检测返回的数据是否发生了更改,用于防止伪造重新渲染。

除了可以在使用useSWR的时候进行配置,SWR还支持为所有的SWR Hook提供全局配置。SWR的全局配置需要借用组件SWRConfig,例如以下是一个采用全局配置的示例。

import useSWR, { SWRConfig } from 'swr';

function Dashboard() {
  const { data: events } = useSWR('/api/events');
  const { data: users } = useSWR('/api/user', { refreshInterval: 0 });

  // 使用数据并返回UI
}

function App() {
  return (
    <SWRConfig value={{ refreshInterval: 3000 }}>
      <Dashboard />
    </SWRConfig>
  );
}

数据获取

SWR获取数据通常都是由fetcher函数来完成的,fetcher函数的返回值一般都是一个Promise。这个fetcher函数所接受的参数就是useSWR中的key,其返回值将赋予useSWR的返回值中的data,如果fetcher使用reject抛出了错误,那么抛出的这个错误将会被赋予error

以下是一个使用Axios库来进行AJAX访问的SWR示例。

import axios from 'axios';
import useSWR from 'swr';

const fetcher = url => axios.get(url).then(res => res.data);

function App() {
  const { data, error } = useSWR('/api/data', fetcher);

  // 其他处理部分
}

如果需要在SWR Hook中获取数据服务端提供的错误信息或者需要自定义错误信息,那么就需要自定义从fetcher中抛出的错误。例如以下fetcher的示例。

async function fetcher(url) {
  const res = await fetch(url);

  if (!res.ok) {
    const error = new Error('some error message');
    error.info = await res.json();
    error.status = res.status;
    throw error;
  }

  return res.json();
}

在默认情况下,SWR采用指数退避算法来重新发送请求,如果需要自定义或者更加细致的控制SWR进行错误重试的策略,可以使用onErrorRetry配置项来定义。以下是一个自定义错误重试的示例。

const { data, error } = useSWR('/api/usr', fetcher, {
  onErrorRetry: (err, key, config, revalidate, { retryCount }) => {
    // 直接return将会阻止重试动作
    if (error.status === 404) return;

    if (key === '/api/usr') retrun;

    // 当重试次数已经大于10次的时候停止重试
    if (retryCount >= 10) return;

    // 设置5秒以后进行重试动作,revalidate函数就是用于执行重试动作的
    setTimeout(() => revalidate({ retryCount: retryCount }), 5000);
  }
});

SWR的另一个常用特性就是自动重新请求,也就是平时所说的轮询,但是SWR的自动重新请求更加强大了而已。当选项revalidateOnFocus开启的时候,SWR就可以使位于后台的未激活的浏览器标签页或者从休眠状态恢复的系统刷新数据。而定时轮询数据也只需要设置refreshInterval一项而已。

key参数的选择

useSWRkey参数作为数据的唯一标识,能够完成的功能还有很多。SWR会将key参数赋予fetcher函数作为参数,所以在选择key参数的时候通常都是使用所需要访问的数据提供端的URL。但是除了字符串类型的key以外,null、函数、数组都可以作为key来使用,而且根据这些类型的key,还可以实现很多更高级的特性。

如果key使用了null或者函数,那么在keynull或者函数抛出错误的时候,fetcher函数将不会被启动。例如:

// 使用三元表达式来进行有条件的请求
const { data } = useSWR(shouldFetch ? '/api/usr' : null, fetcher);

// 使用Lambda表达式返回一个假值来进行有条件的请求
const { data } = useSWR(() => (shouldFetch ? '/api/usr' : null), fetcher);

// 或者直接让作为key的函数抛出错误
const { data } = useSWR(() => '/api/usr?id=' + user.id, fetcher);

当传递一个函数作为key的时候,SWR会使用其返回值来作为真正的key,如果在这个函数中使用了其他SWR返回的数据,那么SWR就会等待其他的请求完成,这样就形成了数据请求之间的依赖关系。例如以下示例。

function Orders() {
  const { data: user } = useSWR('/api/usr');
  const { data: orders } = useSWR(() => 'api/orders?uid=' + user.id);

  if (!orders) return 'Loading';
  return <div>{order.length} Orders found.</div>;
}

当数组作为key参数的时候,SWR所获取的数据就会与其中的每一个数据相关联。

例如使用数组来作为key

const fetcher = (url, token) => fetch(url, { method: 'post', body: { token: token } }).then(res => res.json);

function User() {
  const { data: user } = useSWR(['/api/usr', token], fetcher);

  // 处理其他的逻辑
}

在进行判断的时候,SWR会使用浅比较来跟踪key中成员的变化。所以如果在key中使用数组,那么其中的成员就需要保持一定的稳定性,因为任何值的变化,都将会导致SWR重新获取数据。例如以下两种请求。

// 以下这种请求是不对的,SWR会认为key一直在变化
const { data } = useSWR(['/api/usr', { id }], fetcher);

// 使用比较稳定的值才会有效果
const { data } = useSWR(['/api/usr', id], (url, id) => fetcher(url, { id }));

无限加载

无限加载是目前UI设计时比较常用的一个特性,在一些情况下可以直接替代传统的分页技术。传统的分页技术在使用SWR实现的时候已经非常简单,只需要确定URL即可。

例如以下专门用于获取分页页面的组件。

function Page({ page }) {
  const { data } = useSWR(`/api/data?page=${page}`, fetcher);

  return data.map(item => <div key={item.id}>{item.name}</div>);
}

对于普通的页面加载是可以这样来使用的,但是对于无限加载来说,通常都需要通过一个Hook来触发多个请求,这时就需要使用SWR提供的新的Hook了。useSWRInfinite Hook的使用格式如下。

const { data, error, isValidating, mutate, size, setSize } = useSWRInfinite(getKey, fetcher?, options?);

useSWRInfinite的使用格式可以看出来,它与useSWR的一个不同点在于,useSWRInfinite需要传入一个函数来返回key,而不是直接使用一个字符串,现在这个getKey函数也固定了所接受的参数,第一个是当前页面的索引,第二个是上一页的数据。而且useSWRInfinite返回的内容也发生了一些变化。主要的变化有以下这些。

  • data,虽然与useSWR返回数据的数据项名称一样,但是useSWRInfinite返回的是一个数组,其中每个元素都是一个请求的返回值。
  • size,即将请求和返回的页面数量。
  • setSize,设置需要请求的页面数量。

useSWRInfinite基本上可以使用useSWR所有的配置参数,但还多了以下几个配置项。

  • initialSize,设置最初应加载的页面数量,默认为1
  • revalidateAll,设置始终尝试重新验证所有页面,默认为false
  • persistSize,设置当第一页的key发生变化的时候,是否将page size或者initialSize重置为1,默认为false

以下是一个useSWRInfinite的使用示例。

const getKey = (pageIndex, previousPage) => {
  // 如果getKey函数返回null,那么请求就不会再开始
  if (previousPage && !previousPage.length) return null;
  // 如果回到了首页,那么就可以传送不带有page参数的key
  if (pageIndex == 0) return `/orders`;
  return `/orders?page=${pageIndex}`;
};

function Orders() {
  const { data, size, setSize } = useSWRInfinite(getKey, fetcher);
  if (!data) return <Loading />;

  // 计算已经获取的内容数量
  let orderCount = 0;
  data.forEach(item => (orderCount += item.length));

  return (
    <div>
      <p>{orderCount} Orders listed.</p>
      {data.map((orders, index) => {
        return orders.map(order => <Order detail={order} />);
      })}
      <button onClick={() => setSize(size + 1)}>Load More</button>
    </div>
  );
}

useSWRInfinite在使用的使用还有一些状态判断技巧,在需要的时候可以参考以下示例。

  • !data && !error,是否正在加载第一页数据。
  • (!data && !error) || (size > 0 && data && typeof data[size - 1] === "undefined"),是否正在加载更多的页面。
  • data?.[0]?.length === 0,数据服务端没有返回数据。
  • isValidating && data && data.length === size,是否正在刷新数据。

重新验证

经由SWR管理的请求数据,除了可以通过轮询等方法获得更新以外,还可以通过SWR提供的mutate函数来通知SWR执行强制重新验证(数据刷新)操作,这个重新验证可以使所有使用指定key的组件和模块都发生刷新。mutate函数是SWR提供的一个全局函数,最简单的使用方法是传递要重新刷新的key进去。

例如以下这种常见的登出操作。

import useSWR, { mutate } from 'swr';

function NavBar() {
  return (
    <div>
      <Avatar />
      <button
        onClick={() => {
          revokeToken();
          mutate('/api/usr');
        }}>
        Logout
      </button>
    </div>
  );
}

mutate提供第二个参数还可以使本地缓存的数据立刻发生变化。例如mutate('/api/usr', { ...data, name: newUserName }, false),在这个简单的示例中,第三个参数关闭了mutate执行远程重新验证的功能,而将数据更新仅限制在了本地。

另外,还可以在mutate函数中传递一个返回Promise的函数,来对重新验证进行额外定义并返回更新后的数据。例如以下示例。

mutate(`/api/orders`, async orders => {
  // 更新一个项目的内容,并获得更新以后的数据
  const updatedOrder = await fetch(`/api/order/${orderId}`, {
    method: 'PATCH',
    body: JSON.stringify({ flag: 3 })
  });

  const filteredOrders = orders.filter(order => order.id !== orderId);
  return [...filteredOrders, updatedOrder];
});

useSWR Hook返回的mutate函数的功能与SWR提供的全局mutate功能是一致的,但在使用时并不需要提供key参数。

React Query

React Query是一种用于获取、缓存、同步和更新服务器状态的工具库,它的功能与SWR更相似,但是功能要远比SWR库要更多。与Redux、MobX等管理客户端状态的工具库不同,React Query管理的是服务器端状态,这就要求React应用必须拥有与服务端进行交互的库,例如Axios或者fetch,但是对于用于执行AJAX访问的工具库,React Query并没有限制应用的选择,AJAX工具库只需要支持返回Promise对象即可,或者也可以是通过GraphQL来操作数据。

与SWR一样,React Query在React和React Native中都可以使用,并且也提供了对于Typescript的支持。但不同的是,React Query还提供了一套方便的Devtools。

要在项目中使用React Query,可以通过以下命令来完成安装。

npm i @tanstack/react-query
yarn add @tanstack/react-query

Tip

React Query在从v3升级到v4的时候,其库名称发生了变化,从react-query变成了@tanstack/react-query

基本概念与组件

在React Query中,有三个基本概念支撑了整个数据管理操作。

  • 查询:React Query从服务端获取数据的操作。React Query在完成从服务端获取数据后,还会同时对应用中的客户端缓存进行管理。
  • 更新:React Query将用户数据推送到服务端的操作。不论是新建数据还是修改数据,对于React Query来说,都是数据更新操作。
  • 查询失效:获取一个查询的失效状态是React Query对在客户端缓存的数据有效性进行观测的核心节点。React Query的客户端数据缓存的刷新操作,都是由查询失效操作触发的。

为了实现这三个基本概念,React Query提供了QueryClient供使用。但是要把QueryClient放入应用中,就需要QueryClientProvider的支持。那么这样一来,整个应用的启动文件就会变成以下这个样子。

import { QueryClient, QueryClientProvider } from 'react-query';

const queryClient = new QueryClient();

function main() {
  return (
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClient>
  );
}

QueryClientProvider可以把QueryClient的实例注入到应用全局,供应用组件树使用。具体原理可以参考Context。

其他的组件在使用的时候,则需要利用useQueryClient这个Hook,然后再搭配useQuery Hook来声明所需要使用的查询。

import { useQueryClient, useQuery } from 'react-query';
import { queryAction } from '../queries';

function ActionComponent() {
  const client = useQueryClient();
  const query = useQuery('action', queryAction);

  return <div></div>;
}

在以上示例中展示了查询的引用,具体的查询构建和使用将在后面的章节中详细描述。

基本使用

前一节的示例虽然已经展示了要在一个应用中如何使用React Query,但是并不完整。接下来将使用一个完整的数据获取、数据更新、缓存控制的流程来说明在大部分场景中,React Query的使用方法。

在大部分的应用场景中,我们都需要从服务端取得数据,然后在应用中做出一些操作后,需要将更新的数据保存回服务端,此时我们之前从服务端获取的数据就需要进行刷新。这个数据操作的流程在使用React Query以后,会变得十分简练和清晰。

首先,将React Query引入到应用中,这一步与上一节中的应用启动示例相同。

import { QueryClient, QueryClientProvider } from 'react-query';
import App from './App.tsx';

const queryClient = new QueryClient();

function main() {
  return (
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  );
}

接下来,定义两个与服务端进行交互的数据操作方法。

import axios, { AxiosResponse } from 'axios';

export async function getThings(): Promise<Thing[]> {
  return axios.get<Thing[]>('/things');
}

export async function deleteThings(id: number): Promise<AxiosResponse<boolean>> {
  return axios.delete<AxiosResponse<boolean>>(`/things?id=${id}`);
}

然后就可以在组件中使用已经定义好的这些查询了。

import { useMutation, useQuery, useQueryClient } from 'react-query';
import { deleteThings, getThings } from './queries.ts';

function App() {
  const client = useQueryClient();

  const things = useQuery(['things'], getThings);
  const mutation = useMutation(deleteThings, {
    onSuccess: () => {
      client.invalidateQueries(['things']);
    }
  });

  return (
    <div>
      <ul>
        {things.data.map(thing => (
          <li key={thing.id}>
            {thing.name}
            <button onClick={() => mutation.mutate(thing.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default App;

在这个示例中,每个列表项都带有一个删除按钮,当点击这个按钮的时候,这个列表项就会被删除,并且会重新查询列表内容,刷新整个列表。其中所涉及到的具体功能的使用,将在后面的章节进行进一步说明。

Devtools配置

为了方便对React Query进行的查询和缓存数据进行调试,React Query提供了一套非常好用的调试工具。这套调试工具被封装在react-query/devtools包中,但这个包并不需要独立安装,在把React Query安装的项目的时候,这个调试工具就已经被附带一并安装好了。

所以要在项目开发过程中使用React Query提供的调试工具,只需要在项目中引入它,再找一个地方把它放入页面即可。

Tip

在默认情况下,React Query的调试工具只会在process.env.NODE_ENV === 'development'的时候在页面上展现和打包进入生成文件,所以在进行生产环境编译和打包时,不必手工去除调试工具的引入。

调试工具在页面上的展示有两种模式,一种是浮动模式,一种是嵌入模式。但是不管使用哪种模式,调试工具组件在应用中的位置,应该越靠近QueryClientProvider标签越好。如果只想在某一个页面中使用调试工具,那么调试工具标签应该尽可能的放置到页面的根组件下。

在浮动模式中,React Query会在页面的一个角落里提供一个图标来供切换调试工具的显示,而展开后的调试工具也将附着在窗口的下半部分。在应用中使用浮动模式需要在项目中引入ReactQueryDevtools,并像以下示例这样将其放入项目的组件树中。

import { QueryClientProvider, QueryClient } from 'react-query';
import { ReactQueryDevtools } from 'react-query/devtools';

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      {/* 这里放置应用的其他部分 */}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

在上面这个示例中可以看到ReactQueryDevtools标签使用了一个属性initialIsOpen,除了这个属性以外,ReactQueryDevtools还提供了其他若干常用的选项。

  • initialIsOpen,设定为true的时候表示在页面刚加载好的时候,调试工具面板是开启的状态。
  • position,设定用于控制调试工具面板开关在页面上放置的位置,默认为"bottom-left",除此之外还可以设置为"top-left""top-right""bottom-right"

嵌入模式则不太一样,在嵌入模式中,调试工具面板是作为应用中的一个正常组件出现的,而且会在页面指定位置始终存在和展示。嵌入模式需要引入的是ReactQueryDevtoolsPanel标签。但是在大部分应用中,我们可能使用浮动模式的调试工具会更多一些,所以如果有需要使用嵌入模式调试工具的时候,可以直接参考React Query的文档来进行更详细的配置。

构建查询

查询是React Query工作的基础,我们在一个应用使用React Query也是通过构建一个个的查询来开始的。在React Query中构建一个查询,通常是通过useQuery或者useInfiniteQuery两个Hook来定义的。查询通常都是使用GET或者POST方法从服务端获取数据的,但是就RESTful风格API定义习惯来说,查询数据一般都仅指GET方法。

定义一个查询,以下两个内容:

  • 用于代表和识别查询的唯一键,也常被称为Key。
  • 一个可以返回Promise类型值的函数,这个函数可以返回一个具体的值,或者抛出一个异常。

以下是一个比较简单的查询定义示例。

import axios from 'axios';
import { useQuery } from 'react-query';

function getThings(): Promise<Thing[]> {
  return axios.get<Thing[]>('/things');
}

function Things() {
  const { data } = useQuery(['things'], getThings);

  return (
    <ul>
      {data.map(d => (
        <li>{d.name}</li>
      ))}
    </ul>
  );
}

在这个简单的示例中,使用了与前面的示例不同的获取查询返回数据的方法,没有直接使用useQuery Hook直接返回的对象来访问返回数据,而是采用了解构赋值的方法仅从其中获取了查询所返回的数据内容。解构出来的data字段中的内容实际上非常好理解,就是查询从服务端获取到的实际数据,但是在useQuery这个Hook中,除了用于承载数据的data字段,还有以下字段可以使用。

  • status,用于获取当前查询的状态,查询的状态采用字符串表示,可以取以下值。
    • 'loading',表示查询正在请求数据,但是还未获取到任何数据。
    • 'error',表示查询在请求过程中发生了错误。
    • 'success',表示查询请求已经成功并且所获取到的数据已经可以访问了。
    • 'idle',表示查询目前处于空闲或者被禁用的状态。
  • isLoading,表示查询是否处于正在请求数据的状态,是status === 'loading'的快捷表示。
  • isError,表示查询是否发生了错误,是status === 'error'的快捷表示。
  • isSuccess,表示查询是否已经成功获取到了数据,是status === 'success'的快捷表示。
  • isIdle,表示查询是否正处于空闲或者被禁用状态,是status === 'idle'的快捷表示。
  • isFetching,表示查询在任何情况下都处于正在获取数据的状态(包括后台查询的状态),那么这个字段就会返回true
  • error,表示查询出现的具体错误,其中错误信息可以使用error.message来获取。
  • data,用于承载从服务端获取到的具体数据。

虽然useQuery提供了上面这么多可以使用的字段,但实际上在应用项目的日常使用中,最常用的还是集中在isLoadingisErrorerrordata上。

根据以上这些可以使用的状态字段,可以把前面那个简单的示例的状态显示再丰富一下。

function Things() {
  const { isLoading, isError, error, data } = useQuery(['things'], getThings);

  if (isLoading) {
    return <div>Loading...</div>;
  }

  if (isError) {
    return <div>Error: ${error.message}.</div>;
  }

  return (
    <ul>
      {data.map(d => (
        <li>{d.name}</li>
      ))}
    </ul>
  );
}

查询在使用过程中,需要牢记以下规则,否则可能会出现数据操作与期望不符的情况。

  1. 在默认情况下,useQueryuseInfiniteQuery两个Hook返回的实例,会将已经缓存的数据数据视为过期数据。如果需要更改查询实例的这个特性可以配置全局或者查询的staleTime参数,越长的参数值会指示查询实例更多的利用已经缓存的数据。
  2. 被标记为过期的查询会在以下条件下自动重新查询。
  3. 查询被挂载到了一个新的实例上。
  4. 浏览器窗口重新获得了焦点。
  5. 客户机重新连接到了网络。
  6. 查询配置了轮询时间。
  7. 一个查询结果如果在没有更多的活跃查询实例,将会被标记为失活状态并被缓存起来,被缓存的查询结果可以在后续的查询中被直接使用。
  8. 被标记为失活状态的查询结果将在五分钟后被GC回收。
  9. 如果一个查询出现错误,那么将以指数退避的形式静默重试三次,如果经过重试后,请求依旧失败,那么将会直接返回错误状态及信息。
  10. 在默认情况下,查询结果在结构上是共享的,用以检测数据是否发生了实际的更改。这个特性可以辅助使useMemouseCallback保持稳定。

useQuery中,定义查询的第一个参数即是代表查询的Key,这个Key一般都为数组,但是其元素可以是任意类型。

Warning

在React Query v3中,代表查询的Key可以是字符串,但是在React Query v4中,代表查询的Key只能是数组。

当一个查询需要更多的数据来唯一的描述其查询获得的数据时,就需要使用到数组甚至嵌套对象来作为查询Key。一般来说,对于数组或者嵌套对象的选择可以依照以下标准。

  • 如果查询结果是分层、索引等类型的,建议使用数组组合所有的条件。
  • 如果查询是跟具体条件、查询关键字等相关的,建议使用嵌套对象作为数组元素来组合查询条件。

具体的使用可以参考以下示例。

// 查询Key为 ['things', 5],通常可以用于表示获取索引ID为5的记录
const { data } = useQuery(['things', 5], fetchThings);

// 查询Key为 ['things', 5, { enabled: true }],通常可以用于获取索引ID为5但是仍带有额外附加条件的查询
const { data } = useQuery(['things', 5, { enabled: true }], fetchThings);

// 查询Key为 ['things', { keyword: 'test', page: 3 }],通常用于给定具体查询条件时使用
const { data } = useQuery(['things', { keyword: 'test', page: 3 }], fetchThings);

对于出现在查询Key中的数组元素来说,其中的元素顺序决定了React Query对于查询的识别。例如在以下几个查询中,这几个Key会被React Query认为是不相同的。

const { data } = useQuery(['things', status, page], fetchThings);

const { data } = useQuery(['things', page, status], fetchThings);

const { data } = useQuery(['things', undefined, page, status], fetchThings);

但是如果在查询Key中使用嵌套对象,那么嵌套对象中的字段顺序是不会影响React Query对于查询的识别的。例如在以下几个查询中,React Query会认为它们都是相同的查询。

const { data } = useQuery(['things', { status, page }], fetchThings);

const { data } = useQuery(['things', { page, status }], fetchThings);

const { data } = useQuery(['things', { page, status, keyword }], fetchThings);

Tip

造成这种情况的原因是,React Query会对查询Key进行散列然后对其进行确定和识别,所以作为查询Key中数组元素的内容将会直接影响查询Key的散列结果,但是嵌套对象作为一个数组元素存在,那么其中的内容就不再能影响查询Key的散列结果了。

所以如果需要唯一的对查询进行标识,就必须将查询的必要条件包含在组成查询Key的数组中。

在一般的使用中,常常会将变量作为查询Key的组成部分使用。这样一来,对于查询函数的定义就变得比较复杂,但是React Query支持将查询Key传入查询函数的方法。这样定义的查询函数就要简单的多了。查询函数所收到的参数是查询定义的时候产生的Key本身,所以在接收的时候可以采用解构赋值的方式进行提取。以下是对数组形式传入和嵌套对象方式传入的示例。

// 使用数组形式的查询Key
// 查询的定义
function DataComponent() {
  const { data } = useQuery(['things', page, status], fetchThings);
}

// 定义查询函数
function fetchThings({ queryKey }) {
  const [_key, page, status] = queryKey;
  // 这里执行具体的查询功能
  return new Promise();
}
// 使用嵌套对象形式的查询Key
// 查询的定义
function DataComponent() {
  const { data } = useQuery(['things', { page, status }], fetchThings);
}

// 定义查询函数
function fetchThings({ queryKey }) {
  const [_key, { page, status }] = querykey;
  // 这里执行具体的查询功能
  return new Promise();
}

从这两个简单的示例可以看出来,查询函数在获取查询Key的时候,都是通过解构语法从实参的queryKey字段中获取的。要理解为何这样做,只需要看一下useQuery的另一种使用形式就明白了。

function DataComponent() {
  const { data } = useQuery({
    queryKey: ['things', page, status],
    queryFn: fetchThings,
    ...config
  });
}

从上面这个简单的示例中就可以知道useQuery在定义查询的时候都向查询函数传递了哪些内容。所以如果不使用示例中的这种对象定义形式,那么config就可以作为useQuery的第三个参数存在。在这个config参数中,可以对查询的一些常见功能特性进行配置。例如以下这些参数。

  • enabled,配置查询是否可以自动被运行。如果搭配其他变量作为条件,可以实现依赖查询,在指定的依赖项就绪以后就会立刻运行查询。
  • staleTime,已经缓存的数据被视为过期的时间,取值单位为毫秒。
  • cacheTime,未被使用的和非活动数据在内存中被保持的时间,取值单位为毫秒。
  • refetchInterval,设置查询的轮询时间,取值单位也为毫秒。还可以给给定一个函数来动态的决定下一次查询的启动时间。
  • refetchOnWindowFocus,配置查询在浏览器窗口重新获得焦点以后,是否需要重新查询结果。
  • retry,配置查询的失败重试次数,如果设置为false,那么就会关闭查询的失败重试。
  • retryDelay,配置查询失败后的重试时间间隔。但是需要注意的是,React Query默认是采用指数退避方式进行重试的,所以在一般情况下并不提倡自行配置这个选项。
  • placeholderData,配置当查询尚未执行的时候可以使用的占位数据。这个选项可以配合useMemo来使用。注意,这里的占位数据并不是初始数据,初始数据是由另一个选项来配置的。
  • initialData,在查询尚未执行时,对缓存中的数据进行初始预置。initialData中的数据受staleTime影响。
  • keepPreviousData,可以接受布尔值,如果设置为true,那么在从查询获取新的数据的时候,将会保留旧的查询结果。
  • onSuccess,设置当查询执行成功以后的事件响应函数。
  • onError,设置当查询执行失败以后的事件响应函数。
  • onSettled,设置当查询执行完成以后的事件响应函数,相当于onSuccessonError的集合。
  • select,用于在查询成功完成后,对获取到的数据进行变换或者筛选。

分页查询

分页查询是所有的应用中经常会使用到的一种查询。因为应用所要呈现的数据量过大,导致数据无法在一个页面内全部展示完全,所以就需要对数据进行分页。分页查询中常常会遇到的问题是如果频繁的进行切换页面的操作,那么应用将会反复的向服务端发起请求来获取指定页面的数据。为了优化应用的这一需求,React Query通过在查询中配置keepPreviousData选项,可以将所有已经获取过的页面数据都缓存起来,就可以节省大部分的数据获取请求。

其实通过前文对于查询Key的说明,直接为不同的页面配置不同的查询Key也是可以做到将已经获取的查询结果缓存起来的。但是这样一来,使用查询的UI界面组件就会不断的响应请求成功和请求失败的事件,从而造成UI界面不必要的刷新动作。但是使用了keepPreviousData选项以后,React Query就不会在已经存在缓存的数据上抛出查询成功和查询失败的事件了,而是会静默的从服务端获取数据,并在获取的数据有新的变化时,悄悄地对缓存进行替换。

以下是一个操作分页数据的示例。

function ThingsList() {
  const [page, setPage] = useState(0);
  const { isLoading, isError, error, data, isPreviousData } = useQuery(['things', page], () => fetchThingsPaged(page), {
    keepPreviousData: true
  });

  return (
    <>
      {isLoading ? <div>加载中...</div> : null}
      {isError ? <div>数据获取错误: {error.message}</div> : null}
      <div>当前是第 {page + 1} 页</div>
      <ul>
        {data.items.map(it => (
          <li>{it.name}</li>
        ))}
      </ul>
      <button onClick={() => setPage(old => Math.max(old - 1, 0))} disabled={page === 0}>
        上一页
      </button>
      <button
        onClick={() => {
          if (!isPreviousData && data.hasMore) {
            setPage(old => old + 1);
          }
        }}
        disabled={isPreviousData || !data?.hasMore}>
        下一页
      </button>
    </>
  );
}

无限查询

另外一种操作分页数据的方法是无限获取数据。这种对于大量数据的处理方式经常可以在移动端应用中以无限滚动的形式看到。React Query对肿着无限获取数据的需求也提供了支持,只是在使用的时候并不是使用useQuery Hook函数,而是使用useInfiniteQuery Hook函数。

useQuery不同,useInfiniteQuery所返回的值和要提供的参数有很大的区别。首先看useInfiniteQuery在使用的时候需要提供的参数。

  • getNextPageParam,设置用于获取下一页数据所使用参数的函数。
  • getPreviousPageParam,设置用于获取上一页数据所使用参数的函数。

这两个参数所接收的函数的签名格式是相同的,都是(page: any, allPage: number) => any | undefined。这个函数需要返回一个可以用于生成查询参数的值,例如页码,或者是直接返回undefined以表示没有更多的数据。函数中所需要使用到的类型是anypage参数,实际上是来自于对于分页数据的记录。

那么为了使useInfiniteQuery能够正常的工作,就必须了解useInfiniteQuery返回的内容。

  • data,保存持有所有查询获取到的全部数据。
  • data.pages,以数组的形式保存每一条查询获取到的分页数据。
  • data.pageParams,以数组的形式保存每一条查询所使用的查询参数。注意,这里的内容将被传入getNextPageParamgetPreviousPageParam函数中作为其第一个参数。
  • fetchNextPagefetchPreviousPage,提供应用组件中调用获取下一页数据和上一页数据的函数。
  • hasNextPagehasPreviousPage,提供应用组件对是否存在下一页以及上一页进行判断的布尔值标志。
  • isFetchingNextPageisFetchingPreviousPage,提供应用组件获取当前查询正在获取数据的状态。
  • refetch,提供应用组件用于手动重新查询某一指定页面的数据的函数。

除了以上列出的这些返回内容以外,useInfiniteQuery还具有useQuery所返回的全部内容。具体useInfiniteQuery的使用可以参考以下示例。

function UnlimitedThings() {
  const fetchThings = ({ pageParam = 1 }) => axios.get<Things[]>(`/api/things?page=${pageParam}`);

  const { data, error, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError } = useInfiniteQuery(
    ['things'],
    fetchThings,
    {
      getNextPageParam: (lastPage, pages) => lastPage.nextPage
    }
  );

  return (
    <>
      {isLoading ? <div>加载中...</div> : null}
      {isError ? <div>出现错误:${error.message}</div> : null}
      <ul>
        {data.pages.map((group, i) => (
          <Fragment key={i}>
            {group.items.map(it => (
              <li>{it.name}</li>
            ))}
          </Fragment>
        ))}
      </ul>
      <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}>
        {isFetchingNextPage ? '加载更多内容中...' : hasNextPage ? '加载更多' : '没有更多内容供加载了'}
      </button>
    </>
  );
}

如果无限查询的缓存结果变为了过期状态,那么整个查询就需要被重新加载,查询中所保存的每一个页面都会被顺序的从第一页开始完成加载。如果手动将无限查询的结果从缓存中移除了,那么无限查询再次开始时,将会重新从第一页开始获取。除此之外,还可以使用useInfiniteQuery返回的refetch函数完成手动加载的功能。refetch函数接受一个对象作为参数,其中要求使用refetchPage字段定义所要重新获取的页面。

数据更新

在React Query中,数据更新功能主要对应HTTP POST、PUT、PATCH、DELETE等需要对数据和资源做出修改的请求。这些请求都有一个普遍的特点,就是在HTTP请求完成以后,应用页面上的数据会发生变化,需要重新加载。React Query中的useMutation Hook就提供了应用调用修改服务端数据请求的能力,只要再配合React Query中提供的缓存控制功能,就可以非常方便的完成修改服务端数据之后立刻对浏览器端的数据进行刷新的功能。

useMutation Hook的使用要比useQuery简单很多。useMutation主要是返回一个用于执行数据变化的对象,并通过这个对象中函数的调用来完成数据更改请求的一系列控制。

以下是useMutation的一个简单使用示例。

function NewThing() {
  const mutation = useMutation(newThingForm => {
    return axios.post(`/api/things`, newThingForm);
  });

  return (
    <>
      {mutation.isLoading ? <div>正在添加数据...</div> : null}
      {mutation.isError ? <div>出现错误:{mutation.error.message}</div> : null}
      <button onClick={() => mutation.mutate({ id: new ID(), name: 'the new one' })}>创建新记录</button>
    </>
  );
}

useQuery一样,useMutation也会返回statuserrordata等内容,并且其使用方式也于useQuery返回的内容相同。

useMutation返回的名为mutate的函数在使用的时候只需要注意其会把它所接受到的参数,直接传递给用于处理变更的变更函数。所以在定义变更处理时,需要留心变更函数中对于所接受的参数的处理。useMutation所返回的内容中常用的主要有以下这些。

  • mutate,返回一个签名为(variables: TVariables, { onSuccess, onSettled, onError }) => void的函数,主要用于启动数据更新过程。
  • mutateAsync,返回一个签名为(variables: TVariables, { onSuccess, onSettled, onError }) => Promise<TData>的异步函数,主要也用于启动数据更新过程。
  • reset,返回一个签名为() => void的函数,用于清除变更功能的内部状态,将其重置到其定义时的最原始状态。

useMutation在定义变更的时候,可以接受两个参数,第一个参数是用于处理变更的函数,其签名为(variables: TVariables) => Promise<TData>。从这个函数签名可以看出来,传递给mutate函数的参数是怎样被使用的。useMutation的另一个参数是一个对象,主要用于对useMutation定义的变更的特性进行配置,其中常用的配置字段主要有以下这些。

  • onMutate,接受一个签名为(variables: TVariables) => Promise<TContext | void> | TContext | void的函数,用于在变更被调用之前进行额外的处理使用。
  • onSuccess,接受一个签名为(data: TData, variables: TVariables, context?: TContext) => Promise<any> | void的处理函数,用于对成功执行变更事件的响应处理。
  • onError,接受一个签名为(error: TError, variables: TVariables, context?: TContext) => Promise<any> | void的处理函数,用于对执行变更失败以后的事件进行响应。
  • onSettled,接受一个签名为(data: TData, error: TError, variables: TVariables, context?: TContext) => Promise<any> | void的处理函数,用于执行变更以后所产生的事件的响应处理,相当于onSuccessonError的集合。

Warning

这里所介绍的useMutation Hook中并不包含对于查询缓存的控制,所以即便是成功执行了变更,也无法直接使界面上的数据发生自动刷新。如果需要完成数据自动刷新的功能,需要配合下一节的内容来使用。

数据缓存控制

在大部分情况下利用React Query自带的缓存控制策略就已经可以很好很高效的完成数据的获取工作。但是在一些数据被修改以后,那么让苦等React Query自动控制的缓存过期机制就有一些不合适宜了,这时就需要使用QueryClient中提供的缓存过期通知功能来使React Query得知缓存中的某些数据已经过期而启动数据的重新获取过程。

React Query在QueryClient中通过invalidateQueries方法提供了失效全部查询缓存和失效指定查询缓存的方法。这个方法的调用就像是启动了一个链式反应,React Query会无视之前查询中的所有配置,直接将缓存的查询结果标记为过期状态,并且在后台启动对这些过期数据的重新获取的过程。

以下是invalidateQueries的使用格式示例。

// 将已经缓存的所有查询都标记为过期状态
queryClient.invalidateQueries();
// 将已经缓存的指定查询标记为过期状态
queryClient.invalidateQueries('things');

有了这个方法,就可以结合到数据更新的过程中,在数据更新完成以后使React Query自动重新获取最新的数据了。以下是数据缓存控制与数据更新的组合示例。

function NewThing() {
  const queryClient = useQueryClient();
  const mutation = useMutation(
    newThingForm => {
      return axios.post(`/api/things`, newThingForm);
    },
    {
      onSuccess: () => {
        queryClient.invalidateQueries('things');
      }
    }
  );

  return (
    <>
      {mutation.isLoading ? <div>正在添加数据...</div> : null}
      {mutation.isError ? <div>出现错误:{mutation.error.message}</div> : null}
      <button onClick={() => mutation.mutate({ id: new ID(), name: 'the new one' })}>创建新记录</button>
    </>
  );
}

在使用invalidateQueries方法的时候,指定要标记为过期的查询的Key也同样遵循useQuery中对于查询Key的匹配原则。

RxJS

RxJS 是一个使用观察者模式实现的异步和事件处理库。其整个功能的核心是Observable类,并由此派生出了许多相关的功能类。要在项目中使用 RxJS,可以使用以下命令完成安装。

npm install rxjs
yarn add rxjs

基本概念

RxJS 中主要有以下几个基本概念。

  • Observable,表示一个数据或者事件的分发来源。
  • Observer,表示一组回调函数,用来响应从 Observable 中发出的数据或者事件。
  • Subscription,表示 Observable 的执行过程,通常用来取消回调的执行。
  • Operator,用于对 Observable 发出的数据或者事件进行处理的纯函数。
  • Subject,相当于 EventEmitter,用于向 Observer 发送数据。
  • Scheduler,并行执行的分发器,主要用于协调数据。

Observable

Observable 是 RxJS 中的基础,是所有数据处理过程的数据来源。Observable 实例在创建时可以使用一个带有next()error()complete()方法的对象,其中next()方法用于发出一个数据,error()方法用于发出一个错误,complete()方法用于结束 Observable 实例的数据发送。

以下示例实现了一个简单的 Observable 数据发送。

import { Observable } from 'rxjs';

const observable = new Observable(subscriber => {
  subscriber.next(1);
  subscriber.next(2);
  setTimeout(() => {
    subscriber.next(3);
    subscriber.complete();
  }, 1000);
});

当这个 Observable 创建完成以后,其中的数据并不会发送出来,必须要有订阅才能开始发送数据,一个 Observable 实例只能被一个 Observer 订阅。对于 Observable 的订阅可以参考以下示例。

observable.subscribe({
  next(x) {
    console.log(x);
  },
  error(err) {
    console.error(err);
  },
  complete() {
    console.log('Done');
  }
});

可以看出对 Observable 的订阅就是针对 Observable 中三种方法发出数据的对应处理。通过.subscribe()方法会返回一个Subscription类型实例,可以通过其注销掉已经建立的订阅。.subscribe()方法可以直接接受三个函数,分别对next()error()complete()进行相应,并且其中的一些可以省略。

Observable 还提供了一个.pipe()方法,其中可以传入一系列的 Operator,这些 Operator 将会按照.pipe()中的排列顺序对 Observable 发出的数据进行操作,经过.pipe()处理的数据将会传递给 Observer。

Subject

Subject 也是一种 Observable,但是具备数据的群发能力,可以被多个 Observer 订阅,并且还可以手动控制其中数据的发送。

import { from, Subject } from 'rxjs';
import { multicast } from 'rxjs/operators';

const source = from([1, 2, 3]);
const subject = new Subject();
const multicasted = source.pipe(multicast(subject));

multicasted.connect();

Operator

RxJS 中的 Operator 实际上就是函数,在 RxJS 中有两种 Operator,一种是管道操作符(Piped Operator),一种是创建操作符(Creation Operator)。

创建操作符则是用来快速创建 Observable。例如of(1, 2, 3)。常用的创建操作符主要有以下这些。

操作符执行功能
ajax执行 Ajax 访问。
bindCallback将传统的回调 API 转换为 Observable
bindNodeCallback将 NodeJS 的回调 API 风格转换为 Observable
defer当有订阅的时候才创建 Observable
defer创建一个空白的 Observable
from以给定的对象为源创建 Observable
fromEvent将给定的 DOM 事件转换为 Observable
generate按照给定的规则生成 Observable
interval生成一个定时发出计数信号的 Observable
of将现有序列转换成 Observable
range创建一个会发送指定序列的 Observable
throwError创建一个只会抛出指定错误的 Observable
timer创建一个在指定延时之后发出计数信号的 Observable
iif创建一个根据指定条件发送信息的 Observable
combineLatest将两个 Observable 交叉合并在一起。
concat将两个 Observable 交叉合并在一起。
forkJoin将所有 Observable 的最终值合并在一起。
forkJoin将所有 Observable 合并。
race从多个 Observable 中选择第一个被发送的值。
race组合多个 Observable 发送的值合并成键值对。

对于可以合并多个 Observable 的方法,需要参考 RxJS 的文档来根据示意图选择使用。

管道操作符主要用于在.pipe()方法中对 Observable 发出的数据进行变换。例如。

observable.pipe(
  filter(x => x % 2 !== 0),
  map(x => x * 2)
);

以下列举一些常用的管道操作符。

操作符执行功能
buffer(notifier)缓存直到notifier发送一个值作为信号。
bufferTime(time)缓存一段时间内发送的值。
bufferWhen(obsesrvable)缓存数据直到另一个 Observable 发送信号。
concatMap()合并两个 Observable 并同时进行变换。
expand()根据另一个 Observable 进行扩展。
groupBy()Observable 发送的值进行分组。
map()Observable 发送的值进行变换。
mergeMap()合并两个 Observable 的同时进行变换。
partition()按照条件将 Observable 分散成多个 Observable
partition()从发送的值中取得键值相近的值。
partition()对发送的值进行迭代计算,立刻返回结果。
switchMap()合并两个 Observable 并同时进行变换。
window()根据另一个 Observable 的发送进行分组。
debounce()延迟一段时间进行发送。
distinct()对发送的值进行去重。
elementAt()选择指定索引的值。
filter()选择符合条件的值。
first()选择第一个值。
last()选择最后一个值。
sample()根据另一个 Observable 的发送进行采样。
skip()丢弃指定数量的值。
take()只取指定数量的值。
throttle()间隔一段时间进行发送。
combineAll()组合 Observable
concatAll()展平并连接 Observable
mergeAll()按照发送顺序展平合并。
startWith()将指定值添加到起始发送位置。
multicast()返回多个 ConnectableObservable 供群发。
publish()返回一个 ConnectableObservable 供自由连接。
catchError()捕获错误并重新发送其他 Observable
retry()重新请求发送。
retry()附加一个副作用处理过程。
every()确定所有值是否满足条件。
find()选择符合条件的值。
count()发送值的总个数。
max()发送值的最大值。
min()发送值的最小值。
reduce()对发送的值进行迭代计算,最后返回结果。

Immer

Immer是一个用来操作不可变数据的小型库。不可变数据在React中有着十分广泛的应用,对于不可变数据的变化检测也十分的容易:如果对一个对象的引用没有发生改变,那么这个对象就没有发生改变。而且在克隆对象的时候,对于对象中未发生改变的部分就可以不必复制了,仅需要在内存中与旧对象共享即可。

不可变对象在React中的应用也对应了React中组件不可变的原则,这使得React对于组件树的处理和渲染变得十分高效。

虽然通过手写传统的代码也可以实现不可变数据,但是Immer的使用可以帮助不可变范式的遵守,并且可以主动检测一些意外的发生,同时还会利用Proxy保证对象副本的创建和效率。而且在项目中使用Immer几乎是无感的,Immer的学习成本和使用成本都非常的低。

要在应用中使用Immer,只需要在应用项目目录中执行以下命令即可。

npm install immer
yarn add immer

初始化功能

Immer为了保证尽可能小的体积,所以对其中的一些功能设计了开关,在生产模式下,没有启用的功能将不会占据任何编译输出大小。

这些设计的开关及其使用条件可以参考以下列表。

  • enableES5(),用于在应用中启用ES5支持,主要用在比较旧的Javascript环境中,例如IE和React Native。
  • enableMapSet(),用于ES2015环境下,启用对原生Map和Set的操作。
  • enablePatches(),用于启用JSON补丁支持,可以跟踪对Draft对象所作出的所有更改。
  • enableAllPlugins(),启用以上所列举的所有功能。

以上开关函数只需要在应用运行期间执行一次即可。对于React应用来说,这个操作可以在负责启动应用的启动文件中完成。

核心函数

在Immer中,用于对数据进行的操作都是通过一个函数来进行的:produce。这个函数也是Immer包的默认导出,所以可以在使用的时候直接采用默认导入即可。

produce函数的签名也十分的简单,如以下所示。

produce<T>(currentState: T, recipe: (draftState: Draft<T>) => void): T

在这个函数签名中可以看出,其实如果要进行数据的修改,其实只需要两个元素:数据的原始状态和要进行的修改操作。所以produce函数可以接受的两个参数的功能是这样的。

  • currentState,这是所需要执行数据变化的基础数据状态,这个状态在操作中和操作结束后不会发生任何改变。
  • recipe,这是一个用于定义数据变化如何发生的函数,它需要接受一个参数,代表不可变对象的代理,其内容是不可变对象的原始状态,对不可变对象的代理构建的变化,将在最终被整合进新的不可变对象中。

以下是一个核心函数的应用示例。

import produce from 'immer';

const baseState = [
  { title: 'Star War', watches: 5 },
  { title: 'Star Trek', watches: 4 }
];

const newState = produce(baseState, draftState => {
  draftState[1].watches += 1;
});

在这个示例中,任何对draftState作出的修改,最终都将被应用到新的状态对象中。所以在recipe函数中,直接对其接受到的Draft<T>类型的参数进行所需要的修改即可。

在Immer库中,对这种调用produce函数建立新对象状态的函数有一个专用的名称:producer,其形式通常为(baseState, ...arguments) => resultState

传入produce函数中的recipe函数可以是异步的,如果在produce中使用异步的recipe,那么新对象将在返回的Promise被解析之后才会生成。

柯里化

柯里化是一个函数式编程中经常使用到的名词,它是指将一个可以接受多个参数的函数转变成一个仅接受一个参数,但返回一系列可以接受余下参数并返回结果的新函数的技术。简而言之,如果可以接受多个参数的函数中,其中的一个参数被固定了,那么就可以得到一个只需要接受余下参数的函数。

例如对于函数\( f(x, y) = y^x \),如果其中的\( y \)可以被固定为\( y = 2 \),那么就可以得到一个新函数\( g(x) = f(x, 2) = 2^{x} \)。此时原来的函数\( f(x, y) \)就被柯里化成更加简单的函数\( g(x) \)了。如果一个函数可以接受更多的参数,那么这个函数在柯里化之后,就会形成一系列的函数。

但是对于Immer库中的produce函数来说,柯里化可以允许对producer函数进行柯里化,通过固定一些参数来通过调用produce函数生成新的函数来简化对象状态的修改。

例如在以下的示例中,produce函数就被用一个柯里化的producer包装了起来。

import produce from 'immer';

const toggleTodo = (state, id) =>
  produce(state, draft => {
    // 从一个Draft对象查找获得的对象依旧是一个Draft对象。
    // 对这个新获取到的对象进行修改,依旧可以将变更应用到原始的数据状态上。
    const todo = draft.find(t => todo.id === id);
    todo.completed = !todo.completed;
  });

// 上面这个柯里化后的toggleTodo函数可以像下面一样使用。
const newTodos = toggleTodo(todos, 1);

在React应用中使用Immer

其实React中提供的Hook useState已经提供了不可变数据的基本操作了,使用useState构建的任何State,都必须通过成对返回的setXXX方法修改其中的内容。

结合使用useState和Immer,就可以大大简化组件中状态的深度更新操作。例如以下示例所示。

import produce from 'immer';
import { useCallback, useState } from 'react';

const TodoList = () => {
  const [todos, setTodos] = useState([]);

  const handleToggleAction = useCallback(id => {
    // 这里利用produce创建了todo列表的新实例,而无需手动克隆原有的todo列表。
    setTodos(
      produce(draft => {
        const todo = draft.find(t => t.id === id);
        todo.completed = !todo.completed;
      })
    );
  }, []);

  return <div>{/* 这里输出Todo列表以及配置Callback的调用 */}</div>;
};

当示例中的这种用法可以形成一种模式的时候,那么就存在将这种逻辑提升一下的必要了。所以Immer库通过名为use-immer的包提供了一个专用的Hook:useImmer

要使用useImmer,只需要在应用项目中安装use-immer即可。利用这个Hook重写上面的示例,就会让代码变得更加简单。

import { useCallback } from 'react';
import { useImmer } from 'use-immer';

const TodoList = () => {
  const [todos, setTodos] = useImmer([]);

  const handleToggleAction = useCallback(id => {
    // 这里不再需要调用produce了,useImmer已经在其内部完成了这件事情。
    // 此时的setTodos就不再是之前useState返回的直接接受一个新值的简单函数了,而是一个接受一个操作Draft的函数的函数。
    setTodos(draft => {
      const todo = draft.find(t => t.id === id);
      todo.completed = !todo.completed;
    });
  }, []);

  return <div>{/* 这里输出Todo列表以及配置Callback的调用 */}</div>;
};

与此同理,useReducer也可以与Immer结合使用,例如以下示例。

import produce from 'immer';
import { useCallback, useReducer } from 'react';

const TodoList = () => {
  // 这里将柯里化的produce函数作为reduce函数传给了useReducer。
  const [todos, dispatch] = useReducer(
    produce((draft, action) => {
      switch (action.type) {
        case 'add':
          draft.push({ id: action.id, title: 'new todo', complete: false });
          break;
        case 'toggle':
          const todo = draft.find(t => t.id === action.id);
          todo.complete = !todo.complete;
          break;
        default:
          break;
      }
    }),
    []
  );

  const handleToggleAction = useCallback(id => {
    dispatch({ type: 'toggle', id });
  });

  return <div>{/* 这里输出Todo列表以及配置Callback的调用 */}</div>;
};

同样的,use-immer包中也提供了一个Hook:useImmerReducer来简化这种模式的代码。那么使用这个Hook重写上面的示例,也可以让括号减少一层。

import { useCallback } from 'react';
import { useImmerReducer } from 'use-immer';

const TodoList = () => {
  // 采用useImmerReducer以后,就省去了调用produce的过程。
  const [todos, dispatch] = useImmerReducer((draft, action) => {
    switch (action.type) {
      case 'add':
        draft.push({ id: action.id, title: 'new todo', complete: false });
        break;
      case 'toggle':
        const todo = draft.find(t => t.id === action.id);
        todo.complete = !todo.complete;
        break;
      default:
        break;
    }
  }, []);

  const handleToggleAction = useCallback(id => {
    dispatch({ type: 'toggle', id });
  });

  return <div>{/* 这里输出Todo列表以及配置Callback的调用 */}</div>;
};

producer中返回新数据

在之前的示例中,producer函数中是只需要对draft参数进行操作,不需要从中返回任何内容的,如果需要返回数据,通常也只是返回draft。但是在一些特定的需求下,从producer中返回其他数据也是十分有意义的。

例如在以下示例中,有一些返回操作是可以使用的,而有一些返回操作是不必要的。具体哪些操作在Immer中是可以使用的,可以直接参考一下示例中的说明。

import produce, { nothing } from 'immer';

const todos = produce((draft, action) => {
  switch (action.type) {
    case 'add':
      // 这是Immer中标准的写法,函数无需返回任何内容。
      draft.push(action.payload);
      return;
    case 'edit':
      // 在函数中直接返回draft与不返回任何内容的效果是等价的。
      // 但是这种用法没有任何意义,只是多敲了几个字母。
      const todo = draft.find(t => t.id === action.payload.id);
      todo.title = action.payload.title;
      return draft;
    case 'load':
      // 这里直接返回了一个全新的数据状态,新的数据状态会替代draft中携带的数据状态。
      return action.payload;
    case 'redefine':
      // 这种用法是不可以的,直接重新声明draft的内容不能修改draft中携带的状态,
      // 也不能让produce函数返回新的状态。
      draft = [{ id: 1, title: 'new todo', complete: false }];
      return;
    case 'editNnew':
      // 这种用法是不可以的,修改一个draft的同时返回一个全新的状态是不被允许的。
      draft[2].complete = !draft[2].complete;
      return [{ id: 1, title: 'new todo', complete: false }, ...draft];
    case 'redefine-exp':
      // 这种用法可以返回一个新的状态,但是这种用法完全没有必要,只会增加代码中的复杂度。
      return [{ id: 1, title: 'new todo', complete: false }, ...draft];
    case 'empty':
      // 这是从produce函数中修改draft内容为undefined的标准写法。
      return nothing;
    case 'empty-alt':
      // 从produce函数中直接返回undefined会让Immer认为producer只是修改并返回了draft,
      // 所以直接返回undefined时实际上返回的依旧还是draft。
      return undefined;
  }
});

类型定义

在使用Typescript编写项目的时候,书写类型生命是一个非常好的习惯,但是困扰我们的往往就是不能确定类型要如何书写。

Immer库提供了以下这些比较实用的类型供描述项目中使用到的类型使用。

  • Immutable<T>,将可变类型转换为不可变类型。
  • Draft<T>,将不可变类型转换为可变类型。