现代化 React 开发:Hooks、Redux Starter Kit 和 SWR

前言


如果 2019 年还需要写 React 的文章的话,我希望是些新东西。既然都到 2019 年底了,那最好是使用这些新东西的实际体验和经验了。

互联网上基本不需要有人再给 React 增添一份新的基础文章了,因为现在 React 的官网有完整的中文文档,质量也挺高。仅通过个人博客学来的零散甚至可能是错误的经验是没有好处的。本文是对 React 的新功能、生态的相关介绍以及实际开发体验,并非 React 任何方面的入门教程。

传统开发


记得在几年前的时候,我比较广泛的使用过一段时间的 React。那会儿用复杂的 class 组件构建页面,Redux 管理状态,通过 Redux 中间件处理网络请求。自认为如鱼得水,实际上开发效率并不高。并且任何项目过不了多久,前端就会相当的庞杂。

实际上我的博客的后台前端基本还处于这种传统 React 开发模式。从 GitHub 的代码统计就可以端倪:Javascript 占比是 47%,而 Elixir 占比是 46%。恐怕很多没听过 Elixir 的前端会误以为本博客是 NodeJs 开发的。要知道本博客具有完整的业务功能和诸多看不见的附加设计,还包括高覆盖率的测试。然而整个后端的代码量仍然比不过区区前端的 JS 代码,况且这个 JS 量主要集中在后台管理页面中,因为前台页面是 LiveView 实现的,不需要多少 JS 的参与。

我认为造成这个后端代码量比不过前端的奇怪效应,主要原因有二:

  1. Elixir 以及 Elixir 生态带来了极高的开发效率
  2. React 的传统开发模式冗余又复杂

第一点无需多提,具备一定的 Elixir 经验并合理利用社区生态即可做到,Elixir 高效且优雅,不可多得。第二点就是大坑了,React 带来了强大的 SPA 应用和先进的前端开发模式,但其效率真的不可恭维。每添加一个 reducer 和 action 都觉得繁琐又麻烦,所有不相关的逻辑会大量集中在同一个组件生明周期中,无法拆分。

下面是我博客当前的后台 API:

   category_path  GET     /api/admin/categories                  BlogWeb.API.Admin.CategoryController :index
   category_path  GET     /api/admin/categories/:id              BlogWeb.API.Admin.CategoryController :show
   category_path  POST    /api/admin/categories                  BlogWeb.API.Admin.CategoryController :create
   category_path  PATCH   /api/admin/categories/:id              BlogWeb.API.Admin.CategoryController :update
                  PUT     /api/admin/categories/:id              BlogWeb.API.Admin.CategoryController :update
   category_path  DELETE  /api/admin/categories/:id              BlogWeb.API.Admin.CategoryController :delete
        tag_path  GET     /api/admin/tags                        BlogWeb.API.Admin.TagController :index
        tag_path  GET     /api/admin/tags/:id                    BlogWeb.API.Admin.TagController :show
        tag_path  POST    /api/admin/tags                        BlogWeb.API.Admin.TagController :create
        tag_path  PATCH   /api/admin/tags/:id                    BlogWeb.API.Admin.TagController :update
                  PUT     /api/admin/tags/:id                    BlogWeb.API.Admin.TagController :update
        tag_path  DELETE  /api/admin/tags/:id                    BlogWeb.API.Admin.TagController :delete
redirection_path  POST    /api/admin/redirections                BlogWeb.API.Admin.RedirectionController :create
redirection_path  PATCH   /api/admin/redirections/:id            BlogWeb.API.Admin.RedirectionController :update
                  PUT     /api/admin/redirections/:id            BlogWeb.API.Admin.RedirectionController :update
