深圳幻海软件技术有限公司 欢迎您!

在现代 JavaScript 中如何安全获取网络数据

2023-02-28

Fetch-错误方法在JavaScript中fetch非常棒。但是,您的代码中可能会散布着这样的内容:复制constres=awaitfetch('/user')constuser=awaitres.json()1.2.这段代码虽然简单易用,但存在许多问题。你可以说“哦,是的,错误处理”,然后像这样

Fetch - 错误方法

在 JavaScript 中fetch非常棒。

但是,您的代码中可能会散布着这样的内容:

const res = await fetch('/user')
const user = await res.json()
  • 1.
  • 2.

这段代码虽然简单易用,但存在许多问题。

你可以说“哦,是的,错误处理”,然后像这样重写它:

try {
  const res = await fetch('/user')
  const user = await res.json()
} catch (err) {
  // 错误处理
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

当然,这是一个改进,但仍然存在问题。

在这里,我们假设 user 实际上是一个用户对象……但这假设我们得到了 200 响应。

但 fetch 不会针对非 200 状态抛出错误,因此您实际上可能收到 400(错误请求)、401(未授权)、404(未找到)、500(内部服务器错误)或各种其他问题 .

一种更安全但更丑陋的方式

因此,我们可以进行另一个更新:

try {
  const res = await fetch('/user')

  if (!res.ok) {
    switch (res.status) {
      case 400: /* Handle */ break
      case 401: /* Handle */ break
      case 404: /* Handle */ break
      case 500: /* Handle */ break
    }
  }

  // User是这次的用户
  const user = await res.json()
} catch (err) {
  // 错误处理
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.

现在,我们终于很好地使用了 fetch。 但这可能有点笨拙,因为每次都必须记住,而且你必须希望你团队中的每个人每次都能处理这些情况。

它在控制流方面也不是最优雅的。 在可读性方面,我个人更喜欢本文开头的有问题的代码。 它读起来很干净——获取用户,解析为 json,用用户对象做事。

但在这种格式中,我们获取用户、处理一堆错误情况、解析 json、处理其他错误情况等。这有点不和谐,尤其是此时我们在业务逻辑之上和之下都有错误处理,而不是 集中在一个地方。

一种不那么丑陋的方式

如果请求有问题,一个更优雅的解决方案可能是抛出异常,而不是在多个地方处理错误:

try {
  const res = await fetch('/user')

  if (!res.ok) {
    throw new Error('Bad fetch response')
  }
  const user = await res.json()
} catch (err) {
  // 错误处理
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

但是我们还有最后一个问题——当需要处理错误时,我们丢失了很多有用的上下文。 我们实际上无法访问 catch 块中的 res,因此在处理错误时我们实际上并不知道响应的状态代码或主体是什么。

这将使我们很难知道要采取的最佳措施,并给我们留下非常无用的日志。

此处改进的解决方案可能是创建您自己的自定义错误类,您可以在其中转发响应详细信息:

class ResponseError extends Error {
  constructor(message, res) {
    super(message)
    this.response = res
  }
}
try {
  const res = await fetch('/user')

  if (!res.ok) {
    throw new ResponseError('Bad fetch response', res)
  }
  const user = await res.json()
} catch (err) {
  // 处理错误,可以完全访问状态和正文
  switch (err.response.status) {
    case 400: /* Handle */ break
    case 401: /* Handle */ break
    case 404: /* Handle */ break
    case 500: /* Handle */ break
  }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.

现在,当我们保留状态代码时,我们可以更智能地处理错误。

例如,我们可以在 500 上提醒用户我们遇到了问题,并可能重试或联系我们的支持。

或者如果状态为 401,他们当前未授权,可能需要重新登录等。

创建包装器

我有一个关于我们最新最好的解决方案,最后一个问题——它仍需要开发人员每次都编写一些像样的样板文件。 在整个项目范围内进行更改,或强制使用此结构,仍然是一个挑战。

这就是我们可以根据需要包装 fetch 来处理事情的地方:

class ResponseError extends Error {
  constructor(message, res) {
    this.response = res
  }
}
export async function myFetch(...options) {
  const res = await fetch(...options)
  if (!res.ok) {
    throw new ResponseError('Bad fetch response', res)
  }
  return res
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.

然后我们可以按如下方式使用它:

try {
  const res = await myFetch('/user')
  const user = await res.json()
} catch (err) {
  // Handle issues via error.response.*
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

在我们的最后一个例子中,最好确保我们有一个统一的方式来处理错误。 这可能包括给用户的警报、日志记录等。

开源解决方案

探索很有趣,但重要的是要记住,您不必总是为事物创建自己的包装器。 以下是一些流行且可能值得使用的现有选项,包括一些小于 1kb 的选项:

Axios

axios 是一个非常流行的 JS 取数据选项,它自动为我们处理了上面的几个场景。

try {
  const { data } = await axios.get('/user')
} catch (err) {
  // 根据error.response.*进行错误处理
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

我对 Axios 的唯一批评是它对于一个简单的数据获取包装器来说大得惊人。 因此,如果 大小是您的首要任务(我认为这通常应该是为了保持您的性能一流),您可能需要查看以下两个选项之一:

Redaxios

如果你喜欢 Axios,但不喜欢它会给你的包增加 11kb大小,Redaxios 是一个很好的选择,它使用与 Axios 相同的 API,但不到 1kb。

import axios from 'redaxios'
// 像往常一样使用
  • 1.
  • 2.

Wretch

一个较新的选项是 Wretch,它是 Fetch 的一个非常薄的包装器,就像 Redaxios 一样。 Wretch 的独特之处在于它在很大程度上仍然感觉像fetch,但为您提供了处理常见状态的有用方法,这些状态可以很好地链接在一起:

const user = await wretch("/user")
  .get()
  // 以更易于阅读的方式处理错误情况
  .notFound(error { /* ... */ })
  .unauthorized(error { /* ... */ })
  .error(418, error { /* ... */ })
  .res(response /* ... */)
  .catch(error { /* 其他错误*/ })
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.

也不要忘记安全地写入数据

最后但同样重要的是,我们不要忘记直接使用 fetch 在通过 POST、PUT 或 PATCH 发送数据时可能会遇到常见的陷阱

你能发现这段代码中的错误吗?

// 这里至少有一个错误,你能发现吗?
const res = await fetch('/user', {
  method: 'POST',
  body: { name: 'Steve Sewell', company: 'Builder.io' }
})
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

至少有一个,但可能是两个。

首先,如果我们发送 JSON,body 属性必须是一个 JSON 序列化的字符串:

const res = await fetch('/user', {
  method: 'POST',
  // ✅ 我们必须对这个主体进行 JSON 序列化
  body: JSON.stringify({ name: 'Steve Sewell', company: 'Builder.io' })
})
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

这很容易忘记,但如果我们使用 TypeScript,这至少可以自动为我们提示。

TypeScript 不会为我们捕获的另一个错误是我们没有在此处指定 Content-Type 标头。 许多后端要求您指定它,否则它们将无法正确处理正文。

const res = await fetch('/user', {
  headers: {
    // ✅ 如果我们发送序列化的 JSON,我们应该设置 Content-Type:
    'Content-Type': 'application/json'
  },
  method: 'POST',
  body: JSON.stringify({ name: 'Steve Sewell', company: 'Builder.io' })
})
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.

现在,我们有了一个相对健壮和安全的解决方案。

(可选)向我们的包装器添加自动 JSON 支持

我们也可以决定在包装器中为这些常见情况添加一些安全措施。 例如使用以下代码:

const isPlainObject = value value?.constructor === Object

export async function myFetch(...options) {
  let initOptions = options[1]
  // 如果我们为 fetch 指定了一个 RequestInit
  if (initOptions?.body) {
    // 如果我们传递了一个 body 属性并且它是一个普通对象或数组
    if (Array.isArray(initOptions.body) || isPlainObject(initOptions.body)) {
      //创建一个新的选项对象序列化主体并确保我们有一个内容类型的header
        initOptions = {
        ...initOptions,
        body: JSON.stringify(initOptions.body),
        headers: {
          'Content-Type': 'application/json',
          ...initOptions.headers
        }
      }
    }
  }

  const res = await fetch(...initOptions)
  if (!res.ok) {
    throw new ResponseError('Bad fetch response', res)
  }
  return res
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.

现在我们可以像这样使用我们的包装器:

const res = await myFetch('/user', {
  method: 'POST',
  body: { name: 'Steve Sewell', company: 'Builder.io' }
})
  • 1.
  • 2.
  • 3.
  • 4.

简单安全。 我喜欢。

开源解决方案

虽然定义我们自己的抽象既有趣又有趣,但让我们可以指出几个流行的开源项目如何自动为我们处理这些情况:

Axios/Redaxios

对于 Axios 和 Redaxios,类似于我们带有原始提取的原始“有缺陷”代码的代码实际上按预期工作:

const res = await axios.post('/user', {
  name: 'Steve Sewell', company: 'Builder.io' 
})
  • 1.
  • 2.
  • 3.

Wretch

同样,对于 Wretch,最基本的示例也可以按预期工作:

const res = await wretch('/user').post({ 
  name: 'Steve Sewell', company: 'Builder.io' 
})
  • 1.
  • 2.
  • 3.

(可选)使我们的包装器类型安全

最后但同样重要的是,如果你想围绕 fetch 实现自己的包装器,如果你正在使用它,我们至少要确保它是类型安全的 TypeScript。

这是我们的最终代码,包括类型定义:

const isPlainObject = (value: unknown) => value?.constructor === Object
class ResponseError extends Error {
  response: Response
  constructor(message: string, res: Response) {
    super(message)
    this.response = res
  }
}
export async function myFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
  let initOptions = init
  if (initOptions?.body) {
     if (Array.isArray(initOptions.body) || isPlainObject(initOptions.body)) {
        initOptions = {
        ...initOptions,
        body: JSON.stringify(initOptions.body),
        headers: {
          "Content-Type": "application/json",
          ...initOptions.headers,
        },
      }
    }
  }
  const res = await fetch(input, initOptions)
  if (!res.ok) {
    throw new ResponseError("Bad response", res)
  }
  return res
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.

最后一个陷阱

当使用我们新型类型安全提取包装器时,您将遇到最后一个问题。 在typescript的 catch 块中,默认错误是任何类型(any)

try {
  const res = await myFetch
} catch (err) {
  // 哦,错误是“任何(any)”类型
  if (err.respons.status === 500) ...
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

你可以说,哦! 我只输入错误:

try {
  const res = await myFetch
} catch (err: ResponseError) {
  //  TS error 1196: Catch clause variable type annotation must be 'any' or 'unknown' if specified
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

呃,没错,我们不能在 TypeScript 中输入错误。 那是因为从技术上讲,你可以在任何地方将任何东西放入 TypeScript。 以下是所有有效的 JavaScript/TypeScript,理论上可以存在于任何 try 块中

throw null
throw { hello: 'world' }
throw 123
// ...
  • 1.
  • 2.
  • 3.
  • 4.

更不用说 fetch 本身可能会抛出它自己的错误,这不是 ResponseError,例如网络错误,例如没有可用的连接。

我们也可能不小心在我们的 fetch 包装器中有一个合法的错误,它会抛出其他错误,比如 TypeError

因此,此包装器的最终、干净且类型安全的用法类似于:

try {
  const res = await myFetch
  const user = await res.body()
} catch (err: unknown) {
  if (err instanceof ResponseError) {
    switch (err.response.status) { ... }
  } else {
    throw new Error('An unknown error occured when fetching the user', {
      cause: err
    })
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

在这里,我们可以使用 instanceof 检查 err 是否是 ResponseError 实例,并在错误响应的条件块中获得完整的类型安全。

然后,如果发生任何意外错误,我们也可以重新抛出错误,并使用 JavaScript 中新的 cause 属性转发原始错误详细信息,以便更好地调试。

可重用的错误处理

最后,最好不要总是为每个 HTTP 调用的每个可能的错误状态都定制一个switch。

将我们的错误处理封装到一个可重用的函数中会更好,我们可以在处理任何我们知道需要特殊逻辑的一次性情况后将其用作回退,因为该调用是该调用所独有的。

例如,我们可能有一种常用的方式,希望用“哎呀,对不起,请联系技术支持”消息提醒用户出现500问题,或者对于401问题,如果没有更具体的方式来处理这个特定请求的状态,就会使用“请再次登录”消息。

在实践中,它可以是这样的:

try {
  const res = await myFetch('/user')
  const user = await res.body()
} catch (err) {
  if (err instanceof ResponseError) {
    if (err.response.status === 404) {
      // 这个调用的特殊逻辑,我们想要处理这个状态,比如在404上,我们似乎没有这个用户
      return
    }
  }
  // ⬇️ 处理任何其他我们不需要特殊逻辑的事情,只需要我们的默认处理
  handleError(err)
  return
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.

我们可以这样实现:

export function handleError(err: unkown) {
  // 保存到我们选择的日志服务
  saveToALoggingService(err);

  if (err instanceof ResponseError) {
    switch (err.response.status) {
      case 401:
        // 提示用户重新登录
        showUnauthorizedDialog()
        break;
      case 500: 
        // 向用户显示一个对话框,我们有一个错误,并再试一次,如果还不行,请联系技术支持
        showErrorDialog()
        break;
      default:
        // Show 
        throw new Error('Unhandled fetch response', { cause: err })
    }
  } 
  throw new Error('Unknown fetch error', { cause: err })
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.

使用Wretch

这是我认为Wretch的亮点之一,因为上面的代码可能类似于:

try {
  const res = await wretch.get('/user')
    .notFound(() { /* 特殊的未找到逻辑 */ })
  const user = await res.body()
} catch (err) {
  // 使用默认处理程序捕获其他所有内容
  handleError(err);
  return;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

使用Axios/Redaxios

使用Axios或Redaxios,看起来与我们最初的示例类似

try {
  const { data: user } = await axios.get('/user')
} catch (err) {
  if (axios.isAxiosError(err)) {
    if (err.response.status === 404) {
      // 未找到的逻辑
      return
    }
  }
  //使用默认处理程序捕获其他所有内容
  handleError(err)
  return
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.

结论

这样就完成了!

如果不清楚,我个人建议使用现成的包装器来实现fetch,因为它们可能非常小(1-2kb),通常有更多的文档、测试和社区,而且已经被其他人证明和验证了是一个有效的解决方案。

但这一切都说了,无论你是选择手动使用fetch,编写自己的包装器,还是使用开源包装器——为了你的用户和你的团队,请确保正确地获取你的数据。