现代 JavaScript 中的安全数据获取

2025-06-04

现代 JavaScript 中的安全数据获取

取回——错误的方式

fetch在 JavaScript 中非常棒。

但是,您的代码中可能会出现类似这样的情况:



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


Enter fullscreen mode Exit fullscreen mode

虽然该代码简单易懂,但仍然存在一些问题。

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



try {
  const res = await fetch('/user')
  const user = await res.json()
} catch (err) {
  // Handle the error
}


Enter fullscreen mode Exit fullscreen mode

这当然是一种进步,但仍然存在问题。

在这里,我们假设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 *actually* is the user this time
  const user = await res.json()
} catch (err) {
  // Handle the error
}


Enter fullscreen mode Exit fullscreen mode

现在,我们终于很好地使用了fetch。但是每次都要记住写出来,这可能有点麻烦,而且你不得不希望团队中的每个人每次都能处理这些情况。

就控制流而言,它也并非最优雅。就可读性而言,我个人更喜欢本文开头那段有问题的代码(在某些方面)。它读起来相当简洁——获取用户信息,解析为 JSON,然后处理用户对象。

但是在这种格式下,我们必须获取用户,处理一堆错误情况,比较 json,处理其他错误情况等。这有点令人不快,特别是当我们在业务逻辑的上方下方都有错误处理时,而不是集中在一个地方。

一种不那么丑陋的方法

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



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

  if (!res.ok) {
    throw new Error('Bad fetch response')
  }

  // User *actually* is the user this time
  const user = await res.json()
} catch (err) {
  // Handle the error
}


Enter fullscreen mode Exit fullscreen mode

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

这会让我们很难知道应该采取的最佳行动,并且给我们留下非常无用的日志。

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



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) {
  // Handle the error, with full access to status and body
  switch (err.response.status) {
    case 400: /* Handle */ break
    case 401: /* Handle */ break
    case 404: /* Handle */ break
    case 500: /* Handle */ break
  }
}


Enter fullscreen mode Exit fullscreen mode

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

例如,我们可以提醒用户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
}


Enter fullscreen mode Exit fullscreen mode

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



try {
  const res = await myFetch('/user')
  const user = await res.json()
} catch (err) {
  // Handle issues via error.response.*
}


Enter fullscreen mode Exit fullscreen mode

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

开源解决方案

探索这些很有趣,但重要的是要记住,你不必总是自己创建包装器。以下是一些流行的、可能值得使用的现有选项,其中一些大小不到 1kb:

Axios

Axios是 JS 中非常流行的数据获取选项,它可以自动为我们处理上述几种情况。



try {
  const { data } = await axios.get('/user')
} catch (err) {
  // Handle issues via error.response.*
}


Enter fullscreen mode Exit fullscreen mode

我对 Axios 唯一的不满是,对于一个简单的数据获取包装器来说,它的大小实在太大了。所以,如果你优先考虑的是 kb 大小(我认为通常情况下,为了保持最佳性能,应该优先考虑),你可以考虑以下两个选项之一:

Redaxios

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



import axios from 'redaxios'
// use as you would normally


Enter fullscreen mode Exit fullscreen mode

可怜虫

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



const user = await wretch("/user")
  .get()
  // Handle error cases in a more human-readable way
  .notFound(error => { /* ... */ })
  .unauthorized(error => { /* ... */ })
  .error(418, error => { /* ... */ })
  .res(response => /* ... */)
  .catch(error => { /* uncaught errors */ })


Enter fullscreen mode Exit fullscreen mode

不要忘记安全地写入数据

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

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



// 🚩 We have at least one bug here, can you spot it?
const res = await fetch('/user', {
  method: 'POST',
  body: { name: 'Steve Sewell', company: 'Builder.io' }
})


Enter fullscreen mode Exit fullscreen mode

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

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



const res = await fetch('/user', {
  method: 'POST',
  // ✅ We must JSON-serialize this body
  body: JSON.stringify({ name: 'Steve Sewell', company: 'Builder.io' })
})


Enter fullscreen mode Exit fullscreen mode

这很容易忘记,但如果我们使用 TypeScript,至少可以自动捕获这一点。

还有一个 TypeScript 无法捕获的错误是,我们没有Content-Type在这里指定 header。许多后端都要求你指定 header,否则它们将无法正确处理 body。



const res = await fetch('/user', {
  headers: {
    // ✅ If we are sending serialized JSON, we should set the Content-Type:
    'Content-Type': 'application/json'
  },
  method: 'POST',
  body: JSON.stringify({ name: 'Steve Sewell', company: 'Builder.io' })
})


Enter fullscreen mode Exit fullscreen mode

现在,我们有一个相对强大且安全的解决方案。

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

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



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

