
Axios header authorization 设置Zustand 保存主题和暗色模式TanStack Query 和 Toast 控制重试和通知TanStack Query 数据的有效期和缓存Axios 和 JWT 机制结合时,在客户端可以通过以下两种方式设置请求头 Authorization:
首先拦截器设置:
const instance = axios.create();
instance.interceptors.request.use((config) => {
config.headers["Authorization"] = "Bearer " + localStorage.getItem("token");
return config;
});
其特点是动态性强,不怕 token 动态刷新,缺点是逻辑每次都走一遍,当然性能上几乎可以忽略。
其次是在 instance 上设置:
const instance = axios.create({
baseURL: "/api",
headers: {
Authorization: "Bearer " + localStorage.getItem("token"),
},
});
// 当 token 变化时,需要手动更新
instance.defaults.headers["Authorization"] = "Bearer " + newToken;
只在初始化时或手动更新时设置,避免每次请求都走一遍,仅需要再刷新的时候重新设置。
这对产品来说没有什么价值,但是对开发者的个人体感来说是有价值的。此前我都是使用拦截器,现在我更倾向通过实例来设置。
对比一下二者:
| 方式 | 触发频率 | 动态性 | 手动维护 | 适用场景 |
|---|---|---|---|---|
| 拦截器设置 | 每次请求 | ✅ 自动获取最新 | ❌ 不需要 | token 可能过期/刷新 |
| instance.defaults 设置 | 初始化/更新时 | ❌ 静态 | ✅ 需要手动更新 | token 固定或生命周期较长 |
在 iOS Safari 浏览器中,100 vh 的行为和浏览器不一致。
/* 在桌面浏览器中 */
.full-height {
height: 100vh; /* 总是等于视口高度 */
}
/* 在 iOS Safari 中 */
.full-height {
height: 100vh; /* 包含了地址栏和工具栏的高度! */
}
直接看图说话,左侧全屏展示时是常规 100 vh,右侧为常规操作时页面,其 100 vh 可能和开发者期望的高度不一致。

