【教程向】从零开始创建浏览器插件(六)实战篇
在这篇文章中,我们将详细介绍一个名为“摸鱼King”的Chrome扩展程序的开发思路。这个扩展程序的主要功能是在用户浏览网页时提供便捷的方式来摸鱼看小说。
完整的工程我放在了完整工程,可以下载下来自己试一试。
1. 主要功能和组件
我们以一个摸鱼看小说的插件为例,需要实现导入(存储)小说、选择(读取)小说、设置分页参数、渲染小说文字展示、快捷键等功能。
- 名称和版本:
- 名称:摸鱼King
- 版本:1.0
- 描述:摸鱼看小说
- 权限需求:
- [activeTab]允许扩展访问当前活动标签页的信息。
- [storage]允许扩展存储和检索数据。
- [scripting]允许扩展运行脚本。
- 背景脚本:
- 使用[background.js]作为服务工作线程,这是manifest v3中推荐的方式,可以有效管理和维护扩展的后台逻辑。
- 弹出页面:
- 默认弹出页面设置为[popup/popup.html],用户可以通过点击扩展图标来触发这个页面,进行更多交互操作。
- 内容脚本:
- [content/content.js]在所有网页上运行(由
<all_urls>
匹配),并在文档加载结束时执行。这允许扩展在网页内容完全加载后修改或访问网页内容。
- 图标:
- 使用[icon128.png]作为扩展的默认图标,显示在浏览器的扩展栏中。
2. 开发思路
开发这个扩展程序的主要思路是提供一个简单而直接的方式来增强用户的浏览体验,通过在浏览器中直接提供摸鱼看小说的功能。通过内容脚本,扩展可以在用户浏览网页时插入小说内容或相关功能,而不干扰网页的主要功能。
- 背景脚本处理扩展的核心逻辑,如数据存储和跨标签页的操作。
- 弹出页面提供用户界面,使用户可以进行简单的交互,如选择小说或调整设置。
- 内容脚本直接与网页交互,插入或修改网页内容,提供摸鱼功能。
3.项目初始化
由上面的思路我们可以整理出一个项目结构目录以及一个manifest配置文件。
目录结构:
moyuKing/
│
├── manifest.json
│
├── background.js # 背景脚本
│
├── popup/ # 弹出页面目录
│ └── popup.html # 弹出页面HTML
│ └── popup.js # 弹出页面JS
│ └──popup.css # 弹出页面CSS
│
├── content/ # 内容脚本目录
│ └── content.js # 内容脚本
│
└── icon128.png # 扩展图标
manifest.json
{
"manifest_version": 3,
"name": "摸鱼king",
"version": "1.0",
"description": "摸鱼看小说",
"permissions": [
"activeTab",
"storage",
"scripting"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup/popup.html",
"default_icon": {
"128": "icon128.png"
}
},
"content_scripts": [
{
"matches": [
"<all_urls>"
],
"js": [
"content/content.js"
],
"run_at": "document_end"
}
],
"icons": {
"128": "icon128.png"
}
}
4.background.js
背景脚本主要实现数据存储和另外两个脚本之间的数据通信。
IndexedDB 是一种运行在浏览器中的非关系型数据库,适合于存储大量结构化数据,我们选用它来实现数据存储,以下代码打开数据库、创建对象存储、读写数据以及更新数据等操作。
初始化数据库
IndexedDB的操作首先是创建或打开一个数据库。我们定义了一个IDBUtil
对象,封装了与数据库操作相关的方法。
const IDBUtil = {
dbName: "myDatabase",
storeName: "idStore",
version: 1,
async openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = (event) => reject(event.target.errorCode);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, { keyPath: "id" });
}
};
request.onsuccess = (event) => resolve(event.target.result);
});
},
// 其他方法...
};
在openDB
方法中,我们检查数据库是否存在指定的对象存储,如果不存在,则创建一个新的对象存储。keyPath
为对象存储的主键。
数据读写
在IndexedDB中,数据的读写需要通过事务来完成。以下是如何插入和检索数据的示例:
async setId(id) {
const db = await this.openDB();
const transaction = db.transaction(this.storeName, "readwrite");
const store = transaction.objectStore(this.storeName);
const request = store.put({ id: "unique", value: id });
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve();
request.onerror = (event) => reject(event.target.errorCode);
});
},
async getId() {
const db = await this.openDB();
const transaction = db.transaction(this.storeName, "readonly");
const store = transaction.objectStore(this.storeName);
const request = store.get("unique");
return new Promise((resolve, reject) => {
request.onsuccess = (event) => resolve(request.result ? request.result.value : null);
request.onerror = (event) => reject(event.target.errorCode);
});
},
在这里,setId
方法通过事务在对象存储中存储了一个ID值,而getId
方法则用来检索这个值。
更新数据
数据的更新可以复用之前的插入逻辑,因为IndexedDB的put
方法会替换已有的数据记录:
async updateId(newId) {
return this.setId(newId);
},
扩展功能
除了基础的数据库操作,我们还展示了如何在浏览器扩展的background脚本中实现更复杂的逻辑,例如更新页面信息、保存文件内容等。这些操作同样基于IndexedDB事务来实现数据的一致性。
const updatePageInfo = async (id, pageNum, pageSize) => {
const db = await openDB();
const transaction = db.transaction(["files"], "readwrite");
const store = transaction.objectStore("files");
const request = store.get(id);
request.onsuccess = () => {
const data = request.result;
data.pageNum = pageNum;
data.pageSize = pageSize;
store.put(data);
};
};
在updatePageInfo
方法中,我们首先通过ID获取文件记录,然后更新其页码和页面大小信息。
完整代码
除此之外,我们还需要提供几个事件的监听来实现数据通信,完整代码如下:
const IDBUtil = {
dbName: "myDatabase",
storeName: "idStore",
version: 1, // 可以根据需要更新数据库结构时增加版本号
// 打开(或初始化)数据库
async openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = (event) => {
console.error("Database error:", event.target.errorCode);
reject(event.target.errorCode);
};
// 第一次创建数据库或版本更新时触发
request.onupgradeneeded = (event) => {
const db = event.target.result;
// 创建一个新的存储对象
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, {
keyPath: "id",
autoIncrement: false,
});
}
};
request.onsuccess = (event) => {
console.log("Database opened successfully");
resolve(event.target.result);
};
});
},
// 设置ID值
async setId(id) {
const db = await this.openDB();
const transaction = db.transaction(this.storeName, "readwrite");
const store = transaction.objectStore(this.storeName);
const request = store.put({ id: "unique", value: id });
return new Promise((resolve, reject) => {
request.onsuccess = () => {
console.log("ID stored successfully");
resolve();
};
request.onerror = (event) => {
console.error("Error storing the ID:", event.target.errorCode);
reject(event.target.errorCode);
};
});
},
// 获取ID值
async getId() {
const db = await this.openDB();
const transaction = db.transaction(this.storeName, "readonly");
const store = transaction.objectStore(this.storeName);
const request = store.get("unique");
return new Promise((resolve, reject) => {
request.onsuccess = () => {
if (request.result) {
console.log("ID retrieved successfully:", request.result.value);
resolve(request.result.value);
} else {
console.log("ID not found");
resolve(null);
}
};
request.onerror = (event) => {
console.error("Error retrieving the ID:", event.target.errorCode);
reject(event.target.errorCode);
};
});
},
// 更新ID值
async updateId(newId) {
// 此处的更新操作实际上与setId方法相同,因为put方法会替换已有记录或新增一条记录
return this.setId(newId);
},
};
// 打开(或创建)数据库
const openDB = () => {
return new Promise((resolve, reject) => {
// 增加版本号以触发onupgradeneeded事件
const request = indexedDB.open("FileDB", 1);
request.onupgradeneeded = function (event) {
let db = event.target.result;
let transaction = event.target.transaction; // 获取引用的事务
if (!db.objectStoreNames.contains("files")) {
db.createObjectStore("files", { keyPath: "id", autoIncrement: true });
}
// 确保所有记录都有pageNum和pageSize字段
const store = transaction.objectStore("files");
store.openCursor().onsuccess = function (event) {
var cursor = event.target.result;
if (cursor) {
var updateData = cursor.value;
updateData.pageNum = updateData.pageNum || 0; // 默认值
updateData.pageSize = updateData.pageSize || 20; // 默认值
cursor.update(updateData);
cursor.continue();
}
};
};
request.onerror = function (event) {
console.error("Database error: ", event.target.errorCode);
reject(event.target.errorCode);
};
request.onsuccess = function (event) {
resolve(event.target.result);
};
});
};
const updatePageInfo = async (id, pageNum, pageSize) => {
const db = await openDB();
const transaction = db.transaction(["files"], "readwrite");
const store = transaction.objectStore("files");
const request = store.get(id);
return new Promise((resolve, reject) => {
request.onsuccess = () => {
const data = request.result;
if (data) {
data.pageNum = pageNum;
data.pageSize = pageSize;
const updateRequest = store.put(data);
updateRequest.onsuccess = () => {
console.log("Page info updated successfully");
resolve();
};
updateRequest.onerror = (event) => {
console.error("Error updating page info:", event.target.errorCode);
reject(event.target.errorCode);
};
} else {
console.log("No data found with id:", id);
reject("No data found");
}
};
request.onerror = (event) => {
console.error("Error fetching data to update:", event.target.errorCode);
reject(event.target.errorCode);
};
});
};
// 保存文件内容到IndexedDB
const saveToDB = async (content, name) => {
const db = await openDB();
const transaction = db.transaction(["files"], "readwrite");
const store = transaction.objectStore("files");
const request = store.add({
content: content,
name: name,
pageNum: 0,
pageSize: 20,
});
request.onsuccess = () => {
let id = request.result;
IDBUtil.updateId(id).then(() => {
initData(id);
});
};
request.onerror = () => console.error("Error saving file content to DB");
};
const initData = async (id) => {
const db = await openDB();
const transaction = db.transaction(["files"], "readonly");
const store = transaction.objectStore("files");
const request = store.get(id * 1);
request.onsuccess = function (event) {
console.log("onsuccess", request);
if (request.result) {
const contents = request.result;
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
var currentTab = tabs[0];
if (currentTab) {
chrome.tabs.sendMessage(
currentTab.id,
{ action: "FROM_POPUP", data: { id, ...contents } },
function (response) {
console.log(response.status);
}
);
}
});
} else {
console.log("No data found with id:", id);
}
};
request.onerror = function () {
console.error("Error fetching data:", request.error);
};
};
chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
if (request.action === "fetchDataFromIndexedDB") {
openDB().then((db) => {
const transaction = db.transaction(["files"], "readonly");
const store = transaction.objectStore("files");
const request = store.getAll();
request.onerror = function (event) {
sendResponse({ status: "error", data: event.target.errorCode });
};
request.onsuccess = function (event) {
const result = event.target.result;
console.log("fetchDataFromIndexedDB", event);
sendResponse({ status: "success", data: result });
};
});
}
if (request.action === "saveToDB") {
console.log("saveToDB", request);
saveToDB(request.data.content, request.data.name).then(() => {
sendResponse({ status: "success" });
});
}
if (request.action === "getId") {
IDBUtil.getId().then((id) => {
sendResponse({ status: "success", data: id });
});
}
if (request.action === "updateId") {
IDBUtil.updateId(request.data).then(() => {
initData(request.data).then(() => {
sendResponse({ status: "success" });
});
});
}
if (request.action === "updatePage") {
updatePageInfo(
request.data.id,
request.data.pageNum,
request.data.pageSize
).then(() => {
sendResponse({ status: "success" });
});
}
if (request.action === "initPage") {
IDBUtil.getId().then((id) => {
initData(id).then(() => {
sendResponse({ status: "success" });
});
});
}
return true; // 异步响应
});
5.popup
popup里我们要实现一个弹窗,用来展示上传小说、选择小说、回显和设置小说分页参数的功能。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>摸鱼king</title>
<link rel="stylesheet" type="text/css" href="popup.css"/>
</head>
<body>
<div>
<div class="row">
<div class="lable">导入小说:</div>
<div class="btn" id="importBtn">导入</div>
</div>
<div class="row">
<div class="lable">选择小说:</div>
<select id="mySelect" name="mySelect">
</select>
</div>
<div class="row">
<div class="lable">页码:</div>
<input class="value" oninput = "value=value.replace(/[^\d]/g,'')" id="myPageNum"/>
</div>
<div class="row">
<div class="lable">每页字符:</div>
<input class="value" oninput = "value=value.replace(/[^\d]/g,'')" id="myPageSize"/>
</select>
</div>
<input style="display: none;" type="file" id="fileInput">
</div>
<script src="popup.js"></script>
</body>
</html>
let localOptions = [];
let curOption = null;
// 读取IndexedDB中的内容
const loadOptionsFromDb = async () => {
// 请求 Background 获取 IndexedDB 数据
chrome.runtime.sendMessage(
{ action: "fetchDataFromIndexedDB" },
function (response) {
addOptionsToSelect(response.data);
}
);
};
// 绑定事件到文件选择
document
.getElementById("fileInput")
.addEventListener("change", async function (e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (e) => {
const content = e.target.result;
chrome.runtime.sendMessage(
{ action: "saveToDB", data: { content, name: file.name } },
function (response) {
loadOptionsFromDb();
}
);
// await saveToDB(content, file.name); // 读取文件内容后保存到DB
};
reader.readAsText(file);
});
// 函数来动态添加选项到下拉选择框
async function addOptionsToSelect(options) {
localOptions = options;
const selectElement = document.getElementById("mySelect");
// 清除所有现有的选项
selectElement.innerHTML = "";
chrome.runtime.sendMessage({ action: "getId" }, function (response) {
let curId = response.data;
// 循环传入的选项数组并添加到select元素
options.forEach((option) => {
const optElement = document.createElement("option");
optElement.value = option.id;
optElement.textContent = option.name;
selectElement.appendChild(optElement);
});
if (curId) {
selectElement.value = curId * 1;
curOption = localOptions.find((item) => item.id * 1 === curId * 1);
document.getElementById("myPageNum").value = curOption.pageNum*1;
document.getElementById("myPageSize").value = curOption.pageSize*1;
// getDataByIdFromDb(curId);
}
});
}
document
.getElementById("importBtn")
.addEventListener("click", async function (e) {
document.getElementById("fileInput").click();
});
// 添加事件监听,绑定到 select 更改操作
document
.getElementById("mySelect")
.addEventListener("change", async function (e) {
curOption = localOptions.find((item) => item.id*1 === this.value*1);
document.getElementById("myPageNum").value = curOption.pageNum*1;
document.getElementById("myPageSize").value = curOption.pageSize*1;
chrome.runtime.sendMessage(
{ action: "updateId", data: this.value },
function (response) {
// chrome.tabs.query(
// { active: true, currentWindow: true },
// function (tabs) {
// chrome.tabs.sendMessage(tabs[0].id, {
// type: "FROM_POPUP",
// data: response.content,
// });
// }
// );
}
);
});
document
.getElementById("myPageNum")
.addEventListener("change", async function (e) {
curOption.pageNum = this.value * 1;
chrome.runtime.sendMessage(
{
action: "updatePage",
data: {
id: curOption.id,
pageNum: this.value * 1,
pageSize: curOption.pageSize*1,
},
},
function (response) {
chrome.tabs.query(
{ active: true, currentWindow: true },
function (tabs) {
var currentTab = tabs[0];
if (currentTab) {
chrome.tabs.sendMessage(
currentTab.id,
{
action: "FROM_POPUP",
data: { id: curOption.id, ...curOption },
},
function (response) {
console.log(response);
}
);
}
}
);
}
);
});
document
.getElementById("myPageSize")
.addEventListener("change", async function (e) {
curOption.pageSize = this.value * 1;
chrome.runtime.sendMessage(
{
action: "updatePage",
data: {
id: curOption.id,
pageNum: curOption.pageNum,
pageSize: this.value * 1,
},
},
function (response) {
chrome.tabs.query(
{ active: true, currentWindow: true },
function (tabs) {
var currentTab = tabs[0];
if (currentTab) {
chrome.tabs.sendMessage(
currentTab.id,
{
action: "FROM_POPUP",
data: { id: curOption.id, ...curOption },
},
function (response) {
console.log(response);
}
);
}
}
);
}
);
});
loadOptionsFromDb();
.row{
display: flex;
margin-top: 10px;
}
.lable{
width: 100px;
}
.value{
flex: 1;
}
.btn{
width: 35px;
height: 20px;
line-height: 20px;
border-radius: 3px;
background-color: #409EFF;
text-align: center;
color: #fff;
cursor: pointer;
}
这段代码属于一个Chrome扩展程序的popup.js脚本,用于处理弹出页面(popup)的一些用户交互和数据操作。下面我将简要分析和解释每部分的功能:
- 变量定义:
let localOptions = [];
let curOption = null;
这里定义了两个变量,localOptions
用于存储从数据库(可能是IndexedDB)加载的选项数据,curOption
用于存储当前选定的选项。
- 加载选项数据:
const loadOptionsFromDb = async () => {
chrome.runtime.sendMessage({ action: "fetchDataFromIndexedDB" }, function (response) {
addOptionsToSelect(response.data);
});
};
这个函数通过发送消息给后台脚本来加载数据。使用chrome.runtime.sendMessage
,请求后台操作数据库获取数据,并将获取的数据通过addOptionsToSelect
函数动态添加到下拉选择框。
- 文件选择和文件读取:
document.getElementById("fileInput").addEventListener("change", async function (e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (e) => {
const content = e.target.result;
chrome.runtime.sendMessage({ action: "saveToDB", data: { content, name: file.name } }, function (response) {
loadOptionsFromDb();
});
};
reader.readAsText(file);
});
这部分代码处理文件输入。当用户选择一个文件时,使用FileReader
读取文件内容,然后将内容发送到后台脚本存储到数据库中。文件读取完成后,重新加载并显示选项数据。
- 动态添加选项到下拉选择框:
async function addOptionsToSelect(options) {
// 省略代码…
}
这个函数用于处理从数据库中获取的选项数据,创建并添加option
元素到select
下拉菜单。它还处理选择框中当前选定选项的显示。
- 更新页面和页面大小选择:
document.getElementById("myPageNum").addEventListener("change", function (e) { … });
document.getElementById("myPageSize").addEventListener("change", function (e) { … });
这两部分代码允许用户通过输入框修改页面号(pageNum
)和页面大小(pageSize
),并发送这些数据到后台脚本进行更新。
- 绑定导入按钮:
document.getElementById("importBtn").addEventListener("click", function (e) {
document.getElementById("fileInput").click();
});
这里将点击事件绑定到导入按钮,用于触发文件输入框的点击动作,从而允许用户选择文件。
总之,该脚本主要处理文件的导入、数据的加载和显示、以及用户对某些设置(如页面大小和页码)的更改,并与后台通信更新数据。这显示了一个典型的Chrome扩展程序的交互逻辑,用于增强浏览器的功能。
6.content
这一部分要实现在用户浏览的页面实现一个阅读区域,以及隐藏、显示、翻页等快捷键功能。
let updateInner=()=>{
floatingElement.innerText = content.slice(
pageNum * pageSize,
(pageNum + 1) * pageSize
);
}
chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
if (request.action === "FROM_POPUP") {
console.log("Received data from background:", request.data);
id = request.data.id;
content = request.data.content;
pageSize = request.data.pageSize || 20;
pageNum = request.data.pageNum || 0;
updateInner();
}
});
let id = null;
let pageNum = 0;
let pageSize = 20;
let content = "";
// 创建浮动元素
const floatingElement = document.createElement("div");
floatingElement.id = "moyuKingDom";
floatingElement.style.position = "fixed";
floatingElement.style.bottom = "10px";
floatingElement.style.left = "10px";
floatingElement.style.backgroundColor = "rgba(0, 0, 0, 0.1)";
floatingElement.style.color = "white";
floatingElement.style.padding = "5px";
floatingElement.style.borderRadius = "5px";
floatingElement.style.cursor = "pointer";
floatingElement.style.display = "none"; // 初始隐藏
floatingElement.style.zIndex = "999999";
document.body.appendChild(floatingElement);
let handelPage = (e) => {
if (e.keyCode == 68) {
//下一页
if((content.length/pageSize)<=pageNum){
return
}
pageNum++;
chrome.runtime.sendMessage(
{ action: "updatePage", data: {id,pageNum,pageSize} },
function (response) {
}
);
updateInner();
}
if (e.keyCode == 65) {
if (pageNum === 1) {
return;
}
pageNum--;
chrome.runtime.sendMessage(
{ action: "updatePage", data: {id,pageNum,pageSize} },
function (response) {
}
);
updateInner();
//上一页
}
};
// 触发
document.addEventListener("keydown", function (e) {
if (e.ctrlKey && e.keyCode == 77) {
// let dom = document.getElementById('moyuKingDom')
if (floatingElement.style.display === "none") {
floatingElement.style.display = "block";
document.addEventListener("keydown",handelPage)
} else {
floatingElement.style.display = "none";
document.removeEventListener("keydown",handelPage)
}
}
});
chrome.runtime.sendMessage(
{ action: "initPage" },
function (response) {
}
);
这段代码是一个JavaScript代码片段,用于Chrome扩展开发。它实现了一个浮动元素在网页上的分页显示和翻页功能,具体功能如下:
- 创建浮动元素:
- 创建一个
div
元素作为浮动元素,初始时隐藏,当用户触发特定快捷键时显示。 - 该元素的样式设置使其固定在页面左下角,具有半透明黑色背景和白色文本。
- 消息监听与数据处理:
- 使用
chrome.runtime.onMessage.addListener
监听来自其他部分(如popup或background script)的消息。 - 当接收到
FROM_POPUP
动作时,更新全局变量id
,content
,pageSize
,pageNum
,并调用updateInner
函数来更新浮动元素的显示内容。
- 分页显示内容:
-
updateInner
函数用于根据当前页码pageNum
和每页大小pageSize
从content
中切片获取文本并显示在浮动元素中。
- 翻页控制:
-
handelPage
函数处理键盘事件来实现翻页功能。按键D
(键码68)实现向下翻页,按键A
(键码65)实现向上翻页。 - 翻页时通过发送
updatePage
消息给Chrome扩展的其他部分(如background script)通知页码变更。
- 快捷键控制显示:
- 在
document
上添加键盘事件监听,当用户同时按下Ctrl
和M
(键码77)时,切换浮动元素的显示与隐藏状态。 - 显示时添加对翻页键的监听,隐藏时移除监听。
- 初始化消息发送:
- 发送
initPage
消息来进行可能的初始化操作。
这个代码主要适用于需要在网页上显示和分页浏览较长文本内容的Chrome扩展,例如阅读辅助工具或笔记工具等。
总结
至此我们已经完成了全部功能,在浏览器中加载该文件夹即可在页面中测试功能。
本文通过一个实际的例子展示了如何在浏览器扩展中使用IndexedDB进行数据的存储与管理。通过封装操作逻辑,我们可以更加方便地在扩展中进行数据处理,从而增强扩展的功能和用户体验。希望这篇文章能帮助你理解并实践IndexedDB在浏览器