Electron基础

基础概念

Electron流程模型

Electron 采用多进程模型,包括主进程/渲染器进程

其中主进程包括所有可用的功能,即可以访问所有node api,而用于向用户展示的渲染器则只能使用浏览器提供的接口,但渲染器中仍然可以通过预加载文件的方式来获得更高的权限

主进程一般指的都是main.js及其附属的js,而渲染器进程(有时此文档中也会称为子进程)则是由html和js渲染的前端页面。

类比于互联网开发中常见的前后端开发,前台指的就是渲染器进程,而后台可看作主进程

Electron上下文隔离

上下文隔离基础原理

一种隔离手段,目的是防止在网页环境下(html/js)中直接访问到Electron/Node的api接口,防止出现安全性问题,在新版本的Electron中,上下文隔离默认启用

在很多情况下进程间仍然是需要进行通信的,比如渲染器中要将用户的输入保存为硬盘上的文件,此时就涉及到了访问操作系统文件接口的操作,此时我们就可以通过预加载文件的方式向渲染器提供一个保存文件的方法,以实现功能的扩展

关于安全性

对于安全性,我们在向渲染器提供新功能时,一定注意的是要将api封装为一种方法或者一种业务,而不是直接将api导出;类比于传统前后端的关系,前台当然可以获取到后台的数据,但绝对不能直接将操作数据库的api开放给前台,而是将这些api以一种业务接口的形式向前端暴露,最大限度的保障系统安全

上下文隔离案例(渲染器端写文件到当前目录)

1. 主进程中创建事件监听

main.js中,添加一个ipcMain监听

// 从node中引入文件模块
const fs = require('fs')
// 创建ipcMain事件监听
ipcMain.on('save-title', (event, title) => {
  // 同步写文件
  fs.writeFileSync("./title.txt",title)
})
2. 创建预加载文件,开放指定方法

在根目录创建文件preload.js,其内容为:

// 预加载文件
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
    setTitle: (title) => ipcRenderer.send('set-title', title),
    saveTitle: (title) => ipcRenderer.send('save-title', title)
})
3. 修改index文件,增加输入框和按钮

修改index.html文件,如下:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
  <title>Electron Test Demo</title>
</head>

<body>
  <form>
    窗口标题: <input id="title" />
    <button id="btn" type="button">修改</button>
    <button id="btn-save" type="button">保存</button>
    <script src="./renderer.js"></script>
  </form>
</body>

</html>
4.增加渲染器JS文件,并增加相应的操作

index.html同级目录下创建文件renderer.js,其内容为:

const setButton = document.getElementById('btn')
const saveButton = document.getElementById('btn-save')
const titleInput = document.getElementById('title')
setButton.addEventListener('click', () => {
    const title = titleInput.value
    window.electronAPI.setTitle(title)
});
saveButton.addEventListener('click', () => {
    const title = titleInput.value
    window.electronAPI.saveTitle(title)
});
5. 启动测试与预期

执行npm run start即可

正常情况下,输入内容后点击修改,可以修改当前窗口的标题,如果点击保存,可以在根目录创建一个title.txt文件

Electron进程间通信

进程间通信(IPC)是Electron中极为主要的部分,Electron支持各个进程互相通信,上文在流程模型中提到过,Electron应用是基于多进程模型的,因此无论是主进程还是一个或多个渲染器进程间都有可能进行数据交换,常见的情况有:

渲染器与主进程通信

渲染器进程和主进程通信一般有两种方式,一种是单向的传递数据,另一种则可以实现双向的通信

send/on

案例同上下文隔离案例

此方法关键点在于主进程会监听一个事件,之后子进程(渲染器进程)会触发这个事件,因此这种方式是单项信息传递的

handle/invoke

此案例中,系统启动后主界面存在一个输入框,用户可以手动输入或点击选择按钮选择地址,点击保存会将选择或输入的路径放到运行目录的title.txt中

需要注意的是点击选择的操作,要求用户选择文件的窗口由主函数弹出(Node方式),同时需要主函数将用户选择的路径返回给渲染器使用,因此通过hanle/invoke方式进行双向传递

