Electron开发

目录

一、主进程/渲染进程/preload/IPC入门

1.1、前期准备

1.2、名词解释

1.3、主题切换

二、进程与通信

2.1、IPC进程间通信

案例1:渲染进程→主进程(单向)

案例2:渲染进程→主进程(双向)

案例3:主进程→渲染进程

2.2、消息端口(MessageChannel)、流式回复

案例1:渲染进程把Port发给主进程

案例2:两个渲染进程通过主进程建立通道

案例3:主窗口与Worker窗口直连

[案例4:回复流(一次请求,多次数据 + close)](#案例4:回复流(一次请求,多次数据 + close))

2.3、效率进程(utility.js)

案例1:CPU密集型任务(eg:大量计算、数据处理、加解密、压缩)

案例2:崩溃隔离(eg:第三方插件、不稳定的原生模块、实验性功能)

案例3:渲染进程与效率进程直接通信(MessagePort)

2.4、多线程(Worker线程)

案例1:耗时计算Worker (eg:大量数据处理、复杂计算任务)

[案例2:使用Node.js API的Worker(eg:读取大文件、文件系统操作)](#案例2:使用Node.js API的Worker(eg:读取大文件、文件系统操作))

案例3:定时任务Worker(eg:后台运行定时任务、启动和停止定时器)

2.5、进程沙盒(sandbox)

[2.5.1、默认沙盒(Electron 20+ 默认行为)](#2.5.1、默认沙盒(Electron 20+ 默认行为))

[2.5.2、禁用沙盒(sandbox: false)](#2.5.2、禁用沙盒(sandbox: false))

2.5.3、全局启用沙盒(app.enableSandbox)

[2.5.4、沙盒环境下的 Preload 脚本演示](#2.5.4、沙盒环境下的 Preload 脚本演示)

一、主进程/渲染进程/preload/IPC入门

官方文档:https://www.electronjs.org/zh/docs/latest/tutorial/installation

应用举例:VScode、钉钉、腾讯会议...

案例来源:https://gitee.com/sharetoyouclub/electron-demo

1.1、前期准备

1、安装vs code和node.js(建议v22.14.0),npm对应为v10.9.2;

2、创建项目(二选一)

(1)、创建新项目(会自动安装Electron,快速开始)
npm create @quick-start/electron@latest后进入文件夹,再从第三步继续执行

(2)、手动搭建(已有Node项目想添加Electron)

新建文件夹并初始化npm init -y;安装:npm install electron --save-dev,手动创建主进程文件main.js和入口HTML;

3、安装依赖包:npm install

4、测试运行:npm run start,或自己修改配置文件,增加vite命令,就可以在浏览器运行;

5、打包工具:Electron Builder/Electron Forge/electron-packager等,用于生成安装包与自动更新。打包命令:npm run package(不创建安装程序),npm run make(创建安装程序)。

1.2、名词解释

Electron应用至少有两个「角色」:
主进程 (负责创建窗口、系统API)和渲染进程 (显示用户界面)。

两者不能直接互相调函数,要通过preload预加载脚本 安全地暴露接口,或用IPC (进程间通信)传消息。
主进程:main.js。每个Electron应用有且只有一个主进程。它负责:

(1)、创建和管理应用窗口(渲染进程)

(2)、处理应用程序生命周期(启动、退出、前后台切换)

(3)、与操作系统原生API交互

(4)、管理菜单、对话框等系统级组件
渲染进程:renderer.js。每个Electron窗口都是一个独立的渲染进程。它负责:

(1)、运行在Chromium浏览器环境中

(2)、使用HTML、CSS、JavaScript构建界面

(3)、每个窗口都是独立的进程,互不影响

(4)、通过IPC与主进程通信
预加载脚本:preload.js。预加载脚本是连接主进程和渲染进程的"桥梁"。它负责:

(1)、在渲染进程加载网页之前运行

(2)、有权访问Node.jsAPI和DOM

(3)、通过contextBridge安全地暴露API给渲染进程
IPC通信:由于主进程与渲染进程是独立的,所以必须通过 IPC 传递数据。

常见问题

Q:为什么需要预加载脚本
A:为了安全。渲染进程不能直接访问Node.js API,它作为桥梁,可以安全地暴露必要的API。

Q:`contextBridge `是什么?
A:是个安全API,用于在主进程和渲染进程之间传递数据,避免直接暴露Node.js API。

Q:为什么macOS 关闭窗口后应用不退出?
A:用户体验习惯。关闭窗口后应用仍在Dock中运行,用户可以通过点击Dock图标重新打开窗口。

Q:IPC通信 是同步还是异步
A:本示例使用的是异步通信(`ipcRenderer.invoke()`和`ipcMain.handle()`)。也可以使用同步通信(`ipcRenderer.sendSync()`和`ipcMain.on()`),但不推荐。

1.3、主题切换

系统主题 nativeTheme或自己切换,和CSSprefers-color-scheme配合。当主进程通过 `nativeTheme.themeSource` 改变主题时,浏览器会检测到系统偏好变化,CSS 媒体查询会自动生效,样式就会自动切换。

step1:应用界面index.html

html 复制代码
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Hello World!</title>
    <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';" />
    <link rel="stylesheet" type="text/css" href="./styles.css">
</head>
<body>
    <p>当前主题来源: <strong id="theme-source">System</strong></p>
    <button id="toggle-dark-mode">切换模式</button>
    <button id="reset-to-system">重置为系统主题</button>
    <script src="renderer.js"></script>
</body>
</html>

step2:styles.css

css 复制代码
:root {
    color-scheme: light dark;
  }

  @media (prefers-color-scheme: dark) {
    body { background: #333; color: white; }
  }
  
  @media (prefers-color-scheme: light) {
    body { background: #ddd; color: black; }
  }

step3:渲染进程脚本renderer.js

javascript 复制代码
document
  .getElementById("toggle-dark-mode")
  .addEventListener("click", async () => {
    const isDarkMode = await window.darkMode.toggle();
    document.getElementById("theme-source").innerHTML = isDarkMode
      ? "Dark"
      : "Light";
  });

document
  .getElementById("reset-to-system")
  .addEventListener("click", async () => {
    await window.darkMode.system();
    document.getElementById("theme-source").innerHTML = "System";
  });

step4:预加载脚本preload.js

javascript 复制代码
const { contextBridge, ipcRenderer } = require('electron/renderer')
// 向渲染进程暴露 安全的API
contextBridge.exposeInMainWorld('darkMode', {
  toggle: () => ipcRenderer.invoke('dark-mode:toggle'),
  system: () => ipcRenderer.invoke('dark-mode:system')
})

step5:主进程main.js

javascript 复制代码
const { app, BrowserWindow, ipcMain, nativeTheme } = require("electron/main");
const path = require("node:path");
function createWindow() {
  // 创建浏览器窗口
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, "preload.js"),
      // 开启开发者工具
      devTools: true,
    },
  });
  // 自动打开开发者工具,或者按Ctrl+Shift+I或Cmd+Option+I打开开发者工具
  win.webContents.openDevTools();
  // 加载应用的 index.html 文件
  win.loadFile("index.html");
}

ipcMain.handle("dark-mode:toggle", () => {
  if (nativeTheme.shouldUseDarkColors) {
    nativeTheme.themeSource = "light";
  } else {
    nativeTheme.themeSource = "dark";
  }
  return nativeTheme.shouldUseDarkColors;
});

ipcMain.handle("dark-mode:system", () => {
  nativeTheme.themeSource = "system";
});
// 当 Electron 完成初始化并准备创建浏览器窗口时调用此方法。
app.whenReady().then(() => {
  createWindow();
  // macOS 特殊逻辑:点击 Dock 图标时重新创建窗口
  app.on("activate", () => {
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow();
    }
  });
});
// 当所有窗口关闭时触发(仅在 macOS 外生效)
app.on("window-all-closed", () => {
  if (process.platform !== "darwin") {
    app.quit(); // 退出应用
  }
});

二、进程与通信

*为了快速理解:看完本章后,直接亲手敲一遍"效率进程"的案例1,成功运行后豁然开朗。

2.1、IPC进程间通信

案例1:渲染进程→主进程(单向)

step1:应用界面index.html

html 复制代码
<body>
  <h1>案例 1:渲染进程 → 主进程(单向)</h1>
  <p>通过 IPC 把输入框内容设为窗口标题,只发不收。</p>
  <label for="title">窗口标题:</label>
  <input type="text" id="title" placeholder="输入新标题">
  <button type="button" id="btn">设置标题</button>
  <p class="hint">通道:set-title,用法:ipcRenderer.send + ipcMain.on</p>
  <script src="./renderer.js"></script>
</body>

step2:渲染进程脚本renderer.js

javascript 复制代码
const setButton = document.getElementById('btn')
const titleInput = document.getElementById('title')
setButton.addEventListener('click', () => {
  const title = titleInput.value || '未命名'
  window.electronAPI.setTitle(title)
})

step3:预加载脚本preload.js

javascript 复制代码
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
  setTitle: (title) => ipcRenderer.send('set-title', title)
})

