发布于 2026-01-06 2 阅读
0

使用 TypeScript、io-ts 和 fp-ts 在 React 中以函数式方式获取数据 DEV 的全球展示挑战赛,由 Mux 呈现:展示你的项目!

使用 TypeScript、io-ts 和 fp-ts 在 React 中以函数式方式获取数据

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

过去几天,我一直在开发一个 React 应用。这是一个非常简单的应用,甚至不需要数据库。但是,我不想把所有内容都嵌入到应用的 JSX 中,因为其中一些内容会频繁更新。所以我决定使用几个简单的 JSON 文件来存储这些内容。

这个应用程序是某个会议的网站,我想要创建一个如下所示的页面:

为了生成类似上图所示的页面,我将数据存储在以下 JSON 文件中:

[
    { "startTime": "08:00", "title": "Registration & Breakfast", "minuteCount": 60 },
    { "startTime": "09:00", "title": "Keynote", "minuteCount": 25 },
    { "startTime": "09:30", "title": "Talk 1 (TBA)", "minuteCount": 25 },
    { "startTime": "10:00", "title": "Talk 2 (TBA)", "minuteCount": 25 },
    { "startTime": "10:30", "title": "Talk 3 (TBA)", "minuteCount": 25 },
    { "startTime": "10:55", "title": "Coffee Break", "minuteCount": 15 },
    { "startTime": "11:10", "title": "Talk 4 (TBA)", "minuteCount": 25 },
    { "startTime": "11:40", "title": "Talk 5 (TBA)", "minuteCount": 25 },
    { "startTime": "12:10", "title": "Talk 6 (TBA)", "minuteCount": 25 },
    { "startTime": "12:35", "title": "Lunch, Networking & Group Pic", "minuteCount": 80 },
    { "startTime": "14:00", "title": "Talk 7 (TBA)", "minuteCount": 25 },
    { "startTime": "14:30", "title": "Talk 8 (TBA)", "minuteCount": 25 },
    { "startTime": "15:00", "title": "Talk 9 (TBA)", "minuteCount": 25 },
    { "startTime": "15:25", "title": "Coffee Break", "minuteCount": 15 },
    { "startTime": "15:40", "title": "Talk 10 (TBA)", "minuteCount": 25 },
    { "startTime": "16:10", "title": "Talk 11 (TBA)", "minuteCount": 25 },
    { "startTime": "16:40", "title": "Talk 12 (TBA)", "minuteCount": 25 },
    { "startTime": "17:10", "title": "Closing Remarks", "minuteCount": 25 }
]
Enter fullscreen mode Exit fullscreen mode

问题

虽然使用 JSON 文件让我的工作更轻松,但在 React 中获取数据却是一项非常重复且繁琐的任务。更糟糕的是,HTTP 响应中包含的数据可能与我们预期的数据完全不同。

fetch 调用的类型不安全特性对 TypeScript 用户来说尤其危险,因为它会削弱 TypeScript 的许多优势。所以我决定做一些实验,尝试找到一个合适的自动化解决方案。

过去几个月,我一直在学习函数式编程和范畴论方面的知识,因为我一直在写一本名为《使用 TypeScript 进行函数式编程实战》的书。

这篇博文我不会深入探讨范畴论,但需要先解释一些基本概念。范畴论定义了一些类型,这些类型在处理副作用时特别有用。

范畴论类型允许我们使用类型系统来表达潜在的问题,其优势在于它们强制代码在编译时正确处理副作用。例如,类型Either可以用来表示一个类型可以是Left另一个类型RightEither当我们想要表达某些事情可能会出错时,类型就非常有用。例如,一个fetch调用可以返回错误(左图)或一些数据(右图)。

A) 确保错误得到处理

我希望确保我的fetch呼叫返回是一个Either实例,以确保我们不会在没有首先保证响应不是错误的情况下尝试访问数据。

我很幸运,因为我不需要自己实现这个类型。相反,我可以简单地使用fp-ts开源模块Either中包含的实现。fp -ts 对该类型的定义如下:Either

declare type Either<L, A> = Left<L, A> | Right<L, A>;
Enter fullscreen mode Exit fullscreen mode

B)确保数据经过验证

我想要解决的第二个问题是,即使请求返回了一些数据,其格式也可能与应用程序预期的不符。我需要一些运行时验证机制来验证响应的模式。幸运的是,我无需从头开始实现运行时验证机制,而是可以使用另一个开源库:io-ts

解决方案

简而言之,本节将详细介绍解决方案的实现细节。如果您只对最终的消费者 API 感兴趣,可以跳过此部分直接阅读“结果”部分。

io-ts 模块允许我们声明一个模式,该模式可用于在运行时执行验证。我们还可以使用 io-ts 从给定的模式生成类型。以下代码片段展示了这两个功能:

import * as io from "io-ts";

export const ActivityValidator = io.type({
    startTime: io.string,
    title: io.string,
    minuteCount: io.number
});

export const ActivityArrayValidator = io.array(ActivityValidator);

export type IActivity = io.TypeOf<typeof ActivityValidator>;
export type IActivityArray = io.TypeOf<typeof ActivityArrayValidator>;
Enter fullscreen mode Exit fullscreen mode

我们可以使用此decode方法来验证某些数据是否符合模式。返回的验证结果decode是一个Either实例,这意味着我们将得到验证错误(左图)或有效数据(右图)。

我的第一步是封装fetchAPI,使其同时使用 fp-ts 和 io-ts,以确保响应要么Either代表错误(左),要么代表有效数据(右)。这样一来,返回的 Promisefetch就不会被拒绝。相反,它始终会被解析为一个Either实例:

import { Either, Left, Right } from "fp-ts/lib/Either";
import { Type, Errors} from "io-ts";
import { reporter } from "io-ts-reporters";

