最新国产好看的视频,伊人天堂AV在线,国产Aaaaaa视频,蜜臀视频在线观看一区,人妻av色图,密臀久久久精品影片,青青视频免费观看毛片,久草在线观看视,国产三级精品色情在线

使用Electron將Web應(yīng)用打包成桌面軟件

 更新時(shí)間:2026年05月28日 09:06:44   作者:夜雪聞竹  
本文為前端開(kāi)發(fā)者提供了一站式指南,學(xué)習(xí)如何使用Electron將Web應(yīng)用打包成桌面軟件,涵蓋主進(jìn)程與渲染進(jìn)程概念、BrowserWindow配置、安全模型及Fastify服務(wù)器嵌入等,通過(guò)實(shí)例詳細(xì)解析Electron核心概念及安全配置,幫助開(kāi)發(fā)者快速掌握Electron開(kāi)發(fā)技巧

本文面向:想把 Web 應(yīng)用打包成桌面軟件的前端開(kāi)發(fā)者。
預(yù)計(jì)閱讀時(shí)間:12 分鐘
最終效果:理解 Electron 的核心概念(主進(jìn)程、渲染進(jìn)程、安全模型),掌握 BrowserWindow 配置、服務(wù)器嵌入和窗口狀態(tài)持久化。

Electron 是什么

Electron 把 Chromium(Chrome 的內(nèi)核)和 Node.js 打包到了一起。你的 Web 前端跑在 Chromium 里,你的后端邏輯跑在 Node.js 里,兩者通過(guò) IPC(進(jìn)程間通信)連接。VS Code、Slack、Discord 都是 Electron 應(yīng)用。

對(duì) Web 開(kāi)發(fā)者來(lái)說(shuō),最大的好處是:你已有的 HTML/CSS/JS/React 代碼可以直接復(fù)用,不需要學(xué) Swift 或 C++。

最小項(xiàng)目結(jié)構(gòu)

一個(gè) Electron 項(xiàng)目至少需要三樣?xùn)|西:

my-app/
  package.json          # 入口指向 main.js
  main.js               # 主進(jìn)程:創(chuàng)建窗口、管理生命周期
  preload.js            # 預(yù)加載腳本:安全地暴露 API 給前端
  index.html            # 你的 Web 頁(yè)面

package.json 里的 "main": "main.js" 告訴 Electron 從哪個(gè)文件啟動(dòng)。運(yùn)行 npx electron . 就能打開(kāi)一個(gè)桌面窗口,里面顯示你的 HTML。

主進(jìn)程 vs 渲染進(jìn)程

這是 Electron 最核心的概念:

  • 主進(jìn)程(Main Process):運(yùn)行 main.js 的 Node.js 環(huán)境。它能訪問(wèn)文件系統(tǒng)、操作系統(tǒng) API、創(chuàng)建窗口。一個(gè)應(yīng)用只有一個(gè)主進(jìn)程。
  • 渲染進(jìn)程(Renderer Process):每個(gè) BrowserWindow 里跑的是一個(gè)獨(dú)立的 Chromium 頁(yè)面,和你在瀏覽器里打開(kāi)網(wǎng)頁(yè)一樣。它不能直接訪問(wèn) Node.js API。

兩個(gè)進(jìn)程各司其職。主進(jìn)程負(fù)責(zé)"管家"工作(窗口、托盤(pán)、菜單、系統(tǒng)交互),渲染進(jìn)程負(fù)責(zé)"展示"工作(UI、用戶(hù)交互)。它們通過(guò) contextBridge 安全地通信。

BrowserWindow 配置

創(chuàng)建窗口的核心是 new BrowserWindow(options)。ChatCrystal 的配置如下:

const win = new BrowserWindow({
  width: state.width,       // 從保存的狀態(tài)恢復(fù),或默認(rèn) 1280
  height: state.height,     // 默認(rèn) 800
  x: state.x,
  y: state.y,
  minWidth: 900,            // 最小寬度,防止窗口被拖得太小
  minHeight: 600,
  show: false,              // 先不顯示,等頁(yè)面加載完再顯示
  title: "ChatCrystal",
  icon: iconPath,
  webPreferences: {
    preload: path.join(__dirname, "preload.js"),
    contextIsolation: true,
    nodeIntegration: false,
    sandbox: true,
  },
});