redirection_path  DELETE  /api/admin/redirections/:id            BlogWeb.API.Admin.RedirectionController :delete
    article_path  PUT     /api/admin/articles/:id/draft          BlogWeb.API.Admin.ArticleController :draft
    article_path  PUT     /api/admin/articles/:id/recycle        BlogWeb.API.Admin.ArticleController :recycle
    article_path  PUT     /api/admin/articles/:id/restore        BlogWeb.API.Admin.ArticleController :restore
    article_path  PUT     /api/admin/articles/:id/pin            BlogWeb.API.Admin.ArticleController :pin
    article_path  PUT     /api/admin/articles/:id/unpin          BlogWeb.API.Admin.ArticleController :unpin
    article_path  GET     /api/admin/articles/drafted            BlogWeb.API.Admin.ArticleController :drafted_list
    article_path  GET     /api/admin/articles/recycled           BlogWeb.API.Admin.ArticleController :recycled_list
    article_path  GET     /api/admin/articles/non_normal         BlogWeb.API.Admin.ArticleController :non_normal_list
    article_path  GET     /api/admin/articles/normal             BlogWeb.API.Admin.ArticleController :normal_list
    article_path  GET     /api/admin/articles/redirected         BlogWeb.API.Admin.ArticleController :redirected_list
    article_path  POST    /api/admin/articles/preview            BlogWeb.API.Admin.ArticleController :preview
    article_path  GET     /api/admin/articles                    BlogWeb.API.Admin.ArticleController :index
    article_path  GET     /api/admin/articles/:id                BlogWeb.API.Admin.ArticleController :show
    article_path  POST    /api/admin/articles                    BlogWeb.API.Admin.ArticleController :create
    article_path  PATCH   /api/admin/articles/:id                BlogWeb.API.Admin.ArticleController :update
                  PUT     /api/admin/articles/:id                BlogWeb.API.Admin.ArticleController :update
    article_path  DELETE  /api/admin/articles/:id                BlogWeb.API.Admin.ArticleController :delete
    setting_path  GET     /api/admin/settings/preview            BlogWeb.API.Admin.SettingController :preview
    setting_path  POST    /api/admin/settings/counter_sync       BlogWeb.API.Admin.SettingController :counter_sync

要知道给每一个 API 都包装一层调用,然后再基于一个或多个 API 调用函数封装出一个 action 函数,最后才能 dispatch 发起。还没完,每一个 API 都需要有更多的后续:它们用相同的流程处理请求和错误、定义多个动作类型(发起、接收和失败)和对应的多套状态更新逻辑、在渲染前还要判断一次 isLoaded(或 isLoading)。最后才能生成 UI,这个期间我们做了太多的重复劳动,但它们又不可不做。

我们的精力浪费在这些逻辑简单到以至于熟练的复制粘贴和代码替换的冗余代码,我们的体验和对新 API 的热情就破坏在这些无意义的废代码上。这是 React 或前端开发令人不悦的主要原因,即使这样做确实能创造出强大的前端应用。

Hooks


当前改善 Class 组件复杂度的最佳方式,不是使用第三方生态的工具或基于 Class 进行抽象和包装基础组件,而是干脆放弃 Class。

在传统 Class 组件中,我们常常会看到这种代码:

componentDidUpdate(prevProps, prevState, _snapshot) {
  if (prevState.field1 !== this.state.field1 ){
    // 执行对应逻辑
  }

  if (prevState.field2 !== this.state.field2 ){
    // 执行对应逻辑
  }
}

大量根据 state 或 props 变化而触发的不相关逻辑都塞入一个 componentDidUpdate 生命周期方法中。当一个组件特别复杂的时候,会显得混乱不堪,必须用注释强制隔开。

生命周期方法不仅仅是将不相关的逻辑混在一起了,还可能将明明相关的逻辑强制拆分。例如下面这样的情景:

componentDidUpdate(_prevProps, _prevState, _snapshot) {
  // 激活 Tab 状态
  // 给对应 Tab 添加 .active 类样式
  this.tab.current.classList.add("active");
}

componentWillUnmount() {
  // 取消激活 Tab 状态
  // 给对应 Tab 删除 .active 类样式
  this.tab.current.classList.remove("active");
}

可以看到明明是跟当前组件对应的 Tab 的激活/取消激活相关的逻辑,却被强制拆分到两个生命周期方法中。然后 componentWillUnmount 又可能混入其它完全不相关的逻辑,造成双重混乱。

后来的 React 弃用/移除了一部分生命周期方法,并且 Hook 基本没有提生命周期。这是因为 React 在简化生命周期的复杂度并模糊其概念,要知道 React 完整的生命周期数量很多,名字又长,比 Android 的 Activety 还复杂。

对于 Hook 而言,无需声明任何生命周期,更加不用在意 this 的指向问题。因为 Hook 是一个函数,并且通常是纯函数,通过 Hook API 产生副作用。

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

