浏览器出于安全考虑,设计了一套严格的沙箱机制。当你用<input type="file">选择文件时,浏览器只会返回一个虚拟路径(比如C:\fakepath\test.txt),而不是真实的D:\documents\test.txt。这个限制是为了防止恶意网站通过JavaScript窃取你的本地文件系统信息。
我在实际项目中遇到过更棘手的情况:客户需要上传整个文件夹并保留目录结构。这时候相对路径完全不够用,必须拿到完整的绝对路径才能重建文件夹层级。试过各种Hack方法后,最终发现只有Electron能真正突破这个限制——因为它本质上是一个带Chromium内核的Node.js运行时环境。
先创建一个标准的Vue3项目:
bash复制npm init vue@latest vue3-electron-path
cd vue3-electron-path
npm install
然后添加Electron依赖:
bash复制npm install electron electron-builder --save-dev
关键配置在vue.config.js中:
javascript复制module.exports = {
pluginOptions: {
electronBuilder: {
nodeIntegration: true, // 启用Node.js集成
contextIsolation: false // 关闭上下文隔离
}
}
}
踩坑提醒:如果你用的是Electron 12+版本,还需要额外配置sandbox: false。我曾在版本升级时被这个配置卡了半天,控制台一直报require is not defined的错误。
在background.js(Electron主进程)中添加文件对话框逻辑:
javascript复制const { app, BrowserWindow, ipcMain, dialog } = require('electron')
ipcMain.handle('open-dialog', async (event, options) => {
return await dialog.showOpenDialog({
properties: ['openFile', 'multiSelections'],
...options
})
})
在Vue组件中(渲染进程)这样调用:
javascript复制import { ipcRenderer } from 'electron'
const selectFiles = async () => {
const result = await ipcRenderer.invoke('open-dialog', {
title: '选择PDF文件',
filters: [{ name: 'PDF', extensions: ['pdf'] }]
})
console.log(result.filePaths) // 这里就是绝对路径数组
}
直接在浏览器中运行会报ipcRenderer is not defined,需要做环境判断:
javascript复制const useElectronAPI = () => {
if (window.require) {
return window.require('electron')
}
return null
}
// 使用示例
const electron = useElectronAPI()
if (electron) {
electron.ipcRenderer.send('message')
}
在background.js中加入这段代码,可以保持Vue的热更新功能:
javascript复制if (process.env.WEBPACK_DEV_SERVER_URL) {
win.loadURL(process.env.WEBPACK_DEV_SERVER_URL)
if (!process.env.IS_TEST) win.webContents.openDevTools()
} else {
win.loadFile('index.html')
}
出于安全考虑,应该限制可访问的目录范围。在background.js中添加路径校验:
javascript复制const ALLOWED_PATHS = [
process.env.HOME,
process.env.USERPROFILE,
'/Users/Shared'
]
ipcMain.handle('safe-open-dialog', (event, options) => {
const result = dialog.showOpenDialogSync(options)
return {
...result,
filePaths: result.filePaths.filter(path =>
ALLOWED_PATHS.some(allowed => path.startsWith(allowed))
)
}
})
在package.json中添加这些配置项:
json复制"build": {
"files": ["dist/**/*"],
"extraResources": [
{
"from": "src/assets",
"to": "assets"
}
]
}
实测发现一个坑:如果在Vue单文件组件顶部直接写const { ipcRenderer } = window.require('electron'),会导致构建失败。正确做法是在方法内部动态调用。
不同操作系统路径分隔符不同(Windows用\,Mac/Linux用/),推荐使用path模块统一处理:
javascript复制const path = window.require('path')
// 转换示例
const normalizedPath = path.normalize('C:\\Users\\test\\file.txt')
// 输出: C:/Users/test/file.txt
下面是一个可直接复用的FileExplorer组件:
vue复制<template>
<div>
<button @click="openFileDialog">选择文件</button>
<ul>
<li v-for="file in files" :key="file">
{{ file }}
<button @click="showFileInfo(file)">详情</button>
</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ipcRenderer } from 'electron'
const files = ref([])
const openFileDialog = async () => {
try {
const result = await ipcRenderer.invoke('open-dialog', {
properties: ['openFile', 'multiSelections']
})
files.value = result.filePaths
} catch (err) {
console.error('文件选择失败:', err)
}
}
const showFileInfo = (filePath) => {
const fs = window.require('fs')
const stats = fs.statSync(filePath)
console.log(`文件大小: ${stats.size}字节`)
}
</script>
在background.js创建窗口时,关闭这些高风险功能:
javascript复制new BrowserWindow({
webPreferences: {
devTools: process.env.NODE_ENV === 'development',
webSecurity: true,
allowRunningInsecureContent: false
}
})
对所有从渲染进程接收的路径参数进行校验:
javascript复制const isValidPath = (path) => {
const pathRegex = /^[a-zA-Z]:\\[\\\S|*\S]?.*$|^\/[^\/]+.*$/
return pathRegex.test(path) && !path.includes('..')
}
ipcMain.handle('read-file', (event, filePath) => {
if (!isValidPath(filePath)) {
throw new Error('非法路径')
}
return fs.readFileSync(filePath)
})
在package.json中添加:
json复制"scripts": {
"debug": "electron --inspect=9229 ."
}
然后用Chrome访问chrome://inspect,点击"Configure"添加localhost:9229,就能像调试普通Node.js应用一样打断点了。
安装electron-log包记录IPC通信:
javascript复制const log = require('electron-log')
ipcMain.on('message', (event, arg) => {
log.info('收到渲染进程消息:', arg)
event.reply('reply', 'pong')
})
日志文件默认会保存在:
~/.config/{appName}/logs~/Library/Logs/{appName}%USERPROFILE%\AppData\Roaming\{appName}\logs用流式API代替同步读取:
javascript复制const fs = require('fs')
const readStream = fs.createReadStream('large-file.zip')
readStream.on('data', (chunk) => {
process.send(chunk) // 分片发送给渲染进程
})
创建preload.js暴露安全API:
javascript复制const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
selectFiles: (options) => ipcRenderer.invoke('open-dialog', options)
})
然后在窗口配置中加载:
javascript复制new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})
处理Windows和Unix路径差异:
javascript复制const toUnixPath = (path) => {
return path.replace(/\\/g, '/')
}
const toWindowsPath = (path) => {
return path.replace(/\//g, '\\')
}
判断当前操作系统:
javascript复制const { platform } = require('os')
const isWindows = platform() === 'win32'
const isMac = platform() === 'darwin'
在删除/修改文件前先验证:
javascript复制const fs = require('fs')
const checkWritePermission = (path) => {
try {
fs.accessSync(path, fs.constants.W_OK)
return true
} catch {
return false
}
}
Windows下需要提升权限时:
javascript复制const { app } = require('electron')
if (process.platform === 'win32') {
app.commandLine.appendSwitch('high-dpi-support', 'true')
app.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')
}
设置5秒超时限制:
javascript复制const selectFilesWithTimeout = () => {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('操作超时'))
}, 5000)
ipcRenderer.invoke('open-dialog')
.then(result => {
clearTimeout(timer)
resolve(result)
})
.catch(reject)
})
}
在主进程中监听崩溃事件:
javascript复制win.webContents.on('render-process-gone', (event, details) => {
dialog.showErrorBox('应用崩溃', `原因: ${details.reason}`)
win.reload()
})
在Vue组件中添加拖拽区域:
vue复制<div
@dragover.prevent
@drop="handleDrop"
class="drop-zone"
>
拖放文件到这里
</div>
<script>
const handleDrop = (e) => {
const files = e.dataTransfer.files
for (let file of files) {
console.log(file.path) // Electron会注入path属性
}
}
</script>
使用electron-store持久化存储:
javascript复制const Store = require('electron-store')
const store = new Store()
// 保存路径
store.set('recentFiles', [
...store.get('recentFiles', []),
newPath
].slice(0, 10))
// 读取记录
const recentFiles = store.get('recentFiles', [])
安装测试依赖:
bash复制npm install jest @vue/test-utils electron-mocks --save-dev
测试示例:
javascript复制jest.mock('electron', () => ({
ipcRenderer: {
invoke: jest.fn().mockResolvedValue({
filePaths: ['/test/path']
})
}
}))
test('文件选择功能', async () => {
const { result } = renderHook(() => useFileDialog())
await act(() => result.current.openDialog())
expect(result.current.files).toEqual(['/test/path'])
})
使用spectron进行端到端测试:
javascript复制const Application = require('spectron').Application
const path = require('path')
const app = new Application({
path: require('electron'),
args: [path.join(__dirname, '..')]
})
beforeAll(async () => {
await app.start()
})
afterAll(async () => {
if (app && app.isRunning()) {
await app.stop()
}
})
打包时必须签名,否则会被系统拦截:
json复制"build": {
"win": {
"certificateFile": "build/cert.pfx",
"signingHashAlgorithms": ["sha256"]
},
"mac": {
"identity": "Developer ID Application: Your Name (XXXXXX)"
}
}
集成electron-updater:
javascript复制const { autoUpdater } = require('electron-updater')
autoUpdater.on('update-downloaded', () => {
dialog.showMessageBox({
type: 'info',
message: '更新已下载',
detail: '重启应用以完成更新'
}).then(() => {
autoUpdater.quitAndInstall()
})
})
我们团队最近开发了一个需要批量处理PDF文件的应用。核心需求包括:
关键实现代码:
javascript复制const processPDFs = async () => {
const { filePaths } = await ipcRenderer.invoke('open-dialog', {
properties: ['openDirectory']
})
const pdfPaths = []
const walkDir = (dir) => {
const files = fs.readdirSync(dir)
files.forEach(file => {
const fullPath = path.join(dir, file)
if (fs.statSync(fullPath).isDirectory()) {
walkDir(fullPath)
} else if (path.extname(file) === '.pdf') {
pdfPaths.push(fullPath)
}
})
}
walkDir(filePaths[0])
return pdfPaths
}
如果觉得Electron太重,可以试试Rust编写的Tauri:
bash复制npm create tauri-app@latest
优势:
通过chrome.fileSystem API获取受限访问权限:
javascript复制chrome.fileSystem.chooseEntry({
type: 'openDirectory'
}, (entry) => {
console.log(entry.fullPath)
})
限制:需要用户手动授权,且路径格式特殊(不是原生路径)
通过@electron/remote模块:
javascript复制const { dialog } = require('@electron/remote')
const showNativeDialog = () => {
dialog.showMessageBoxSync({
type: 'warning',
message: '操作确认',
buttons: ['确定', '取消']
})
}
在主进程中添加:
javascript复制const { globalShortcut } = require('electron')
app.whenReady().then(() => {
globalShortcut.register('CommandOrControl+Shift+O', () => {
win.webContents.send('open-file-dialog')
})
})
使用electron-shared-memory避免IPC传输大文件:
javascript复制const sharedMemory = require('electron-shared-memory')
// 主进程
sharedMemory.set('fileData', buffer)
// 渲染进程
const buffer = sharedMemory.get('fileData')
窗口关闭时清理资源:
javascript复制win.on('closed', () => {
win = null
// 释放其他资源...
})
集成Sentry监控:
javascript复制const Sentry = require('@sentry/electron')
Sentry.init({
dsn: 'YOUR_DSN',
tracesSampleRate: 0.2
})
// 在渲染进程捕获异常
window.addEventListener('error', (event) => {
Sentry.captureException(event.error)
})
通过electron-usage收集匿名数据:
javascript复制const usage = require('electron-usage')
usage.start({
appId: 'your-app-id',
autoSubmit: true
})