在main.js中增加处理对话框的方法和handle事件
const { app, BrowserWindow, ipcMain, Menu, dialog } = require('electron')
const path = require('path')
const fs = require('fs')

async function handleFileOpen() {
    const { canceled, filePaths } = await dialog.showOpenDialog()
    if (canceled) {
        return
    } else {
        return filePaths[0]
    }
}

function createWindow() {
    const mainWindow = new BrowserWindow({
        show: false,
        webPreferences: {
            preload: path.join(__dirname, 'preload.js')
        }
    })

    Menu.setApplicationMenu(null);
    mainWindow.loadFile('html/index.html')
    mainWindow.on('ready-to-show', () => {
        mainWindow.show()
    })
}

app.whenReady().then(() => {
    ipcMain.handle('dialog:openFile', handleFileOpen)
    ipcMain.on('set-title', (event, title) => {
        const webContents = event.sender
        const win = BrowserWindow.fromWebContents(webContents)
        win.setTitle(title)
    })
    ipcMain.on('save-title', (event, title) => {
        fs.writeFileSync("./title.txt", title)
    })
    createWindow()
    app.on('activate', function () {
        if (BrowserWindow.getAllWindows().length === 0) createWindow()
    })
    app.on('window-all-closed', function () {
        if (process.platform !== 'darwin') app.quit()
    })
})
在preload.js中增加开放的接口
// 预加载文件
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
    setTitle: (title) => ipcRenderer.send('set-title', title),
    saveTitle: (title) => ipcRenderer.send('save-title', title),
    openFile: () => ipcRenderer.invoke('dialog:openFile')
})
修改index.html,为其增加部分功能
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
  <title>Electron Test Demo</title>
</head>

<body>
  <form>
    数据路径: <input id="title" />
    <button id="btn" type="button">选择</button>
    <button id="btn-save" type="button">
      保存
    </button>
    <script src="./renderer.js"></script>
  </form>
</body>

</html>
修改renderer.js,为html中的元素增加操作
const setButton = document.getElementById('btn')
const saveButton = document.getElementById('btn-save')
const titleInput = document.getElementById('title')
setButton.addEventListener('click', async () => {
    const filePath = await window.electronAPI.openFile()
    titleInput.value = filePath;
    window.electronAPI.setTitle(filePath)
});
saveButton.addEventListener('click', () => {
    const title = titleInput.value
    window.electronAPI.saveTitle(title)
});

主进程与渲染器进程通信

主进程到渲染器的通信一般通过webContents方式

此示例中,用户选择文件的操作改为了菜单选择的方式,因此实际上是主进程调用选择文件窗口,并将获得的结果传递给渲染器进行显示

修改main.js
const { app, BrowserWindow, Menu, ipcMain, dialog } = require('electron')
const path = require('path')

async function handleFileOpen() {
    const { canceled, filePaths } = await dialog.showOpenDialog()
    if (canceled) {
        return
    } else {
        return filePaths[0]
    }
}

function createWindow() {
    const mainWindow = new BrowserWindow({
        webPreferences: {
            preload: path.join(__dirname, 'preload.js')
        }
    })

    const menu = Menu.buildFromTemplate([
        {
            label: app.name,
            submenu: [
                {
                    click: async () => mainWindow.webContents.send('select-file', await handleFileOpen()),
                    label: '打开文件',
                }
            ]
        }

    ])

    Menu.setApplicationMenu(menu)
    mainWindow.loadFile('html/index.html')

    // 如果放开下方的注释,会在启动时打开开发者工具(渲染器)
    // mainWindow.webContents.openDevTools()
}

app.whenReady().then(() => {
    // 监听改变后的路径
    ipcMain.on('value', (_event, value) => {
        console.log("Select File:", value)
    })

    createWindow()

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

app.on('window-all-closed', function () {
    if (process.platform !== 'darwin') app.quit()
})
修改preload.js
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
  handleCounter: (callback) => ipcRenderer.on('select-file', callback)
})
修改index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
    <title>Menu Counter</title>
  </head>
  <body>
    当前选择的路径: <span id="counter"></span>
    <script src="./renderer.js"></script>
  </body>
