十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

解析electron-vue桌面后台管理骨架:主进程构建、路由自动加载与菜单Tab联动

解析electron-vue桌面后台管理骨架:主进程构建、路由自动加载与菜单Tab联动 简介面向桌面应用开发者的 Electron Vue.js 整合示例包以 Element UI 作为界面层适合希望快速上手跨平台桌面应用与前后端技术融合的初中级开发者常用于后台管理系统、数据看板与本地工具类桌面应用。压缩包共45个文件以 JavaScript 脚本、Vue 组件、JSON 配置为主并含样式、字体、图标及持续集成配置整体约25.91MB。项目重点演示 Vue Router 自动加载路由以及 Element UI 菜单导航与选项卡联动可清晰看到动态路由按 URL 加载组件的机制以及菜单切换后选项卡内容的响应式更新。工程配置覆盖 Babel 语法转换、Webpack 开发与构建、Travis CI 与 AppVeyor 自动集成且包内主进程、渲染进程、路由与状态管理目录划分清晰包含 SourceHanSans 等字体资源省去部分环境配置时间并附带 README 说明便于本地运行和二次开发。目前已有465人学习适合作为搭建 Electron-vue 后台管理类桌面应用的起步样板。1. 从 electron-vue.zip 拆出的桌面应用骨架打开 electron-vue.zip 之前你要意识到这不是一个能直接跑起来就完事的 Demo。这个压缩包里装着一套完整的桌面后台管理骨架Electron 提供跨平台的主进程和窗口Vue 负责渲染层Element UI 接管菜单、表格这类中后台组件Webpack 用三份配置把 main、renderer 和 web 三种构建目标拆开。对刚接触桌面开发的人它是理解 electron 进程模型最直接的活样本对写过 Electron 项目的人里面关于自动加载路由、字体资源压缩和 CI 构建的细节也值得翻一遍。解压后先看src/renderer和.electron-vue这比README.md更能说明项目意图。2. 主进程与 Webpack 构建链路main/renderer 双配置的选型Electron 应用至少有两个进程主进程main创建BrowserWindow渲染进程renderer加载页面。主进程跑在 Node 环境渲染进程本身是 Chromium。为了把两者分别打包到dist/electron/main.js和dist/webelectron-vue 在.electron-vue下放了webpack.main.config.js和webpack.renderer.config.js。拆开配置比一份配置用多个 entry 更清晰因为两者的target和打包规则不同。2.1 目录分工与入口映射从压缩包内容看src/main/index.js是主进程入口src/renderer/main.js是渲染进程入口.electron-vue放构建脚本。dist/electron用来放主进程产物dist/web放渲染进程产物。这个结构对应关系很直接目录/文件作用构建链路中的位置src/main/index.js创建 BrowserWindow、处理生命周期webpack.main.config.js 的 entrysrc/renderer/main.js创建 Vue 根实例、挂载 #appwebpack.renderer.config.js 的 entry.electron-vue/webpack.main.config.js生成主进程 bundle产物写入 dist/electron.electron-vue/webpack.renderer.config.js生成渲染进程 bundle产物写入 dist/web.electron-vue/dev-runner.js并行执行两个 watch 构建并拉起 Electron只出现在开发模式看这个列表能直接对应到 Electron 的进程模型主进程产物必须在 Electron 启动前存在渲染进程产物是 HTML/JS/CSS 的经典 web 包。两者混在一起时调试定位会变得很痛苦。2.2 主进程配置target 与 Node 环境保留webpack.main.config.js 里最容易忽略的是target: electron-main和node选项。一个可靠的主进程配置通常长这样const path require(path) const webpack require(webpack) module.exports { target: electron-main, entry: { main: path.join(__dirname, ../src/main/index.js) }, output: { path: path.join(__dirname, ../dist/electron), filename: main.js }, mode: process.env.NODE_ENV production ? production : development, node: { __dirname: false, __filename: false }, module: { rules: [ { test: /\.js$/, exclude: /node_modules/, use: { loader: babel-loader, options: { cacheDirectory: true } } } ] }, plugins: [ new webpack.DefinePlugin({ process.env.NODE_ENV: JSON.stringify(process.env.NODE_ENV || development) }) ] }这里target: electron-main告诉 Webpack 不要为浏览器环境做 polyfill也不要假设window存在。node.__dirname: false是关键Electron 主进程里__dirname本来就指向编译后的文件目录如果让 Webpack 把它替换成假的路径app.getAppPath()和BrowserWindow的加载路径会全部错位。DefinePlugin把 NODE_ENV 暴露给源码这样在src/main/index.js里可以用if (process.env.NODE_ENV development)决定是否打开 DevTools、是否加载 vue-devtools。cacheDirectory是 babel-loader 的缓存开关开了以后二次构建能明显变快。2.3 渲染进程配置vue-loader 与 html-webpack-plugin渲染进程配置和普通 Vue 项目很接近区别在于target: electron-renderer和输出目录。示意如下const path require(path) const HtmlWebpackPlugin require(html-webpack-plugin) const webpack require(webpack) module.exports { target: electron-renderer, entry: { renderer: path.join(__dirname, ../src/renderer/main.js) }, output: { path: path.join(__dirname, ../dist/web), filename: renderer.js }, module: { rules: [ { test: /\.vue$/, loader: vue-loader }, { test: /\.js$/, exclude: /node_modules/, loader: babel-loader }, { test: /\.css$/, use: [style-loader, css-loader] } ] }, plugins: [ new HtmlWebpackPlugin({ template: path.join(__dirname, ../src/renderer/index.ejs), filename: index.html }), new webpack.DefinePlugin({ process.env.NODE_ENV: JSON.stringify(process.env.NODE_ENV || development) }) ] }HtmlWebpackPlugin会从src/renderer/index.ejs生成dist/web/index.html并把 renderer.js 自动注入。.ejs模板里能使用htmlWebpackPlugin.options变量适合动态注入页面 title。需要注意老一代 electron-vue 模板里vue-loader要配合VueLoaderPlugin使用具体写法取决于项目里锁定的 vue-loader 版本。压缩包里只有package-lock.json没有确认版本所以别拿这份配置直接跑要看node_modules实际安装的版本。2.4 dev-runner 与 npm scripts 的串行逻辑开发模式下.electron-vue/dev-runner.js通常做两件事先用 child_process 并行启动两个 Webpack watch再等主进程产物生成后 spawn 出 Electron。一个最小写法是const { spawn } require(child_process) const path require(path) const electron require(electron) const mainFile path.join(__dirname, ../dist/electron/main.js) const child spawn(electron, [mainFile], { stdio: inherit, env: process.env }) child.on(close, code process.exit(code))这里第一个参数electron是 npm 包暴露的可执行文件路径spawn 后等于在命令行里敲electron dist/electron/main.js。stdio: inherit把主进程的 console 输出接到当前终端这样 Vue warning、Electron 错误一目了然。把这条链路映射到 package.json scripts 上我一般这样组织{ scripts: { dev: node .electron-vue/dev-runner.js, build: node .electron-vue/build.js, build:web: node .electron-vue/build.js --web } }build.js负责在生产模式下跑主进程和渲染进程两次构建--web对应webpack.web.config.js把同一套 Vue 代码编译成纯浏览器版本。调npm run dev时先看dist/electron/main.js是否生成再看 dev-runner 是否正确传入入口文件顺序反了就会报Cannot find module ../dist/electron/main.js。3. 渲染进程 Vue 路由自动加载与 index.ejs 模板注入渲染进程的路由组织方式直接决定后台项目后续加模块的麻烦程度。在 electron-vue 结构里src/renderer/router是目录store也是目录。路由文件怎么放、怎么导入是第一个值得看的地方。3.1 为什么 electron-vue 需要 hash 模式路由Electron 加载的是本地file://协议下的dist/web/index.html。如果 Vue Router 使用mode: history跳转到/login后刷新Chromium 会尝试在本地文件系统找对应路径结果大概率是白屏。对于桌面应用路由不应该依赖服务端重写mode: hash是更稳的选择。这是 electron-vue 项目跟传统 Web 项目第一个明显的分叉点。3.2 使用 require.context 自动装载路由模块路由自动加载的核心是 Webpack 的require.context。常见做法是把每个功能模块的路由作为独立文件放到src/renderer/router/routes下然后在index.js里一次性导入import Vue from vue import Router from vue-router Vue.use(Router) const routesContext require.context(./routes, true, /\.js$/) const routes routesContext.keys() .map(key routesContext(key).default) .reduce((acc, route) acc.concat(Array.isArray(route) ? route : [route]), []) export default new Router({ mode: hash, routes })require.context接受三个参数第一个是相对路径./routes表示要扫描的目录第二个true表示同时读取子目录第三个正则/\.js$/只匹配 JS 文件。keys()返回匹配到的文件列表再通过routesContext(key)获取模块导出内容。这里有个很容易踩的坑export default可能导出的是单个路由对象也可能导出包含子路由的数组。上面用Array.isArray做了归一化避免concat([undefined])时向路由表里插入空值。如果你在这个项目里新增了routes/user.js但路由没有生效先检查是不是漏了default导出。3.3 路由参数和 meta 在菜单联动里的作用自动加载不会影响路由参数。普通路由文件里这样写export default { path: /user/:id, name: UserDetail, component: () import(/views/UserDetail.vue), meta: { title: 用户详情 } }动态参数:id用于详情页meta.title后面会被 Element UI 的菜单和 Tab 直接读取。菜单的index、路由的path、Tab 的path这三者要保持一致否则联动就会出现“点击菜单高亮了但 Tab 内容不是对应页面”的问题。实际项目里我一般用$router.resolve(path)拿动态路由的解析结果而不是手工拼接标题。3.4 index.ejs 在构建时的变量注入模板文件放在src/renderer/index.ejs作用是给HtmlWebpackPlugin提供 HTML 骨架。src/renderer/main.js创建 Vue 根实例时挂载到#appimport Vue from vue import App from ./App.vue import router from ./router import store from ./store new Vue({ router, store, render: h h(App) }).$mount(#app)同时模板里要有对应的挂载点!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / title% htmlWebpackPlugin.options.title %/title /head body div idapp/div /body /html注意% %是 EJS 的插值语法。htmlWebpackPlugin.options.title来自HtmlWebpackPlugin构造参数可以在渲染进程 webpack 配置中传入也可以在package.json里统一维护。这样打包出来的index.html会自动带上正确的 title不用每个环境手改。src/renderer/main.js里没有写el: #app而是用$mount(#app)两者等价但$mount更灵活可以在挂载前做一些权限校验或全局前置操作。配合 Vue Router 的beforeEach可以在路由切换前判断登录状态再决定是放行还是跳回登录页。4. Element UI 菜单导航与 Tab 选项卡联动状态与路由的双向绑定这个项目里最有复用价值的部分是 Element UI 菜单和选项卡联动。Element UI 的el-menu负责导航el-tabs负责已打开页面。两者要联动核心是让路由和 Vuex 成为唯一数据源菜单点击后更新 Vuex 中的 Tab 列表和激活路径Tab 切换时更新菜单高亮。这样无论从哪个入口进入页面状态都是同步的。4.1 侧边菜单的激活状态绑定侧边栏组件里el-menu的default-active绑定到 Vuex 里的tabs.activetemplate el-menu :default-active$store.state.tabs.active selecthandleSelect router el-menu-item index/dashboard i classel-icon-s-data/i span slottitle仪表盘/span /el-menu-item el-menu-item index/system i classel-icon-setting/i span slottitle系统设置/span /el-menu-item /el-menu /templaterouter属性是 Element UI 提供的一个快捷方式菜单选中后会自动router.push(index)省去手动跳转。但联动 Tab 还需要拿到路由的meta.title所以通常不依赖这个快捷跳转而是自己在select回调里处理。下面的写法更可控methods: { handleSelect(path) { const resolved this.$router.resolve(path) const tab { path, name: resolved.route.name, title: resolved.route.meta resolved.route.meta.title } this.$store.commit(tabs/ADD_TAB, tab) this.$router.push(path) } }this.$router.resolve(path)会根据路由表把字符串路径解析成完整 route 对象然后从meta里取菜单标题。这样写的好处是菜单数据不需要单独维护一套 title 列表路由表就是菜单信息的唯一来源。ADD_TABmutation 里做了去重和设置激活项见下面。4.2 Vuex 管理 Tab 状态Tab 列表和激活项放在 Vuex 中是因为侧边栏和顶栏 tabs 是两个不共享 props 的兄弟组件。创建一个独立的tabsmoduleconst state { visited: [], active: } const mutations { ADD_TAB(state, tab) { const exists state.visited.some(item item.path tab.path) if (!exists) { state.visited.push(tab) } state.active tab.path }, SET_ACTIVE(state, path) { state.active path }, REMOVE_TAB(state, path) { const index state.visited.findIndex(item item.path path) if (index -1) state.visited.splice(index, 1) if (state.active path) { const last state.visited[state.visited.length - 1] state.active last ? last.path : /dashboard } } } export default { namespaced: true, state, mutations }这个 module 只用了state和mutations没有放 actions因为 Tab 操作是纯同步的状态更新。ADD_TAB里some按path去重激活路径总是被赋值为最新点击的路径。REMOVE_TAB在关闭当前 Tab 后回退到最后一个 Tab没有剩余 Tab 时回到/dashboard。这里的关键是菜单高亮和 Tab 激活共用tabs.active一旦active变更el-menu的default-active和el-tabs的v-model会同时更新。Element UI 的组件监听是响应式的所以不需要手动调用方法同步。4.3 顶部选项卡的关闭与切换选项卡组件使用el-tabs的closable属性支持关闭el-tabs v-model$store.state.tabs.active typecard closable tab-clickhandleTabClick tab-removehandleTabRemove el-tab-pane v-foritem in $store.state.tabs.visited :keyitem.path :labelitem.title :nameitem.path /el-tab-pane /el-tabstab-click时只更新 Vuex 的 active不调用路由跳转因为v-model已经改变tabs.active而菜单组件监听了这个值tab-remove时提交REMOVE_TAB然后做一次路由跳转handleTabClick(tab) { this.$store.commit(tabs/SET_ACTIVE, tab.name) this.$router.push(tab.name) }, handleTabRemove(name) { this.$store.commit(tabs/REMOVE_TAB, name) const active this.$store.state.tabs.active if (active) this.$router.push(active) }注意el-tab-pane的name必须与路由path完全一致包括开头的/。否则从 Tab 切回菜单时会因为字符串匹配不上导致菜单高亮丢失。这是排查联动失效时最先看的三个位置之一菜单 index 是否为路由 path、tab name 是否为路由 path、Vuex active 是否没有在$router.push后保留。4.4 resetMessage.js 在后台系统里的实际用途项目根目录的resetMessage.js我一般会理解为重置 Element UI 消息实例的工具。Element UI 的Message每次调用都会向 body 追加一个节点快速切换菜单时上一个请求的错误提示可能还在屏幕上等 3 秒后自动关闭又会打扰后续操作。一个常见的做法是import { Message } from element-ui let messageInstance null export default function resetMessage(options) { if (messageInstance) { messageInstance.close() messageInstance null } messageInstance Message(options) return messageInstance }Message(options)返回实例保存到模块级变量里下次调用前先close()。把项目里所有的Message.xxx替换成这个包装函数就能保证屏幕上同一时间只有一个提示。这个文件放在根目录而不在src里很可能是为了方便主进程或 preload 脚本复用如果只给渲染进程用应该挪到src/renderer/utils里。放在根目录的问题是Webpack 两个入口的 include 范围如果没有覆盖它import 时可能报模块解析错误。5. 打包前的字体压缩与 CI 配置修正构建产物和持续集成是两个最容易拖慢交付的环节。electron-vue 模板默认把static目录下的静态资源原样复制到打包产物中sourcehansans.*这些思源黑体字体文件就是典型对象。不要在打包前急着压缩 JS先检查字体体积。5.1 字体格式优先级与 font-display思源黑体全量 ttf 动辄十几 MB仓库里同时放了 ttf、svg、eot、woff、woff2实际 Electron 打包时只需要 woff2 和 woff。用font-face时要按体积从小到大排font-face { font-family: SourceHanSansCN; src: url(../static/font/sourcehansans.woff2) format(woff2), url(../static/font/sourcehansans.woff) format(woff); font-weight: 400; font-display: swap; }Electron 使用的 Chromium 版本较新woff2 完全支持svg/eot 可以删掉。font-display: swap让页面先用系统字体渲染字体文件加载完成后替换避免首屏白屏。如果观察到打包后布局异常很大概率是字体 CSS 的url相对路径没对上或static资源没有拷贝到dist/web。常见做法是使用 Webpack 的CopyWebpackPlugin把 static 目录复制到输出目录并在 CSS 里写相对路径。5.2 应用图标的三个文件build/icons下的 icon.ico、256x256.png、icon.icns 对应 Windows、Linux、macOS 三种平台。electron-builder 在打包时会根据平台自动挑对应文件。如果图标不生效先检查 package.json 的build字段是否指向了这些路径{ build: { appId: com.example.electronvue, productName: ElectronVueAdmin, directories: { output: release }, mac: { icon: build/icons/icon.icns }, win: { icon: build/icons/icon.ico }, linux: { icon: build/icons/256x256.png } } }appId保持域名反转格式directories.output表示安装包输出目录。wine 环境在 Linux 上打 Windows 包时会用到 icon.ico所以三个图标文件最好都保留。5.3 CI 两条链路的配置.travis.yml和appveyor.yml分别覆盖 macOS/Linux 和 Windows 构建。Travis 一般跑在 macOS 虚拟机上可以打 dmgAppVeyor 用 Windows Server可以打 exe。两者逻辑相同都是安装依赖后执行 build 脚本os: osx language: node_js node_js: lts/* install: - npm install script: - npm run build这里node_js: lts/*避免锁死具体版本。CI 机器上没有 node_modules 缓存所以 install 阶段不要用npm ci --offline。如果项目使用package-lock.json在 CI 里推荐npm ci而不是npm install因为前者会严格按 lock 文件安装并且速度更快。遇到 CI 构建时字体文件缺失先看.gitignore是否把dist和static/font忽略了再确认 build 脚本是否在复制静态资源后执行。这个项目里dist目录被放在压缩包中说明模板作者可能在本地构建过、提交了部分产物但这不应该被当作线上流程依赖CI 里应该每次全新构建。本文还有配套的精品资源点击获取
返回列表