step4:主进程main.js

javascript 复制代码
// ======================核心代码======================
function handleSetTitle(event, title) {
  const win = BrowserWindow.fromWebContents(event.sender);
  if (win) win.setTitle(title);
}
app.whenReady().then(() => {
  ipcMain.on("set-title", handleSetTitle);
});

案例2:渲染进程→主进程(双向)

step1:应用界面index.html

html 复制代码
<body>
  <h1>案例 2:渲染进程 → 主进程(双向)</h1>
  <p>点击按钮打开系统文件选择框,主进程返回选中路径。</p>
  <button type="button" id="btn">打开文件</button>
  路径:<strong id="filePath">(未选择)</strong>
  <p class="hint">通道:dialog:openFile,用法:ipcRenderer.invoke + ipcMain.handle</p>
  <script src="./renderer.js"></script>
</body>

step2:渲染进程脚本renderer.js

javascript 复制代码
const btn = document.getElementById('btn')
const filePathEl = document.getElementById('filePath')

btn.addEventListener('click', async () => {
  const filePath = await window.electronAPI.openFile()
  filePathEl.textContent = filePath || '(未选择)'
})

step3:预加载脚本preload.js

javascript 复制代码
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
  openFile: () => ipcRenderer.invoke('dialog:openFile')
})

step4:主进程main.js

javascript 复制代码
// ======================核心代码======================
async function handleFileOpen() {
  const { canceled, filePaths } = await dialog.showOpenDialog({})
  if (!canceled && filePaths.length) return filePaths[0]
  return null
}
app.whenReady().then(() => {
  ipcMain.handle('dialog:openFile', handleFileOpen)
})

案例3:主进程→渲染进程

step1:应用界面index.html

html 复制代码
<body>
  <h1>案例 3:主进程 → 渲染进程</h1>
  <p>通过菜单"+1 / -1"向渲染进程发消息,更新计数;渲染进程可把当前值发回主进程。</p>
  <p>当前值:<strong id="counter">0</strong></p>
  <p class="hint">通道:update-counter / counter-value</p>
  <p>用法:webContents.send + ipcRenderer.on</p>
  <script src="./renderer.js"></script>
</body>

step2:渲染进程脚本renderer.js

javascript 复制代码
const counterEl = document.getElementById('counter')
window.electronAPI.onUpdateCounter((value) => {
  const oldValue = Number(counterEl.textContent)
  const newValue = oldValue + value
  counterEl.textContent = newValue
  window.electronAPI.counterValue(newValue)
})

step3:预加载脚本preload.js

javascript 复制代码
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
  onUpdateCounter: (callback) => {
    ipcRenderer.on('update-counter', (_event, value) => callback(value))
  },
  counterValue: (value) => ipcRenderer.send('counter-value', value)
})

step4:主进程main.js

javascript 复制代码
// ======================核心代码======================
const menu = Menu.buildFromTemplate([
    {
      label: app.name,
      submenu: [
        { label: '+1', click: () => mainWindow.webContents.send('update-counter', 1) },
        { label: '-1', click: () => mainWindow.webContents.send('update-counter', -1) }
      ]
    }
  ])
  Menu.setApplicationMenu(menu)
  mainWindow.loadFile('index3.html')
  ipcMain.on('counter-value', (_event, value) => {
    console.log('当前计数:', value)
  })
}