幾個(gè)關(guān)鍵點(diǎn):

  • show: false 配合 ready-to-show 事件使用,避免窗口先閃一下白屏再加載內(nèi)容。
  • minWidth / minHeight 保證 UI 不會(huì)被壓到變形。
  • webPreferences 是安全相關(guān)的配置,下面專(zhuān)門(mén)講。

安全設(shè)置

Electron 的安全模型遵循一個(gè)原則:渲染進(jìn)程不應(yīng)該有特權(quán)。三個(gè)配置項(xiàng)實(shí)現(xiàn)這一點(diǎn):

contextIsolation: true:渲染進(jìn)程的 JavaScript 和 preload 腳本運(yùn)行在不同的上下文里。即使網(wǎng)頁(yè)被注入惡意代碼,它也無(wú)法訪問(wèn) preload 腳本里的 Node.js 對(duì)象。

nodeIntegration: false:渲染進(jìn)程里不能直接 require('fs') 之類(lèi)的 Node.js 模塊。這是關(guān)閉的,因?yàn)榫W(wǎng)頁(yè)內(nèi)容可能來(lái)自用戶(hù)輸入(比如 AI 對(duì)話內(nèi)容),如果能執(zhí)行任意 Node.js 代碼就是遠(yuǎn)程代碼執(zhí)行漏洞。

sandbox: true:進(jìn)一步限制,讓渲染進(jìn)程連 Chromium 的擴(kuò)展 API 都用不了,只保留最基本的 Web 能力。

那渲染進(jìn)程怎么和主進(jìn)程通信?通過(guò) preload 腳本:

// preload.ts
import { contextBridge } from "electron";

contextBridge.exposeInMainWorld("electronAPI", {
  isElectron: true,
  versions: {
    electron: process.versions.electron,
    node: process.versions.node,
    chrome: process.versions.chrome,
  },
});

contextBridge.exposeInMainWorld 是唯一安全的方式,它把指定的對(duì)象掛到 window.electronAPI 上。前端代碼可以讀 window.electronAPI.isElectron 來(lái)判斷自己是不是跑在 Electron 里,但無(wú)法訪問(wèn)任何危險(xiǎn)的 Node.js API。

CSP(內(nèi)容安全策略) 是另一層防護(hù)。ChatCrystal 在生產(chǎn)環(huán)境設(shè)置了嚴(yán)格的 CSP 頭:

session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
  callback({
    responseHeaders: {
      ...details.responseHeaders,
      "Content-Security-Policy": [
        "default-src 'self';" +
        " script-src 'self';" +
        " style-src 'self' 'unsafe-inline';" +
        " img-src 'self' data: blob:;" +
        " font-src 'self' data:;" +
        " connect-src 'self' http://localhost:* ws://localhost:*;" +
        " object-src 'none';" +
        " base-uri 'self'",
      ],
    },
  });
});

CSP 告訴瀏覽器:只允許加載同源的腳本,禁止內(nèi)聯(lián)腳本(script-src 'self'),禁止插件(object-src 'none')。這對(duì) ChatCrystal 尤其重要,因?yàn)樗鼤?huì)渲染 AI 對(duì)話內(nèi)容,必須防止 XSS 注入。

注意開(kāi)發(fā)環(huán)境跳過(guò)了 CSP,因?yàn)?Vite 的 HMR(熱更新)需要注入內(nèi)聯(lián)腳本。

嵌入 Fastify 服務(wù)器

很多 Electron 應(yīng)用只是展示靜態(tài)頁(yè)面,但 ChatCrystal 需要一個(gè)后端服務(wù)器來(lái)處理數(shù)據(jù)庫(kù)、向量搜索等邏輯。做法是把 Fastify 服務(wù)器直接嵌入主進(jìn)程:

async function startServer(port: number) {
  const serverEntry = pathToFileURL(
    path.join(app.getAppPath(), "server", "dist", "server", "src", "index.js"),
  ).href;

  const serverModule = await Function(
    "specifier",
    "return import(specifier)",
  )(serverEntry);

  return serverModule.createServer({ port, host: "127.0.0.1" });
}

