Development
Project Structure
Conventions
It is recommended to use the following project structure:
.
├──src
│ ├──main
│ │ ├──index.ts
│ │ └──...
│ ├──preload
│ │ ├──index.ts
│ │ └──...
│ └──renderer # with vue, react, etc.
│ ├──src
│ ├──index.html
│ └──...
├──electron.vite.config.ts
├──package.json
└──...
.
├──src
│ ├──main
│ │ ├──index.ts
│ │ └──...
│ ├──preload
│ │ ├──index.ts
│ │ └──...
│ └──renderer # with vue, react, etc.
│ ├──src
│ ├──index.html
│ └──...
├──electron.vite.config.ts
├──package.json
└──...
With this convention, electron-vite can work with minimal configuration.
When running electron-vite, it will automatically find the main process, preload script and renderer entry ponits. The default entry points:
- Main process:
<root>/src/main/{index|main}.{js|ts|mjs|cjs}
- Preload script:
<root>/src/preload/{index|preload}.{js|ts|mjs|cjs}
- Renderer:
<root>/src/renderer/index.html
It will throw an error if the entry points cannot be found. You can fix it by setting the build.rollupOptions.input
option.
See the example in the next section.
Customizing
Even though we strongly recommend the project structure above, it is not a requirement. You can configure it to meet your scenes.
Suppose you have the following project structure:
.
├──electron
│ ├──main
│ │ ├──index.ts
│ │ └──...
│ └──preload
│ ├──index.ts
│ └──...
├──src # with vue, react, etc.
├──index.html
├──electron.vite.config.ts
├──package.json
└──...
.
├──electron
│ ├──main
│ │ ├──index.ts
│ │ └──...
│ └──preload
│ ├──index.ts
│ └──...
├──src # with vue, react, etc.
├──index.html
├──electron.vite.config.ts
├──package.json
└──...
Your electron.vite.config.ts
should be:
import { defineConfig } from 'electron-vite'
import { resolve } from 'path'
export default defineConfig({
main: {
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'electron/main/index.ts')
}
}
}
},
preload: {
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'electron/preload/index.ts')
}
}
}
},
renderer: {
root: '.',
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'index.html')
}
}
}
}
})
import { defineConfig } from 'electron-vite'
import { resolve } from 'path'
export default defineConfig({
main: {
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'electron/main/index.ts')
}
}
}
},
preload: {
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'electron/preload/index.ts')
}
}
}
},
renderer: {
root: '.',
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'index.html')
}
}
}
}
})
NOTE
By default, the renderer's working directory is located in src/renderer
. In this example, the renderer root
option should be set to '.'
.
Using Preload Scripts
Preload scripts are injected before a web page loads in the renderer. To add features to your renderer that require privileged access, you can define global objects through the contextBridge API.
The role of preload scripts:
- Augmenting the renderer: preload scripts run in a context that has access to the HTML DOM APIs and a limited subset of Node.js and Electron APIs.
- Communicating between main and renderer processes: use Electron's
ipcMain
andipcRenderer
modules for inter-process communication (IPC).
Learn more about preload scripts.
Example
- Create a preload script to expose
functions
andvariables
into renderer viacontextBridge.exposeInMainWorld
.
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('electron', {
ping: () => ipcRenderer.invoke('ping')
})
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('electron', {
ping: () => ipcRenderer.invoke('ping')
})
- To attach this script to your renderer process, pass its path to the
webPreferences.preload
option in theBrowserWindow
constructor:
import { app, BrowserWindow } from 'electron'
import path from 'path'
const createWindow = () => {
const win = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
},
})
ipcMain.handle('ping', () => 'pong')
win.loadFile('index.html')
}
app.whenReady().then(() => {
createWindow()
})
import { app, BrowserWindow } from 'electron'
import path from 'path'
const createWindow = () => {
const win = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
},
})
ipcMain.handle('ping', () => 'pong')
win.loadFile('index.html')
}
app.whenReady().then(() => {
createWindow()
})
- Use exposed functions and variables in the renderer process:
const func = async () => {
const response = await window.electron.ping()
console.log(response) // prints out 'pong'
}
func()
const func = async () => {
const response = await window.electron.ping()
console.log(response) // prints out 'pong'
}
func()
Limitations of Sandboxing
From Electron 20 onwards, preload scripts are sandboxed by default and no longer have access to a full Node.js environment. Practically, this means that you have a polyfilled require function (similar to Node's require module) that only has access to a limited set of APIs.
Available API | Details |
---|---|
Electron modules | Only Renderer Process Modules |
Node.js modules | events , timers , url |
Polyfilled globals | Buffer , process , clearImmediate , setImmediate |
NOTE
Because the require
function is a polyfill with limited functionality, you will not be able to use CommonJS modules to separate your preload script into multiple files, unless sandbox: false
is specified.
In Electron, renderer sandboxing can be disabled on a per-process basis with the sandbox: false
preference in the BrowserWindow constructor.
const win = new BrowserWindow({
webPreferences: {
sandbox: false
}
})
const win = new BrowserWindow({
webPreferences: {
sandbox: false
}
})
Learn more about Electron Process Sandboxing.
Efficient
Perhaps some developers think that using preload scripts is inconvenient and inflexible. But why we recommend:
- It's safe practice, most popular Electron apps (slack, visual studio code, etc.) do this.
- Avoid mixed development (nodejs and browser), make renderer as a regular web app and easier to get started for web developers.
Based on efficiency considerations, it is recommended to use @electron-toolkit/preload. It's very easy to expose Electron APIs (ipcRenderer, webFrame, process) into renderer.
First, use contextBridge
to expose Electron APIs into renderer only if context isolation is enabled, otherwise just add to the DOM global.
import { contextBridge } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
} catch (error) {
console.error(error)
}
} else {
window.electron = electronAPI
}
import { contextBridge } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
} catch (error) {
console.error(error)
}
} else {
window.electron = electronAPI
}
Then, use the Electron APIs directly in the renderer process:
// Send a message to the main process with no response
window.electron.ipcRenderer.send('electron:say', 'hello')
// Send a message to the main process with the response asynchronously
window.electron.ipcRenderer.invoke('electron:doAThing', '').then(re => {
console.log(re)
})
// Receive messages from the main process
window.electron.ipcRenderer.on('electron:reply', (_, args) => {
console.log(args)
})
// Send a message to the main process with no response
window.electron.ipcRenderer.send('electron:say', 'hello')
// Send a message to the main process with the response asynchronously
window.electron.ipcRenderer.invoke('electron:doAThing', '').then(re => {
console.log(re)
})
// Receive messages from the main process
window.electron.ipcRenderer.on('electron:reply', (_, args) => {
console.log(args)
})
Learn more about @electron-toolkit/preload.
NOTE
@electron-toolkit/preload
need to disable the sandbox
.
IPC SECURITY
The safest way is to use a helper function to wrap the ipcRenderer
call rather than expose the ipcRenderer module directly via context bridge.
Webview
The easiest way to attach a preload script to a webview is through the webContents will-attach-webview
event handler.
mainWindow.webContents.on('will-attach-webview', (e, webPreferences) => {
webPreferences.preload = join(__dirname, '../preload/index.js')
})
mainWindow.webContents.on('will-attach-webview', (e, webPreferences) => {
webPreferences.preload = join(__dirname, '../preload/index.js')
})
nodeIntegration
Currently, electorn-vite not support nodeIntegration
. One of the important reasons is that vite's HMR is implemented based on native ESM. But there is also a way to support that is to use require
to import the node module which is not very elegant. Or you can use plugin vite-plugin-commonjs-externals to handle.
Perhaps there's a better way to support this in the future. But It is important to note that using preload scripts is a better and safer option.
dependencies
vs devDependencies
For the main process and preload scripts, the best practice is to externalize dependencies and only bundle our own code.
We need to install the dependencies required by the app into the
dependencies
ofpackage.json
. Then useexternalizeDepsPlugin
to externalize them without bundle them.jsimport { defineConfig, externalizeDepsPlugin } from 'electron-vite' export default defineConfig({ main: { plugins: [externalizeDepsPlugin()] }, preload: { plugins: [externalizeDepsPlugin()] }, // ... })
import { defineConfig, externalizeDepsPlugin } from 'electron-vite' export default defineConfig({ main: { plugins: [externalizeDepsPlugin()] }, preload: { plugins: [externalizeDepsPlugin()] }, // ... })
When packaging the app, these dependencies will also be packaged together, such as
electron-builder
. Don't worry about them being lost. On the other hand,devDependencies
will not be packaged.It is important to note that some modules that only support
ESM
(e.g.lowdb
,execa
,node-fetch
), we should not externalize it. We should letelectron-vite
bundle it into aCJS
standard module to support Electron.jsimport { defineConfig, externalizeDepsPlugin } from 'electron-vite' export default defineConfig({ main: { plugins: [externalizeDepsPlugin({ exclude: ['lowdb'] })], build: { rollupOptions: { output: { manualChunks(id) { if (id.includes('lowdb')) { return 'lowdb' } } } } } }, // ... })
import { defineConfig, externalizeDepsPlugin } from 'electron-vite' export default defineConfig({ main: { plugins: [externalizeDepsPlugin({ exclude: ['lowdb'] })], build: { rollupOptions: { output: { manualChunks(id) { if (id.includes('lowdb')) { return 'lowdb' } } } } } }, // ... })
For renderers, it is usually fully bundle, so dependencies are best installed in
devDependencies
. This makes the final package more smaller.
Multiple Windows App
When your electron app has multiple windows, it means there are multiple html files or preload files. You can modify your config file like this:
// electron.vite.config.js
export default {
main: {},
preload: {
build: {
rollupOptions: {
input: {
browser: resolve(__dirname, 'src/preload/browser.js'),
webview: resolve(__dirname, 'src/preload/webview.js')
}
}
}
},
renderer: {
build: {
rollupOptions: {
input: {
browser: resolve(__dirname, 'src/renderer/browser.html'),
webview: resolve(__dirname, 'src/renderer/webview.html')
}
}
}
}
}
// electron.vite.config.js
export default {
main: {},
preload: {
build: {
rollupOptions: {
input: {
browser: resolve(__dirname, 'src/preload/browser.js'),
webview: resolve(__dirname, 'src/preload/webview.js')
}
}
}
},
renderer: {
build: {
rollupOptions: {
input: {
browser: resolve(__dirname, 'src/renderer/browser.html'),
webview: resolve(__dirname, 'src/renderer/webview.html')
}
}
}
}
}
How to Load Multi-Page
Check out the Using HMR section for more details.
Passing CLI Arguments to Electron App
It is recommended to handle command line via Env Variables and Modes:
- For Electron CLI command:
import { app } from 'electron'
if (import.meta.env.MAIN_VITE_LOG === 'true') {
app.commandLine.appendSwitch('enable-logging', 'electron_debug.log')
}
import { app } from 'electron'
if (import.meta.env.MAIN_VITE_LOG === 'true') {
app.commandLine.appendSwitch('enable-logging', 'electron_debug.log')
}
In development, you can use the above method to handle. After distribution, you can directly attach arguments supported by Electron. e.g. .\app.exe --enable-logging
.
NOTE
electron-vite already supports inspect
, inspect-brk
and remote-debugging-port
commands, so you don’t need to do this for those commands. See Command Line Interface for more details.
- For app arguments:
const param = import.meta.env.MAIN_VITE_MY_PARAM === 'true' || /--myparam/.test(process.argv[2])
const param = import.meta.env.MAIN_VITE_MY_PARAM === 'true' || /--myparam/.test(process.argv[2])
- In development, using
import.meta.env
andModes
to decide whether to use. - In production (app), using
process.argv
to handle.