| 案例 | 方向 | 主进程 API | 渲染/预加载 API | 是否等回复 |

| 1 | 渲染→主 | `ipcMain.on ` | `ipcRenderer.send` | 否 |

| 2 | 渲染→主 | `ipcMain.handle ` | `ipcRenderer.invoke` | 是(Promise) |

| 3 | 主→渲染 | `webContents.send ` | `ipcRenderer.on` | 可选(再 send 回去) |

2.2、消息端口(MessageChannel)、流式回复

案例1:渲染进程把Port发给主进程

step1:应用界面index.html

html 复制代码
<body>
  <h1>案例 1:渲染进程把 Port 发给主进程</h1>
  <p>渲染进程创建 MessageChannel,把 port1 通过 IPC 发给主进程,主进程用 MessagePortMain 收消息并回复。</p>
  <button id="send">发一条消息给主进程</button>
  <div id="log"></div>
  <p class="hint">只有 postMessage 能传输 MessagePort;主进程用 port.on('message')、port.start()。</p>
  <script src="./renderer.js"></script>
</body>

step2:渲染进程脚本renderer.js

javascript 复制代码
const logEl = document.getElementById('log')
const sendBtn = document.getElementById('send')

function log(msg) {
  logEl.textContent += new Date().toLocaleTimeString() + ' ' + msg + '\n'
}

window.electronAPI.onMessage((data) => {
  log('收到主进程回复: ' + JSON.stringify(data))
})

sendBtn.addEventListener('click', () => {
  const payload = { answer: 42, time: Date.now() }
  window.electronAPI.postMessage(payload)
  log('已发送: ' + JSON.stringify(payload))
})

log('已把 port1 发给主进程,可点击按钮发送消息。')

step3:预加载脚本preload.js

javascript 复制代码
const { contextBridge, ipcRenderer } = require('electron')

// 在 preload 里创建 MessageChannel 并发送 port1,避免从渲染进程传 port 导致 "Invalid value for transfer"
const channel = new MessageChannel()
const port1 = channel.port1
const port2 = channel.port2
ipcRenderer.postMessage('port', null, [port1])

// contextBridge 不能直接暴露 MessagePort(会丢 postMessage 等原生方法),改为暴露封装函数
contextBridge.exposeInMainWorld('electronAPI', {
  postMessage: (data) => port2.postMessage(data),
  onMessage: (callback) => {
    port2.onmessage = (e) => callback(e.data)
  }
})

step4:主进程main.js

javascript 复制代码
// ======================核心代码======================
app.whenReady().then(() => {
  // 渲染进程通过 postMessage 把 MessagePort 发过来(只有 postMessage 能传 port)
  ipcMain.on('port', (event) => {
    const port = event.ports[0]
    // 主进程里变成 MessagePortMain,用 Node 风格 .on('message', ...)
    port.on('message', (e) => {
      console.log('主进程收到:', e.data)
      port.postMessage({ received: true, echo: e.data })
    })
    port.start()
  })
})

功能:

渲染进程创建 `MessageChannel`,把 port1 通过 `ipcRenderer.postMessage('port', null, [port1])` 发给主进程;主进程用 `event.ports[0]` 拿到 **MessagePortMain**,`port.on('message', ...)` 收消息、`port.postMessage(...)` 回复,最后 `port.start()`。
要点:

(1)、只有 **postMessage** 能传 port,send/invoke 不行

(2)、主进程里是 **MessagePortMain**,用 `.on('message', (e) => e.data)`、`.start()`。

案例2:两个渲染进程通过主进程建立通道

step1:应用界面index.html

step2:渲染进程脚本renderer.js

step3:预加载脚本preload.js

javascript 复制代码
const { contextBridge, ipcRenderer } = require('electron')

// 不能把 port 直接传给渲染进程(contextBridge 会丢原生方法),在 preload 里持有 port 并暴露封装函数
let port = null
let messageCallback = null

ipcRenderer.on('port', (event) => {
  port = event.ports[0]
  if (messageCallback) port.onmessage = (ev) => messageCallback(ev.data)
})

contextBridge.exposeInMainWorld('electronAPI', {
  postMessage: (data) => {
    if (port) port.postMessage(data)
  },
  onMessage: (callback) => {
    messageCallback = callback
    if (port) port.onmessage = (ev) => callback(ev.data)
  }
})

step4:主进程main.js

javascript 复制代码
app.whenReady().then(async () => {
  const mainWindow = new BrowserWindow({
    width: 520,
    height: 400,
    show: false,
    webPreferences: {
      contextIsolation: true,
      preload: path.join(__dirname, 'preload2.js')
    }
  })
  mainWindow.loadFile('index2a.html')

  const secondaryWindow = new BrowserWindow({
    width: 520,
    height: 400,
    show: false,
    webPreferences: {
      contextIsolation: true,
      preload: path.join(__dirname, 'preload2.js')
    }
  })
  secondaryWindow.loadFile('index2b.html')

  const { port1, port2 } = new MessageChannelMain()

  mainWindow.once('ready-to-show', () => {
    mainWindow.show()
    mainWindow.webContents.postMessage('port', null, [port1])
  })
  secondaryWindow.once('ready-to-show', () => {
    secondaryWindow.show()
    secondaryWindow.webContents.postMessage('port', null, [port2])
  })
})

功能:

主进程创建 **MessageChannelMain**,在 `ready-to-show` 后把 port1 发给窗口 A、port2 发给窗口 B;两窗口通过 preload 收到 port 后,用 `port.postMessage` / `port.onmessage` 直接通信,主进程不再参与
要点:

(1)、主进程用 `new MessageChannelMain()` 得到 `port1`、`port2`。

(2)、用 `webContents.postMessage('port', null, [port1])` 把端口发给对应窗口。

(3)、预加载里 `ipcRenderer.on('port', (event) => event.ports[0])`,再通过 contextBridge 把 port 交给页面。

案例3:主窗口与Worker窗口直连

step1:应用界面index.html

html 复制代码
<body>
  <h1>案例 3:主窗口与 Worker 窗口直连</h1>
  <p>Worker 是隐藏窗口,主窗口通过主进程拿到与 Worker 的 MessagePort,直接通信(主进程不转发内容)。</p>
  <input type="number" id="num" value="21">
  <button id="send">发给 Worker 计算 (×2)</button>
  <div id="log"></div>
  <p class="hint">主进程只负责建立 channel 并分发 port,不参与后续消息内容。</p>
  <script src="./renderer3.js"></script>