這里有個(gè)技巧:Function("specifier", "return import(specifier)") 是一個(gè)繞過(guò) Electron 主進(jìn)程 CJS 限制的 workaround。Electron 的主進(jìn)程默認(rèn)是 CommonJS 模塊,不能直接用 await import() 加載 ESM 模塊。通過(guò) Function 構(gòu)造器可以繞過(guò)這個(gè)限制。

啟動(dòng)時(shí)先檢測(cè)端口,如果 3721 被占用就隨機(jī)分配一個(gè):

function findFreePort(preferred: number): Promise<number> {
  return new Promise((resolve, reject) => {
    const srv = net.createServer();
    srv.listen(preferred, "127.0.0.1", () => {
      srv.close(() => resolve(preferred));
    });
    srv.on("error", () => {
      const srv2 = net.createServer();
      srv2.listen(0, "127.0.0.1", () => {
        const port = (srv2.address() as net.AddressInfo).port;
        srv2.close(() => resolve(port));
      });
    });
  });
}

然后創(chuàng)建窗口,加載服務(wù)器的 URL:

mainWindow = createWindow();
const url = devUrl || `http://localhost:${serverPort}`;
await mainWindow.loadURL(url);

開(kāi)發(fā)模式下 devUrl 指向 Vite 開(kāi)發(fā)服務(wù)器(http://localhost:13721),生產(chǎn)模式下指向內(nèi)嵌的 Fastify 服務(wù)器。

窗口狀態(tài)持久化

用戶(hù)把窗口拖到副屏、調(diào)了大小,下次打開(kāi)應(yīng)該恢復(fù)到原來(lái)的位置和尺寸。ChatCrystal 用一個(gè) JSON 文件實(shí)現(xiàn):

function loadWindowState(): WindowState {
  try {
    const data = readFileSync(getWindowStatePath(), "utf-8");
    return JSON.parse(data);
  } catch {
    return { width: 1280, height: 800, isMaximized: false };
  }
}

function saveWindowState(win: BrowserWindow): void {
  const isMaximized = win.isMaximized();
  const bounds = isMaximized
    ? (lastNormalBounds ?? win.getBounds())
    : win.getBounds();
  writeFileSync(getWindowStatePath(), JSON.stringify(state));
}

保存的路徑是 app.getPath("userData")/window-state.json,這是 Electron 提供的用戶(hù)數(shù)據(jù)目錄,跨平臺(tái)且不會(huì)和應(yīng)用代碼混在一起。

還有一個(gè)細(xì)節(jié):如果用戶(hù)拔掉了外接顯示器,上次保存的窗口位置可能在屏幕外面。所以恢復(fù)時(shí)要檢查:

if (state.x !== undefined && state.y !== undefined) {
  const displays = screen.getAllDisplays();
  const visible = displays.some((d) => {
    const b = d.bounds;
    return (
      state.x! >= b.x - 50 &&
      state.x! < b.x + b.width &&
      state.y! >= b.y - 50 &&
      state.y! < b.y + b.height
    );
  });
  if (!visible) {
    state.x = undefined;  // 重置位置,讓系統(tǒng)自動(dòng)放置
    state.y = undefined;
  }
}

- 50 的容差是為了處理窗口邊緣剛好貼著屏幕邊界的情況。

單實(shí)例鎖

桌面應(yīng)用通常只允許運(yùn)行一個(gè)實(shí)例。用戶(hù)雙擊圖標(biāo)時(shí),如果已經(jīng)有實(shí)例在跑,應(yīng)該把已有窗口激活,而不是再開(kāi)一個(gè)。

const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
  app.quit();
}

拿到鎖的實(shí)例繼續(xù)運(yùn)行。沒(méi)拿到鎖的直接退出。同時(shí)監(jiān)聽(tīng) second-instance 事件,當(dāng)用戶(hù)再次嘗試啟動(dòng)時(shí),把已有窗口顯示出來(lái):

app.on("second-instance", () => {
  if (mainWindow) {
    if (mainWindow.isMinimized()) mainWindow.restore();
    mainWindow.show();
    mainWindow.focus();
  }
});

應(yīng)用生命周期

Electron 的生命周期事件串起了整個(gè)應(yīng)用的運(yùn)行邏輯。ChatCrystal 的啟動(dòng)流程在 app.whenReady() 里:

