feat: 新增多标签功能
This commit is contained in:
@@ -12,7 +12,7 @@ ipcMain.on('window-max', (event) => {
|
|||||||
const webContent = event.sender
|
const webContent = event.sender
|
||||||
const win = BrowserWindow.fromWebContents(webContent)
|
const win = BrowserWindow.fromWebContents(webContent)
|
||||||
if (win.isMaximized()) {
|
if (win.isMaximized()) {
|
||||||
win.restore()
|
win.unmaximize()
|
||||||
} else {
|
} else {
|
||||||
win.maximize()
|
win.maximize()
|
||||||
}
|
}
|
||||||
|
|||||||
41
src/main.ts
41
src/main.ts
@@ -1,26 +1,31 @@
|
|||||||
import { app, BrowserWindow, ipcMain } from "electron";
|
import { app, BrowserWindow, ipcMain, shell } from "electron";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import started from "electron-squirrel-startup";
|
import started from "electron-squirrel-startup";
|
||||||
|
import { TabManager } from './main/tab-manager'
|
||||||
|
import { registerTabIpc } from './main/ipc'
|
||||||
|
|
||||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||||
if (started) {
|
if (started) {
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// const inDevelopment = process.env.NODE_ENV === "development";
|
const isDev = !!MAIN_WINDOW_VITE_DEV_SERVER_URL;
|
||||||
const createWindow = () => {
|
const createWindow = () => {
|
||||||
// Create the browser window.
|
// Create the browser window.
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 900,
|
width: 900,
|
||||||
height: 670,
|
height: 670,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
resizable: false, // 禁止拖拽放大缩小
|
frame: false,
|
||||||
maximizable: false, // 禁止最大化
|
windowButtonVisibility: false,
|
||||||
minimizable: true, // 允许最小化
|
resizable: true,
|
||||||
|
maximizable: true,
|
||||||
|
minimizable: true,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
// devTools: inDevelopment,
|
devTools: isDev,
|
||||||
// nodeIntegration: true,
|
nodeIntegration: false,
|
||||||
// contextIsolation: false,
|
contextIsolation: true,
|
||||||
|
sandbox: true,
|
||||||
preload: path.join(__dirname, "preload.js"),
|
preload: path.join(__dirname, "preload.js"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -29,6 +34,18 @@ const createWindow = () => {
|
|||||||
mainWindow.loadURL("https://www.baidu.com")
|
mainWindow.loadURL("https://www.baidu.com")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle("external-open", (_event, url: string) => {
|
||||||
|
try {
|
||||||
|
const allowed = /^https:\/\/(www\.)?(baidu\.com|\w+[\.-]?\w+\.[a-z]{2,})(\/.*)?$/i;
|
||||||
|
if (typeof url === "string" && allowed.test(url)) {
|
||||||
|
return shell.openExternal(url);
|
||||||
|
}
|
||||||
|
throw new Error("URL not allowed");
|
||||||
|
} catch (e) {
|
||||||
|
return Promise.reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// and load the index.html of the app.
|
// and load the index.html of the app.
|
||||||
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
||||||
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
|
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
|
||||||
@@ -38,8 +55,13 @@ const createWindow = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open the DevTools.
|
if (isDev) {
|
||||||
mainWindow.webContents.openDevTools();
|
mainWindow.webContents.openDevTools();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = new TabManager(mainWindow)
|
||||||
|
registerTabIpc(tabs)
|
||||||
|
tabs.create('about:blank')
|
||||||
};
|
};
|
||||||
|
|
||||||
// This method will be called when Electron has finished
|
// This method will be called when Electron has finished
|
||||||
@@ -66,3 +88,4 @@ app.on("activate", () => {
|
|||||||
|
|
||||||
// In this file you can include the rest of your app's specific main process
|
// In this file you can include the rest of your app's specific main process
|
||||||
// code. You can also put them in separate files and import them here.
|
// code. You can also put them in separate files and import them here.
|
||||||
|
import "./controller/changeWindowSize.js";
|
||||||
|
|||||||
13
src/main/ipc.ts
Normal file
13
src/main/ipc.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { TabManager } from './tab-manager'
|
||||||
|
|
||||||
|
export const registerTabIpc = (tabs: TabManager) => {
|
||||||
|
ipcMain.handle('tab:create', (_e, url?: string) => tabs.create(url))
|
||||||
|
ipcMain.handle('tab:list', () => tabs.list())
|
||||||
|
ipcMain.handle('tab:navigate', (_e, payload: { tabId: string; url: string }) => tabs.navigate(payload.tabId, payload.url))
|
||||||
|
ipcMain.handle('tab:reload', (_e, tabId: string) => tabs.reload(tabId))
|
||||||
|
ipcMain.handle('tab:back', (_e, tabId: string) => tabs.goBack(tabId))
|
||||||
|
ipcMain.handle('tab:forward', (_e, tabId: string) => tabs.goForward(tabId))
|
||||||
|
ipcMain.handle('tab:switch', (_e, tabId: string) => tabs.switch(tabId))
|
||||||
|
ipcMain.handle('tab:close', (_e, tabId: string) => tabs.close(tabId))
|
||||||
|
}
|
||||||
137
src/main/tab-manager.ts
Normal file
137
src/main/tab-manager.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { BrowserView, BrowserWindow } from 'electron'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
|
type TabId = string
|
||||||
|
type TabInfo = { id: TabId; url: string; title: string; isLoading: boolean; canGoBack: boolean; canGoForward: boolean }
|
||||||
|
|
||||||
|
const UI_HEIGHT = 88
|
||||||
|
|
||||||
|
export class TabManager {
|
||||||
|
private win: BrowserWindow
|
||||||
|
private views: Map<TabId, BrowserView> = new Map()
|
||||||
|
private activeId: TabId | null = null
|
||||||
|
private skipNextNavigate: Map<TabId, boolean> = new Map()
|
||||||
|
|
||||||
|
constructor(win: BrowserWindow) {
|
||||||
|
this.win = win
|
||||||
|
this.win.on('resize', () => this.updateActiveBounds())
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): TabInfo[] {
|
||||||
|
return Array.from(this.views.entries()).map(([id, view]) => this.info(id, view))
|
||||||
|
}
|
||||||
|
|
||||||
|
create(url?: string): TabInfo {
|
||||||
|
const id = randomUUID()
|
||||||
|
const view = new BrowserView({ webPreferences: { sandbox: true } })
|
||||||
|
this.views.set(id, view)
|
||||||
|
this.attach(id)
|
||||||
|
const target = url && url.length > 0 ? url : 'about:blank'
|
||||||
|
view.webContents.loadURL(target)
|
||||||
|
this.bindEvents(id, view)
|
||||||
|
const info = this.info(id, view)
|
||||||
|
this.win.webContents.send('tab-created', info)
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
switch(tabId: TabId): void {
|
||||||
|
if (!this.views.has(tabId)) return
|
||||||
|
this.attach(tabId)
|
||||||
|
this.win.webContents.send('tab-switched', { tabId })
|
||||||
|
}
|
||||||
|
|
||||||
|
close(tabId: TabId): void {
|
||||||
|
const view = this.views.get(tabId)
|
||||||
|
if (!view) return
|
||||||
|
if (this.activeId === tabId) {
|
||||||
|
this.win.removeBrowserView(view)
|
||||||
|
this.activeId = null
|
||||||
|
}
|
||||||
|
view.webContents.destroy()
|
||||||
|
this.views.delete(tabId)
|
||||||
|
this.win.webContents.send('tab-closed', { tabId })
|
||||||
|
const next = this.views.keys().next().value as TabId | undefined
|
||||||
|
if (next) this.switch(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(tabId: TabId, url: string): void {
|
||||||
|
const view = this.views.get(tabId)
|
||||||
|
if (!view) return
|
||||||
|
this.skipNextNavigate.set(tabId, true)
|
||||||
|
view.webContents.loadURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
reload(tabId: TabId): void {
|
||||||
|
const view = this.views.get(tabId)
|
||||||
|
if (!view) return
|
||||||
|
view.webContents.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack(tabId: TabId): void {
|
||||||
|
const view = this.views.get(tabId)
|
||||||
|
if (!view) return
|
||||||
|
if (view.webContents.canGoBack()) view.webContents.goBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
goForward(tabId: TabId): void {
|
||||||
|
const view = this.views.get(tabId)
|
||||||
|
if (!view) return
|
||||||
|
if (view.webContents.canGoForward()) view.webContents.goForward()
|
||||||
|
}
|
||||||
|
|
||||||
|
private attach(tabId: TabId): void {
|
||||||
|
const view = this.views.get(tabId)
|
||||||
|
if (!view) return
|
||||||
|
if (this.activeId && this.views.get(this.activeId)) {
|
||||||
|
const prev = this.views.get(this.activeId)!
|
||||||
|
this.win.removeBrowserView(prev)
|
||||||
|
}
|
||||||
|
this.activeId = tabId
|
||||||
|
this.win.addBrowserView(view)
|
||||||
|
this.updateActiveBounds()
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateActiveBounds(): void {
|
||||||
|
if (!this.activeId) return
|
||||||
|
const view = this.views.get(this.activeId)
|
||||||
|
if (!view) return
|
||||||
|
const [width, height] = this.win.getContentSize()
|
||||||
|
view.setBounds({ x: 0, y: UI_HEIGHT, width, height: Math.max(0, height - UI_HEIGHT) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private bindEvents(id: TabId, view: BrowserView): void {
|
||||||
|
const send = () => this.win.webContents.send('tab-updated', this.info(id, view))
|
||||||
|
view.webContents.on('did-start-loading', send)
|
||||||
|
view.webContents.on('did-stop-loading', send)
|
||||||
|
view.webContents.on('did-finish-load', send)
|
||||||
|
view.webContents.on('page-title-updated', send)
|
||||||
|
view.webContents.on('did-navigate', send)
|
||||||
|
view.webContents.on('did-navigate-in-page', send)
|
||||||
|
|
||||||
|
view.webContents.on('will-navigate', (event, url) => {
|
||||||
|
if (this.skipNextNavigate.get(id)) {
|
||||||
|
this.skipNextNavigate.set(id, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
this.create(url)
|
||||||
|
})
|
||||||
|
|
||||||
|
view.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
this.create(url)
|
||||||
|
return { action: 'deny' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private info(id: TabId, view: BrowserView): TabInfo {
|
||||||
|
const wc = view.webContents
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
url: wc.getURL(),
|
||||||
|
title: wc.getTitle(),
|
||||||
|
isLoading: wc.isLoading(),
|
||||||
|
canGoBack: wc.canGoBack(),
|
||||||
|
canGoForward: wc.canGoForward()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,32 @@
|
|||||||
const { contextBridge, ipcRenderer } = require('electron');
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
openBaidu: () => ipcRenderer.invoke('open-baidu')
|
openBaidu: () => ipcRenderer.invoke('open-baidu')
|
||||||
});
|
})
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('api', {
|
||||||
|
versions: process.versions,
|
||||||
|
external: {
|
||||||
|
open: (url: string) => ipcRenderer.invoke('external-open', url)
|
||||||
|
},
|
||||||
|
window: {
|
||||||
|
minimize: () => ipcRenderer.send('window-min'),
|
||||||
|
maximize: () => ipcRenderer.send('window-max'),
|
||||||
|
close: () => ipcRenderer.send('window-close')
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
create: (url?: string) => ipcRenderer.invoke('tab:create', url),
|
||||||
|
list: () => ipcRenderer.invoke('tab:list'),
|
||||||
|
navigate: (tabId: string, url: string) => ipcRenderer.invoke('tab:navigate', { tabId, url }),
|
||||||
|
reload: (tabId: string) => ipcRenderer.invoke('tab:reload', tabId),
|
||||||
|
back: (tabId: string) => ipcRenderer.invoke('tab:back', tabId),
|
||||||
|
forward: (tabId: string) => ipcRenderer.invoke('tab:forward', tabId),
|
||||||
|
switch: (tabId: string) => ipcRenderer.invoke('tab:switch', tabId),
|
||||||
|
close: (tabId: string) => ipcRenderer.invoke('tab:close', tabId),
|
||||||
|
on: (event: 'tab-updated' | 'tab-created' | 'tab-closed' | 'tab-switched', handler: (payload: any) => void) => {
|
||||||
|
const listener = (_e: any, payload: any) => handler(payload)
|
||||||
|
ipcRenderer.on(event, listener)
|
||||||
|
return () => ipcRenderer.removeListener(event, listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -8,8 +8,8 @@ const routes = [
|
|||||||
// },
|
// },
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
name: "Home",
|
name: "Browser",
|
||||||
component: () => import("@/views/home/index.vue"),
|
component: () => import("@/views/browser/BrowserLayout.vue"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/about",
|
path: "/about",
|
||||||
|
|||||||
130
src/views/browser/BrowserLayout.vue
Normal file
130
src/views/browser/BrowserLayout.vue
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-screen w-screen bg-gray-100">
|
||||||
|
<div class="flex items-center gap-1 px-3 h-10 border-b bg-white" style="-webkit-app-region: drag">
|
||||||
|
<div class="flex items-center gap-2 mr-2">
|
||||||
|
<button class="w-3 h-3 rounded-full bg-red-500" style="-webkit-app-region: no-drag" @click="onCloseWindow"></button>
|
||||||
|
<button class="w-3 h-3 rounded-full bg-yellow-400" style="-webkit-app-region: no-drag" @click="onMinimizeWindow"></button>
|
||||||
|
<button class="w-3 h-3 rounded-full bg-green-500" style="-webkit-app-region: no-drag" @click="onMaximizeWindow"></button>
|
||||||
|
</div>
|
||||||
|
<div v-for="t in tabs" :key="t.id" class="flex items-center px-2 py-1 rounded cursor-pointer" :class="t.id===activeId?'bg-blue-100':'hover:bg-gray-200'" @click="onSwitch(t.id)" style="-webkit-app-region: no-drag">
|
||||||
|
<span class="text-sm mr-2 truncate max-w-[160px]">{{ t.title || t.url || '新标签页' }}</span>
|
||||||
|
<button class="text-gray-600 hover:text-black" style="-webkit-app-region: no-drag" @click.stop="onCloseTabId(t.id)">✕</button>
|
||||||
|
</div>
|
||||||
|
<button class="ml-2 px-2 py-1 rounded bg-gray-200 hover:bg-gray-300" style="-webkit-app-region: no-drag" @click="onNewTab">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 px-3 h-12 border-b bg-gray-50">
|
||||||
|
<button class="px-2 py-1 bg-gray-200 rounded" @click="onBack" :disabled="!active?.canGoBack">←</button>
|
||||||
|
<button class="px-2 py-1 bg-gray-200 rounded" @click="onForward" :disabled="!active?.canGoForward">→</button>
|
||||||
|
<button class="px-2 py-1 bg-gray-200 rounded" @click="onReload">⟳</button>
|
||||||
|
<input class="flex-1 px-3 py-1 border rounded-full" v-model="address" @keyup.enter="onNavigate" placeholder="输入地址后回车" />
|
||||||
|
</div>
|
||||||
|
<div class="h-[calc(100vh-5.5rem)]"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref, onMounted, computed } from 'vue'
|
||||||
|
|
||||||
|
type TabInfo = { id: string; url: string; title: string; isLoading: boolean; canGoBack: boolean; canGoForward: boolean }
|
||||||
|
|
||||||
|
const tabs = reactive<TabInfo[]>([])
|
||||||
|
const activeId = ref<string>('')
|
||||||
|
const address = ref<string>('')
|
||||||
|
|
||||||
|
const active = computed(() => tabs.find(t => t.id === activeId.value))
|
||||||
|
|
||||||
|
const refreshActiveAddress = () => {
|
||||||
|
address.value = active.value?.url || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncList = async () => {
|
||||||
|
const list: TabInfo[] = await (window as any).api.tabs.list()
|
||||||
|
tabs.splice(0, tabs.length, ...list)
|
||||||
|
if (!activeId.value && list.length > 0) activeId.value = list[0].id
|
||||||
|
refreshActiveAddress()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onNewTab = async () => {
|
||||||
|
const info: TabInfo = await (window as any).api.tabs.create('about:blank')
|
||||||
|
activeId.value = info.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSwitch = async (id: string) => {
|
||||||
|
await (window as any).api.tabs.switch(id)
|
||||||
|
activeId.value = id
|
||||||
|
refreshActiveAddress()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCloseTab = async () => {
|
||||||
|
if (!activeId.value) return
|
||||||
|
await (window as any).api.tabs.close(activeId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeUrl = (u: string) => {
|
||||||
|
const trimmed = u.trim()
|
||||||
|
if (!trimmed) return ''
|
||||||
|
if (/^(https?:|file:|about:)/i.test(trimmed)) return trimmed
|
||||||
|
return `https://${trimmed}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const onNavigate = async () => {
|
||||||
|
if (!activeId.value || !address.value) return
|
||||||
|
const url = normalizeUrl(address.value)
|
||||||
|
if (!url) return
|
||||||
|
await (window as any).api.tabs.navigate(activeId.value, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onReload = async () => {
|
||||||
|
if (!activeId.value) return
|
||||||
|
await (window as any).api.tabs.reload(activeId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBack = async () => {
|
||||||
|
if (!activeId.value) return
|
||||||
|
await (window as any).api.tabs.back(activeId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onForward = async () => {
|
||||||
|
if (!activeId.value) return
|
||||||
|
await (window as any).api.tabs.forward(activeId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCloseTabId = async (id: string) => {
|
||||||
|
await (window as any).api.tabs.close(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCloseWindow = () => (window as any).api.window.close()
|
||||||
|
const onMinimizeWindow = () => (window as any).api.window.minimize()
|
||||||
|
const onMaximizeWindow = () => (window as any).api.window.maximize()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await syncList()
|
||||||
|
;(window as any).api.tabs.on('tab-created', (info: TabInfo) => {
|
||||||
|
const i = tabs.findIndex(t => t.id === info.id)
|
||||||
|
if (i === -1) tabs.push(info)
|
||||||
|
activeId.value = info.id
|
||||||
|
refreshActiveAddress()
|
||||||
|
})
|
||||||
|
;(window as any).api.tabs.on('tab-updated', (info: TabInfo) => {
|
||||||
|
const i = tabs.findIndex(t => t.id === info.id)
|
||||||
|
if (i >= 0) tabs[i] = info
|
||||||
|
if (activeId.value === info.id) refreshActiveAddress()
|
||||||
|
})
|
||||||
|
;(window as any).api.tabs.on('tab-closed', ({ tabId }: { tabId: string }) => {
|
||||||
|
const i = tabs.findIndex(t => t.id === tabId)
|
||||||
|
if (i >= 0) tabs.splice(i, 1)
|
||||||
|
if (activeId.value === tabId) {
|
||||||
|
const next = tabs[0]
|
||||||
|
activeId.value = next?.id || ''
|
||||||
|
refreshActiveAddress()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
;(window as any).api.tabs.on('tab-switched', ({ tabId }: { tabId: string }) => {
|
||||||
|
activeId.value = tabId
|
||||||
|
refreshActiveAddress()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
||||||
149
src/views/browser/README.md
Normal file
149
src/views/browser/README.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# Electron 多标签浏览器实现计划(高性能方案)
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
- 在现有 Electron + Vue + TypeScript + TailwindCSS 项目中实现类似 Chrome 的多标签页浏览体验:创建、切换、关闭、前进、后退、刷新、地址输入、拓展插件入口、收藏入口。
|
||||||
|
- 采用高性能与安全优先的架构:主进程 `BrowserView` 管理;渲染层仅通过受控 API;IPC 类型明确;持久化与插件机制可扩展。
|
||||||
|
|
||||||
|
## 当前项目结构与集成点
|
||||||
|
- 主进程入口:`src/main.ts`(窗口创建与 IPC 注册,参考 `src/main.ts:11-57`)
|
||||||
|
- 预加载:`src/preload.ts`(已暴露 `window.api`,参考 `src/preload.ts:7-17`)
|
||||||
|
- 渲染层入口:`index.html` + `src/renderer.ts`(Vue 应用挂载,参考 `src/renderer.ts:35-45`)
|
||||||
|
- 现有 IPC 控制器:`src/controller/changeWindowSize.js`(窗口操作)
|
||||||
|
|
||||||
|
## 高性能技术方案
|
||||||
|
- 选型:使用 `BrowserView` 每个标签一个 `BrowserView`,由主进程统一管理。
|
||||||
|
- 原因:`BrowserView` 与主窗口同进程但独立渲染,API 完整(`webContents` 导航、生命周期事件),相较 `<webview>` 更易管控、性能与兼容性更好。
|
||||||
|
- 视图复用与生命周期:
|
||||||
|
- 活跃标签:将其 `BrowserView` attach 到主窗口;非活跃标签:detach(保留引用与状态),避免多视图同时渲染占用资源。
|
||||||
|
- 限制最大同时 attach 数量(默认 1),保障 GPU/CPU 负载稳定。
|
||||||
|
- 会话与隔离:
|
||||||
|
- 默认共享 `session`(`partition: 'persist:main'`),减少多会话开销。
|
||||||
|
- 可按需支持隔离会话(隐私标签):`partition: 'tab:<id>'`。
|
||||||
|
- 导航性能与安全:
|
||||||
|
- 导航统一通过主进程 `view.webContents.loadURL(url)`;前进/后退/刷新使用 `goBack/goForward/reload`。
|
||||||
|
- 外链打开统一通过 `shell.openExternal(url)`,白名单校验(已存在 `external-open` 通道,参考 `src/main.ts:33-43`)。
|
||||||
|
- 地址栏与状态:
|
||||||
|
- 渲染层地址栏仅发起 IPC;主进程执行导航并广播当前标签的 `url/title/loading/historyState`。
|
||||||
|
- 书签与持久化:
|
||||||
|
- 存储方案优先使用 `electron-store`(JSON,轻量、跨平台);高并发或大数据可切换 `better-sqlite3`。
|
||||||
|
- 插件入口:
|
||||||
|
- 设计内部插件机制(非 Chrome 扩展):定义 `onTabCreated/onNavigate/beforeLoad/afterLoad` 等钩子;插件注册于主进程。
|
||||||
|
- Chrome 扩展仅作为可选(`session.loadExtension`),受限于兼容性与审核,不作为默认方案。
|
||||||
|
|
||||||
|
## 模块设计
|
||||||
|
### 主进程(`src/main/`)
|
||||||
|
- `TabManager`:管理 `BrowserView` 生命周期与状态
|
||||||
|
- 方法:`create(url?)`、`switch(tabId)`、`close(tabId)`、`navigate(tabId, url)`、`reload(tabId)`、`goBack(tabId)`、`goForward(tabId)`、`list()`。
|
||||||
|
- 事件:`tab-updated`(title/url/loading)、`tab-created`、`tab-closed`、`tab-switched`。
|
||||||
|
- `IPCRegistry`:集中注册通道
|
||||||
|
- `tab:create | tab:switch | tab:close | tab:navigate | tab:reload | tab:back | tab:forward | tab:list`
|
||||||
|
- `bookmark:add | bookmark:remove | bookmark:list | bookmark:folders`
|
||||||
|
- `plugin:invoke(hook, payload)`(内部插件钩子转发)
|
||||||
|
- `BookmarkStore`:封装 `electron-store` 或 SQLite(可插拔)
|
||||||
|
- `PluginHost`:插件注册与钩子执行(有序、可熔断)
|
||||||
|
|
||||||
|
### 预加载(`src/preload.ts`)
|
||||||
|
- 扩展现有 `window.api`:
|
||||||
|
- `tabs.*`:与上述 IPC 一一对应,统一 `invoke` 调用;`tabs.on(event, handler)` 用于订阅主进程广播(通过 `ipcRenderer.on` 包装)。
|
||||||
|
- `bookmarks.*`:增删改查接口。
|
||||||
|
- `plugins.invoke(hook, payload)`:触发插件钩子。
|
||||||
|
|
||||||
|
### 渲染层(Vue)
|
||||||
|
- 布局页面:`BrowserLayout`(地址栏、标签条、控制区、书签/插件入口)
|
||||||
|
- 组件:
|
||||||
|
- `TabBar`:显示标签列表,支持新建/切换/关闭、拖拽排序(后续)
|
||||||
|
- `AddressBar`:URL 输入与状态展示(加载中、锁标识、HTTPS)
|
||||||
|
- `Controls`:后退/前进/刷新/新建标签按钮
|
||||||
|
- `BookmarksPane`:收藏夹入口(侧栏或菜单)
|
||||||
|
- `PluginsMenu`:插件入口(菜单或面板)
|
||||||
|
- 路由:`/browser` 作为应用主界面;初始打开一个空白或主页标签。
|
||||||
|
|
||||||
|
## IPC 通道与类型(示例)
|
||||||
|
```ts
|
||||||
|
type TabId = string;
|
||||||
|
interface TabInfo { id: TabId; url: string; title: string; isLoading: boolean; canGoBack: boolean; canGoForward: boolean }
|
||||||
|
|
||||||
|
// 请求
|
||||||
|
tab:create(url?: string) => TabInfo
|
||||||
|
tab:switch(tabId: TabId) => void
|
||||||
|
tab:close(tabId: TabId) => void
|
||||||
|
tab:navigate({ tabId, url }: { tabId: TabId, url: string }) => void
|
||||||
|
tab:reload(tabId: TabId) => void
|
||||||
|
tab:back(tabId: TabId) => void
|
||||||
|
tab:forward(tabId: TabId) => void
|
||||||
|
tab:list() => TabInfo[]
|
||||||
|
|
||||||
|
// 广播
|
||||||
|
tab-updated: TabInfo
|
||||||
|
tab-created: TabInfo
|
||||||
|
tab-closed: { tabId: TabId }
|
||||||
|
tab-switched: { tabId: TabId }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据模型
|
||||||
|
- `Tab`:`{ id, view, url, title, isLoading, createdAt }`
|
||||||
|
- `Bookmark`:`{ id, title, url, folderId?, createdAt }`
|
||||||
|
- `Folder`:`{ id, name, parentId? }`
|
||||||
|
- `Plugin`:`{ id, name, hooks }`
|
||||||
|
|
||||||
|
## 性能优化要点
|
||||||
|
- 仅 attach 当前活跃 `BrowserView` 到窗口;对非活跃标签 `detach` 保留状态。
|
||||||
|
- 控制最大标签数(例如 20),超过时启用 LRU 释放策略(可配置)。
|
||||||
|
- 启用硬件加速,保持默认;避免禁用 GPU。
|
||||||
|
- 监听 `did-stop-loading`/`did-finish-load` 更新状态,减少渲染层轮询。
|
||||||
|
- 对地址栏与状态广播使用节流(例如 100ms)。
|
||||||
|
- 持久化异步批量写入(书签、会话恢复)。
|
||||||
|
|
||||||
|
## 安全策略
|
||||||
|
- 保持 `contextIsolation: true`、`sandbox: true`、`nodeIntegration: false`(已在 `src/main.ts:20-26`)
|
||||||
|
- 外链统一 `shell.openExternal` + 白名单(已在 `src/main.ts:33-43`)
|
||||||
|
- 严格 CSP 保留于渲染层 `index.html`;`BrowserView` 加载外域不受该 CSP 限制,但需根据业务限制域名。
|
||||||
|
|
||||||
|
## 迭代计划(三阶段)
|
||||||
|
1. 核心能力(主进程 + IPC + 预加载)
|
||||||
|
- 实现 `TabManager` 与全部导航方法
|
||||||
|
- 注册 IPC(tabs/bookmarks/plugins)与广播
|
||||||
|
- 预加载扩展 `window.api.tabs/*`、事件订阅封装
|
||||||
|
2. 渲染层界面
|
||||||
|
- 新建 `BrowserLayout` 与基础组件(TabBar、AddressBar、Controls)
|
||||||
|
- 打通地址栏与导航;同步标题与加载状态
|
||||||
|
- 书签入口基础增删与列表展示
|
||||||
|
3. 插件与高级能力
|
||||||
|
- `PluginHost` 与钩子机制;内置示例插件(如:拦截导航统计)
|
||||||
|
- 标签拖拽排序、会话恢复、快捷键(Ctrl/Cmd+T/W、Ctrl/Cmd+L、Ctrl/Cmd+R、Alt+←/→)
|
||||||
|
|
||||||
|
## 集成步骤(细化)
|
||||||
|
- 主进程:新增 `src/main/tab-manager.ts`、`src/main/ipc.ts`、`src/main/bookmark-store.ts`、`src/main/plugin-host.ts`;在 `src/main.ts` 初始化与注册。
|
||||||
|
- 预加载:扩展 `window.api`,新增 `tabs`、`bookmarks`、`plugins` 命名空间。
|
||||||
|
- 渲染层:新增 `/browser` 页面与组件,替换应用首页或通过路由进入。
|
||||||
|
|
||||||
|
## 决策点(需确认)
|
||||||
|
1. 标签视图方案:仅 `BrowserView`(推荐),允许 `<webview>` 兼容模式
|
||||||
|
2. 最大标签数量与释放策略:默认 20,接受LRU 释放
|
||||||
|
3. 会话策略:全部共享 `session` ,支持隐私标签独立 `partition`
|
||||||
|
4. 书签存储:默认 `electron-store` ,暂不考虑 SQLite(`better-sqlite3`)
|
||||||
|
5. 插件能力边界:需要支持加载 Chrome 扩展(`session.loadExtension`)
|
||||||
|
6. 首页与地址栏行为:默认主页 URL、空白页(`about:blank`),可自定义欢迎页
|
||||||
|
7. 路由集成:将 `/browser` 作为默认首页,保留现有 `/about` 等页面
|
||||||
|
|
||||||
|
## 验收与测试
|
||||||
|
- 开发启动:`npm run start`,验证创建/切换/关闭/导航/刷新基本路径
|
||||||
|
- 事件广播:在 Vue 中订阅 `tab-updated`,确保标题/加载进度实时更新
|
||||||
|
- 书签:新增/删除/持久化验证与重启恢复
|
||||||
|
- 性能:在 10+ 标签场景观察 CPU/GPU 占用与 UI 响应
|
||||||
|
- 安全:尝试非法域名导航,验证白名单拦截与错误提示
|
||||||
|
|
||||||
|
---
|
||||||
|
后续我将基于以上计划逐步实现。在“决策点”中的问题请先确认,我将据此微调实现(例如是否支持隐私标签、是否引入 SQLite、是否兼容 `<webview>`)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**待反馈决策**
|
||||||
|
- 顶部 UI 高度与 `BrowserView` 边界
|
||||||
|
- 当前使用固定偏移 `64px` 对齐顶部 UI。是否改为渲染层动态上报高度(例如在布局变化时通过 IPC 设置),以适配不同分辨率与主题?
|
||||||
|
- Chrome 扩展加载
|
||||||
|
- 文档中确认“需要支持加载 Chrome 扩展”。建议作为后续阶段实现,采用 `session.loadExtension` 并隔离到特定 `partition`,以减少对主会话的影响;请确认是否需要默认加载的扩展清单或仅提供入口。
|
||||||
|
|
||||||
|
如果以上决策点确认,我将继续第二阶段:完善 UI 高度动态设置与扩展加载入口,同时补充书签持久化(基于 `userData` 目录 JSON 文件)与插件钩子框架。
|
||||||
Reference in New Issue
Block a user