</html>
修改renderer.js
const counter = document.getElementById('counter')

window.electronAPI.handleCounter((event, value) => {
    counter.innerText = value
    event.sender.send('value', value)
})

渲染器进程与渲染器进程通信

渲染器进程之间无法直接进行通信,但可以将主进程作为中间人的形式进行通信,或者通过消息端口(MessagePort)的方式进行直接的通信

具体代码请参照下文的Electron消息端口部分的示例,在该部分中,通过Electron消息端口的方式实现了两个渲染器进程间的通信

Electron进程沙盒化

Electron和Chromium不同,Electron中执行的js大部分情况下是可以看作可信代码的,因为这部分代码一般情况下都是用户自行控制的,但仍然存在一些危险性,所以Electron使用了一种混合沙盒化的机制,在这种机制下,前端的js可以获得更多的权限和功能,但同样的也会引起更深层和更危险的漏洞

为渲染器启用沙盒化

// main.js
app.whenReady().then(() => {
  const win = new BrowserWindow({
    webPreferences: {
      sandbox: true
    }
  })
  win.loadURL('https://google.com')
})

全局范围内启用沙盒化

这种操作会使所有的渲染器强制

// main.js
app.enableSandbox()
app.whenReady().then(() => {
  // no need to pass `sandbox: true` since `app.enableSandbox()` was called.
  const win = new BrowserWindow()
  win.loadURL('https://google.com')
})

Electron消息端口

消息端口区别于其他的通信方式是它可以持续传递消息,并且一般情况下渲染器进程间通信都需要借助此功能

在此案例下,所有html文件及其附属js都存放在根目录的html文件夹中

主进程(main.js)

const { BrowserWindow, app, ipcMain, MessageChannelMain } = require('electron')

app.whenReady().then(async () => {

    // 用户信息窗口
    const user = new BrowserWindow({
        show: true,
        webPreferences: {
            nodeIntegration: true,
            contextIsolation: false,
            enableRemoteModule: true
        }
    })
    // 加载网页
    await user.loadFile('html/user.html')

    
    // 主窗口
    const mainWindow = new BrowserWindow({
        webPreferences: {
            nodeIntegration: true,
            contextIsolation: false,
            enableRemoteModule: true
        }
    })
    mainWindow.loadFile('html/app.html')

    // 主窗口开启开发者工具
    mainWindow.webContents.openDevTools()
    // 用户窗口开启开发者工具
    user.webContents.openDevTools()
    
    // 监听app窗口事件
    ipcMain.on('request-user-channel', (event) => {
        if (event.senderFrame === mainWindow.webContents.mainFrame) {
            // 创建通道
            const { port1, port2 } = new MessageChannelMain()
            // 将创建的通道送到user窗口
            user.webContents.postMessage('user-client', null, [port1])
            // 将另一端送到app窗口
            event.senderFrame.postMessage('provide-user-channel', null, [port2])
        }
    })
})

渲染器进程(app.html)

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <div id="main">
        <p>
            用户名为:
            <span id="name">默认</span>
        </p>
        <p>
            <button id="sendMessage">
                发送消息
            </button>
        </p>
    </div>
    <script src="./app.js"></script>
</body>

</html>

渲染器进程(app.js)

const { ipcRenderer } = require('electron')
const nameSpan = document.getElementById('name')
const sendMessage = document.getElementById('sendMessage')

// 与user窗口的通信管道
var userPort = undefined;

// 从主进程获取消息对象
ipcRenderer.send('request-user-channel')

// 主进程返回
ipcRenderer.on('provide-user-channel', (event) => {
    userPort = event.ports[0];
    userPort.onmessage = (event) => {
        nameSpan.innerText = event.data;
    }
    userPort.postMessage("系统初始化消息")
})

// 向user发送消息
sendMessage.addEventListener('click', () => {
    // 发送消息
    userPort.postMessage(nameSpan.innerText)
});

