前言
25届菜鸟最近正在很苦逼的秋招中,前几天面了一个做浏览器插件的公司,一面当天告知通过,然后告诉我二面的作业是基于chatgpt api, 用React, 做一个简单Chrome 浏览器翻译插件,因为我是主Vue,React只会一点点,这家公司是主React,所以当时本来想拒绝的,但是因为浏览器插件一直是我比较感兴趣的东西,所以就想着正好趁这个机会学习一下。
准备阶段
初始化项目
- 使用
create-react-app创建项目,之后安装依赖 - 然后就可以删除一下不需要的文件,开始进行开发
浏览器拓展组成
- 浏览器拓展主要由以下文件组成
manifest.json插件的配置文件popup页面,也就是点击插件图标,弹出来的内容content script是插入到页面中的脚本background是在浏览器后台Service Workers中运行的文件
manifest.json 配置
这个文件要放在根目录里边,包含的是插件的各种信息,如插件的名字和描述等
{
"manifest_version": 3,
"name": "Chrome Translation Plugin",
"version": "1.0",
"description": "一个翻译插件",
}
在浏览器拓展管理中可以看到配置的插件信息:
其他关键配置在下文介绍。
popup
配置完popup组件,点击插件的图标,可以看到弹出的内容,
// src/popup/index.jsx
function Popup() {
return <div>Translation Plugin</div>
}
export default Popup
// src/index.js
import React from "react"
import ReactDOM from "react-dom/client"
import Popup from "@/popup"
const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(
<React.StrictMode>
<Popup />
</React.StrictMode>
)
content script
content script是注入到浏览器页面中执行的js脚本,也就是说,本插件的功能——选中文字,并且展示翻译的逻辑是在content script中做的。
在content script中可以获取到目标页面的DOM并且修改,但是它与目标页面的js是不会出现相互污染的,即两者互相隔离。
background script
background script运行在浏览器后台的Service Workers中运行,在页面上是看不到的,但是可以在拓展管理中,打开一个控制台查看:
在这个控制台中就可以查看background script的情况:
那么在background script中需要我们做什么呢,根据background script的用途(在浏览器的Service Workes中运行),可以想到,发送请求可以在这里进行。
那么你可能会问,为什么不在content script中进行发送请求呢,答案很简单——跨域问题,当选中某个页面的文字调用翻译接口的时候,也就是从当前页面向服务器发送请求,就会产生跨域问题。但是在background script中可以完美解决这个问题,在MDN文档中也可以看到这段话:
Webpack配置
如果直接对项目进行打包生成build文件夹,那么浏览器是无法加载插件的,这是因为项目是由react脚手架构建的,直接打包并不符合浏览器拓展的要求,所以需要对Webpack进行配置:
暴露webpack
执行命令:
npm eject
执行之后可以看到项目中多出了两个文件夹
然后配置一下webpack.config.js
- 配置路径别名
// config/webpack.config.js
alias: {
"react-native": "react-native-web",
// Allows for better profiling with ReactDevTools
...(isEnvProductionProfile && {
"react-dom$": "react-dom/profiling",
"scheduler/tracing": "scheduler/tracing-profiling"
}),
...(modules.webpackAliases || {}),
// 添加这一行
"@": path.join(__dirname, "..", "src")
},
- 配置打包后生成的文件名
// config/webpack.config.js
output: {
path: paths.appBuild,
pathinfo: isEnvDevelopment,
// 下边三行
filename: isEnvProduction
? "static/js/[name].js"
: isEnvDevelopment && "static/js/[name].bundle.js",
chunkFilename: isEnvProduction
? "static/js/[name].chunk.js"
: isEnvDevelopment && "static/js/[name].chunk.js",
assetModuleFilename: "static/media/[name].[hash][ext]"
}
这样配置之后打包就可以生成如下文件:
- 配置popup只引入自己的index.js
new HtmlWebpackPlugin(
Object.assign(
{},
{
inject: true,
// 新增这里
chunks: ["main"],
template: paths.appHtml
},
如果不配置,那么也会把background js和content js也引入进来。
开发阶段
准备好以上配置之后,就可以开始着手开发了,首先写一下popup页面,也就是点击拓展按钮出现的提示,上边已经写过,一行文本即可:
function Popup() {
return <div>Translation Plugin</div>
}
export default Popup
然后就是content script和background script;
开发content script
这是本次开发的重点之一,再来简单回顾一下任务:在页面上选中文字 --> 出现翻译按钮 --> 点击翻译 --> 发送请求 --> 展示翻译结果
- 先来定义几个变量表示我们需要的数据:
// 选中的文本
const [selection, setSelection] = useState(null)
// 选中文本的位置
const [rect, setRect] = useState(null)
// 返回的翻译的结果
const [translation, setTranslation] = useState("")
// 数据是否传输完成
const [isDone, setIsDone] = useState(false)
// 绑定到弹窗节点
const floatWindowRef = useRef(null)
// 绑定翻译按钮的节点
const buttonRef = useRef(null)
- 再来看需要的方法:
const debounce = (fn, wait) => {
let timer = null
return function (...args) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => fn.apply(this, args), wait)
}
}
因为在触发选中事件的时候,选中多个文字会一直触发,所以我这里只好写一个防抖来解决,之前没有做过类似的需求,所以只想到了这种方法,如果大佬们有更好的,可以评论区指点一二。
接着就是开始选中文字的时候要进行的操作:
useEffect(() => {
const handlerSelect = debounce(() => {
console.log("开始选中")
floatWindowRef.current.style.display = "none"
setTranslation("")
const sel = window.getSelection()
console.log("sel=>", sel)
if (sel.toString().length > 0) {
setSelection(sel)
const rect = sel.getRangeAt(0).getBoundingClientRect()
const button = buttonRef.current
button.style.display = "block"
button.style.left = `${rect.right + 5}px`
button.style.top = `${
rect.top + window.scrollY + rect.height / 2 - 10
}px`
setRect(rect)
setIsDone(false)
} else {
setSelection(null)
}
}, 800)
document.addEventListener("selectionchange", handlerSelect)
}, [])
可以看到这段代码的作用:
- 选中文本的同时要隐藏弹窗并且清除翻译结果,这是因为如果用户在上一次选中文字翻译之后,没有手动关闭弹窗,那么再次选中文字的时候弹窗仍然在页面上,显然这是不合理的
- 然后通过
window.getSelection()拿到选中的文本,判断选中的文字个数(为什么要判断,因为单击也可以触发selectionchange事件) - 接着拿到选中文字在浏览器视口的位置,从而计算出此时浮现出的翻译按钮应该在什么位置
- 然后更新
rect,因为弹窗的位置也需要基于这个位置进行计算
更新弹窗的逻辑在这里:
useEffect(() => {
if (translation) {
// 更新浮窗位置
if (floatWindowRef.current) {
floatWindowRef.current.style.display = "block"
floatWindowRef.current.style.left = `${rect.left}px`
floatWindowRef.current.style.top = `${rect.bottom - 10}px`
}
setTranslation(translation)
}
}, [translation])
当然点击翻译按钮的时候也需要更新弹窗位置:
这里先看一下点击翻译按钮之后发生了什么:
const handleTranslateClick = () => {
buttonRef.current.style.display = "none"
if (selection) {
fetchTranslation(selection.toString())
floatWindowRef.current.style.display = "block"
floatWindowRef.current.style.left = `${rect.left}px`
floatWindowRef.current.style.top = `${rect.bottom - 10}px`
}
}
const fetchTranslation = (text) => {
chrome.runtime.sendMessage({ action: "translate", text })
}
可以看到,这里将选中的文本内容,传入fetchTranslation方法中,然后通过chrome.runtime.sendMessage)发送到background script,将文本发送过去,同时标记此时要进行翻译。
浏览器插件通信
既然说到了通信,那么这里先来看一下这里的浏览器拓展中的通信是怎么做的,只列举一下我用到的,肯定不全(因为俺也是第一次接触这玩意)。
- content script 向 background script 发送文本信息:
chrome.runtime.sendMessage({ action: "translate", text })
- background script 在收到文本之后并且调用接口拿到翻译结果再向 content script 发过去:
chrome.tabs.query(
// 这里可以拿到当前活动标签页的信息
{ active: true, currentWindow: true },
(tabs) => {
if (tabs.length > 0) {
// 拿到当前活动页的id
const tabId = tabs[0].id
// 然后将翻译结果发送过去
chrome.tabs.sendMessage(tabId, {
// 发送一些标识
action: "sendRes",
success: true,
// 翻译结果
translation: "翻译的结果"
})
console.log(choice.delta.content)
} else {
console.error("No active tab found.")
}
}
)
- 然后
content script还需要接受background script返回来的结果,怎么接收呢?
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === "sendRes" && request.translation) {
console.log("译文:", request.translation)
setTranslation((prevTranslations) => [
...prevTranslations,
request.translation
])
}
if (request.action === "translationComplete") {
setIsDone(true)
}
})
这里可以看到,我们可以在消息监听器的回调函数中拿到 background script 返回来的信息,然后更新我们用来存放翻译结果的数组。
流式传输数据
现在离实现作业的要求还差一个流式传输数据,那么这个的实现还是在background script中来进行,这里我调用的是讯飞星火大模型的接口(虽然挂我简历,但我依然爱他),本来是想调用他的机器翻译的,但是不知道为什么一直不行,显示身份验证失败,所以就调用了普通的接口,然后在传进来的文本后边拼接上 的英文翻译是什么,简单粗暴,也只能出此下策了,如:
content: `${text}的英文翻译是什么`
然后回归正题,看一下这一部分是怎么做的吧:
- 拿到文本,配置好请求体和请求头等
// 同样是在消息监听器中拿到 content script 传过来的数据
chrome.runtime.onMessage.addListener(async (request, sender, sendResponse) => {
if (request.action === "translate") {
// text 就是我们在content script 传进来的文本
const { text } = request
// 看讯飞大模型的接口文档
let postBody = {
model: "4.0Ultra",
messages: [
{
role: "user",
content: `${text}的英文翻译是什么`
}
],
// 这里可以让返回的数据为流式数据
stream: true
}
const options = {
method: "POST",
headers: {
"Content-Type": "application/json",
// 这里是需要自己去讯飞开放平台申请个人的密钥
Authorization: "Bearer xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
body: JSON.stringify(postBody)
}
)
- 发送请求流式获取数据并传递
try {
const response = await fetch(config.hostUrl, options)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const reader = response.body.getReader()
function stream() {
reader
.read()
.then(({ done, value }) => {
// 如果done为 true,说明流式数据接收完毕
if (done) {
console.log("Stream complete")
// 这里可以获取到当前活跃标签页
chrome.tabs.query(
{ active: true, currentWindow: true },
(tabs) => {
if (tabs.length > 0) {
// 拿到标签页的id
const tabId = tabs[0].id
// 向标签页,也就是content script 所在的页面通信
chrome.tabs.sendMessage(tabId, {
action: "translationComplete",
success: true
})
} else {
console.error("No active tab found.")
}
}
)
return
}
let textStr = new TextDecoder().decode(value)
if (textStr.startsWith("data: ")) {
textStr = textStr.substring(6)
}
try {
let dataObject = JSON.parse(textStr)
// 这里拿到处理好的数据
if (dataObject.choices && Array.isArray(dataObject.choices)) {
dataObject.choices.forEach((choice) => {
// 然后向标签页发送
chrome.tabs.query(
{ active: true, currentWindow: true },
(tabs) => {
if (tabs.length > 0) {
const tabId = tabs[0].id
chrome.tabs.sendMessage(tabId, {
action: "sendRes",
success: true,
translation: choice.delta.content
})
console.log(choice.delta.content)
} else {
console.error("No active tab found.")
}
}
)
})
}
} catch (error) {
console.error("Failed to parse JSON:", error)
sendResponse({ success: false, error: error.message, done: true })
}
// 递归调用以继续读取流
stream()
})
.catch((error) => {
console.error("Error reading stream:", error)
sendResponse({ success: false, error: error.message, done: true })
})
}
// 开始执行
stream()
return true // 保持消息通道打开
} catch (error) {
console.error("Fetch error:", error)
sendResponse({ success: false, error: error.message, done: true })
}
模板部分就省略了,我这里做的很简陋。
打包看效果
打包
npm run build
然后会生成一个build文件夹,通过 chrome://extensions 打开浏览器拓展配置页面,开启开发者模式,然后选择加载已解压的拓展程序,选择刚刚打包好的build文件夹,这样拓展就撞到浏览器上了,看一下效果:
结尾
上边只是实现了最简单的一个翻译的效果,本菜鸟在写这篇文章的后半部分的时候已经二面过了,面的过程中又被面试官拷打了如果选中的是输入框中的文字怎么办,这种情况使用上边的确定位置的方法是行不通的(寄),还有现场写出拖拽翻译结果的弹窗,只基本实现了拖拽(寄中寄),还有如果不使用 selectchange事件 来触发翻译按钮展示要怎么办(想不到,寄中寄中寄),等等诸多问题吧。二面到明天已经一周了,还是没结果,八成是寄寄了。