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

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.17 14:09:57