export default () => {
  const [field1, setField1] = useState(0);
  const [field2, setField2] = useState(0);

  useEffect(() => {
    // field1 状态变化,执行逻辑1
  }, [field1]);

  useEffect(() => {
    // field2 状态变化,执行逻辑2
  }, [field2]);

  return <div>...</div>;
};

在上面的代码中:useState 函数创建 state 中的字段,useEffect 函数替代生命周期,我们重点提一下 useEffect 函数。

所谓 Hook 的概念,其实很好理解。例如 GitHub 可以通过 WebHooks 远程调用 CI 服务器的接口,通知 CI 服务器开始工作。这里的 Hook 其实就是定义了一种 commit “事件”,该事件的执行逻辑是对远程接口的调用。当然 GitHub 的“事件类型”非常多,例如创建/关闭 issue、打开 PR、fork 项目等等。Windows 也提供了 Hook 相关的 API,它通过截获进程的消息,触发自己的“事件”。

我们可以看到 useEffect 函数传递了另一个函数作为参数。很明显,React 的 Hook 方式就是回调代码,毕竟我们都给它一个函数了。无论是调用远程接口、监听进程消息或者回调函数,它们的目的都是类似的:在不改变已有的执行流程的前提下嵌入自己的“钩子”,通常“钩子”可以嵌在所支持的任意一个“点”的前或后,作为“事件”来触发。

useEffect 默认是组件挂载和更新两种生命周期(执行流程)的结合,当存在第二个参数的时候,第二个参数会作为组件更新时的判断逻辑。

useEffect(() => {
  // field1 状态变化,执行逻辑1
}, [field1]);

这个 useEffect 调用注册了一个在组件挂载时执行的回调,以及在 field1 这个 state 字段发生变化时的回调,和 Class 组件示例代码中的 componentDidUpdate 生命周期方法中的判断是等同的。不仅如此, useEffect 还结合了 Class 组件中的 componentWillUnmount 生命周期,也就是第一个函数参数的返回值。

useEffect(() => {
  // 转载组件时当前激活 Tab
  tab.current.classList.add("active");

  // 卸载组件时取消当前 Tab 激活
  return () => { tab.current.classList.remove("active") }
}, []);

代码如上,我们让 useEffect 的回调代码返回一个新的函数,该函数会在组件被卸载时执行。从这里我们可以注意到,相关的逻辑不再被强制拆分,Class 组件示例中提到的第二个问题就不存在。

因为 useEffect 可以声明多个,就如同第一个 Hook 示例代码中的那样,我们为不同的状态变化执行不同的逻辑。所以自然能而然的解决了 Class 组件示例中的第一个问题,那就是对不相关逻辑的拆分。

有关 Hooks 还有更多的 API,Class 组件能实现的通过 Hook 都能更加方便的实现。React 官方给出了建议:你没有必要重写曾经的 Class 组件,新的组件应该使用 Hook。虽然 Class 组件并不会被删除,但你没有理由再继续使用它。

Redux Starter Kit


Redux 想必是很多人诟病 React 生态的因素之一,毕竟它真的太繁琐了。就好像 Java 开发,啥都没干,先定义一堆的 interface,条条框框太多。

Redux 还存在完全没有任何意义的冗余,例如 action 里边的常量的值。实现一个 reducer 要整出一堆的样板代码,令人难受。

下面是一个常见 reducer 定义和其关联的 action 函数:

const INCREMENT = 'INCREMENT'
const DECREMENT = 'DECREMENT'

function increment() {
  return { type: INCREMENT }
}

function decrement() {
  return { type: DECREMENT }
}

function counter(state = 0, action) {
  switch (action.type) {
    case INCREMENT:
      return state + 1
    case DECREMENT:
      return state - 1
    default:
      return state
  }
}

如果我告诉你,其中的常量声明是多余的,返回 action 是多余的,甚至连 switch 匹配都是多余的,你还信这是 Redux 吗?这就是 Redux Starter Kit 做的事情。

Redux Starter Kit 大幅度改善了 Redux 的样板代码现象,能高效、精简的定义 reducer,并且它出自官方之手。另外不要忘记它仍然是 Redux,即使经过一层封装,原有的思想也全部存在。

使用 Redux Starter Kit 改进传统的 Redux 代码,会是这个样子:

const counterSlice = createSlice({
  name: 'counter',
  initialState: 0,
  reducers: {
    increment: state => state + 1,
    decrement: state => state - 1
  }
})

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