</body>
html 复制代码
<!-- worker.html 隐藏 -->
<body>
  <p>Worker 窗口(隐藏),收到数字后 ×2 并回复。</p>
  <script src="./renderer3-worker.js"></script>
</body>

step2:渲染进程脚本renderer.js

javascript 复制代码
const logEl = document.getElementById('log')
const sendBtn = document.getElementById('send')
const numInput = document.getElementById('num')
function log(msg) {
  logEl.textContent += new Date().toLocaleTimeString() + ' ' + msg + '\n'
}
window.electronAPI.requestWorkerChannel()
window.electronAPI.onMessage((data) => {
  log('Worker 结果: ' + data)
})
log('已连接 Worker')
sendBtn.addEventListener('click', () => {
  const n = Number(numInput.value) || 0
  window.electronAPI.postMessage(n)
  log('已发送: ' + n)
})
javascript 复制代码
// Worker 窗口:收到数字后 ×2 并 postMessage 回去
window.electronAPI.onMessage((data) => {
  const result = data * 2
  window.electronAPI.postMessage(result)
})

step3:预加载脚本preload.js

javascript 复制代码
const { contextBridge, ipcRenderer } = require('electron')
// 不能把 port 直接传给渲染进程(会丢 postMessage 等),在 preload 里持有 port 并暴露封装函数
let port = null
let messageCallback = null
ipcRenderer.on('provide-worker-channel', (event) => {
  port = event.ports[0]
  if (messageCallback) port.onmessage = (ev) => messageCallback(ev.data)
})
contextBridge.exposeInMainWorld('electronAPI', {
  requestWorkerChannel: () => ipcRenderer.send('request-worker-channel'),
  postMessage: (data) => {
    if (port) port.postMessage(data)
  },
  onMessage: (callback) => {
    messageCallback = callback
    if (port) port.onmessage = (ev) => callback(ev.data)
  }
})
javascript 复制代码
const { contextBridge, ipcRenderer } = require('electron')
// 不能把 port 直接传给渲染进程(会丢 postMessage 等),在 preload 里持有 port 并暴露封装函数
let port = null
let messageCallback = null
ipcRenderer.on('new-client', (event) => {
  port = event.ports[0]
  if (messageCallback) port.onmessage = (ev) => messageCallback(ev.data)
})
contextBridge.exposeInMainWorld('electronAPI', {
  postMessage: (data) => {
    if (port) port.postMessage(data)
  },
  onMessage: (callback) => {
    messageCallback = callback
    if (port) port.onmessage = (ev) => callback(ev.data)
  }
})

step4:主进程main.js

javascript 复制代码
// ==============核心代码===================  
const mainWindow = new BrowserWindow({
    width: 520,
    height: 420,
    webPreferences: {
      contextIsolation: true,
      preload: path.join(__dirname, 'preload3-app.js')
    }
  })
  mainWindow.loadFile('index3.html')
  // 主窗口请求 worker 通道时,建立 MessageChannel,一端给 worker,一端给主窗口
  ipcMain.on('request-worker-channel', (event) => {
    const { port1, port2 } = new MessageChannelMain()
    workerWindow.webContents.postMessage('new-client', null, [port1])
    event.sender.postMessage('provide-worker-channel', null, [port2])
  })
})
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit()
})

功能:

Worker 是一个隐藏的 BrowserWindow。主窗口通过 IPC 向主进程请求"worker 通道";主进程建立 **MessageChannelMain**,一端发给 Worker 窗口(`new-client`),一端发回主窗口(`provide-worker-channel`)。之后主窗口和 Worker 直接通过 port 通信,主进程不转发消息内容。
要点:

(1)、主进程监听 `request-worker-channel`,创建 channel,`port1` 给 worker、`port2` 给 `event.sender`(主窗口)。

(2)、Worker 用 `ipcRenderer.on('new-client', ...)` 收 port,主窗口用 `ipcRenderer.on('provide-worker-channel', ...)` 收 port。

案例4:回复流(一次请求,多次数据 + close)

step1:应用界面index.html

html 复制代码
<body>
  <p>一次请求,主进程通过 port 连续发送多条数据后 close,实现"流式"回复。</p>
  <label>数值</label><input type="number" id="element" value="42">
  <label>条数</label><input type="number" id="count" value="10">
  <button id="btn">请求流</button>
  <div id="log"></div>
  <p class="hint">通道:give-me-a-stream;主进程 for 循环 postMessage 后 replyPort.close()。</p>
  <script src="./renderer.js"></script>
</body>

step2:渲染进程脚本renderer.js

javascript 复制代码
const logEl = document.getElementById('log')
const btn = document.getElementById('btn')
const elementInput = document.getElementById('element')
const countInput = document.getElementById('count')
function log(msg) {
  logEl.textContent += new Date().toLocaleTimeString() + ' ' + msg + '\n'
}
btn.addEventListener('click', () => {
  const element = Number(elementInput.value) || 42
  const count = Number(countInput.value) || 10
  logEl.textContent = ''

  window.electronAPI.requestStream(
    { element, count },
    (data) => log('收到: ' + data),
    () => log('流结束')
  )
  log('已发送请求,等待主进程流式回复...')
})

step3:预加载脚本preload.js

javascript 复制代码
const { contextBridge, ipcRenderer } = require('electron')

// 在 preload 里创建 MessageChannel 并发送 port2,避免从渲染进程传 port 导致 "Invalid value for transfer"
contextBridge.exposeInMainWorld('electronAPI', {
  requestStream: (payload, onData, onClose) => {
    const { port1, port2 } = new MessageChannel()
    port1.onmessage = (e) => onData(e.data)
    port1.onclose = () => onClose && onClose()
    port1.start()
    ipcRenderer.postMessage('give-me-a-stream', payload, [port2])
  }
})

step4:主进程main.js