app.whenReady().then(async () => {
  // 1. 確定數(shù)據(jù)目錄
  const dataDir = getDataDir();
  mkdirSync(dataDir, { recursive: true });

  // 3. 設(shè)置環(huán)境變量
  process.env.ELECTRON = "true";
  process.env.DATA_DIR = dataDir;
  if (app.isPackaged) {
    process.env.ELECTRON_PACKAGED = "true";
  }

  // 4. 設(shè)置 CSP(生產(chǎn)環(huán)境)

  // 5. 檢測(cè)端口
  serverPort = await findFreePort(3721);

  // 6. 啟動(dòng) Fastify 服務(wù)器(開(kāi)發(fā)模式跳過(guò),服務(wù)器單獨(dú)運(yùn)行)
  if (!process.env.VITE_DEV_URL) {
    const server = await startServer(serverPort);
    serverShutdown = server.shutdown;
  }

  // 7. 創(chuàng)建窗口、加載頁(yè)面、創(chuàng)建托盤(pán)
  mainWindow = createWindow();
  await mainWindow.loadURL(url);
  createTray(mainWindow, serverPort);
});

退出時(shí),before-quit 事件觸發(fā)優(yōu)雅關(guān)閉:

app.on("before-quit", (e) => {
  if (!isQuitting) {
    e.preventDefault();
    isQuitting = true;
    const timeout = setTimeout(() => {
      app.exit(1);  // 10 秒超時(shí)強(qiáng)制退出
    }, 10000);
    gracefulShutdown()
      .finally(() => {
        clearTimeout(timeout);
        app.quit();
      });
  }
});

gracefulShutdown 依次關(guān)閉 Fastify 服務(wù)器和系統(tǒng)托盤(pán)。10 秒超時(shí)是為了防止關(guān)閉流程卡死——如果數(shù)據(jù)庫(kù)保存之類(lèi)的事情出了問(wèn)題,應(yīng)用不會(huì)永遠(yuǎn)掛在那里。

窗口關(guān)閉時(shí)不是真正退出,而是隱藏到托盤(pán):

win.on("close", (e) => {
  saveWindowState(win);
  if (!isQuitting) {
    e.preventDefault();
    win.hide();
  }
});

用戶(hù)通過(guò)托盤(pán)菜單的"Quit"才真正退出,這時(shí) isQuitting 已經(jīng)被設(shè)為 true,close 事件不會(huì)被攔截。

下一步

你現(xiàn)在了解了 Electron 的核心概念:主進(jìn)程與渲染進(jìn)程的分工、BrowserWindow 配置、安全模型、服務(wù)器嵌入、窗口狀態(tài)持久化、單實(shí)例鎖和生命周期管理。

如果你想深入:

  • IPC 通信ipcMain.handle / ipcRenderer.invoke 用于渲染進(jìn)程調(diào)用主進(jìn)程的功能(比如打開(kāi)文件對(duì)話框)
  • 自動(dòng)更新electron-updater 可以實(shí)現(xiàn)應(yīng)用內(nèi)更新
  • 打包發(fā)布electron-builder 可以打包成 Windows 安裝包(NSIS)、macOS DMG、Linux AppImage
  • 性能優(yōu)化:懶加載窗口、減少主進(jìn)程阻塞操作

ChatCrystal 的完整 Electron 代碼在 electron/ 目錄下,可以直接作為參考項(xiàng)目。從一個(gè)能跑的最小結(jié)構(gòu)開(kāi)始,逐步加上你需要的功能,這是最快的學(xué)習(xí)路徑。

以上就是使用Electron將Web應(yīng)用打包成桌面軟件的詳細(xì)內(nèi)容,更多關(guān)于Electron Web應(yīng)用打包成桌面軟件的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!

