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

如何使用原生JavaScript、HTML和CSS在Electron中实现单页应用(SPA)及路由问题咨询

嘿,聊聊 Electron 里用原生 JS/CSS 实现 SPA 的那些事

我之前也折腾过类似的场景,刚好能给你一些实际的建议和代码示例:

先回答你的第一个疑问:控制区块显隐算不算 SPA?

这种通过切换DOM元素显示/隐藏来切换页面内容的方式,属于SPA的简易实现——毕竟SPA的核心就是单HTML文件内切换内容,不刷新页面。不过它是无路由的SPA,适合功能简单的小应用;如果需要支持浏览器前进后退、书签保存,或者页面结构复杂,带路由的方案会更实用。

解决 location.pathname 返回文件路径的坑

在Electron里直接加载本地HTML时,location.pathname会返回文件系统的绝对路径(比如/Users/xxx/app/index.html),这是因为默认用file://协议加载本地资源,不是HTTP协议的路径规则。这里有两种靠谱的解决思路:

方案1:Hash模式路由(零配置,最省心)

Hash模式用URL中#后面的部分做路由(比如#/posts),这种方式不会触发页面刷新,Electron也能完美识别。你只需要把路由逻辑里的location.pathname换成location.hash.slice(1)(去掉开头的#),再监听hashchange事件就行。

方案2:History模式路由(更美观的无# URL)

如果想要干净的URL(不带#),就得在Electron主进程里做些配置:拦截页面跳转,让Electron始终加载主HTML文件,再交给前端路由处理。具体步骤我后面会给代码示例。

完整可运行的Hash模式示例

主进程代码(main.js)

const { app, BrowserWindow } = require('electron');
const path = require('path');

function createWindow() {
  const mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: true,
      contextIsolation: false, // 如果你需要在渲染进程直接用Node API,否则可以设为true并写preload脚本
    }
  });

  mainWindow.loadFile(path.join(__dirname, 'index.html'));
}

app.whenReady().then(() => {
  createWindow();

  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) createWindow();
  });
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit();
});

渲染进程代码(index.html)

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Electron SPA Demo</title>
  <style>
    .nav-btn { margin: 0 10px; padding: 8px 16px; cursor: pointer; border: none; border-radius: 4px; background: #2196F3; color: white; }
    .nav-btn:hover { background: #1976D2; }
    .page { padding: 20px; }
  </style>
</head>
<body>
  <nav>
    <button class="nav-btn" data-path="#/">Dashboard</button>
    <button class="nav-btn" data-path="#/posts">Posts</button>
    <button class="nav-btn" data-path="#/settings">Settings</button>
  </nav>
  <div id="app"></div>

  <script>
    // 页面组件类
    class Dashboard {
      async getHtml() {
        return `<div class="page"><h1>Dashboard</h1><p>欢迎来到你的控制台!这里是默认首页~</p></div>`;
      }
    }

    class Posts {
      async getHtml() {
        return `<div class="page"><h1>文章列表</h1><ul><li>第一篇技术博客</li><li>Electron路由实践</li><li>原生JS SPA技巧</li></ul></div>`;
      }
    }

    class Settings {
      async getHtml() {
        return `<div class="page"><h1>设置页面</h1><p>在这里调整应用的各项参数:</p><label><input type="checkbox" /> 开启夜间模式</label></div>`;
      }
    }

    // 路由工具函数(和你视频里的逻辑一致,适配Hash路径)
    function getParams(match) {
      const values = match.result.slice(1);
      const keys = Array.from(match.route.path.matchAll(/:(\w+)/g)).map(result => result[1]);
      return Object.fromEntries(keys.map((key, i) => [key, values[i]]));
    }

    function pathToRegex(path) {
      return new RegExp(`^${path.replace(/:\w+/g, '([^/]+)')}$`);
    }

    // 核心路由逻辑
    const router = async () => {
      const routes = [
        { path: "/", view: Dashboard },
        { path: "/posts", view: Posts },
        { path: "/settings", view: Settings }
      ];

      // 获取当前Hash路由(去掉#)
      const currentPath = location.hash.slice(1) || '/';

      // 匹配路由
      const potentialMatches = routes.map(route => ({
        route: route,
        result: currentPath.match(pathToRegex(route.path))
      }));

      let match = potentialMatches.find(p => p.result !== null);
      if (!match) match = { route: routes[0], result: [currentPath] };

      // 渲染对应页面
      const view = new match.route.view(getParams(match));
      document.querySelector("#app").innerHTML = await view.getHtml();
    };

    // 初始化路由 + 监听Hash变化
    window.addEventListener('hashchange', router);
    window.addEventListener('load', router);

    // 导航按钮点击事件
    document.querySelectorAll('.nav-btn').forEach(btn => {
      btn.addEventListener('click', e => {
        location.hash = e.target.dataset.path;
      });
    });
  </script>
</body>
</html>

如果你想用视频里的History模式

只需要在主进程中添加页面跳转拦截逻辑,同时修改渲染进程的路由监听:

主进程新增代码(main.js的createWindow函数里)

mainWindow.webContents.on('will-navigate', (event, url) => {
  const parsedUrl = new URL(url);
  // 只允许加载主HTML文件,其他路由都交给前端处理
  if (parsedUrl.pathname !== path.join(__dirname, 'index.html')) {
    event.preventDefault();
    // 提取路由路径,通知前端更新
    const routePath = parsedUrl.pathname.replace(path.dirname(path.join(__dirname, 'index.html')), '');
    mainWindow.webContents.send('update-route', routePath);
  }
});

渲染进程修改路由逻辑

把Hash相关的部分换成History模式:

// 核心路由逻辑修改
const router = async (currentPath = location.pathname) => {
  const routes = [
    { path: "/", view: Dashboard },
    { path: "/posts", view: Posts },
    { path: "/settings", view: Settings }
  ];

  // 匹配路由(逻辑和之前一致)
  const potentialMatches = routes.map(route => ({
    route: route,
    result: currentPath.match(pathToRegex(route.path))
  }));

  let match = potentialMatches.find(p => p.result !== null);
  if (!match) match = { route: routes[0], result: [currentPath] };

  const view = new match.route.view(getParams(match));
  document.querySelector("#app").innerHTML = await view.getHtml();
};

// 监听主进程的路由更新事件
const { ipcRenderer } = require('electron');
ipcRenderer.on('update-route', (event, path) => {
  history.pushState({}, '', path);
  router(path);
});

// 监听浏览器前进后退
window.addEventListener('popstate', () => router(location.pathname));

// 导航按钮修改
document.querySelectorAll('.nav-btn').forEach(btn => {
  btn.addEventListener('click', e => {
    const path = e.target.dataset.path.slice(1); // 去掉#
    history.pushState({}, '', path);
    router(path);
  });
});

这样就能实现不带#的干净URL路由了。

内容的提问来源于stack exchange,提问作者L. de Troz

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.04.29 08:38:15