用React+AI写一个Chrome浏览器翻译插件

312 阅读5分钟

前言

25届菜鸟最近正在很苦逼的秋招中,前几天面了一个做浏览器插件的公司,一面当天告知通过,然后告诉我二面的作业是基于chatgpt api, 用React, 做一个简单Chrome 浏览器翻译插件,因为我是主Vue,React只会一点点,这家公司是主React,所以当时本来想拒绝的,但是因为浏览器插件一直是我比较感兴趣的东西,所以就想着正好趁这个机会学习一下。

image.png

准备阶段

初始化项目

  • 使用create-react-app创建项目,之后安装依赖
  • 然后就可以删除一下不需要的文件,开始进行开发

浏览器拓展组成

  • 浏览器拓展主要由以下文件组成
    • manifest.json 插件的配置文件
    • popup 页面,也就是点击插件图标,弹出来的内容
    • content script 是插入到页面中的脚本
    • background 是在浏览器后台Service Workers中运行的文件

manifest.json 配置

这个文件要放在根目录里边,包含的是插件的各种信息,如插件的名字和描述

{
    "manifest_version": 3,
    "name": "Chrome Translation Plugin",
    "version": "1.0",
    "description": "一个翻译插件",
}

在浏览器拓展管理中可以看到配置的插件信息:

image.png

其他关键配置在下文介绍。

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>
)

recording.gif

content script

content script是注入到浏览器页面中执行的js脚本,也就是说,本插件的功能——选中文字,并且展示翻译的逻辑是在content script中做的。

content script中可以获取到目标页面的DOM并且修改,但是它与目标页面的js是不会出现相互污染的,即两者互相隔离。

background script

background script运行在浏览器后台的Service Workers中运行,在页面上是看不到的,但是可以在拓展管理中,打开一个控制台查看:

image.png

在这个控制台中就可以查看background script的情况:

image.png

那么在background script中需要我们做什么呢,根据background script的用途(在浏览器的Service Workes中运行),可以想到,发送请求可以在这里进行。

那么你可能会问,为什么不在content script中进行发送请求呢,答案很简单——跨域问题,当选中某个页面的文字调用翻译接口的时候,也就是从当前页面向服务器发送请求,就会产生跨域问题。但是在background script中可以完美解决这个问题,在MDN文档中也可以看到这段话:

image.png

Webpack配置

如果直接对项目进行打包生成build文件夹,那么浏览器是无法加载插件的,这是因为项目是由react脚手架构建的,直接打包并不符合浏览器拓展的要求,所以需要对Webpack进行配置:

暴露webpack

执行命令:

npm eject

执行之后可以看到项目中多出了两个文件夹

image.png

然后配置一下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]"
  }

这样配置之后打包就可以生成如下文件:

image.png

  • 配置popup只引入自己的index.js
new HtmlWebpackPlugin(
    Object.assign(
      {},
      {
        inject: true,
        // 新增这里
        chunks: ["main"],
        template: paths.appHtml
      },

如果不配置,那么也会把background jscontent js也引入进来。

开发阶段

准备好以上配置之后,就可以开始着手开发了,首先写一下popup页面,也就是点击拓展按钮出现的提示,上边已经写过,一行文本即可:

function Popup() {
  return <div>Translation Plugin</div>
}
export default Popup

然后就是content scriptbackground 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 scriptbackground 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文件夹,这样拓展就撞到浏览器上了,看一下效果:

recording.gif

结尾

上边只是实现了最简单的一个翻译的效果,本菜鸟在写这篇文章的后半部分的时候已经二面过了,面的过程中又被面试官拷打了如果选中的是输入框中的文字怎么办,这种情况使用上边的确定位置的方法是行不通的(寄),还有现场写出拖拽翻译结果的弹窗,只基本实现了拖拽(寄中寄),还有如果不使用 selectchange事件 来触发翻译按钮展示要怎么办(想不到,寄中寄中寄),等等诸多问题吧。二面到明天已经一周了,还是没结果,八成是寄寄了。