相關(guān)文章

  • 關(guān)于js datetime的那點(diǎn)事

    關(guān)于js datetime的那點(diǎn)事

    關(guān)于js datetime的一些使用經(jīng)驗(yàn)分享,想要了解datetime日期操作的朋友可以參考下。
    2011-11-11
  • js實(shí)現(xiàn)擲骰子小游戲

    js實(shí)現(xiàn)擲骰子小游戲

    這篇文章主要為大家詳細(xì)介紹了js實(shí)現(xiàn)擲骰子小游戲,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下
    2019-10-10
  • 前端解決頁(yè)面請(qǐng)求接大規(guī)模并發(fā)問(wèn)題的代碼實(shí)現(xiàn)

    前端解決頁(yè)面請(qǐng)求接大規(guī)模并發(fā)問(wèn)題的代碼實(shí)現(xiàn)

    這篇文章主要介紹了前端解決大規(guī)模并發(fā)請(qǐng)求問(wèn)題的多種方法,包括按需加載、分頁(yè)與無(wú)限滾動(dòng)、緩存機(jī)制、減少HTTP請(qǐng)求、使用CDN、前端限流、異步加載、預(yù)加載、優(yōu)化資源大小、代碼分割以及服務(wù)器端渲染或靜態(tài)站點(diǎn)生成,需要的朋友可以參考下
    2024-11-11
  • 聊聊JavaScript中.?、??、??=的用法以及含義

    聊聊JavaScript中.?、??、??=的用法以及含義

    今天項(xiàng)目中突然遇到了不太明白的寫(xiě)法,下面這篇文章主要給大家介紹了關(guān)于JavaScript中.?、??、??=的用法以及含義的相關(guān)資料,文中通過(guò)示例代碼介紹的非常詳細(xì),需要的朋友可以參考下
    2022-04-04
  • js算法實(shí)例之字母大小寫(xiě)轉(zhuǎn)換

    js算法實(shí)例之字母大小寫(xiě)轉(zhuǎn)換

    實(shí)現(xiàn)javascript?英文首字母大寫(xiě)有多種方法,下面這篇文章主要給大家介紹了關(guān)于js算法實(shí)例之字母大小寫(xiě)轉(zhuǎn)換的相關(guān)資料,文中通過(guò)示例代碼介紹的非常詳細(xì),需要的朋友可以參考下
    2022-12-12
  • 維護(hù)loading加載狀態(tài)的幾個(gè)方法小結(jié)

    維護(hù)loading加載狀態(tài)的幾個(gè)方法小結(jié)

    在項(xiàng)目開(kāi)發(fā)中,當(dāng)頁(yè)面請(qǐng)求接口時(shí),組件局部或者頁(yè)面全局顯示loading加載遮罩可謂是司空見(jiàn)慣了,下面來(lái)討論一下如何優(yōu)雅的使用loading狀態(tài),文中通過(guò)代碼示例介紹的非常詳細(xì),需要的朋友可以參考下
    2024-09-09
  • javascript查詢(xún)字符串參數(shù)的方法

    javascript查詢(xún)字符串參數(shù)的方法

    這篇文章主要介紹了javascript查詢(xún)字符串參數(shù)的方法,實(shí)例分析了javascript獲取URL中對(duì)應(yīng)參數(shù)的使用技巧,需要的朋友可以參考下
    2015-01-01
  • JavaScript實(shí)現(xiàn)前端實(shí)時(shí)搜索功能

    JavaScript實(shí)現(xiàn)前端實(shí)時(shí)搜索功能

    這篇文章主要為大家詳細(xì)介紹了JavaScript實(shí)現(xiàn)前端實(shí)時(shí)搜索功能 ,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下
    2017-04-04
  • JS解惑之Object中的key是有序的么

    JS解惑之Object中的key是有序的么

    這篇文章主要介紹了JS解惑之Object中的key是有序的么,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧
    2019-05-05
  • echarts多條折線圖動(dòng)態(tài)分層的實(shí)現(xiàn)方法

    echarts多條折線圖動(dòng)態(tài)分層的實(shí)現(xiàn)方法

    這篇文章主要介紹了echarts多條折線圖動(dòng)態(tài)分層的實(shí)現(xiàn)方法,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧
    2019-05-05

最新評(píng)論

宁城县| 灵宝市| 海晏县| 阿拉善左旗| 乐东| 江城| 宝丰县| 岳普湖县| 宁远县| 新化县| 琼海市| 临沧市| 名山县| 诏安县| 岱山县| 曲周县| 武定县| 阳朔县| 松溪县| 正宁县| 饶平县| 桓仁| 宁蒗| 沙湾县| 社旗县| 杭锦后旗| 东莞市| 旬阳县| 海盐县| 榆中县| 革吉县| 延津县| 沙湾县| 新宾| 高邑县| 莱芜市| 湖南省| 犍为县| 荔波县| 达孜县| 江达县|