渲染器进程(user.html)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <p>
        系统消息:
        <span id="sysInfo">
            暂无
        </span>
    </p>
    <p>

        用户名:
        <input id="name" aria-label="name" />
        <button id="btn-save" type="button">设置</button>
    </p>
    <script src="./user.js"></script>
</body>
</html>

渲染器进程(user.js)

const { ipcRenderer } = require('electron')
const sysInfo = document.getElementById('sysInfo')
const nameInput = document.getElementById('name')
const saveButton = document.getElementById('btn-save')

// app窗口通信管道
var appPort = undefined;

// 监听主进程发送的通信管道
ipcRenderer.on('user-client', (event) => {
    appPort = event.ports[0];
    // 当接收app窗口的消息
    appPort.onmessage = (event) => {
        sysInfo.innerText = event.data;
    }
})

// 监听按钮点击事件
saveButton.addEventListener('click', () => {
    // 向app窗口发送消息
    appPort.postMessage(nameInput.value)
});

常用功能

Electron代码调试

为vscode增加项目启动配置

在项目根目录的.vscode文件夹中,新建launch.json文件,其内容如下:

{
  "version": "0.2.0",
  "compounds": [
    {
      "name": "Main + renderer",
      "configurations": ["Main", "Renderer"],
      "stopAll": true
    }
  ],
  "configurations": [
    {
      "name": "Renderer",
      "port": 9222,
      "request": "attach",
      "type": "pwa-chrome",
      "webRoot": "${workspaceFolder}"
    },
    {
      "name": "Main",
      "type": "pwa-node",
      "request": "launch",
      "cwd": "${workspaceFolder}",
      "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
      "windows": {
        "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd"
      },
      "args": [".", "--remote-debugging-port=9222"],
      "outputCapture": "std",
      "console": "integratedTerminal"
    }
  ]
}

创建完成后,即可直接通过快捷键F5运行面板直接运行/调试

为指定窗口打开开发者工具

// win为渲染器实例对象
win.webContents.openDevTools()

Electron原生菜单

main.js

const { BrowserWindow, app, Menu, MessageChannelMain } = require('electron')

app.whenReady().then(async () => {
    
    // 主窗口
    const mainWindow = new BrowserWindow({
        webPreferences: {
            nodeIntegration: true,
            contextIsolation: false,
            enableRemoteModule: true
        }
    })
    // 菜单模板
    const menuTemplate = [
      {
        // 菜单项文本
        label: '系统',
        // 子菜单
        submenu: [
          {
            label: '关于',
            accelerator: 'CmdOrCtrl+Shift+A'
          },
          // 分割栏
          {
            type: 'separator'
          },
          {
            label: '退出',
            // 快捷键
            accelerator: 'CmdOrCtrl+Q',
            click: () => {
              mainWindow.close();
            }
          }
        ]
      }
    ];
    // 通过模板创建菜单
    const menu = Menu.buildFromTemplate(menuTemplate);
    // 设置菜单
    Menu.setApplicationMenu(menu);
})

Electron托盘图标

main.js

const { BrowserWindow, app, Tray,Menu, MessageChannelMain } = require('electron')

app.whenReady().then(async () => {
    // 主窗口
    const mainWindow = new BrowserWindow({
        webPreferences: {
            nodeIntegration: true,
            contextIsolation: false,
            enableRemoteModule: true
        }
    })

    const menuTemplate = [
        {
            // 设置菜单项文本
            label: '系统',
            // 设置子菜单
            submenu: [
                {
                    label: '关于',
                    // 设置菜单角色
                    role: 'about'
                },
                // 分割栏
                {
                    type: 'separator'
                },
                {
                    label: '退出',
                    // 设置菜单的热键
                    accelerator: 'Command+Q',
                    click: () => {
                        mainWindow.close();
                    }
                }
            ]
        }
    ];
    // 通过模板创建菜单
    const menu = Menu.buildFromTemplate(menuTemplate);
    // 设置托盘图标
    // 请预先在此目录下准备图标文件
    tray = new Tray('images/icon.png');
    // 设置托盘图标描述
    tray.setToolTip('Electron Demo Beta 2.0')
    // 设置托盘菜单
    tray.setContextMenu(menu)
    // 在窗口关闭时销毁tray
    mainWindow.on('closed', () => {
        tray.destroy();
    });
})

