在TypeScript中使用fetch的详细教程

2,205 阅读1分钟

在将一些代码迁移到TypeScript时,我遇到了一些小障碍,想和大家分享一下。

用例

EpicReact.dev研讨会上,当我教授如何进行HTTP请求时,我使用GraphQL Pokemon API。这里是我们如何提出请求的:

const formatDate = date =>
  `${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')} ${String(
    date.getSeconds(),
  ).padStart(2, '0')}.${String(date.getMilliseconds()).padStart(3, '0')}`

async function fetchPokemon(name) {
  const pokemonQuery = `
    query PokemonInfo($name: String) {
      pokemon(name: $name) {
        id
        number
        name
        image
        attacks {
          special {
            name
            type
            damage
          }
        }
      }
    }
  `

  const response = await window.fetch('https://graphql-pokemon2.vercel.app/', {
    // learn more about this API here: https://graphql-pokemon2.vercel.app/
    method: 'POST',
    headers: {
      'content-type': 'application/json;charset=UTF-8',
    },
    body: JSON.stringify({
      query: pokemonQuery,
      variables: {name: name.toLowerCase()},
    }),
  })

  const {data, errors} = await response.json()
  if (response.ok) {
    const pokemon = data?.pokemon
    if (pokemon) {
      // add fetchedAt helper (used in the UI to help differentiate requests)
      pokemon.fetchedAt = formatDate(new Date())
      return pokemon
    } else {
      return Promise.reject(new Error(`No pokemon with the name "${name}"`))
    }
  } else {
    // handle the graphql errors
    const error = new Error(errors?.map(e => e.message).join('\n') ?? 'unknown')
    return Promise.reject(error)
  }
}

下面是一个使用/输出的例子:

fetchPokemon('pikachu').then(data => console.log(data))

这个日志:

{
  "id": "UG9rZW1vbjowMjU=",
  "number": "025",
  "name": "Pikachu",
  "image": "https://img.pokemondb.net/artwork/pikachu.jpg",
  "attacks": {
    "special": [
      {
        "name": "Discharge",
        "type": "Electric",
        "damage": 35
      },
      {
        "name": "Thunder",
        "type": "Electric",
        "damage": 100
      },
      {
        "name": "Thunderbolt",
        "type": "Electric",
        "damage": 55
      }
    ]
  },
  "fetchedAt": "16:18 39.159"
}

而对于错误的情况:

fetchPokemon('not-a-pokemon').catch(error => console.error(error))
// Logs: No pokemon with the name "not-a-pokemon"

如果我们犯了一个GraphQL错误(例如,打字imageimag ),那么我们会得到:

{
  "message": "Cannot query field \"imag\" on type \"Pokemon\". Did you mean \"image\"?"
}

输入fetch

好了,现在我们知道fetchPokemon 应该做什么了,让我们开始添加类型。

下面是我如何将代码迁移到TypeScript:

  1. 更新文件名为.ts (如果项目使用React,则为.tsx ),以便在文件中启用TypeScript
  2. 更新所有在我的编辑器中出现红色小方块的代码,直到它们消失。通常,我从导出的函数的输入开始。

在这种情况下,一旦我们在这个文件上启用TypeScript,我们就会得到其中的三个:

Parameter 'such-and-such' implicitly has an 'any' type. ts(7006)

就这样了。每个函数都有一个。所以从一开始,这似乎就会是一个小事,对吗?

所以我们修复了所有的这些:

const formatDate = (date: Date) => {
  // ...
}

async function fetchPokemon(name: string) {
  // ...
  if (response.ok) {
    // ...
  } else {
    // NOTE: Having to explicitly type the argument to `.map` means that
    // the array you're maping over isn't typed properly! We'll fix this later...
    const error = new Error(
      errors?.map((e: {message: string}) => e.message).join('\n') ?? 'unknown',
    )
    // ...
  }
}

现在这些错误都消失了!

使用打出的fetchPokemon

很好,所以让我们使用这个东西:

async function pikachuIChooseYou() {
  const pikachu = await fetchPokemon('pikachu')
  console.log(pikachu.attacks.special.name)
}

我们运行这个,然后......呃哦......。你发现了吗?我们给自己找了一个类型错误 😱special 是一个数组!所以应该是pikachu.attacks.special[0].namefetchPokemon 的返回值是Promise<any> 。看来,我们还没有完全完成。所以,让我们输入预期的PokemonData 返回值:

type PokemonData = {
  id: string
  number: string
  name: string
  image: string
  fetchedAt: string
  attacks: {
    special: Array<{
      name: string
      type: string
      damage: number
    }>
  }
}

很好,有了这个,现在我们可以对我们的返回值进行更明确的说明:

async function fetchPokemon(name: string): Promise<PokemonData> {
  // ...
}

而现在我们会得到一个类型错误,就是我们之前的那个用法,我们可以纠正它。

删除any东西

好了,让我们来看看errors.map 调用的那个不幸的明确类型。正如我前面提到的,这是一个迹象,表明我们的数组没有正确的类型。

快速回顾一下就会发现,dataerrors 都是any:

const {data, errors} = await response.json()

