You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React中Spotify客户端Token刷新机制querySelector报错修复求助

问题描述

纯客户端React应用依赖Spotify API,出于安全考虑未使用官方refresh token逻辑,改为在token即将过期时调用handleAuthorization引导用户重新授权。调试时设置每分钟调用检查函数,出现错误:

Uncaught DOMException: Failed to execute 'querySelector' on 'Document'

相关代码如下:

import React, { useEffect, useState } from 'react';
import { Route, Routes} from 'react-router-dom';
import LandingPage from './components/LandingPage';
import UserDashboard from './components/UserDashboard';
import PlaylistPage from './components/PlaylistPage';
import ConnectionError from './components/ConnectionError';
import 'react-notifications/lib/notifications.css';
import axios from 'axios';

function App() {
  const [token, setToken] = useState('');
  const [profile, setProfile] = useState({});
  const [playlists, setPlaylists] = useState([]);
  const [isOnline, setIsOnline] = useState(true);


  useEffect(() => {
    const handleOnlineStatusChange = () => {
      setIsOnline(window.navigator.onLine);
    };
  
    window.addEventListener('online', handleOnlineStatusChange);
    window.addEventListener('offline', handleOnlineStatusChange);
  
    return () => {
      window.removeEventListener('online', handleOnlineStatusChange);
      window.removeEventListener('offline', handleOnlineStatusChange);
    };
  }, []);
  


  
    const handleAuthorization = () => {
      const clientId = 'my-client-id';
      const redirectUri = 'http://localhost:3000';
      const scopes = ['user-library-read', 'playlist-read-private', 'playlist-modify-private', 'playlist-modify-public', 'playlist-read-collaborative'];
      const authorizationURL = `https://accounts.spotify.com/authorize?client_id=${clientId}&response_type=token&redirect_uri=${encodeURIComponent(
        redirectUri
      )}&scope=${scopes.join('%20')}`;
    
      // Redirect for authorization
      window.location.href = authorizationURL;
    };

    const fetchData = async () => {
      const hash = window.location.hash;
      const tokenParam = new URLSearchParams(hash.substring(1)).get('access_token');
  
      if (tokenParam) {
        // Token is present in the URL
        const newToken = tokenParam;
        window.localStorage.setItem('token', newToken);
        setToken(newToken);
  
        // Redirect to the dashboard
        window.location.href = '/dashboard';
      } else {
        // Token is not present in the URL, handle token expiration or other logic here
        const storedToken = window.localStorage.getItem('token');
  
        if (storedToken) {
          // Token is stored locally, you can set it and perform necessary actions
          setToken(storedToken);
  
          // Fetch user data and playlists here
          try {
            const response = await axios.get('https://api.spotify.com/v1/me', {
              headers: {
                Authorization: `Bearer ${storedToken}`,
              },
            });
  
            const { data } = response;
            setProfile(data);
  
            // Fetch playlists
            try {
              const playlistsResponse = await axios.get('https://api.spotify.com/v1/me/playlists', {
                headers: {
                  Authorization: `Bearer ${storedToken}`,
                },
                params: {
                  limit: 50,
                },
              });
  
              const { data: playlistsData } = playlistsResponse;
              setPlaylists(playlistsData.items);
              console.log(playlistsData.items);
            } catch (error) {
              console.error('Error fetching playlists:', error);
            }
          } catch (error) {
            console.error('Error fetching user data:', error);
          }
        }
      }
    };

    const checkTokenExpiration = () => {
      const storedToken = window.localStorage.getItem('token');
  
      if (storedToken) {
        const tokenData = JSON.parse(atob(storedToken.split('.')[1])); // Decode JWT payload
  
        // Calculate the remaining time before token expiration (in seconds)
        const currentTime = Math.floor(Date.now() / 1000);
        const expirationTime = tokenData.exp;
        const timeToExpiration = expirationTime - currentTime;
  
        // Set a threshold (e.g., 300 seconds) for when to refresh the token
        const tokenRefreshThreshold = 300;
  
        if (timeToExpiration <= tokenRefreshThreshold) {
          // Token is about to expire, refresh it
          handleAuthorization();
        }
      }
    };
  
    useEffect(() => {
      // Check token expiration every minute
      const tokenExpirationCheckInterval = setInterval(() => {
        checkTokenExpiration();
        fetchData();
      }, 60000); // 60 seconds
  
      return () => {
        clearInterval(tokenExpirationCheckInterval);
      };
    }, []);
    


 
  const logout = () => {
    setToken('');
    setProfile({});
    setPlaylists([]);
    window.localStorage.removeItem('token');
    // Redirect the user to the home page after logging out
    window.location.assign('http://localhost:3000');
  };




  return (
    <div>
    {isOnline ? (
      <Routes>
        <Route path="/" element={<LandingPage handleAuthorization={handleAuthorization} />} />
        <Route path="/dashboard" element={<UserDashboard profile={profile} playlists={playlists} logout={logout} />} />
        <Route path="/playlist/:id" element={<PlaylistPage />} />
      </Routes>
    ) : (
      <ConnectionError />
    )}
  </div>
  );
}

export default App;

