Appearance
第12章:进阶实战
实战4:简易浏览器
12.1 需求分析
创建一个简易的桌面浏览器应用,实现以下功能:
- 地址栏输入和导航
- 页面加载和显示
- 前进/后退/刷新按钮
- 窗口控制(最小化、最大化、关闭)
- 页面加载状态显示
- 错误提示
12.2 核心实现
- BrowserWindow:创建应用窗口,设置窗口属性
- webContents:控制网页加载和导航
- 菜单/工具栏:创建应用菜单和工具栏
- IPC通信:主进程与渲染进程之间的通信
- dialog模块:显示错误和警告对话框
12.3 实现步骤
创建项目结构
browser-app/ ├── main.js ├── index.html ├── package.json └── assets/ └── icon.png修改 package.json
json{ "name": "browser-app", "version": "1.0.0", "description": "简易浏览器", "main": "main.js", "scripts": { "start": "electron ." }, "devDependencies": { "electron": "^18.0.0" } }修改 main.js
javascriptconst { app, BrowserWindow, Menu, ipcMain, dialog } = require('electron') const path = require('path') let mainWindow function createWindow() { mainWindow = new BrowserWindow({ width: 1000, height: 700, webPreferences: { nodeIntegration: true, contextIsolation: false } }) mainWindow.loadFile('index.html') mainWindow.webContents.openDevTools() // 创建菜单 createMenu() // 监听页面加载状态 mainWindow.webContents.on('did-start-loading', () => { mainWindow.webContents.send('loading-started') }) mainWindow.webContents.on('did-stop-loading', () => { mainWindow.webContents.send('loading-stopped') }) mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => { mainWindow.webContents.send('load-failed', errorCode, errorDescription) }) mainWindow.webContents.on('did-navigate', (event, url) => { mainWindow.webContents.send('url-changed', url) }) mainWindow.on('closed', () => { mainWindow = null }) } function createMenu() { const template = [ { label: '文件', submenu: [ { label: '新建窗口', accelerator: 'CmdOrCtrl+N', click: () => createWindow() }, { type: 'separator' }, { label: '退出', accelerator: 'CmdOrCtrl+Q', click: () => app.quit() } ] }, { label: '编辑', submenu: [ { role: 'undo' }, { role: 'redo' }, { type: 'separator' }, { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectall' } ] }, { label: '视图', submenu: [ { role: 'reload' }, { role: 'forcereload' }, { role: 'toggledevtools' }, { type: 'separator' }, { role: 'resetzoom' }, { role: 'zoomin' }, { role: 'zoomout' }, { type: 'separator' }, { role: 'togglefullscreen' } ] }, { label: '历史', submenu: [ { role: 'back' }, { role: 'forward' } ] } ] const menu = Menu.buildFromTemplate(template) Menu.setApplicationMenu(menu) } // IPC 事件处理 ipcMain.handle('navigate', (event, url) => { if (!url.startsWith('http://') && !url.startsWith('https://')) { url = 'http://' + url } mainWindow.loadURL(url) return url }) ipcMain.handle('go-back', () => { if (mainWindow.webContents.canGoBack()) { mainWindow.webContents.goBack() return true } return false }) ipcMain.handle('go-forward', () => { if (mainWindow.webContents.canGoForward()) { mainWindow.webContents.goForward() return true } return false }) ipcMain.handle('reload', () => { mainWindow.webContents.reload() return true }) ipcMain.handle('stop-loading', () => { mainWindow.webContents.stop() return true }) app.whenReady().then(createWindow) app.on('window-all-closed', function () { if (process.platform !== 'darwin') app.quit() }) app.on('activate', function () { if (BrowserWindow.getAllWindows().length === 0) createWindow() })修改 index.html
html<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>简易浏览器</title> <style> body { margin: 0; padding: 0; font-family: Arial, sans-serif; height: 100vh; display: flex; flex-direction: column; } .toolbar { background-color: #f0f0f0; padding: 10px; display: flex; align-items: center; border-bottom: 1px solid #ddd; } .nav-buttons { display: flex; margin-right: 10px; } .nav-button { width: 30px; height: 30px; border: none; background-color: #e0e0e0; margin-right: 5px; cursor: pointer; border-radius: 4px; } .nav-button:hover { background-color: #d0d0d0; } .nav-button:disabled { opacity: 0.5; cursor: not-allowed; } .url-bar { flex: 1; display: flex; align-items: center; } #url-input { flex: 1; height: 30px; padding: 0 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; } #go-button { margin-left: 5px; height: 30px; padding: 0 15px; background-color: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; } #go-button:hover { background-color: #45a049; } .loading-indicator { margin-left: 10px; font-size: 12px; color: #666; } #webview-container { flex: 1; position: relative; overflow: hidden; } #webview { width: 100%; height: 100%; border: none; } .error-message { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background-color: white; padding: 20px; border: 1px solid #ddd; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); text-align: center; } .error-message h3 { color: #f44336; margin-top: 0; } .error-message button { margin-top: 15px; padding: 8px 15px; background-color: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; } .error-message button:hover { background-color: #45a049; } </style> </head> <body> <div class="toolbar"> <div class="nav-buttons"> <button class="nav-button" id="back-button" disabled> ← </button> <button class="nav-button" id="forward-button" disabled> → </button> <button class="nav-button" id="reload-button"> ↻ </button> <button class="nav-button" id="stop-button" style="display: none"> ⊗ </button> </div> <div class="url-bar"> <input type="text" id="url-input" placeholder="输入网址..." value="https://www.google.com"> <button id="go-button">前往</button> </div> <div class="loading-indicator" id="loading-indicator" style="display: none"> 加载中... </div> </div> <div id="webview-container"> <iframe id="webview" src="https://www.google.com"></iframe> <div class="error-message" id="error-message" style="display: none"> <h3>加载失败</h3> <div id="error-details"></div> <button id="retry-button">重试</button> </div> </div> <script> const { ipcRenderer } = require('electron') const urlInput = document.getElementById('url-input') const goButton = document.getElementById('go-button') const backButton = document.getElementById('back-button') const forwardButton = document.getElementById('forward-button') const reloadButton = document.getElementById('reload-button') const stopButton = document.getElementById('stop-button') const loadingIndicator = document.getElementById('loading-indicator') const webview = document.getElementById('webview') const errorMessage = document.getElementById('error-message') const errorDetails = document.getElementById('error-details') const retryButton = document.getElementById('retry-button') // 导航到指定 URL async function navigate() { const url = urlInput.value if (!url) return try { const newUrl = await ipcRenderer.invoke('navigate', url) urlInput.value = newUrl } catch (error) { console.error('导航失败:', error) } } // 后退 async function goBack() { const success = await ipcRenderer.invoke('go-back') if (success) { // 导航成功后,webContents 的 did-navigate 事件会更新 URL } } // 前进 async function goForward() { const success = await ipcRenderer.invoke('go-forward') if (success) { // 导航成功后,webContents 的 did-navigate 事件会更新 URL } } // 刷新 async function reload() { await ipcRenderer.invoke('reload') } // 停止加载 async function stopLoading() { await ipcRenderer.invoke('stop-loading') } // 事件监听 goButton.addEventListener('click', navigate) urlInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') { navigate() } }) backButton.addEventListener('click', goBack) forwardButton.addEventListener('click', goForward) reloadButton.addEventListener('click', reload) stopButton.addEventListener('click', stopLoading) retryButton.addEventListener('click', navigate) // 监听主进程消息 ipcRenderer.on('loading-started', () => { loadingIndicator.style.display = 'block' stopButton.style.display = 'block' reloadButton.style.display = 'none' errorMessage.style.display = 'none' }) ipcRenderer.on('loading-stopped', () => { loadingIndicator.style.display = 'none' stopButton.style.display = 'none' reloadButton.style.display = 'block' }) ipcRenderer.on('load-failed', (event, errorCode, errorDescription) => { loadingIndicator.style.display = 'none' stopButton.style.display = 'none' reloadButton.style.display = 'block' errorDetails.textContent = `错误代码: ${errorCode}\n错误描述: ${errorDescription}` errorMessage.style.display = 'block' }) ipcRenderer.on('url-changed', (event, url) => { urlInput.value = url }) </script> </body> </html>运行应用
bashnpm install npm start
实战5:Electron + Vue 桌面应用
12.4 需求分析
创建一个使用 Vue 框架的 Electron 桌面应用,实现以下功能:
- 用 Vue 编写页面
- 实现数据展示和用户交互
- 本地存储用户数据
- 主进程与渲染进程通信
- 响应式设计,适配不同窗口大小
12.5 核心实现
- Vue项目集成Electron:使用 vue-cli-plugin-electron-builder 集成
- IPC通信:主进程与渲染进程之间的通信
- electron-store:本地存储用户数据
- Vue组件:构建用户界面
- Vuex:状态管理
12.6 实现步骤
创建 Vue + Electron 项目
bashvue create vue-electron-app cd vue-electron-app vue add electron-builder安装依赖
bashnpm install electron-store vuex创建 Vuex 存储
javascript// src/store/index.js import { createStore } from 'vuex' export default createStore({ state: { user: null, settings: { theme: 'light', language: 'zh-CN' }, todos: [] }, mutations: { setUser(state, user) { state.user = user }, setSettings(state, settings) { state.settings = { ...state.settings, ...settings } }, addTodo(state, todo) { state.todos.push(todo) }, toggleTodo(state, id) { const todo = state.todos.find(t => t.id === id) if (todo) { todo.completed = !todo.completed } }, deleteTodo(state, id) { state.todos = state.todos.filter(t => t.id !== id) } }, actions: { saveUser({ commit }, user) { commit('setUser', user) // 可以在这里添加保存到本地存储的逻辑 }, updateSettings({ commit }, settings) { commit('setSettings', settings) // 可以在这里添加保存到本地存储的逻辑 }, addTodo({ commit }, todo) { commit('addTodo', { ...todo, id: Date.now() }) // 可以在这里添加保存到本地存储的逻辑 }, toggleTodo({ commit }, id) { commit('toggleTodo', id) // 可以在这里添加保存到本地存储的逻辑 }, deleteTodo({ commit }, id) { commit('deleteTodo', id) // 可以在这里添加保存到本地存储的逻辑 } }, getters: { completedTodos(state) { return state.todos.filter(t => t.completed) }, pendingTodos(state) { return state.todos.filter(t => !t.completed) } } })修改主进程文件
javascript// src/background.js 'use strict' import { app, protocol, BrowserWindow, ipcMain } from 'electron' import { createProtocol } from 'vue-cli-plugin-electron-builder/lib' import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer' import Store from 'electron-store' const isDevelopment = process.env.NODE_ENV !== 'production' // 创建存储实例 const store = new Store({ name: 'vue-electron-app', defaults: { user: null, settings: { theme: 'light', language: 'zh-CN' }, todos: [] } }) // Scheme must be registered before the app is ready protocol.registerSchemesAsPrivileged([ { scheme: 'app', privileges: { secure: true, standard: true } } ]) async function createWindow() { // Create the browser window. const win = new BrowserWindow({ width: 800, height: 600, webPreferences: { // Use pluginOptions.nodeIntegration, leave this alone // See nklayman.github.io/vue-cli-plugin-electron-builder/guide/security.html#node-integration for more info nodeIntegration: process.env.ELECTRON_NODE_INTEGRATION, contextIsolation: !process.env.ELECTRON_NODE_INTEGRATION } }) if (process.env.WEBPACK_DEV_SERVER_URL) { // Load the url of the dev server if in development mode await win.loadURL(process.env.WEBPACK_DEV_SERVER_URL) if (!process.env.IS_TEST) win.webContents.openDevTools() } else { createProtocol('app') // Load the index.html when not in development win.loadURL('app://./index.html') } } // Quit when all windows are closed. app.on('window-all-closed', () => { // On macOS it is common for applications and their menu bar // to stay active until the user quits explicitly with Cmd + Q if (process.platform !== 'darwin') { app.quit() } }) app.on('activate', () => { // On macOS it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. if (BrowserWindow.getAllWindows().length === 0) createWindow() }) // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. app.on('ready', async () => { if (isDevelopment && !process.env.IS_TEST) { // Install Vue Devtools try { await installExtension(VUEJS_DEVTOOLS) } catch (e) { console.error('Vue Devtools failed to install:', e.toString()) } } createWindow() }) // Exit cleanly on request from parent process in development mode. if (isDevelopment) { if (process.platform === 'win32') { process.on('message', (data) => { if (data === 'graceful-exit') { app.quit() } }) } else { process.on('SIGTERM', () => { app.quit() }) } } // IPC 事件处理 ipcMain.handle('get-stored-data', () => { return store.store }) ipcMain.handle('save-stored-data', (event, data) => { store.set(data) return '数据保存成功' })创建 Vue 组件
vue<!-- src/components/TodoList.vue --> <template> <div class="todo-list"> <h2>待办事项</h2> <div class="todo-input"> <input type="text" v-model="newTodo" placeholder="输入新的待办事项" @keyup.enter="addTodo" > <button @click="addTodo">添加</button> </div> <ul class="todo-items"> <li v-for="todo in todos" :key="todo.id" :class="{ completed: todo.completed }"> <input type="checkbox" v-model="todo.completed" @change="toggleTodo(todo.id)"> <span>{{ todo.text }}</span> <button @click="deleteTodo(todo.id)">删除</button> </li> </ul> <div class="todo-stats"> <span>已完成: {{ completedCount }}</span> <span>待完成: {{ pendingCount }}</span> </div> </div> </template> <script> import { mapState, mapActions, mapGetters } from 'vuex' export default { name: 'TodoList', data() { return { newTodo: '' } }, computed: { ...mapState(['todos']), ...mapGetters(['completedTodos', 'pendingTodos']), completedCount() { return this.completedTodos.length }, pendingCount() { return this.pendingTodos.length } }, methods: { ...mapActions(['addTodo', 'toggleTodo', 'deleteTodo']), addTodo() { if (this.newTodo.trim()) { this.addTodo({ text: this.newTodo, completed: false }) this.newTodo = '' } } } } </script> <style scoped> .todo-list { background-color: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } h2 { margin-top: 0; color: #333; } .todo-input { display: flex; margin-bottom: 20px; } .todo-input input { flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 4px 0 0 4px; } .todo-input button { padding: 10px 15px; background-color: #4CAF50; color: white; border: none; border-radius: 0 4px 4px 0; cursor: pointer; } .todo-input button:hover { background-color: #45a049; } .todo-items { list-style: none; padding: 0; margin: 0 0 20px 0; } .todo-items li { display: flex; align-items: center; padding: 10px; border-bottom: 1px solid #eee; } .todo-items li.completed { text-decoration: line-through; color: #999; } .todo-items li input { margin-right: 10px; } .todo-items li span { flex: 1; } .todo-items li button { background-color: #f44336; color: white; border: none; border-radius: 4px; padding: 5px 10px; cursor: pointer; } .todo-items li button:hover { background-color: #d32f2f; } .todo-stats { display: flex; justify-content: space-between; font-size: 14px; color: #666; padding-top: 10px; border-top: 1px solid #eee; } </style>vue<!-- src/components/Settings.vue --> <template> <div class="settings"> <h2>设置</h2> <div class="setting-item"> <label for="theme">主题</label> <select id="theme" v-model="localSettings.theme" @change="updateSettings"> <option value="light">浅色</option> <option value="dark">深色</option> </select> </div> <div class="setting-item"> <label for="language">语言</label> <select id="language" v-model="localSettings.language" @change="updateSettings"> <option value="zh-CN">中文</option> <option value="en-US">英文</option> </select> </div> <div class="setting-item"> <label for="username">用户名</label> <input type="text" id="username" v-model="localUser.name" @change="updateUser"> </div> <div class="setting-item"> <label for="email">邮箱</label> <input type="email" id="email" v-model="localUser.email" @change="updateUser"> </div> </div> </template> <script> import { mapState, mapActions } from 'vuex' export default { name: 'Settings', data() { return { localSettings: { theme: 'light', language: 'zh-CN' }, localUser: { name: '', email: '' } } }, computed: { ...mapState(['settings', 'user']) }, watch: { settings: { handler(newSettings) { this.localSettings = { ...newSettings } }, deep: true }, user: { handler(newUser) { this.localUser = { ...newUser } || { name: '', email: '' } }, deep: true } }, mounted() { this.localSettings = { ...this.settings } this.localUser = { ...this.user } || { name: '', email: '' } }, methods: { ...mapActions(['updateSettings', 'saveUser']), updateSettings() { this.updateSettings(this.localSettings) }, updateUser() { this.saveUser(this.localUser) } } } </script> <style scoped> .settings { background-color: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } h2 { margin-top: 0; color: #333; } .setting-item { margin-bottom: 15px; } label { display: block; margin-bottom: 5px; font-weight: bold; } input, select { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box; } </style>修改 App.vue
vue<template> <div id="app" :class="{ 'dark-theme': isDarkTheme }"> <nav> <div class="logo">Vue + Electron App</div> <div class="nav-links"> <a href="#" @click.prevent="activeView = 'todo'">待办事项</a> <a href="#" @click.prevent="activeView = 'settings'">设置</a> </div> </nav> <main> <TodoList v-if="activeView === 'todo'" /> <Settings v-if="activeView === 'settings'" /> </main> <footer> <p>© {{ new Date().getFullYear() }} Vue + Electron App</p> </footer> </div> </template> <script> import TodoList from './components/TodoList.vue' import Settings from './components/Settings.vue' import { mapState } from 'vuex' import { ipcRenderer } from 'electron' export default { name: 'App', components: { TodoList, Settings }, data() { return { activeView: 'todo' } }, computed: { ...mapState(['settings']), isDarkTheme() { return this.settings.theme === 'dark' } }, async mounted() { // 从本地存储加载数据 const storedData = await ipcRenderer.invoke('get-stored-data') if (storedData) { // 这里可以使用 Vuex actions 来更新状态 console.log('加载存储的数据:', storedData) } }, beforeUnmount() { // 保存数据到本地存储 // 这里可以使用 Vuex actions 来保存状态 } } </script> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: Arial, sans-serif; background-color: #f5f5f5; color: #333; } #app { min-height: 100vh; display: flex; flex-direction: column; transition: background-color 0.3s, color 0.3s; } #app.dark-theme { background-color: #333; color: #f5f5f5; } nav { background-color: #4CAF50; color: white; padding: 15px 20px; display: flex; justify-content: space-between; align-items: center; } .logo { font-size: 18px; font-weight: bold; } .nav-links a { color: white; text-decoration: none; margin-left: 20px; } .nav-links a:hover { text-decoration: underline; } main { flex: 1; padding: 20px; max-width: 800px; margin: 0 auto; width: 100%; } footer { background-color: #333; color: white; text-align: center; padding: 15px; margin-top: auto; } #app.dark-theme footer { background-color: #111; } </style>修改 main.js
javascript// src/main.js import { createApp } from 'vue' import App from './App.vue' import store from './store' createApp(App).use(store).mount('#app')运行应用
bashnpm run electron:serve
12.7 小结
通过本章的两个进阶实战案例,你已经掌握了更复杂的 Electron 应用开发技能:
简易浏览器:学习了如何使用 BrowserWindow 和 webContents 控制网页加载和导航,如何创建应用菜单和工具栏,以及如何处理页面加载状态和错误。
Electron + Vue 桌面应用:学习了如何将 Vue 框架与 Electron 集成,如何使用 Vuex 进行状态管理,如何使用 electron-store 存储用户数据,以及如何构建响应式的用户界面。
这些进阶实战案例涵盖了更复杂的应用场景,帮助你进一步巩固和扩展了 Electron 开发的知识和技能。在接下来的章节中,我们将学习 Electron 应用的打包发布和进阶提升。