javascript 复制代码
app.whenReady().then(() => {
  // 回复流:渲染进程发一个 port 过来,主进程连续 postMessage 多次后 close
  ipcMain.on('give-me-a-stream', (event, msg) => {
    const [replyPort] = event.ports
    for (let i = 0; i < msg.count; i++) {
      replyPort.postMessage(msg.element)
    }
    replyPort.close()
  })
})

功能:

渲染进程创建 MessageChannel,把 port2 和参数(如 `{ element, count }`)通过 `postMessage('give-me-a-stream', msg, [port2])` 发给主进程。主进程按 `count` 次 `replyPort.postMessage(element)`,最后 `replyPort.close()`。渲染进程在 port1 上多次收数据,并在 `port1.onclose` 得知流结束。
要点:

(1)、普通 IPC 只有"发一条 / 回一条"或 invoke 一次回一次;用 port 可以实现"一条请求、多条回复、再关闭"。

(2)、主进程发完后 **replyPort.close()**,渲染进程会触发 **close** 事件。

2.3、效率进程(utility.js)

主进程 :负责创建窗口、响应系统事件,必须保持不卡、不崩。(案例1是全部代码,其余是是核心代码)。
效率进程(utility.js) :独立子进程,可访问 Node.js API,专门干重活或容易崩的活。效率进程必须用 process.parentPort 收发消息,不能用 process.send / process.on('message')

案例1:CPU密集型任务(eg:大量计算、数据处理、加解密、压缩)

Step1:主进程main.js

javascript 复制代码
const { app, BrowserWindow, utilityProcess: forkUtilityProcess } = require('electron/main')
const path = require('node:path')
let utilityProcess = null
function createWindow() {
  const mainWindow = new BrowserWindow({
    width: 1000,
    height: 800,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
      preload: path.join(__dirname, 'preload.js')
    }
  })

  mainWindow.loadFile('index2.html')

  // 等页面加载完成后再创建效率进程,避免渲染进程错过 spawn/ready 消息
  mainWindow.webContents.once('did-finish-load', () => {
    utilityProcess = forkUtilityProcess.fork(path.join(__dirname, 'utility.js'))
    setupUtilityListeners(mainWindow)
  })

  // 处理来自渲染进程的请求
  const { ipcMain } = require('electron/main')
  ipcMain.on('start-task', (event, taskData) => {
    if (utilityProcess && !utilityProcess.killed) {
      utilityProcess.postMessage({
        type: 'start-task',
        data: taskData
      })
    }
  })
}

function setupUtilityListeners(mainWindow) {
  utilityProcess.on('message', (message) => {
    mainWindow.webContents.send('utility-message', message)
  })
  utilityProcess.on('exit', (code) => {
    console.log('效率进程已退出,退出码:', code)
    mainWindow.webContents.send('utility-exit', code)
    utilityProcess = null
  })
  utilityProcess.on('spawn', () => {
    console.log('✅ 效率进程已启动')
    mainWindow.webContents.send('utility-spawn')
  })
}

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

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

Step2:预加载脚本preload.js

javascript 复制代码
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
  startTask: (taskData) => {
    ipcRenderer.send('start-task', taskData)
  },
  onUtilityMessage: (callback) => {
    ipcRenderer.on('utility-message', (event, message) => {
      callback(message)
    })
  },
  onUtilityExit: (callback) => {
    ipcRenderer.on('utility-exit', (event, code) => {
      callback(code)
    })
  },
  onUtilitySpawn: (callback) => {
    ipcRenderer.on('utility-spawn', () => {
      callback()
    })
  }
})

Step3:渲染进程脚本renderer.js

javascript 复制代码
const taskSelect = document.getElementById('taskSelect')
const valueInput = document.getElementById('valueInput')
const startBtn = document.getElementById('startBtn')
const logArea = document.getElementById('logArea')
const statusText = document.getElementById('statusText')
const progressBar = document.getElementById('progressBar')

let taskIdCounter = 0

function addLog(message, type = 'info') {
  const time = new Date().toLocaleTimeString()
  const logEntry = document.createElement('div')
  logEntry.className = `log-entry ${type}`
  logEntry.textContent = `[${time}] ${message}`
  logArea.appendChild(logEntry)
  logArea.scrollTop = logArea.scrollHeight
}

function updateProgress(percent) {
  progressBar.style.width = `${percent}%`
  progressBar.textContent = `${percent}%`
}

// 监听效率进程消息
window.electronAPI.onUtilityMessage((message) => {
  if (message.type === 'task-started') {
    addLog(`任务 ${message.taskId} 已开始`, 'info')
    updateProgress(50)
  } else if (message.type === 'task-completed') {
    addLog(`任务 ${message.taskId} 已完成`, 'success')
    addLog(`结果: ${JSON.stringify(message.result, null, 2)}`, 'success')
    updateProgress(100)
    setTimeout(() => updateProgress(0), 1000)
  } else if (message.type === 'ready') {
    addLog(message.message, 'success')
    statusText.textContent = '就绪'
    statusText.style.color = '#4CAF50'
  }
})

// 监听效率进程退出
window.electronAPI.onUtilityExit((code) => {
  addLog(`效率进程已退出,退出码: ${code}`, 'warning')
  statusText.textContent = '已停止'
  statusText.style.color = '#dc3545'
})

// 监听效率进程启动
window.electronAPI.onUtilitySpawn(() => {
  addLog('效率进程已启动', 'success')
  statusText.textContent = '运行中'
  statusText.style.color = '#4CAF50'
})

// 开始任务
startBtn.addEventListener('click', () => {
  const taskType = taskSelect.value
  const value = parseInt(valueInput.value) || 100
  if (!taskType) {
    addLog('请选择任务类型', 'warning')
    return
  }
  const taskId = ++taskIdCounter
  const taskData = {
    taskId: taskId,
    taskType: taskType,
    value: value
  }
  addLog(`开始任务 ${taskId}: ${taskType} (值: ${value})`, 'info')
  updateProgress(10)
  window.electronAPI.startTask(taskData)
})

// 初始化
addLog('等待效率进程启动...', 'info')

Step4:应用界面index.html