Electron快捷键

在Electron中,快捷键分为三种,即菜单快捷键/全局快捷键/窗体快捷键

菜单快捷键

详见 Electron原生菜单部分,此处不再赘述

全局快捷键

  • 全局快捷键可以在任意区域触发,与窗口是否显示无关

  • 全局快捷键需在主进程中注册

const { BrowserWindow, app, globalShortcut } = require('electron')

app.whenReady().then(async () => {



    // 主窗口
    const mainWindow = new BrowserWindow({
        // 禁止调整大小
        resizable: false,
        // 窗口框架(边框)
        frame: false,
        webPreferences: {
            nodeIntegration: true,
            contextIsolation: false,
            enableRemoteModule: true
        } 
    })
    // 加载页面(界面过于简单且无特殊操作,因此不在代码)
    mainWindow.loadFile('html/app.html')

    globalShortcut.register('CmdOrCtrl+Shift+M', function(){
        mainWindow.close()
    })

})

窗体快捷键

窗体快捷键,实际上可以看作渲染器快捷键,仅在某一个渲染器窗口中生效,这种情况下既可以通过浏览器监听事件的方式实现,也可以通过菜单的方式模拟配置,因为使用较少,后续可能再补充此部分内容

Electron通知消息

const { BrowserWindow, Notification, app, globalShortcut } = require('electron')

app.whenReady().then(async () => {

    // 主窗口
    const mainWindow = new BrowserWindow({
        // 禁止调整大小
        resizable: false,
        // 窗口框架(边框)
        frame: false,
        webPreferences: {
            nodeIntegration: true,
            contextIsolation: false,
            enableRemoteModule: true
        }
    })
    // 加载页面
    mainWindow.loadFile('html/app.html')

    // 注册一个全局快捷键 (Ctrl + Shift + M)
    globalShortcut.register('CmdOrCtrl+Shift+M', function () {
        // 显示通知框
        notification.show()
    })

    // 新建一个通知
    var notification = new Notification({
        // 主标题
        title: '晚上好',
        // 副标题,Windows 11 不显示
        subtitle: '温馨提示',
        // 主要内容
        body: 'Electron 应用程序向您问好,点击此通知将关闭窗口',
        // 是否静音
        silent:false,
        // 通知超时,可为: default|never
        // 如果为never且在windows中,会出现关闭按钮
        // timeoutType:'never',
        // 通知中显示的图标
        icon:"images/icon.png"
    })
    // 当用户点击通知时
    notification.once('click', () => {
        // 关闭通知
        notification.close();
        // 关闭窗口
        mainWindow.close();
    })
    // 当通知显示时
    notification.once('show', () => {
        // 打开开发者工具
        mainWindow.webContents.openDevTools()
    })
    // 当通知被关闭时
    notification.once('close', () => {
        console.log('close notification');  
    })

})

Electron网络调用

Electron Vue

项目创建

准备Vue项目

注意,此处项目中选用的是 Vue 3.x+ Typescript,当然也支持JavascriptVue 2.x,一切由你决定

安装Vue-cli

需要通过vue-cli创建项目,如果你习惯vue-ui或已有项目也可以跳过

npm i @vue/cli -g
创建项目
vue create vte-demo

安装Electron依赖

