Next.js+Electron离线应用中Next Router未挂载问题排查
问题
我用Next.js和TypeScript开发了一个npm包,用于将在线应用的报表模块共享给离线Electron应用。带有<Link>标签的简单页面可正常共享,布局和翻译功能都能正常工作,但当导入报表页面到Electron离线应用时,出现错误:Next Router was not mounted。我查阅了官方文档,尝试过添加router.isReady事件判断初始化状态、确保代码在函数组件内等修改,但仍未找到问题所在。
相关代码如下:
pages/reports-index.tsx
import { ReactElement, useCallback, useEffect, useState } from 'react'; import useRequestUtilities from '../../components/hooks/use-request-utilities'; import UserLayout from '../../components/layouts/user-layout'; import { NextPageWithLayout } from '../_app'; import ReportTableSkeleton from '../../components/loading/report-table-skeleton'; import { useTranslation } from 'react-i18next'; import PageContainer from '../../components/users/page-container'; import { NewReportDetail } from '../../components/new-reports/models/new-report-detail'; import NewReportTable from '../../components/new-reports/new-report-table'; // Nested component const ReportPage: NextPageWithLayout = function () { const { fetchWrapper, logoutUser, nextJsRouter: router, } = useRequestUtilities(); const refetchReports = typeof router.query.refetch === 'string' ? router.query.refetch : router.query?.refetch?.at(0); type viewScreenType = | 'loading' | 'reportsAbsent' | 'reportsPresent' | 'responseError'; const [viewScreen, setViewScreen] = useState<viewScreenType>('loading'); const [responseErrorMsg, setResponseErrorMsg] = useState(''); const [data, setData] = useState<NewReportDetail[]>([]); const { t } = useTranslation(); const fetchReports = useCallback(async function (Refetch = false) { async function handleResponse(response: Response) { const resJson = await response.json(); if (response.ok) { const newReports: NewReportDetail[] = resJson?.reports; if (newReports.length) { if (Refetch) { setData(newReports); } else { setData((old) => { const uniqueNewReports: NewReportDetail[] = []; newReports.forEach((newReport) => { if ( old.find((report) => report.id === newReport.id) === undefined ) { uniqueNewReports.push(newReport); } }); return [...old, ...uniqueNewReports]; }); } setViewScreen('reportsPresent'); } else if (data.length < 1 && newReports.length < 1) { setViewScreen('reportsAbsent'); } } else { if (response.status === 500) { setResponseErrorMsg(t('error.something_went_wrong')); } else { const errorMsg = typeof resJson?.message === 'string' ? resJson.message : resJson?.message?.at(0); setResponseErrorMsg( `Error ${response.status}: ${errorMsg || response.statusText}`, ); } setViewScreen('responseError'); } } function handleError(error: any) { setResponseErrorMsg(t('error.something_went_wrong')); setViewScreen('responseError'); } fetchWrapper({ url: `${process.env.NEXT_PUBLIC_API_BASE_URL_V1}/reports`, includeAuthToken: true, handleResponse, handleError, }); }, []); useEffect(() => { const accessToken = localStorage.getItem('accessToken'); if (!accessToken) { logoutUser(); return; } else { const userInfo = JSON.parse(localStorage.getItem('userInfo')); if (userInfo?.role !== 'LabEngineer') { router.replace('/users'); return; } fetchReports(Boolean(refetchReports)); } }, [refetchReports]); if (viewScreen === 'loading') { return ( <> <div className="py-4 px-6 flex items-center justify-between border-b border-gray-300 font-semibold text-xl text-gray-800"> <div className="bg-gray-300 rounded-full w-20 h-5 animate-pulse" /> </div> <ReportTableSkeleton /> </> ); } if (viewScreen === 'reportsAbsent') { return ( <> <div className="py-4 px-6 flex items-center justify-between border-b border-gray-300 font-semibold text-xl text-gray-800"> {t('reports.report_list_page_title')} </div> <div className="text-center w-full mx-auto pt-16"> <h1 className="font-medium text-2xl leading-6 text-gray-800 mb-4"> Looks like you haven't generated any reports yet </h1> </div> </> ); } if (viewScreen === 'responseError') { return ( <> <div className="py-4 px-6 flex items-center justify-between border-b border-gray-300 font-semibold text-xl text-gray-800"> {t('reports.report_list_page_title')} </div> <div className="text-center w-[512px] mx-auto pt-16"> <h1 className="font-medium text-2xl leading-6 text-gray-800 mb-4"> {responseErrorMsg} </h1> </div> </> ); } return ( <> <div className="py-4 px-6 flex items-center justify-between border-b border-gray-300 font-semibold text-xl text-gray-800"> {t('reports.report_list_page_title')} </div> <NewReportTable data={data} /> </> ); }; export default ReportPage; ReportPage.getLayout = function getLayout(page: ReactElement) { return ( <UserLayout> <PageContainer>{page}</PageContainer> </UserLayout> ); };
components/hooks/use-request-utilities.tsx
import React, { useCallback } from 'react'; import { useRouter } from 'next/router'; function useRequestUtilities() { const router = useRouter(); const logoutUser = useCallback(() => { localStorage.removeItem('userInfo'); localStorage.removeItem('accessToken'); localStorage.removeItem('applicationKey'); router.push('/login'); }, []); const fetchWrapper = useCallback(async function (props: { url: RequestInfo | URL; method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; includeAuthToken?: boolean; body?: any; contentType?: string; applicationIdentifier?: string; initiate?: () => any; handleResponse: (response: Response) => any; handleError: (error: any) => any; handleFinally?: () => any; applicationKey?: string; }) { const { url, method = 'GET', includeAuthToken = false, body, initiate, handleResponse, handleError, handleFinally, applicationKey, } = props; const options: RequestInit = { method, }; if (includeAuthToken || body) { const headersInit: HeadersInit = {}; options.headers = headersInit; if (body) { if (body instanceof FormData) { options.body = body; } else { options.headers['Content-Type'] = props.contentType || 'application/json'; options.body = props.contentType ? body : JSON.stringify(body); const applicationKeyFromStorage = localStorage.getItem('applicationKey'); if (!applicationKeyFromStorage) { options.headers['x-api-key'] = applicationKey; } else { options.headers['x-api-key'] = applicationKeyFromStorage; } } } if (includeAuthToken) { options.headers.Authorization = `Bearer ${localStorage.getItem( 'accessToken', )}`; } } if (initiate) { initiate(); } try { const response = await fetch(url, options); if (includeAuthToken && response.status === 401) { return; } handleResponse(response); } catch (error) { handleError(error); } finally { if (handleFinally) { handleFinally(); } } }, []); return { nextJsRouter: router, logoutUser, fetchWrapper, }; } export default useRequestUtilities;
NewReportTable为基础React-Tanstack表格,未导入next/router或涉及路由相关代码,仅调用fetchWrapper方法。
解决方案
问题根源
Next.js的useRouter钩子依赖Next.js应用内部的Router上下文环境,而Electron离线应用中直接渲染组件时,并没有初始化这个上下文,导致调用useRouter时抛出Next Router was not mounted错误。
修复步骤
1. 抽象路由依赖,支持多环境适配
修改use-request-utilities.tsx,将路由相关逻辑抽象为可注入的参数,让Electron端可以提供自定义的路由实现:
import React, { useCallback, useContext, createContext } from 'react'; import { useRouter as useNextRouter, NextRouter } from 'next/router'; // 创建路由上下文,用于在非Next环境注入自定义路由实现 const RouterContext = createContext<NextRouter | null>(null); export const RouterProvider = RouterContext.Provider; function useRequestUtilities() { // 优先从上下文获取路由,没有则使用Next的useRouter const routerFromContext = useContext(RouterContext); const router = routerFromContext || useNextRouter(); // 给logoutUser添加容错判断,避免router未定义时报错 const logoutUser = useCallback(() => { localStorage.removeItem('userInfo'); localStorage.removeItem('accessToken'); localStorage.removeItem('applicationKey'); if (router) { router.push('/login'); } else { // Electron环境下的跳转逻辑,比如调用Electron的窗口导航 window.location.href = '/login'; } }, [router]); // fetchWrapper逻辑保持不变 const fetchWrapper = useCallback(async function (props: { url: RequestInfo | URL; method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; includeAuthToken?: boolean; body?: any; contentType?: string; applicationIdentifier?: string; initiate?: () => any; handleResponse: (response: Response) => any; handleError: (error: any) => any; handleFinally?: () => any; applicationKey?: string; }) { const { url, method = 'GET', includeAuthToken = false, body, initiate, handleResponse, handleError, handleFinally, applicationKey, } = props; const options: RequestInit = { method, }; if (includeAuthToken || body) { const headersInit: HeadersInit = {}; options.headers = headersInit; if (body) { if (body instanceof FormData) { options.body = body; } else { options.headers['Content-Type'] = props.contentType || 'application/json'; options.body = props.contentType ? body : JSON.stringify(body); const applicationKeyFromStorage = localStorage.getItem('applicationKey'); if (!applicationKeyFromStorage) { options.headers['x-api-key'] = applicationKey; } else { options.headers['x-api-key'] = applicationKeyFromStorage; } } } if (includeAuthToken) { options.headers.Authorization = `Bearer ${localStorage.getItem( 'accessToken', )}`; } } if (initiate) { initiate(); } try { const response = await fetch(url, options); if (includeAuthToken && response.status === 401) { return; } handleResponse(response); } catch (error) { handleError(error); } finally { if (handleFinally) { handleFinally(); } } }, []); return { nextJsRouter: router, logoutUser, fetchWrapper, }; } export default useRequestUtilities;
2. 在Electron端注入自定义路由实现
在Electron应用渲染报表组件时,使用RouterProvider注入适配Electron的路由对象:
import React from 'react'; import ReactDOM from 'react-dom/client'; import ReportPage from '你的npm包路径/pages/reports-index'; import { RouterProvider } from '你的npm包路径/components/hooks/use-request-utilities'; // 模拟Next Router的必要方法,适配Electron环境 const electronRouter = { push: (path: string) => window.location.href = path, replace: (path: string) => window.location.replace(path), query: {}, // 根据Electron实际需求提供query参数 isReady: true, // 其他需要的Router方法可以按需添加 } as any; ReactDOM.createRoot(document.getElementById('root')!).render( <RouterProvider value={electronRouter}> <ReportPage /> </RouterProvider> );
3. 优化报表页面的路由使用逻辑
修改reports-index.tsx中的useEffect,添加router存在性判断,避免在Electron环境中调用未定义的方法:
useEffect(() => { const accessToken = localStorage.getItem('accessToken'); if (!accessToken) { logoutUser(); return; } else { const userInfo = JSON.parse(localStorage.getItem('userInfo')); if (userInfo?.role !== 'LabEngineer') { // 判断router是否可用再执行跳转 if (router) { router.replace('/users'); } else { window.location.replace('/users'); } return; } fetchReports(Boolean(refetchReports)); } }, [refetchReports, router, logoutUser]);
内容的提问来源于stack exchange,提问作者Akii J
相关产品推荐
相关产品推荐