html 复制代码
<style>
    .progress-bar {
      width: 100%;
      height: 20px;
      background-color: #eee;
      border-radius: 10px;
      overflow: hidden;
      position: relative;
    }
    .progress-bar-fill {
      height: 100%;
      background: linear-gradient(90deg, #4a90e2, #357adb);
      width: 0%;
      transition: width 0.3s;
      display: flex;
      align-items: center;
      justify-content: center;
      color: white;
      font-size: 12px;
    }
    .log-area {
      background: #2d2d2d;
      color: #f8f8f2;
      padding: 15px;
      border-radius: 4px;
      height: 300px;
      overflow-y: auto;
      font-size: 14px;
    }

    .log-entry {
      margin-bottom: 8px;
      line-height: 1.6;
      padding: 5px;
      border-radius: 3px;
    }
    .log-entry.info {
      background: orange;
    }
    .log-entry.success {
      background: green;
    }
    .log-entry.warning {
      background: red;
    }
</style>
 <div class="status-bar">
      <span class="status-label">效率进程状态:</span>
      <span class="status-value" id="statusText">启动中...</span>
    </div>

    <div class="task-controls">
      <div class="control-group">
        <label>任务类型:</label>
        <select id="taskSelect">
          <option value="">请选择任务</option>
          <option value="fibonacci">斐波那契数列(计算第 N 项)</option>
          <option value="prime">查找质数(查找 1 到 N 的所有质数)</option>
          <option value="sum">大数求和(计算 1 到 N 的和)</option>
        </select>
      </div>
      <div class="control-group">
        <label>数值:</label>
        <input type="number" id="valueInput" value="100" min="1" max="1000000">
      </div>
      <div class="control-group">
        <button class="btn" id="startBtn">开始任务</button>
      </div>
    </div>

    <div class="progress-container">
      <div class="progress-bar">
        <div class="progress-bar-fill" id="progressBar">0%</div>
      </div>
    </div>

    <div>
      <h3 style="margin-bottom: 15px; color: #333; font-size: 18px;">📝 任务日志</h3>
      <div class="log-area" id="logArea"></div>
    </div>

Step5:效率进程utility.js

javascript 复制代码
// 监听来自主进程的消息(Electron 使用 parentPort)
process.parentPort.on('message', (e) => {
  const message = e.data
  if (message.type === 'start-task') {
    const taskData = message.data
    process.parentPort.postMessage({
      type: 'task-started',
      taskId: taskData.taskId
    })
    const result = performHeavyTask(taskData)
    process.parentPort.postMessage({
      type: 'task-completed',
      taskId: taskData.taskId,
      result: result
    })
  }
})
// CPU 密集型任务:计算斐波那契数列
function performHeavyTask(taskData) {
  const { taskType, value } = taskData
  if (taskType === 'fibonacci') {
    return calculateFibonacci(value)
  } else if (taskType === 'prime') {
    return findPrimes(value)
  } else if (taskType === 'sum') {
    return calculateSum(value)
  }
  return { error: 'Unknown task type' }
}

// 计算斐波那契数列(CPU 密集型)
function calculateFibonacci(n) {
  if (n <= 1) return n
  let a = 0, b = 1
  for (let i = 2; i <= n; i++) {
    const temp = a + b
    a = b
    b = temp
  }
  return {
    n: n,
    result: b,
    message: `斐波那契数列第 ${n} 项: ${b}`
  }
}

// 查找质数(CPU 密集型)
function findPrimes(max) {
  const primes = []
  const startTime = Date.now()
  for (let num = 2; num <= max; num++) {
    let isPrime = true
    for (let i = 2; i <= Math.sqrt(num); i++) {
      if (num % i === 0) {
        isPrime = false
        break
      }
    }
    if (isPrime) {
      primes.push(num)
    }
  const endTime = Date.now()
  return {
    max: max,
    count: primes.length,
    primes: primes.slice(0, 10), // 只返回前10个
    time: endTime - startTime,
    message: `找到 ${primes.length} 个质数(耗时 ${endTime - startTime}ms)`
  }
}

// 计算大数求和(CPU 密集型)
function calculateSum(max) {
  let sum = 0
  const startTime = Date.now()
  for (let i = 1; i <= max; i++) {
    sum += i
  }
  const endTime = Date.now()
  return {
    max: max,
    sum: sum,
    time: endTime - startTime,
    message: `1 到 ${max} 的和: ${sum}(耗时 ${endTime - startTime}ms)`
  }
}
// 发送就绪消息
process.parentPort.postMessage({
  type: 'ready',
  message: '效率进程已就绪,可以处理 CPU 密集型任务',
  pid: process.pid
})
process.on('exit', (code) => {
  console.log('效率进程退出,退出码:', code)
})

案例2:崩溃隔离(eg:第三方插件、不稳定的原生模块、实验性功能)

Step1:主进程main.js

javascript 复制代码
// 主进程监听退出   
   utilityProcess.on('exit', (code) => {
     mainWindow.webContents.send('utility-exit', code)
     if (code !== 0) {
       mainWindow.webContents.send('utility-crashed', code)
     }
     utilityProcess = null
   })
// 崩溃后重启
  ipcMain.on('restart-utility', () => {
    if (utilityProcess && !utilityProcess.killed) {
      utilityProcess.kill()
    }
    utilityProcess = forkUtilityProcess.fork(path.join(__dirname, 'utility3.js'))
    setupUtilityListeners(mainWindow)
  })

案例3:渲染进程与效率进程直接通信(MessagePort)

Step1:主进程main.js

javascript 复制代码
// 主进程创建通道并分发端口 
const { port1, port2 } = new MessageChannelMain()
   utilityProcess.on('spawn', () => {
     utilityProcess.postMessage({ port: port2 }, [port2])
     mainWindow.webContents.postMessage('main-port', null, [port1])
})

Step2:渲染进程脚本renderer.js

javascript 复制代码
// 效率进程接收 port 并通信
ipcRenderer.on('main-port', (event) => {
     rendererPort = event.ports[0]
     rendererPort.on('message', (event) => {
       window.dispatchEvent(new CustomEvent('utility-message', { detail: event.data }))
     })
     rendererPort.start()
   })

Step3:效率进程utility.js

javascript 复制代码
// 效率进程接收 port 并通信
process.on('message', (message, transferList) => {
     if (message.port) {
       rendererPort = message.port
       rendererPort.on('message', (event) => {
         rendererPort.postMessage({ type: 'response', data: '...' })
       })
       rendererPort.start()
     }
   })

常见问题

Q:效率进程Web Worker 有什么区别
A: 前者是独立进程,可用完整 Node.js,崩溃不影响主进程;后者在渲染进程内,通常无 Node。

Q:效率进程崩溃 后如何恢复
A:在主进程监听 `exit`,若 `code !== 0` 可视为异常退出,再 `new UtilityProcess(...)` 创建新进程并重新绑定监听即可。

Q:渲染进程如何与效率进程直接通信
A:主进程只负责创建 `MessageChannel`,把 `port1` 交给渲染进程、`port2` 交给效率进程,之后不参与消息内容转发。渲染进程通过 preload 暴露的 API 使用 port 收发消息。

Q:效率进程里可以用 npm 包 吗?
A:可以,效率进程是标准 Node 环境,可以 `require()` 任意已安装的模块。

2.4、多线程(Worker线程)

问题:如果让主线程执行耗时任务(比如计算1亿次循环),界面会卡住,用户点击按钮没反应。

解决:把耗时任务交给 Worker 线程,主线程继续工作,界面保持流畅。

案例1:耗时计算Worker (eg:大量数据处理、复杂计算任务)

Step1:主进程main.js

javascript 复制代码
 nodeIntegrationInWorker: true // 启用 Worker 中的 Node.js 支持

Step2:渲染进程脚本renderer.js

javascript 复制代码
let worker1 = null;
const calcResult = document.getElementById("calc-result");
const startCalcBtn = document.getElementById("start-calc");
const stopCalcBtn = document.getElementById("stop-calc");

startCalcBtn.addEventListener("click", () => {
  if (worker1) {
    worker1.terminate();
  }
  const iterations =
    parseInt(document.getElementById("iterations").value) || 100000000;
  calcResult.textContent = "正在计算...";
  calcResult.classList.remove("empty");
  startCalcBtn.disabled = true;
  stopCalcBtn.disabled = false;

  // 创建 Worker
  worker1 = new Worker("worker.js");
  // 监听 Worker 消息
  worker1.onmessage = (event) => {
    const { type, data } = event.data;
    if (type === "result") {
      calcResult.textContent =
        `计算完成!\n` +
        `迭代次数: ${data.iterations}\n` +
        `计算结果: ${data.sum}\n` +
        `耗时: ${data.duration}ms`;
      startCalcBtn.disabled = false;
      stopCalcBtn.disabled = true;
      worker1.terminate();
      worker1 = null;
    }
  };
  // 发送计算任务
  worker1.postMessage({ type: "calculate", data: iterations });
});

Step3:worker.js

javascript 复制代码
// 监听主线程发送的消息
self.onmessage = function (event) {
  const { type, data } = event.data
  if (type === 'calculate') {
    // 执行耗时计算
    const result = performHeavyCalculation(data)
    // 发送结果回主线程
    self.postMessage({ type: 'result', data: result })
  }
}
// 耗时计算函数
function performHeavyCalculation(input){
  let sum=0
  const iterations = input || 100000000 // 1亿次循环
  const startTime = Date.now()
  for (let i = 0; i < iterations; i++) {
    sum += i
  }
  const endTime= Date.now()
  const duration = endTime - startTime
  console.log('Worker 1 计算完成,耗时:', duration, 'ms')
  return {
    sum,
    iterations,
    duration
  }
}

Step4:应用界面index.html

html 复制代码
<div class="demo-section">
    <h2>耗时计算</h2>
    <div>
      <label>迭代次数:</label>
<input type="number" id="iterations" value="1000000" min="1000000" max="1000000000" />
    </div>
    <div class="button-group">
      <button id="start-calc">开始计算</button>
      <button id="stop-calc" disabled>停止计算</button>
    </div>
    <div class="result-box empty" id="calc-result">等待计算结果...</div>
</div>
<script src="./renderer.js"></script>

案例2:使用Node.js API的Worker(eg:读取大文件、文件系统操作)

渲染进程renderer.js

javascript 复制代码
let worker2 = null;
const nodeResult = document.getElementById("node-result");
const readFileBtn = document.getElementById("read-file");
const listDirBtn = document.getElementById("list-dir");

function createWorker2() {
  if (worker2) {
    worker2.terminate();
  }
  worker2 = new Worker("worker.js");
  worker2.onmessage = (event) => {
    const { type, data } = event.data;
    if (type === "fileContent") {
      nodeResult.textContent = `文件路径:${data.path}\n` +
        `文件大小:${data.size} 字节\n` +
        `内容预览(前1000字符):\n${data.content}`;
    } else if (type === 'dirList') {
      nodeResult.textContent = `目录路径:${data.path}\n` +
        `文件列表(前20个):\n${data.files.join('\n')}`
    } else if (type === 'error') {
      nodeResult.textContent = `错误:${data}`
    }
    nodeResult.classList.remove('empty')
  };
}
readFileBtn.addEventListener('click', () => {
  createWorker2()
  nodeResult.textContent = '正在读取文件...'
  nodeResult.classList.remove('empty')
  // 读取package.json
  worker2.postMessage({
    type: 'readFile',
    path: { path: './package.json' }
  });
})
listDirBtn.addEventListener('click', () => { 
  createWorker2()
  nodeResult.textContent = '正在列出目录...'
  nodeResult.classList.remove('empty')
  // 列出当前目录
  worker2.postMessage({
    type: 'listDir',
    path: { path: '.' }
  });
})

worker.js

javascript 复制代码
const fs = require('fs')
// 监听主线程发送的消息
self.onmessage = function (event) {
  const { type, path } = event.data
  if (type === 'readFile') {
    try {
      const filePath = path.path
      const content = fs.readFileSync(filePath, 'utf-8')
      self.postMessage({
        type: 'fileContent',
        data: {
          path: filePath,
          content: content.substring(0, 1000),
          size: content.length
        }
      })
    } catch (error) {
      self.postMessage({
        type: 'error',
        data: error.message
      })
    }
  } else if (type === 'listDir') {
    try {
      const dirPath = path.path
      const files = fs.readdirSync(dirPath)
      self.postMessage({
        type: 'dirList',
        data: {
          path: dirPath,
          files: files.slice(0, 20)
        }
      })
    } catch (error) {
      self.postMessage({
        type: 'error',
        data: error.message
      })
    }
  }
}

案例3:定时任务Worker(eg:后台运行定时任务、启动和停止定时器)

渲染进程renderer.js

javascript 复制代码
let worker3 = null
const timerResult = document.getElementById('timer-result')
const timerStatus = document.getElementById('timer-status')
const startTimerBtn = document.getElementById('start-timer')
const stopTimerBtn = document.getElementById('stop-timer')

startTimerBtn.addEventListener('click', function () {
  if (worker3) {
    worker3.terminate()
  }
  const interval = parseInt(document.getElementById('timer-interval').value)
  timerResult.textContent = '定时器启动中...'
  timerResult.classList.remove('empty')
  timerStatus.textContent = '运行中'
  timerStatus.className = 'running'
  startTimerBtn.disabled = true
  stopTimerBtn.disabled = false

  worker3 = new Worker('worker3.js')
  worker3.onmessage = (event) => {
    const { type, data } = event.data
    if (type === 'timerUpdate') {
      timerResult.textContent = `计数器:${data.counter}\n` +
        `时间:${data.timestamp}`
    } else if (type === 'timerStarted') {
      timerResult.textContent = `定时器已启动,间隔:${data.interval}毫秒`
    } else if (type === 'timerStopped') {
      timerResult.textContent = `定时器已停止\n最终计数:${data.finalCount}`
      timerStatus.textContent = '已停止'
      timerStatus.className = 'stopped'
      startTimerBtn.disabled = false
      stopTimerBtn.disabled = true
      worker3.terminate()
      worker3 = null
    }
  }
  // 启动定时器
  worker3.postMessage({
    type: 'start',
    data: { interval }
  })
})
stopTimerBtn.addEventListener('click', () => {
  if (worker3) {
    worker3.postMessage({
      type: 'stopTimer'
    })
  }
})

worker.js

javascript 复制代码
let intervalId = null
let counter = 0
// 监听主线程发送的消息
self.onmessage = function (event) {
  const { type, data } = event.data
  if (type === 'start') {
    // 启动定时器
    const interval = data.interval || 1000
    if (intervalId) {
      clearInterval(intervalId)
    }
    counter = 0
    intervalId = setInterval(() => {
      counter++
      // 定期发送状态
      self.postMessage({
        type: 'timerUpdate',
        data: {
          counter,
          timestamp: new Date().toLocaleTimeString()
        }
      })
    }, interval)
    self.postMessage({
      type: 'timerStarted',
      data: {
        interval
      }
    })
  } else if (type === 'stopTimer') {
    // 停止计时器
    if (intervalId) {
      clearInterval(intervalId)
      intervalId = null
    }
    self.postMessage({
      type: 'timerStopped',
      data: { finalCount: counter }
    })
  }
}

2.5、进程沙盒(sandbox)

沙盒化:渲染进程被限制在安全环境中,不能直接访问 Node.js API 和大部分系统资源。
安全优势:即使渲染进程被注入恶意代码,也无法直接操作文件系统/执行系统命令等危险操作。
通信方式:沙盒化的渲染进程只能通过 IPC 与主进程通信,由主进程代为执行需要权限的操作。

2.5.1、默认沙盒(Electron 20+ 默认行为)

特点

(1)、不设置 `sandbox` 选项,使用默认值(true)

(2)、渲染进程无法直接使用 `require('fs')` 等 Node.js API

(3)、通过 preload 脚本暴露安全的 API

(4)、通过 IPC 调用主进程的文件操作

2.5.2、禁用沙盒(sandbox: false)

特点

(1)、通过设置 sandbox: false 可以禁用单个窗口的沙盒

(2)、禁用沙盒后,渲染进程仍然没有 Node.js 环境(除非同时设置 nodeIntegration: true

(3)、不推荐在生产环境禁用沙盒,这会带来安全风险

(4)、如果必须禁用沙盒,确保只加载可信内容

2.5.3、全局启用沙盒(app.enableSandbox)

特点

(1)、app.enableSandbox() 必须在应用的 ready 事件之前调用

(2)、调用后,所有窗口都会被强制沙盒化,即使设置了 sandbox: false

(3)、这确保了应用的所有渲染进程都处于沙盒环境中

(4)、适用于需要强制所有窗口都启用沙盒的场景

javascript 复制代码
// ⚠️ 重要:必须在 app.whenReady() 之前调用
app.enableSandbox()
function createWindow() {
  const mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      // 即使这里设置 sandbox: false,也会被 app.enableSandbox() 覆盖
      sandbox: false,  // 这个设置会被 app.enableSandbox() 覆盖
      preload: path.join(__dirname, 'preload3.js'),
      contextIsolation: true
    }
  })

2.5.4、沙盒环境下的 Preload 脚本演示

特点

(1)、在沙盒环境下,preload 脚本可以使用有限的 Node.js API

(2)、**可用模块:**electron(部分)、events、timers、url

(3)、**可用全局对象:**Buffer、process、clearImmediate、setImmediate

(4)、不能使用:fs、path、child_process等大部分 Node.js 模块

(5)、需要通过 IPC 与主进程通信来执行需要权限的操作

相关推荐
#做一个清醒的人2 小时前
【Electron】开发两年Electron项目评估报告
前端·electron
lizhongxuan8 小时前
Claude Code 防上下文爆炸:源码级深度解析
前端·后端
天真萌泪9 小时前
JS逆向自用
开发语言·javascript·ecmascript
柳杉9 小时前
震惊!字符串还能这么玩!
前端·javascript
是上好佳佳佳呀10 小时前
【前端(五)】CSS 知识梳理:浮动与定位
前端·css
仍然.10 小时前
算法题目---模拟
java·javascript·算法
wefly201710 小时前
纯前端架构深度解析:jsontop.cn,JSON 格式化与全栈开发效率平台
java·前端·python·架构·正则表达式·json·php
我命由我1234512 小时前
React - 类组件 setState 的 2 种写法、LazyLoad、useState
前端·javascript·react.js·html·ecmascript·html5·js
聊聊MES那点事12 小时前
JavaScript图表控件AG Charts使用教程:使用AG Charts React实时更新柱状图
开发语言·javascript·react.js·图表控件