export async function myFetch(...options) {
  let initOptions = options[1]
  // If we specified a RequestInit for fetch
  if (initOptions?.body) {
    // If we have passed a body property and it is a plain object or array
    if (Array.isArray(initOptions.body) || isPlainObject(initOptions.body)) {
      // Create a new options object serializing the body and ensuring we
      // have a content-type 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
}


Enter fullscreen mode Exit fullscreen mode

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



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


Enter fullscreen mode Exit fullscreen mode

简单又安全。我喜欢。

开源解决方案

虽然定义我们自己的抽象很有趣,但我们一定要指出一些流行的开源项目如何自动为我们处理这些情况:

Axios/Redaxios

对于AxiosRedaxios,与我们原来使用 raw 的“有缺陷”的代码类似的代码fetch实际上可以按预期工作:



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


Enter fullscreen mode Exit fullscreen mode

可怜虫

类似地,对于Wretch来说,最基本的示例也能按预期工作:



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


Enter fullscreen mode Exit fullscreen mode

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

最后,但同样重要的一点是,如果您想实现自己的包装器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 we specified a RequestInit for fetch
  if (initOptions?.body) {
    // If we have passed a body property and it is a plain object or array
    if (Array.isArray(initOptions.body) || isPlainObject(initOptions.body)) {
      // Create a new options object serializing the body and ensuring we
      // have a content-type header
      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
}


Enter fullscreen mode Exit fullscreen mode

最后一个问题

使用我们全新类型安全的 fetch 包装器时,你会遇到最后一个问题。在catchTypeScript 的块中,默认情况下error是以下any类型:



try {
  const res = await myFetch
} catch (err) {
  // 🚩 Doh, error is of `any` type, so we missed the below typo:
  if (err.respons.status === 500) ...
}



Enter fullscreen mode Exit fullscreen mode

你可能会说,哦!我直接输入错误就行了:



try {
  const res = await myFetch
} catch (err: ResponseError) {
  // 🚩 TS error 1196: Catch clause variable type annotation must be 'any' or 'unknown' if specified
}


Enter fullscreen mode Exit fullscreen mode

呃,没错,TypeScript 中不能输入错误。因为从技术上讲,你可以throw在 TypeScript 的任何地方执行任何操作。以下代码都是有效的 JavaScript/TypeScript,理论上可以存在于任何try代码块中。



throw null
throw { hello: 'world' }
throw 123
// ...


Enter fullscreen mode Exit fullscreen mode

更不用说它fetch本身可能会抛出不属于的错误ResponseError,例如网络错误(如没有可用的连接)。

我们还可能意外地在 fetch 包装器中出现合法错误,从而引发其他错误,例如TypeError

因此,此包装器的最终、干净且类型安全的用法将是这样的:



try {
  const res = await myFetch
  const user = await res.body()
} catch (err: unknown) {
  if (err instanceof ResponseError) {
    // Nice and type-safe!
    switch (err.response.status) { ... }
  } else {
    throw new Error('An unknown error occured when fetching the user', {
      cause: err
    })
}


Enter fullscreen mode Exit fullscreen mode

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

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

可重复使用的错误处理

最后,可能不需要总是switch为每个 HTTP 调用的可能的错误状态进行自定义构建。

将我们的错误处理封装到可重用的函数中会很好,在处理任何一次性情况后,我们可以将其用作后备,因为我们知道我们需要针对此调用独有的特殊逻辑。

例如,我们可能有一种常见的方法,即在出现 500 错误时用“哎呀,抱歉,请联系支持人员”消息提醒用户,或者在出现 401 错误时用“请重新登录”消息提醒用户,只要没有更具体的方法来处理此特定请求的状态。

在实践中,这可能看起来像:



try {
  const res = await myFetch('/user')
  const user = await res.body()
} catch (err) {
  if (err instanceof ResponseError) {
    if (err.response.status === 404) {
      // Special logic unique to this call where we want to handle this status,
      // like to say on a 404 that we seem to not have this user
      return
    }
  }
  // ⬇️ Handle anything else that we don't need special logic for, and just want
  // our default handling
  handleError(err)
  return
}


Enter fullscreen mode Exit fullscreen mode

我们可以这样实现:



export function handleError(err: unkown) {
// Safe to our choice of logging service
saveToALoggingService(err);

if (err instanceof ResponseError) {
switch (err.response.status) {
case 401:
// Prompt the user to log back in
showUnauthorizedDialog()
break;
case 500:
// Show user a dialog to apologize that we had an error and to
// try again and if that doesn't work contact support
showErrorDialog()
break;
default:
// Show
throw new Error('Unhandled fetch response', { cause: err })
}
}
throw new Error('Unknown fetch error', { cause: err })
}

Enter fullscreen mode Exit fullscreen mode




与可怜虫

这是我认为 Wretch 闪光的地方,因为上面的代码可能看起来类似这样:



try {
const res = await wretch.get('/user')
.notFound(() => { /* Special not found logic */ })
const user = await res.body()
} catch (err) {
// Catch anything else with our default handler
handleError(err);
return;
}

Enter fullscreen mode Exit fullscreen mode




使用 Axios/Redaxios

使用 Axios 或 Redaxios 时,情况看起来与我们最初的例子类似



try {
const { data: user } = await axios.get('/user')
} catch (err) {
if (axios.isAxiosError(err)) {
if (err.response.status === 404) {
// Special not found logic
return
}
}
// Catch anything else with our default handler
handleError(err)
return
}

Enter fullscreen mode Exit fullscreen mode




结论

我们已经成功了!

如果没有其他明确说明,我个人建议使用现成的包装器进行获取,因为它们可以非常小(1-2kb),并且通常有更多的文档、测试和社区,此外已经被其他人证明和验证为有效的解决方案。

但综上所述,无论您选择手动使用fetch、编写自己的包装器还是使用开源包装器 - 为了您的用户和团队的利益,请务必正确获取您的数据:)

关于我

大家好!我是 Builder.io的 CEO  Steve

如果您喜欢我们的内容,您可以在dev.to、  twitter或我们的 时事通讯上订阅我们 

我们通过拖放组件的方式在您的网站或应用程序上以可视化的方式创建页面和其他 CMS 内容。

您可以在此处阅读有关如何改善您的工作流程的更多信息

您可能会发现它有趣或有用:

Builder.io 演示

文章来源:https://dev.to/builderio/safe-data-fetching-in-modern-javascript-dp4
PREV
下一代编程比你想象的更近
NEXT
如何在移动设备上获得 Google PageSpeed Insights 100 分