export default counterSlice.reducer;

上面已经定义完成了 actions 和相关 reducer,并将它们导出。现在已经可以直接 dispatch 了:

// 导入
import { increment } from "./counter-slice.js";

// 调度一个 action
dispatch(increment())

以上代码仅作举例用途,并不完整。你当然还需要在一个统一管理 reducer 的地方将 reducer 导入,然后通过 combineReducers 函数合并,并传递给 store。这些操作通过 Redux Starter Kit 全部可以更加方便的完成。

这一节的重点在于,将样板代码剥离干净的 Redux 是完全可能的。有人可能会问了,createSlice 函数直接定义好了 action 函数,那么 action 的类型在哪里呢?

实际上,你已经不需要 action type 了,因为我们已经无需 switch 匹配。但若你仍然想得到 type 的话,访问 action 对象的 type 属性即可,例如 increment.type。实际上我也利用了 action type 字符串,在请求出错的时候告知来自哪一个 action。

看过这一节,恐怕大多数人对 Redux 的恐惧会大幅度降低了吧,毕竟我就是活生生的一个例子。另外 Redux Starter Kit 对 Hook 也提供了支持。我个人认为,现在已经不需要写传统的 Redux 代码了。

SWR


传统的 Redux 生态如何处理异步请求?答:中间件。例如 Redux Thunk。

其实我并不认为 Redux Thunk 是一个体验糟糕的东西,但它和 React 并不足够契合,毕竟它是 Redux 生态的一环,本身和 React 就无关。

通过 Thunk 我们可以直接在 action 函数中发起请求,然后返回一个 Promises 即可。对于 Redux 而言,它是近乎完美的异步控制流,但是对于 React(或者说 UI 层面)它的耦合度太低,不够直接。

组件需要数据,请求数据、获取到数据或者获取数据失败,这些是组件自己的事情。它们并不需要状态共享,也就是没必要过一层 Redux 的数据流。然而一般来讲,Redux 的 action 却大多都是用作获取数据的函数。

对于一个请求而言,我们不仅仅要定义一个请求(request) 的 action,还要定义接受(receive)和失败(failed)这额外的两个 action。这就是 Redux 来获取数据的麻烦所在,虽然我们剔除了 Redux 的样板代码,但这却跟样板代码无关。

我们无论如何都无法改进它们,归根结底是 Redux 不够适合做这种事情(发起请求,获取数据),既然你要做这种事情,就必须做这些。

是的,Redux 不背这个锅,这是架构设计和本质功能决定的。既然 Redux 是管理状态的,那么我们就用专门处理请求的家伙替代它。和 Redux 无关的话,那些麻烦自然而然就不存在了。

它就是本章要介绍的主角,SWR。SWR 是一个 Hooks 库,它专门用于远程数据获取,也就是常见的 Ajax 或者 Fetch 请求。仅仅通过 SWR 这一环,就可以发起请求、接收数据并返回错误。它们完全在组件内部完成,不需要将状态以数据流的形式传递出去。

import React, { useEffect } from "react";
import useSWR from "swr";
import fetch from "unfetch"

const fetcher = url => fetch(url).then(r => r.json());

export default () => {
  const dispatch = useDispatch();

  const { data, error } = useSWR("/api/users", fetcher);

  if (error) return <div>载入数据失败,请刷新。</div>;
  if (!data) return <div>载入数据中…… 请稍等。</div>;

  return (<div>{/* 绑定 data 呈现 UI */}</div>)
}

在上面的代码中,我们定义了一个 Hook 组件,通过 SWR 调用了一个远程 API。重点在于我们直接根据返回值判断当前的请求状态并呈现出对应的 UI,不需要订阅 Redux 的状态流并跟踪组件更新。

这一切都在组件内部完成了,这就是 SWR 的优势,简单又直接。但需要提前说明的是,SWR 是一个纯 Hooks 库,不支持 Class 组件。但既然要全面迎接 Hook 了,这根本就不是问题。

结束语


我之所以写本文,并不是因为看了一些看似能改善体验的新生态玩具就拿出来水文。也许实则并没有真实体验过,或者踩过坑?

不,并不是。

首先我说的这三个东西都不是玩具,然后我的新项目已经全面使用它们了,并且非常的纯粹(不用传统方式写组件或处理请求)。它们给我带来了巨大的体验和效率的双重提升,不得不推荐。