在滚动状态时甚至会隐藏地址栏,因此需要一个方案来实现兼容性更好的 100 vh 高度方案。
首先给出常见的方案总结:
| 方案 | 说明 | 优点 | 缺点 |
|---|---|---|---|
height: 100vh |
默认写法 | 简单 | iOS Safari 上会导致页面被遮挡或溢出 |
-webkit-fill-available fallback |
CSS hack | 可在 Safari 上更准确填充视口 | 嵌套层级限制、无法与 calc() 混用,对非 WebKit 有兼容性风险 |
JS + CSS 自定义 --vh |
动态计算并注入变量 | 高精度、可跨浏览器 | 需写 JavaScript,布局依赖 JS 性能 |
height: 100dvh |
最新标准单位 | 最理想,响应式、自适配 | 需浏览器支持(如 Safari 15.4+, Chrome 108+, Firefox 101+) |
当真的需要兼容 iOS Safari 的时候,我才遇到这个问题。这里直接一步到位,跳过前两个方案。接下来再暂时放弃第四个方案,dvh 等新的单位是 2022 年提出的,现在使用恐怕会遇到兼容性问题,不如直接使用方案 3:JS + CSS 定义 css 变量。
上 React 代码(谢谢 UJ):
import { useEffect } from "react"
/**
* 修复 iOS Safari 下 100vh 不准确的问题
* 自动设置 CSS 变量 --app-vh
* 使用 requestAnimationFrame 限流,避免频繁操作 DOM
*/
export function useDomReadyReCalc() {
useEffect(() => {
if (typeof window === "undefined" || typeof document === "undefined") return
const docElm = document.documentElement
// iOS 检测(含 iPad 桌面模式)
const isIOS =
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.userAgent.includes("Mac") && "ontouchend" in document)
let ticking = false
const updateVh = () => {
if (!ticking) {
requestAnimationFrame(() => {
const vh = window.innerHeight * 0.01
docElm.style.setProperty("--app-vh", `${vh}px`)
ticking = false
})
ticking = true
}
}
const setInitVh = () => {
const vh = window.innerHeight * 0.01
docElm.style.setProperty("--app-init-vh", `${vh}px`)
}
// ---- 初始计算 ----
setInitVh()
updateVh()
// ---- 监听 resize ----
window.addEventListener("resize", updateVh)
if (isIOS) {
// iOS 特殊处理
window.addEventListener("orientationchange", updateVh)
if (window.visualViewport) {
window.visualViewport.addEventListener("resize", updateVh)
}
}
// 清理事件
return () => {
window.removeEventListener("resize", updateVh)
if (isIOS) {
window.removeEventListener("orientationchange", updateVh)
if (window.visualViewport) {
window.visualViewport.removeEventListener("resize", updateVh)
}
}
}
}, [])
}
接着,在 React App.tsx 顶层调用一次即可在 CSS 中使用 css 变量来获取准确的高度。
Zustand 在使用 store 中的嵌套属性时需要注意代码的写法,应该使用选择性订阅而不是订阅整个 store,订阅整个 store 会大大增加渲染次数。
export function useIsDarkMode() {
// forSTORE
const theme$_isDarkMode = useThemeStore((s) => s.theme$_isDarkMode);
const theme$_appTheme = useThemeStore((s) => s.theme$_appTheme);
// forSTORE
const { isSysDarkMode } = useIsSysDarkMode();
return {
isDarkMode:
// 看主题
theme$_appTheme
? // 如果有选择过,就按 theme 走
theme$_isDarkMode
: // 不然按 sys 走
isSysDarkMode,
};
}
选择 store 中的某个字段时,应该使用选择性订阅的语法来确保 store 更新的影响范围最小。
TanStack Query 可以在创建 queryClient 的时候对整个 client 做通用配置,其中有一个 retry 的点引起了群友的讨论。
请求在出错、超时的情况下添加重试机制是一种很好的实践。TanStack Query 在默认情况下 retry 次数是 3 次,总共请求四次,有时候我们不需要重试这么多次,并且重试的时候可能会触发 Axios 拦截器中响应的逻辑处理,从而连续弹出多次 Toast。
这里面有两个优化点,首先是 retry 重试机制。将 retry 设置为 false 仅在特殊场景下(产品特性)可选,大多数情况下我们会自定义重试次数。看代码:
retry: (failureCount, error) => {
if (error.status === 404) return false
return failureCount < 2
}
针对性 404 状态码不需要重试,针对重试次数来判断是否需要重试,从而设置重试次数。通过函数来控制重试机制,灵活性大大提升,我们可以在这里做更多的逻辑处理工作,例如 Toast 提示。
此前,笔者尝尝碰到在 Axios 拦截器中写 Toast 的业务需求,于是笔者在群里提出了自己的解决方案:通过请求 url、query params、body 进行序列化处理成唯一的 key 来区分请求,利用防抖机制防止快速或重复的 Toast。
这种方案大伙觉得麻烦,Bruce 提出给 Toast 配置 maxCount 为 1,这样多次重试可以保证页面仅单个 Toast 效果,看起来不会像连续多次 Toast 那样差劲。
但是笔者觉得这样会有问题,同时发生的若干接口可能会需要展示若干个 Toast 内容,限制最大数量将会隐藏先触发的 Toast,如果产品团队不能接受这一点,那么我们就得换一个方案。
最后,提出问题的老徐直接建议我们把逻辑放在 retry 函数中来处理,直接把话题给终结了。
挺好,在 retry 中针对次数来决定是否需要 Toast,既防止连续相同的 Toast 效果不好,也隔离了不同的请求的 Toast 影响,不需要担心多个请求异常的 Toast 展示遗漏,也不需要处理复杂的请求唯一性检查 🚀。
TanStack Query 能够将请求进行缓存,从而减少服务器压力,也能在特殊场景下快速响应数据,减少白屏。
来吧直接说场景:表格翻页渲染,我们希望给用户最佳的翻页体验和快速响应。
其中有两个关键的配置如下:
在表格翻页的时候,如果希望能够立即使用缓存的数据作为响应立即展示,并且同时请求 API 获取数据来更新缓存,那么可以参考如下代码:
// 默认 Query 选项
export const defaultQueryOptions = {
staleTime: 1000, // 1s数据保鲜期
gcTime: 10 * 1000, // v5 中 cacheTime 改名为 gcTime
retry: 2, // 忽略 toast 逻辑
retryDelay: (attemptIndex: number) => Math.min(1000 * 2 ** attemptIndex, 30000),
refetchOnWindowFocus: false,
refetchOnReconnect: true,
};
// 创建 QueryClient 实例
export const createQueryClient = () => {
return new QueryClient({
defaultOptions: {
queries: {
...defaultQueryOptions,
throwOnError: false,
},
mutations: {
retry: 1,
throwOnError: false,
},
},
});
};
接下来请求数据:
export const useUsers = (params: TableParams) => {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: userQueryKeys.list(params),
queryFn: () => fetchUsers(params),
});
如此一来,切换页面的时候如果在 1s 内,则走缓存数据并且不触发 API 请求。如果在 1s 后则立即渲染缓存数据,同时走 API 请求,拿到最新数据之后立即同步 UI 和更新缓存。
开发者可以自行选择 1s 的窗口时间,降低服务器压力。如果有需要我们甚至可以把 staleTime 改为 30s,并且使用 prefetchQuery 提前加载下一页、上一页的数据,从而让用户对分页的数据响应获得更快的体验。
预加载是平衡性能和体验很好的一种方案。
在 C 端,单论 SPA 的场景来说笔者的观点如下:
笔者认为在渲染路由之前,应该执行的是初始化的一些逻辑。
如若任何一个路由都使用 Suspense 和动态加载组件,那么路由在加载之前就应该读取应用的配置,并且初始化 Zustand 的 Store 数据,在初始化完成之前,都展示 Suspense 的 fallback 组件,这是一个全路由通用的组件,甚至可以加上一些品牌相关的 UI 和动画。
另外诸如多语言、主题和设置、CSS 变量、鉴权等都在渲染路由之前初始化,其他的暂时不说,直接说鉴权。
在渲染路由之前,首先得判断当前 token 有效性,这时候就可以通过异步逻辑去获取用户信息,从而确保应用启动之后拿到的是最新的用户信息。
如若直接在 login 的时候将信息和 token 保存到 localStorage,然后直接渲染路由,那么在 token 过期的时候就可能会导致多个组件先后请求自己的数据,并且引发 401 之后的多个逻辑。亦或是 userInfo、token 变更,将 localStorage 中的 userInfo 作为初始值也得处理信息变更的逻辑,否则用户的姓名甚至都可能变化了,UI 还展示旧的信息。
用户信息和 token 有什么必要保存到 localStorage 中呢?我想仅有的原因是用户的个人习惯是通过浏览器刷新功能来重新查看数据。如果真有这部分考量,那么确实应该保存到 localStorage,并且可以结合默认读取 localStorage,同时通过独立的鉴权请求去更新用户信息和 token。
通过 Zustand 初始化数据,那么组件就不需要再通过重复逻辑获取配置数据。其次,Suspense fallback 可以防止白屏和闪烁,也可以让动态加载的好处提现出来,初始 bundle 将会变得更小,每一个路由都拆分成独立的包,用户访问某个路由下才下载对应的代码。
渲染页面之前确保数据是存在的,组件内的代码也可以减少很多不存在的数据逻辑。
如果你也赞同笔者的想法,那么一定要看看这个库:TanStack Router

这个库简直是,啧啧啧。
无懈可击。
在 React 生态下提供完善的 TypeScript 支持、绝佳的路由定义(基于文件目录和配置)机制、独特的路由渲染前上下文控制、数据请求配置、Suspense 机制加持懒加载功能、完善的 URL 序列化和反序列化支持(代替 nuqs)等等。
堪称 Tanstack Query 的绝配。
笔者考虑了很久,最终决定将本文作为付费内容进行发布,我希望自己生产的内容是有价值的,即使其价值很低(😞)。
另外,周刊的内容依然会免费更新,给大家带来自己接触到的新玩意。
Bye.