和原生安装一样,也有几率出现无法安装Electron的情况,参照(#N001)

# 进入创建的目录
cd vte-demo
# 添加Electron依赖
vue add electron-builder

修改依赖版本(可选)

存在一定可能,vue和ts版本冲突,参考(#P001

npm i -D ts-loader@~8.2.0

测试运行

npm run electron:serve

Electron React

Electron进阶

界面效果

const { BrowserWindow, app, ipcMain, MessageChannelMain } = require('electron')

app.whenReady().then(async () => {

    // 主窗口
    const mainWindow = new BrowserWindow({
        // 全屏显示
        fullscreen:true,
        // 窗口是否透明,在Windows中必须禁用窗口框架(frame: false)
        transparent: true,
        // 是否允许调整窗口大小
        resizable: false,
        // 窗口框架(边框)
        frame: false,
        webPreferences: {
            // 开发工具,开启可能影响透明效果
            devTools: false,
            nodeIntegration: true,
            contextIsolation: false,
            enableRemoteModule: true
        } 
    })
    // 加载页面
    mainWindow.loadFile('html/app.html')
    // 忽略用户鼠标操作(相当于鼠标穿透)
    mainWindow.setIgnoreMouseEvents(true)
    // 窗口始终在最前面显示
    mainWindow.setAlwaysOnTop(true)
})

案例:ServeRoot

案例描述

允许监听或发送Http/Https/TCP的测试工具,支持自动化测试脚本,不引入vue或React

功能点

  • 可以建立Http/Https服务器

  • 可以建立TCP服务器

  • 可以发送TCP请求

  • 可以发送Http请求

  • 可以执行简单的js脚本

页面

Http服务页面

Tcp服务页面

Http请求页面

案例:VisualStyle

案例描述

图表样式可视化工具,允许通过图形化的形式对Echart/F2的样式进行配置,通过vue3/ts为基础

其他

文档

Electron Api文档

Node.js Api文档

快捷模板

为了实现快速开发,因此针对不同情况制作了多个Electron模板,链接均为Github,列表如下:

Electron 原生

Electron API示例

Electron + Bootstrap

Electron + Vue2.x + ElementUI

Electron + Vue3.x + Typescript + ElementUI Plus

Electron + React

注释

文档版本

  • 2022-07-14 07:20 初始版本

  • 2022-07-14 09:55 补全Electron基础中部分内容

  • 2022-07-15 12:00 继续补全基础概念中的内容,新增案例

  • 2022-07-18 12:23 继续补全 扩展部分中的内容

勘误

安装包失败,项目无法启动(#N001)

描述

执行npm run start(即electron .)时报错,大致是Electron安装错误,需要重新安装

Electron failed to install correctly, please delete node_modules/electron and try installing again

原因

一般是网络原因造成的包下载不全

解决

修改npm配置

`npm config edit

在打开的编辑器中找到以`registry=`开头的一行,在下方新行中增加:

electron_mirror=https://cdn.npm.taobao.org/dist/electron/

删除之前下载的node_modules包,重新执行 npm i electron即可

窗口启动前短暂白屏(#S001)

描述

在启动项目时,打开窗口不会立刻显示内容,会短暂展示白屏然后再显示页面

原因

系统启动即立刻显示窗口容器,可以先暂时隐藏窗口,等待页面加载完成后再重新显示

解决

创建时

const mainWindow = new BrowserWindow({
  // 启动时显示,此处为false是为了跳过加载完成前的白屏
  show: false,
  webPreferences: {
    preload: path.join(__dirname, 'preload.js')
  }
})

页面加载完成

mainWindow.on('ready-to-show', () => {
  mainWindow.show()
})

Electron + Vue3 + Typescript报错:TS2571(#P002)

描述

TS2571: Object is of type 'unknown'.

原因

新项目中使用了Typescript,因此对数据类型有要求,将报错的代码改为符合Ts要求即可

解决

// 如下修改
console.error('Vue Devtools failed to install')

Electron + Vue3 + Typescript 执行npm run electron:serve 报错(#P001)

描述

 ERROR  Failed to compile with 1 errors                                                  13:54:57

 error  in ./src/background.ts

Module build failed (from ./node_modules/ts-loader/index.js):
TypeError: loaderContext.getOptions is not a function
    at getLoaderOptions (E:\Source\VisualStyle\node_modules\ts-loader\dist\index.js:91:41)       
    at Object.loader (E:\Source\VisualStyle\node_modules\ts-loader\dist\index.js:14:21)

 @ multi ./src/background.ts

原因

ts-loader和vue-cli版本不兼容,将任意一个修改即可

解决

// 修改ts-loader的版本
npm i -D ts-loader@~8.2.0