错误根源

  1. 强制页面刷新中断React渲染流程:使用window.location.href/window.location.assign跳转,会强制刷新页面,导致React组件在未完成卸载/渲染时被中断,引发DOM查询异常。
  2. 重复逻辑与定时器冲突:每分钟同时调用checkTokenExpiration和fetchData,可能出现重复跳转、请求冲突,加剧DOM操作问题。
  3. JWT解码无异常处理:若token格式无效,atob或JSON.parse会抛出错误,导致后续逻辑中断。
  4. 数据获取时机不合理:初始加载时未主动调用fetchData,且重复调用会引发不必要的API请求。

修复方案

1. 替换原生跳转为React路由导航

使用react-router-dom的useNavigate钩子实现无刷新跳转,避免中断React渲染流程:

// 导入useNavigate和useRef
import { Route, Routes, useNavigate } from 'react-router-dom';
import { useRef } from 'react';

function App() {
  const navigate = useNavigate();
  const tokenExpirationCheckInterval = useRef(null); // 用ref保存定时器ID
  // ...其他state定义

  // 修改fetchData中的跳转逻辑
  const fetchData = async () => {
    const hash = window.location.hash;
    const tokenParam = new URLSearchParams(hash.substring(1)).get('access_token');

    if (tokenParam) {
      const newToken = tokenParam;
      window.localStorage.setItem('token', newToken);
      setToken(newToken);
      // 无刷新跳转到dashboard
      navigate('/dashboard');
      // 清除URL中的hash,避免重复解析
      window.history.replaceState({}, document.title, window.location.pathname);
    } else {
      const storedToken = window.localStorage.getItem('token');
      if (storedToken) setToken(storedToken);
    }
  };

  // 修改logout的跳转逻辑
  const logout = () => {
    setToken('');
    setProfile({});
    setPlaylists([]);
    window.localStorage.removeItem('token');
    navigate('/'); // 无刷新跳转到首页
  };

2. 优化token检查与定时器逻辑

  • 给JWT解码添加异常捕获,防止无效token崩溃
  • 仅在存在token时启动定时器,跳转前清除定时器避免重复触发
const checkTokenExpiration = () => {
  const storedToken = window.localStorage.getItem('token');
  if (!storedToken) return;

  try {
    const tokenParts = storedToken.split('.');
    // 验证JWT格式是否正确
    if (tokenParts.length !== 3) throw new Error('Invalid token format');
    const tokenData = JSON.parse(atob(tokenParts[1]));
    const currentTime = Math.floor(Date.now() / 1000);
    const timeToExpiration = tokenData.exp - currentTime;

    if (timeToExpiration <= 300) {
      // 跳转前清除定时器,避免重复触发
      clearInterval(tokenExpirationCheckInterval.current);
      handleAuthorization();
    }
  } catch (error) {
    console.error('Token parsing failed:', error);
    // token无效,直接引导重新授权
    handleAuthorization();
  }
};

// 调整定时器的启动逻辑
useEffect(() => {
  // 初始化时先解析token并获取数据
  fetchData();

  const storedToken = window.localStorage.getItem('token');
  if (storedToken) {
    tokenExpirationCheckInterval.current = setInterval(() => {
      checkTokenExpiration();
    }, 60000); // 每分钟检查一次token过期
  }

  return () => {
    if (tokenExpirationCheckInterval.current) {
      clearInterval(tokenExpirationCheckInterval.current);
    }
  };
}, []);

3. 分离数据获取逻辑,避免重复调用

将用户数据和播放列表的获取逻辑移到token变化的useEffect中,仅在token更新时触发请求:

useEffect(() => {
  if (!token) return;

  const fetchUserDataAndPlaylists = async () => {
    try {
      // 获取用户信息
      const userResponse = await axios.get('https://api.spotify.com/v1/me', {
        headers: { Authorization: `Bearer ${token}` }
      });
      setProfile(userResponse.data);

      // 获取播放列表
      const playlistsResponse = await axios.get('https://api.spotify.com/v1/me/playlists', {
        headers: { Authorization: `Bearer ${token}` },
        params: { limit: 50 }
      });
      setPlaylists(playlistsResponse.data.items);
    } catch (error) {
      console.error('Data fetch failed:', error);
      // 请求失败说明token无效,引导重新授权
      handleAuthorization();
    }
  };

  fetchUserDataAndPlaylists();
}, [token]);

4. 优化在线状态变化逻辑

在线状态恢复时,若有token则重新获取数据,确保数据同步:

useEffect(() => {
  const handleOnlineStatusChange = () => {
    const isNowOnline = window.navigator.onLine;
    setIsOnline(isNowOnline);
    // 重新在线且有token时,刷新数据
    if (isNowOnline && token) {
      const fetchUserDataAndPlaylists = async () => {
        try {
          const userResponse = await axios.get('https://api.spotify.com/v1/me', {
            headers: { Authorization: `Bearer ${token}` }
          });
          setProfile(userResponse.data);

          const playlistsResponse = await axios.get('https://api.spotify.com/v1/me/playlists', {
            headers: { Authorization: `Bearer ${token}` },
            params: { limit: 50 }
          });
          setPlaylists(playlistsResponse.data.items);
        } catch (error) {
          console.error('Data fetch after reconnection failed:', error);
          handleAuthorization();
        }
      };
      fetchUserDataAndPlaylists();
    }
  };

  window.addEventListener('online', handleOnlineStatusChange);
  window.addEventListener('offline', handleOnlineStatusChange);

  return () => {
    window.removeEventListener('online', handleOnlineStatusChange);
    window.removeEventListener('offline', handleOnlineStatusChange);
  };
}, [token]);

内容的提问来源于stack exchange,提问作者sofia

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.10 08:29:51