都 2021 年了,做個(gè) Chrome 插件給自己吧!
本文轉(zhuǎn)載自微信公眾號(hào)「程序員巴士」,作者一只圖雀 。轉(zhuǎn)載本文請(qǐng)聯(lián)系程序員巴士公眾號(hào)。
大家好,我是皮湯。
隨著入職時(shí)間變長,工作不斷的深入,在需要同時(shí)處理多個(gè)任務(wù)的同時(shí),打開幾十上百個(gè)瀏覽器 Tab 頁就必不可少了,而我的工作幾乎都是在各種瀏覽器 Tab 頁之間來回切換,如寫文檔、學(xué)習(xí)新知識(shí)、處理 Bug 單流轉(zhuǎn)、上線等流程,所以我需要對(duì)瀏覽器的 Tab 頁進(jìn)行精細(xì)化管理,以達(dá)到精細(xì)化管理工作流程的目的,于是乎,我對(duì)于瀏覽器的使用變成了下面幾個(gè)階段:
Chrome - 雜亂無章階段
Chrome - 進(jìn)行適當(dāng)整理
Edge - 豎向側(cè)邊欄
但是無論瀏覽器層面提供多少這樣或那樣的輔助,但畢竟瀏覽器的職責(zé)主要是負(fù)責(zé)幫助你更好、更快、更高效的瀏覽網(wǎng)頁,并非是幫你管理知識(shí)和工作流程,所以如果需要個(gè)性化定制的需求,就得自己上手開發(fā)啦!畢竟作為程序員,自己動(dòng)手,豐衣足食嘛 ??。
需求分析
我希望能夠開發(fā)一個(gè) Chrome 瀏覽器插件,當(dāng)前其他瀏覽器如 Edge、Firefox、Brave,以及其他所有使用 Chromimum 開發(fā)的瀏覽器都是支持 Chrome 插件格式的,而這幾大瀏覽器幾乎占據(jù)了近 83% 左右的桌面端瀏覽器市場(chǎng),所以這個(gè) Chrome 插件可以在我喜歡的瀏覽器上運(yùn)行。
以下是 2020.3 到 2021.3 的桌面端瀏覽器占比數(shù)據(jù)
這個(gè)瀏覽器支持傳統(tǒng)的插件點(diǎn)擊彈出欄,以及每次打開一個(gè)新 Tab 都能展示我的應(yīng)用,這樣能夠幫助我隨時(shí)了解我當(dāng)前正在進(jìn)行的工作,大致形式如下:
彈出欄:
新 Tab:
針對(duì)上面需求的形式不知道大家是否比較熟悉了?沒錯(cuò),這個(gè)插件的框架形式和 掘金 的插件類似,我們看下掘金的 Chrome 插件:
彈出框:
新 Tab:
也就是說,在看完本次文章,你基本上擁有了開發(fā)一個(gè)掘金插件的能力,心動(dòng)了???
隨便一提,我們本次開發(fā)插件的技術(shù)棧如下:
- React + TypeScript,基于 Create-React-App 腳手架搭建
通過先進(jìn)的技術(shù)棧來編寫 Chrome 插件??。
前置知識(shí)
Chrome 插件實(shí)際上包含幾個(gè)部分:
- manifest.json 文件,相當(dāng)于整個(gè)項(xiàng)目的入口,里面記錄著此插件的 icon 圖標(biāo)展示、彈出框樣式文件、新建 Tab 邏輯、選項(xiàng)邏輯、內(nèi)容腳本邏輯等
- background.js,此腳本是在整個(gè)瀏覽器啟動(dòng)或者插件加載之后就會(huì)運(yùn)行的一個(gè)腳本文件,它運(yùn)行在 ServiceWorker 里面,通常用于進(jìn)行一些前置的數(shù)據(jù) storage 存儲(chǔ)操作,可以操作所有的 Chrome API
- popup.html,插件的彈出框展示的模板內(nèi)容,可以通過 CSS 控制樣式,JavaScript 控制邏輯
- options.html,右鍵插件 icon 時(shí)彈出菜單頁,點(diǎn)擊菜單頁里面的選項(xiàng)打開的頁面
- content.js,此腳本是在你打開一個(gè)新的網(wǎng)頁的時(shí)候,Chrome 瀏覽器為這個(gè)網(wǎng)頁注入的一個(gè)腳本文件,用于輔助此網(wǎng)頁和你的插件進(jìn)行一個(gè)通信,因?yàn)椴寮倪\(yùn)行環(huán)境是經(jīng)過沙盒隔離的,無法直接操作到 DOM,所以需要通過 content 腳本操作 DOM,然后發(fā)送給到插件的處理邏輯
上述 5 大文件組成了一個(gè) Chrome 插件所需要的必須元素,邏輯關(guān)系如下:
可以看到,其實(shí)開發(fā)一個(gè) Chrome 的插件也是使用 HTML/JavaScript/CSS 這些知識(shí),只不過使用場(chǎng)景,每種 JavaScript 使用的權(quán)限與功能、操作的 API 不太一樣,那么既然是使用基本的 Web 基礎(chǔ)技術(shù),我們就可以借助更為上層的 Web 開發(fā)框架如 React 等來將 Chrome 插件的開發(fā)上升到一個(gè)現(xiàn)代化的程度。
最簡(jiǎn)化插件
確保你安裝了最新版的 Node.js,然后在命令行中運(yùn)行如下命令:
- npx create-react-app chrome-react-extension --template typescript
初始化好項(xiàng)目、安裝完依賴之后,我們可以看到 CRA 產(chǎn)生的模板代碼,其中就有我們需要的 public/manifest.json 文件:
當(dāng)然內(nèi)容并沒有我們上圖那樣豐富我們需要做一些修改,將內(nèi)容改為如下內(nèi)容:
- {
- "name": "Chrome React Extension",
- "description": "使用 React TypeScript 構(gòu)建 Chrome 擴(kuò)展",
- "version": "1.0",
- "manifest_version": 3,
- "action": {
- "default_popup": "index.html",
- "default_title": "Open the popup"
- },
- "icons": {
- "16": "logo192.png",
- "48": "logo192.png",
- "128": "logo192.png"
- }
- }
上述的字段說明如下:
- name:插件的名字,展示在 Chrome 插件 icon 里面,以及插件市場(chǎng)等
- description:簡(jiǎn)介插件時(shí)干嘛的
- version:插件當(dāng)前的版本
- manifest_version:當(dāng)前使用的 manifest 文件的版本,Chrome 插件最小的 manifest 版本是 V3
- action:控制點(diǎn)擊插件 icon 時(shí)的需要反應(yīng)的動(dòng)作(action),這里我們?cè)O(shè)置 hover 時(shí)展示的文字為 default_title,點(diǎn)擊打開展示的內(nèi)容為 index.html
- icons:為展示在 Chrome 插件里面的圖標(biāo)
實(shí)際上 Chrome 插件只能理解原生的 JavaScript,CSS,HTML 等, 所以我們使用 React 學(xué)完之后,需要進(jìn)行構(gòu)建,將構(gòu)建的產(chǎn)物打包給到瀏覽器插件去加載使用,在構(gòu)建時(shí),還有一個(gè)需要注意的就是,為了保證最優(yōu)化性能,CRA 的腳本在構(gòu)建時(shí)會(huì)將一些小的 JS 文件等,內(nèi)聯(lián)到 HTML 文件中,而不是打包成獨(dú)立的 JS 文件,在 Chrome 插件的運(yùn)行環(huán)境下,這種形式的 HTML 是不支持的,會(huì)觸發(fā)插件的 CSP(內(nèi)容安全策略)錯(cuò)誤。
所以為了測(cè)試我們的插件當(dāng)前效果,我們修改構(gòu)建腳本,在 package.json 里面:
- "scripts": {
- "start": "react-scripts start",
- "build": "INLINE_RUNTIME_CHUNK=false react-scripts build",
- "test": "react-scripts test",
- "eject": "react-scripts eject"
- },
通過設(shè)置 INLINE_RUNTIME_CHUNK=false 確保所有的 JS 會(huì)構(gòu)建成獨(dú)立的文件,然后引入到 HTML 中加載使用。
一切準(zhǔn)備完畢,是時(shí)候構(gòu)建我們的 React 應(yīng)用了~ 在命令行中運(yùn)行如下命令:
- npm run build
會(huì)發(fā)現(xiàn)內(nèi)容構(gòu)建輸出在 build/xxx 下面,包含 manifest.json、index.html、對(duì)應(yīng)的 JS/CSS 文件還有圖片等,其中 manifest 中索引了 index.html 來作為點(diǎn)擊插件時(shí)的 Popup 的展示頁,這個(gè)時(shí)候我們就可以使用 Chrome 加載我們構(gòu)建好的文件,來查看插件運(yùn)行效果了:
我們打開擴(kuò)展程序面板,設(shè)置開發(fā)者模式,然后點(diǎn)擊加載文件,選擇我們的 build 文件地址加載:
Magic !我們可以在瀏覽器里面看到我們的插件,并使用它了,一個(gè)最簡(jiǎn)化插件完成!??
當(dāng)然這里我們雖然能夠使用 React/TypeScript 以及一切現(xiàn)代的 Web 開發(fā)技術(shù)來寫插件,但是目前沒有很好的方式能夠?qū)崟r(shí)的進(jìn)行開發(fā)-查看效果,就是我們常見的 HMR、Live Reload 這種技術(shù)暫時(shí)還沒有很好的支持到 Chrome 插件的開發(fā),所以每次我們需要查看編寫的效果都需要構(gòu)建之后點(diǎn)擊插件查看。
當(dāng)然如果純針對(duì) UI 或者和 Chrome API 無關(guān)的邏輯,那么你可以放心的直接在 Web 里面開發(fā),等到開發(fā)完畢再構(gòu)建到 Chrome 插件預(yù)覽即可。
定制新 Tab 邏輯
我們之前的邏輯是,只要新開一個(gè) Tab,那么就會(huì)訪問我們提供的頁面,類似掘金的插件,而且我們也主要到,其實(shí)針對(duì) Popup 頁面只是幾個(gè)按鈕,而重頭戲都在新 Tab 頁界面展示,也就是我們這里其實(shí)需要一個(gè)多頁應(yīng)用?因?yàn)樽罱K要生成頁面,一個(gè)用在 Popup 頁面展示,一個(gè)用在新 Tab 頁展示。
但是我們知道 CRA 腳手架生成的模板是主要用于單頁應(yīng)用,如果需要切換到多頁應(yīng)用有一定的成本,但是我們的 Popup 頁面實(shí)際上就只有幾個(gè)按鈕,所以這里可以做一層簡(jiǎn)化,即 Popup 頁面直接手動(dòng)寫最原始的 HTML/JS/CSS,然后將重頭戲、復(fù)雜的新 Tab 頁的邏輯來用 React TypeScript 等現(xiàn)代 Web 技術(shù)來開發(fā)。
通過這樣設(shè)計(jì)之后,我們的目錄結(jié)構(gòu)變成了如下形式:
其中 manifest.json 的邏輯變成了如下:
- {
- "name": "Chrome React SEO Extension",
- "description": "The power of React and TypeScript for building interactive Chrome extensions",
- "version": "1.0",
- "manifest_version": 3,
- "action": {
- "default_popup": "./popup/index.html",
- "default_title": "Open the popup"
- },
- "chrome_url_overrides": {
- "newtab": "index.html"
- },
- "icons": {
- "16": "logo192.png",
- "48": "logo192.png",
- "128": "logo192.png"
- }
- }
我們可以看到,點(diǎn)擊 Chrome 插件彈出的頁面 Popup,換成了 ./popup/index.html ,而我們新加了一個(gè) chrome_url_overrides 字段,在 newtab 時(shí),我們打開構(gòu)建后的 index.html 文件。
通過上面的操作,我們每次打開一個(gè)新 Tab,都會(huì)展示下面的頁面:
完美!我們已經(jīng)實(shí)現(xiàn)了掘金的插件的核心思想:便捷的獲取技術(shù)知識(shí),就在你每次打開 Tab 時(shí)。
開發(fā) Popup 頁面
接下來我們嘗試改造一下我們的 Popup 頁面,同樣是對(duì)標(biāo)掘金,我們知道掘金的 Popup 頁面是一個(gè)比較簡(jiǎn)單的菜單欄,里面主要是一些用于跳轉(zhuǎn)到新 Tab 或者設(shè)置頁的操作:
我們現(xiàn)在也需要實(shí)現(xiàn)類似的點(diǎn)擊某個(gè)按鈕,跳轉(zhuǎn)到我們新 Tab 頁,打開我們上一部分定制的 Tab 邏輯。
這一部分我們就需要修改 popup/index.html ,添加相關(guān)的 JS 邏輯如下:
- <!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>Fake Juejin Extensions</title>
- <link rel="stylesheet" href="./styles.css" />
- </head>
- <body>
- <ul>
- <li class="open_new_tab">打開新標(biāo)簽頁</li>
- <li class="go_to_github">訪問 Github</li>
- <li class="go_to_settings">設(shè)置</li>
- </ul>
- <script src="popup.js"></script>
- </body>
- </html>
這一次需求我們只會(huì)操作打開新標(biāo)簽頁、訪問 Github,設(shè)置我們不做操作,留給讀者自己去擴(kuò)展。
可以看到我們導(dǎo)入了 popup.js 文件,在這個(gè) JS 文件里,我們需要完成對(duì)應(yīng)打開新標(biāo)簽頁、和訪問 Github 的邏輯配置:
- document.querySelector(".open_new_tab").addEventListener("click", (e) => {
- chrome.tabs.create({}, () => {});
- });
- document.querySelector(".go_to_github").addEventListener("click", (e) => {
- window.open("https://github.com");
- });
可以看到,因?yàn)?popup.js 是運(yùn)行在 Chrome 插件的沙箱環(huán)境下的,所以它能夠使用到 chrome 這個(gè) API,進(jìn)行頁面、瀏覽器等相關(guān)的操作。
當(dāng)我們寫入了上述邏輯之后,我們就可以點(diǎn)擊對(duì)應(yīng)的打開新標(biāo)簽頁,訪問新標(biāo)簽頁并展示我們上一節(jié)說到的內(nèi)容,訪問 Github,則會(huì)跳轉(zhuǎn)到 Github 頁面。
使用 Content 腳本
我們已經(jīng)開發(fā)了新 Tab 頁,開發(fā)了 Popup 邏輯,接下來我們可以嘗試一下通過 content 腳本,來實(shí)現(xiàn)用戶頁面與插件腳本進(jìn)行通信,以間接的操作 DOM。
首先我們需要在 manifest.json 里面注冊(cè) content 相關(guān)的腳本:
- {
- "name": "Chrome React Extension",
- // ...
- "permissions": ["activeTab", "tabs"],
- "content_scripts": [
- {
- "matches": ["http://*/*", "https://*/*"],
- "js": ["./static/js/content.js"]
- }
- ]
- }
上述腳本通過 content_scripts 指定 content 腳本,matches 指定匹配到那些域名時(shí),才執(zhí)行這個(gè)注入腳本的邏輯,js 代表需要注入的腳本的位置,這里我們填寫的為 ./static/js/content.js ,即為通過構(gòu)建之后產(chǎn)生的 JS 內(nèi)容地址。
接著我們?cè)?Tab 頁的 React 項(xiàng)目里面去建立與 content 腳本的通信:
- import React from "react";
- import "./App.css";
- import { DOMMessage, DOMMessageResponse } from "./types";
- function App() {
- // 前置邏輯
- React.useEffect(() => {
- /**
- * We can't use "chrome.runtime.sendMessage" for sending messages from React.
- * For sending messages from React we need to specify which tab to send it to. */ chrome.tabs &&
- chrome.tabs.query(
- {
- active: true,
- currentWindow: true,
- },
- (tabs) => {
- /**
- * Sends a single message to the content script(s) in the specified tab,
- * with an optional callback to run when a response is sent back.
- *
- * The runtime.onMessage event is fired in each content script running
- * in the specified tab for the current extension. */ chrome.tabs.sendMessage(
- tabs[0].id || 0,
- { type: "GET_DOM" } as DOMMessage,
- (response: DOMMessageResponse) => {
- setTitle(response.title);
- setHeadlines(response.headlines);
- }
- );
- }
- );
- });
- return (
- // ... 模板
- );
- }
- export default App;
可以看到我們通過 chome API,去查詢當(dāng)前正在激活的 Tab 頁,然后給這個(gè) Tab 頁的 content 腳本,通過 chrome.tabs.sendMessage 發(fā)了一個(gè) { type: "GET_DOM" } 的消息。
然后我們創(chuàng)建對(duì)應(yīng)的 content 的腳本,在 src/chromeServices 下創(chuàng)建 DOMEvaluator.ts:
- import { DOMMessage, DOMMessageResponse } from "../types";
- // Function called when a new message is received const messagesFromReactAppListener = (
- msg: DOMMessage,
- sender: chrome.runtime.MessageSender,
- sendResponse: (response: DOMMessageResponse) => void
- ) => {
- console.log("[content.js]. Message received", msg);
- const headlines = Array.from(document.getElementsByTagName<"h1">("h1")).map(
- (h1) => h1.innerText
- );
- // Prepare the response object with information about the site const response: DOMMessageResponse = {
- title: document.title,
- headlines,
- };
- sendResponse(response);
- };
- /**
- * Fired when a message is sent from either an extension process or a content script. */ chrome.runtime.onMessage.addListener(messagesFromReactAppListener);
這個(gè)腳本在加載的時(shí)候,通過 onMessage.addListener 監(jiān)聽,然后回調(diào) messagesFromReactAppListener ,在函數(shù)里面,可以直接獲取 DOM,查詢這個(gè)頁面中的 標(biāo)題 和所有的 H1 標(biāo)簽,然后返回。
- import React from "react";
- import "./App.css";
- import { DOMMessage, DOMMessageResponse } from "./types";
- function App() {
- const [title, setTitle] = React.useState("");
- const [headlines, setHeadlines] = React.useState<string[]>([]);
- // ...消息通信邏輯
- return (
- // ... 模板
- <div className="App">
- <h1>SEO Extension built with React!</h1>
- <ul className="SEOForm">
- <li className="SEOValidation">
- <div className="SEOValidationField">
- <span className="SEOValidationFieldTitle">Title</span>
- <span
- className={`SEOValidationFieldStatus ${
- title.length < 30 || title.length > 65 ? "Error" : "Ok"
- }`}
- >
- {title.length} Characters
- </span>
- </div>
- <div className="SEOVAlidationFieldValue">{title}</div>
- </li>
- <li className="SEOValidation">
- <div className="SEOValidationField">
- <span className="SEOValidationFieldTitle">Main Heading</span>
- <span
- className={`SEOValidationFieldStatus ${
- headlines.length !== 1 ? "Error" : "Ok"
- }`}
- >
- {headlines.length}
- </span>
- </div>
- <div className="SEOVAlidationFieldValue">
- <ul>
- {headlines.map((headline, index) => (
- <li key={index}>{headline}</li>
- ))}
- </ul>
- </div>
- </li>
- </ul>
- </div>
- );
- }
- export default App;
然后擴(kuò)展一下 CSS 代碼:
- .App {
- background: #edf0f6;
- padding: 0.5rem;
- }
- .SEOForm {
- list-style: none;
- margin: 0;
- box-shadow: 0 1px 3px 0 rgb(0 0 0 / 10%), 0 1px 2px 0 rgb(0 0 0 / 6%);
- background: #fff;
- padding: 1rem;
- }
- .SEOValidation {
- margin-bottom: 1.5rem;
- }
- .SEOValidationField {
- width: 100%;
- display: flex;
- justify-content: space-between;
- }
- .SEOValidationFieldTitle {
- font-size: 1rem;
- color: #1a202c;
- font-weight: bold;
- }
- .SEOValidationFieldStatus {
- color: #fff;
- padding: 0 1rem;
- height: 1.5rem;
- font-weight: bold;
- align-items: center;
- display: flex;
- border-radius: 9999px;
- }
- .SEOValidationFieldStatus.Error {
- background-color: #f23b3b;
- }
- .SEOValidationFieldStatus.Ok {
- background-color: #48d660;
- }
- .SEOVAlidationFieldValue {
- overflow-wrap: break-word;
- width: 100%;
- font-size: 1rem;
- margin-top: 0.5rem;
- color: #4a5568;
- }
Nice!我們成功編寫了新 Tab 頁模板、邏輯與樣式,以及創(chuàng)建了 Content 腳本邏輯,最后我們的展示效果如下:
然后我們需要進(jìn)行代碼構(gòu)建,因?yàn)?content 我們使用 TypeScript 語法寫,將 content 的邏輯構(gòu)建為單獨(dú)的 JS 輸出,我們安裝 craco 依賴,然后修改對(duì)應(yīng)的腳本:
- yarn add -D craco
- // package.json
- "scripts": {
- "start": "react-scripts start",
- "build": "INLINE_RUNTIME_CHUNK=false craco build",
- "test": "react-scripts test",
- "eject": "react-scripts eject"
- },
將 react-scripts 改為 craco 。
然后新建 craco.config.js ,添加如下內(nèi)容:
- module.exports = {
- webpack: {
- configure: (webpackConfig, { env, paths }) => {
- return {
- ...webpackConfig,
- entry: {
- main: [
- env === "development" &&
- require.resolve("react-dev-utils/webpackHotDevClient"),
- paths.appIndexJs,
- ].filter(Boolean),
- content: "./src/chromeServices/DOMEvaluator.ts",
- },
- output: {
- ...webpackConfig.output,
- filename: "static/js/[name].js",
- },
- optimization: {
- ...webpackConfig.optimization,
- runtimeChunk: false,
- },
- };
- },
- },
- };
準(zhǔn)備完畢,開始構(gòu)建:yarn`` build ,我們會(huì)發(fā)現(xiàn)構(gòu)建目錄輸出如下:
寫在最后
在本篇文章中,我們完整體驗(yàn)了使用 React+TypeScript,開發(fā)新 Tab 內(nèi)容展示頁以及 content 通信腳本,然后通過配置 react-scripts 為 craco 進(jìn)行了分文件構(gòu)建,以及直接開發(fā)原生的 popup 頁,通過這種融匯的技術(shù),成功開發(fā)出了一個(gè)類似掘金框架的 Chrome 插件。
這篇文章沒有介紹的有 background 腳本,以及整體插件內(nèi)容還不夠完善,希望有興趣的讀者可以繼續(xù)探索,將其完善。