这是因为response.json 的返回类型是Promise<any> 。当我第一次意识到这一点时,我很恼火,但在思考了一秒钟后,我意识到我不知道它还能是什么!TypeScript怎么可能知道我的fetch 调用会返回什么数据?所以,让我们用一个小小的类型注释来帮助TypeScript编译器吧:

type JSONResponse = {
  data?: {
    pokemon: Omit<PokemonData, 'fetchedAt'>
  }
  errors?: Array<{message: string}>
}
const {data, errors}: JSONResponse = await response.json()

现在我们可以删除errors.map 上的显式类型,这很好!

const error = new Error(errors?.map(e => e.message).join('\n') ?? 'unknown')

注意那里使用的是Omit 。因为fetchedAt 属性在我们的PokemonData ,但它不是来自于API,所以说它是来自于TypeScript和未来的代码读者的谎言(我们应该避免这样)。

用TypeScript进行Monkey-patching

有了这些,我们现在会得到两个新的错误:

// add fetchedAt helper (used in the UI to help differentiate requests)
pokemon.fetchedAt = formatDate(new Date())
return pokemon

像这样给一个对象添加新的属性,通常被称为 "猴子补丁"。

第一个是针对pokemon.fetchedAt ,它说。

Property 'fetchedAt' does not exist on type 'Pick<PokemonData, "number" | "id" | "name" | "image" | "attacks">'. ts(2339)

第二个是针对return pokemon ,上面写着。

Property 'fetchedAt' is missing in type 'Pick<PokemonData, "number" | "id" | "name" | "image" | "attacks">' but required in type 'PokemonData'. ts(2741)

好吧,大声哭出来吧,TypeScript,第一个是抱怨fetchedAt 不应该存在,而第二个是说它应该存在!请你决定吧!😩

我们总是可以告诉TypeScript去管,并使用一个类型断言将pokemon 投入到一个完整的PokemonData 。但我发现了一个更简单的解决方案。

// add fetchedAt helper (used in the UI to help differentiate requests)
return Object.assign(pokemon, {fetchedAt: formatDate(new Date())})

这使得这两个错误都消失了。Object.assign ,将对象属性组合到目标对象(第一个参数)上,并返回该目标对象。这让编译器很高兴,因为它可以检测到pokemon ,进去时没有fetchedAt ,出来时有fetchedAt

如果你感到好奇,这里是Object.assign 的类型定义。

assign<T, U>(target: T, source: U): T & U;

就是这样!我们现在已经为一个特定的请求成功地输入了fetch 。🎉

为承诺的拒绝值打字

在这里有一个最后的教训。不幸的是,Promise 类型泛型只接受resolved 的值,而不接受rejected 的值。所以我做不到。

async function fetchPokemon(name: string): Promise<PokemonData, Error> {}

原来,这与我的另一个挫折有关。

try {
  throw new Error('oh no')
} catch (error: Error) {
  //            ^^^^^ Catch clause variable type annotation
  //                  must be 'any' or 'unknown' if specified.
  //                  ts(1196)
}

原因是错误可能因为完全意想不到的原因而发生。TypeScript认为你不可能知道是什么触发了错误,所以你不能知道错误会是什么类型。

这有点让人扫兴,但也可以理解。

总结

好了,这里是最终版本。

const formatDate = (date: Date) =>
  `${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')} ${String(
    date.getSeconds(),
  ).padStart(2, '0')}.${String(date.getMilliseconds()).padStart(3, '0')}`

type PokemonData = {
  id: string
  number: string
  name: string
  image: string
  fetchedAt: string
  attacks: {
    special: Array<{
      name: string
      type: string
      damage: number
    }>
  }
}

async function fetchPokemon(name: string): Promise<PokemonData> {
  const pokemonQuery = `
    query PokemonInfo($name: String) {
      pokemon(name: $name) {
        id
        number
        name
        image
        attacks {
          special {
            name
            type
            damage
          }
        }
      }
    }
  `

  const response = await window.fetch('https://graphql-pokemon2.vercel.app/', {
    // learn more about this API here: https://graphql-pokemon2.vercel.app/
    method: 'POST',
    headers: {
      'content-type': 'application/json;charset=UTF-8',
    },
    body: JSON.stringify({
      query: pokemonQuery,
      variables: {name: name.toLowerCase()},
    }),
  })

  type JSONResponse = {
    data?: {
      pokemon: Omit<PokemonData, 'fetchedAt'>
    }
    errors?: Array<{message: string}>
  }
  const {data, errors}: JSONResponse = await response.json()
  if (response.ok) {
    const pokemon = data?.pokemon
    if (pokemon) {
      // add fetchedAt helper (used in the UI to help differentiate requests)
      return Object.assign(pokemon, {fetchedAt: formatDate(new Date())})
    } else {
      return Promise.reject(new Error(`No pokemon with the name "${name}"`))
    }
  } else {
    // handle the graphql errors
    const error = new Error(errors?.map(e => e.message).join('\n') ?? 'unknown')
    return Promise.reject(error)
  }
}

我希望这对你来说是有趣和有用的祝你好运。