export async function fetchJson<T, O, I>(
    url: string,
    validator: Type<T, O, I>,
    init?: RequestInit
): Promise<Either<Error, T>> {
    try {
        const response = await fetch(url, init);
        const json: I = await response.json();
        const result = validator.decode(json);
        return result.fold<Either<Error, T>>(
            (errors: Errors) => {
                const messages = reporter(result);
                return new Left<Error, T>(new Error(messages.join("\n")));
            },
            (value: T) => {
                return new Right<Error, T>(value);
            }
        );
    } catch (err) {
        return Promise.resolve(new Left<Error, T>(err));
    }
}
Enter fullscreen mode Exit fullscreen mode

然后我创建了一个名为 `<Component>` 的 React 组件Remote,它接受一个Either实例作为其属性之一,并包含一些渲染函数。数据可以是 `<Object>`null | Error或 `<Object>` 类型的某个值T

loading当数据为空时调用该函数nullerror当数据为整数时调用该函数Errorsuccess当数据为类型为以下类型的值时调用该函数T

import React from "react";
import { Either } from "fp-ts/lib/either";

interface RemoteProps<T> {
  data: Either<Error | null, T>;
  loading: () => JSX.Element,
  error: (error: Error) => JSX.Element,
  success: (data: T) => JSX.Element
}

interface RemoteState {}

export class Remote<T> extends React.Component<RemoteProps<T>, RemoteState> {

  public render() {
    return (
      <React.Fragment>
      {
        this.props.data.bimap(
          l => {
            if (l === null) {
              return this.props.loading();
            } else {
              return this.props.error(l);
            }
          },
          r => {
            return this.props.success(r);
          }
        ).value
      }
      </React.Fragment>
    );
  }

}

export default Remote;
Enter fullscreen mode Exit fullscreen mode

上述组件用于渲染实例Either,但它不执行任何数据获取操作。因此,我实现了第二个组件Fetchable,它接受一个 `<type>`url和一个 `<type>` validator,以及RequestInit一些可选配置和渲染函数。该组件使用fetch包装器和 `<type>`validator来获取一些数据并进行验证。然后,它将结果Either实例传递给 ` Remote<type>` 组件:

import { Type } from "io-ts";
import React from "react";
import { Either, Left } from "fp-ts/lib/Either";
import { fetchJson } from "./client";
import { Remote } from "./remote";

interface FetchableProps<T, O, I> {
    url: string;
    init?: RequestInit,
    validator: Type<T, O, I>
    loading: () => JSX.Element,
    error: (error: Error) => JSX.Element,
    success: (data: T) => JSX.Element
}

interface FetchableState<T> {
    data: Either<Error | null, T>;
}

export class Fetchable<T, O, I> extends React.Component<FetchableProps<T, O, I>, FetchableState<T>> {

    public constructor(props: FetchableProps<T, O, I>) {
        super(props);
        this.state = {
            data: new Left<null, T>(null)
        }
    }

    public componentDidMount() {
        (async () => {
            const result = await fetchJson(
                this.props.url,
                this.props.validator,
                this.props.init
            );
            this.setState({
                data: result
            });
        })();
    }

    public render() {
        return (
            <Remote<T>
                loading={this.props.loading}
                error={this.props.error}
                data={this.state.data}
                success={this.props.success}
            />
        );
    }

}

Enter fullscreen mode Exit fullscreen mode

结果

我已经将上述所有源代码打包成一个名为react-fetchable 的模块发布。您可以使用以下命令安装该模块:

npm install io-ts fp-ts react-fetchable
Enter fullscreen mode Exit fullscreen mode

然后您可以Fetchable按如下方式导入该组件:

import { Fetchable } from "react-fetchable";
Enter fullscreen mode Exit fullscreen mode

现在我可以实现我一开始描述的页面了:

import React from "react";
import Container from "../../components/container/container";
import Section from "../../components/section/section";
import Table from "../../components/table/table";
import { IActivityArray, ActivityArrayValidator } from "../../lib/domain/types";
import { Fetchable } from "react-fetchable";

interface ScheduleProps {}

interface ScheduleState {}

class Schedule extends React.Component<ScheduleProps, ScheduleState> {
  public render() {
    return (
      <Container>
        <Section title="Schedule">
          <p>
            Lorem ipsum dolor sit amet, consectetur adipiscing elit,
            sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
          </p>
          <Fetchable
            url="/data/schedule.json"
            validator={ActivityArrayValidator}
            loading={() => <div>Loading...</div>}
            error={(e: Error) => <div>Error: {e.message}</div>}
            success={(data: IActivityArray) => {
              return (
                <Table
                  headers={["Time", "Activity"]}
                  rows={data.map(a => [`${a.startTime}`, a.title])}
                />
              );
            }}
          />
        </Section>
      </Container>
    );
  }
}

export default Schedule;
Enter fullscreen mode Exit fullscreen mode

我可以将 URL和验证器一起传递/data/schedule.json给组件。然后,组件将:FetchableActivityArrayValidator

  1. 使成为Loading...
  2. 获取数据
  3. 如果数据有效,则渲染表格。
  4. 如果数据无法加载,则渲染错误,因为数据不符合验证器的要求。

我对这个解决方案很满意,因为它类型安全、声明式,而且只需几秒钟就能启动运行。希望您觉得这篇文章有趣,并尝试一下react-fetchable

另外,如果您对函数式编程或 TypeScript 感兴趣,请查看我即将出版的《使用 TypeScript 进行函数式编程实战》一书。

文章来源:https://dev.to/remojansen/data-fetching-in-react-the-function-way-powered-by-typescript-io-ts--fp-ts-ojf