前言
近来实习公司给了个开发浏览器插件的需求,因此开始研究了一下浏览器插件开发。接这个需求的时候还在学习SoildJS,因此这个插件会使用 SolidJS来开发(看到后面就能明白怎么用其他框架了)。为了能够实现一个简单的工程化,还要使用Vite + TS + nodemon进行一个简单的配置。样式编写上希望能够方便点,希望能用Tailwind CSS。
本文亦从此仓库抄袭学习并做简化,推荐先查看此仓库,能分析清楚就毋需再看本文章了。
本文不注重讲解原理,重在如何实操,上手即学。
若有疑问可评论区提问,若有不足望不吝指正。
实现完成的效果如图
以下都是先前完成的,这一次再带着实现一次,因此最后实际产出会与如下的图上有所出入。
插件 popup
新标签页
涉及
- Vite + TS 简单配置
- Tailwind CSS 简单配置
- SolidJS 简单使用
- Chrome 插件基本开发流程
开发流程
环境配置
建立一个目录SolidJS-Web-Extensions,并且使用pnpm init初始化,然后根据如下文件结构建立文件。
文件结构
SolidJS-Web-Extensions
|- package.json
|- src/
|- background/ -- 用于编写运行于后台的脚本
| |- index.ts
|- popup/ -- 按插件弹出来的窗口
| |- index.tsx
| |- index.html
|- newtab/ -- 新标签页网页
| |- index.tsx
| |- index.html
|- manifest.ts -- 插件的配置文件(标准格式是 json,用 ts 的好处是可以有类型推导)
其中这两个 index.html,都使用如下模板。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>POPUP</title>
<!-- 新标签页的话替换成 SolidJS New Tab -->
</head>
<body>
<div id="root"></div>
<!-- 下面的 script src 中 vite 打包会处理这个所引入的文件,产物中会正确路由到的 -->
<script src="./index.tsx" type="module"></script>
</body>
</html>
先安装现阶段需要的
pnpm i -D vite typescript nodemon
pnpm i -D solid-js vite-plugin-solid // 用于 load solid jsx 的 vite 插件
pnpm i -D @types/webextensions-polyfill @types/node // 类型
配置一下 TS
tsc --init
// tsconfig.json
{
"compilerOptions": {
"target": "es6",
"jsx": "preserve",
"jsxImportSource": "solid-js",
"module": "ESNext",
"moduleResolution": "node",
"baseUrl": "./",
"paths": {
"@root/*": ["src/*"],
"@popup/*": ["src/popup/*"],
"@newtab/*": ["src/newtab/*"],
"@background/*": ["src/background/*"]
},
"types": ["vite/client"],
"noEmit": true,
"allowSyntheticDefaultImports": true,
"allowImportingTsExtensions": true,
"strict": true
}
}
配置一下 Vite
import { defineConfig } from 'vite';
import solid from 'vite-plugin-solid';
export default defineConfig({
plugins: [solid()],
resolve: {
alias: {
'@root': './src',
'@popup': './src/popup',
'@newtab': './src/newtab',
'@background': './src/background',
},
},
build: {
outDir: 'dist',
rollupOptions: {
input: {
popup: './src/popup/index.html',
newtab: './src/newtab/index.html',
background: './src/background/index.ts',
},
output: {
entryFileNames: (chunk) => `src/${chunk.name}/index.js`,
},
},
},
});
配置一下 Nodemon(作为热更新的方案,作者太菜不会写 Vite 的 HMR)
// nodemon.json
{
"env": {
"__DEV__": "true"
},
"watch": ["src", "plugins", "vite.config.ts"],
"ext": "tsx,css,html,ts",
"ignore": ["src/**/*.spec.ts"],
"exec": "vite build"
}
配置一下 package.json
// package.json
{
...
"type": "module",
"scripts": {
"dev": "nodemon",
"build": "vite build"
},
...
}
配置一下 manifest.ts
import { type Manifest } from 'webextension-polyfill';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const pkg = require('../package.json');
const manifest: Manifest.WebExtensionManifest = {
manifest_version: 3,
name: pkg.name,
version: pkg.version,
description: pkg.description,
// 配置后台脚本加载的文件
background: {
service_worker: './src/background/index.js',
type: 'module',
},
action: {
// 配置 popup 加载的 html 文件
default_popup: './src/popup/index.html',
},
chrome_url_overrides: {
// 配置新标签页页面
newtab: './src/newtab/index.html',
},
};
export default manifest;
为了能够将 manifest.ts 输出成 manifest.json 到 dist 目录下,需要自行编写一个 vite 插件。
// plugins/makeManifest.ts
import manifest from '../src/manifest';
import { writeFileSync } from 'fs';
import path from 'path';
import { Plugin } from 'vite';
const __dirname = process.cwd();
const distDir = path.resolve(__dirname, 'dist');
export function makeManifest(): Plugin {
return {
name: 'make-manifest',
buildEnd() {
// 这里有个问题,Vite 的执行会在将文件打包好覆盖到 dist 目录上之前就直接处理好 manifest
// 导致之后覆盖 dist 的时候 manifest 就消失了
// 这里本人的策略就是放入宏任务
setTimeout(() => {
const manifestPath = path.resolve(distDir, 'manifest.json');
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
console.log('\nMake Manifest.json OK!\n');
});
},
};
}
然后将其放入 Vite 中使用
// vite.config.ts
...
plugins: [solid(), makeManifest()]
先编写 popup 的界面测试一下
// src/popup/index.tsx
import { render } from 'solid-js/web';
function bootstrap() {
const root = document.getElementById('root');
render(() => <h1>Hello SolidJS!</h1>, root!);
}
bootstrap();
然后命令行运行:
pnpm dev
命令行中显示开始打包,在项目根目录上生成一个dist目录,即为插件的文件夹。
我们在浏览器中引入这个插件试试看
成功!
至此,环境搭建完成。
配置 Tailwind CSS 的教程,实际上跟着官方文档中 Vite 如何配置的步骤就行了,但注意,请在目录./src下创建index.css,然后根据文档来配置,然后在./src/popup/index.tsx和./src/newtab/index.tsx中都要 import 这个 css。
在 Vue 3 和 Vite 安装 Tailwind CSS - Tailwind CSS 中文文档
/** src/index.css */
@tailwind components;
@tailwind utilities;
body {
margin: 0;
}
切图
Popup
// src/popup/index.tsx
import { render } from 'solid-js/web';
import '@root/index.css';
import { App } from './pages';
function bootstrap() {
const root = document.getElementById('root');
render(() => <App />, root!);
}
bootstrap();
// src/components/counter.tsx
import { createSignal } from 'solid-js';
export function Counter() {
const [count, setCount] = createSignal(0);
return (
<button
class='border-0 outline-1 focus:outline px-2 py-1 text-xl bg-slate-600 rounded-md'
onClick={() => setCount(count() + 1)}
>
Count: {count()}
</button>
);
}
// src/popup/pages/index.tsx
import solidLogo from '@root/assets/solid-logo.svg';
import style from './index.module.css';
import { Counter } from '@root/components/counter.tsx';
export function App() {
return (
<div class='w-[15rem] h-[20rem] bg-slate-400 flex flex-col items-center'>
<h1>
<img
class={style.logo + ' w-[5rem]'}
src={solidLogo}
alt='solid-logo'
/>
</h1>
<h1>Hello SolidJS!</h1>
<Counter />
</div>
);
}
/** src/popup/pages/index.module.css */
.logo {
animation: logo-spin 20s linear infinite;
}
@keyframes logo-spin {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
效果如图
NewTab
浏览器插件还有一个功能就是可以自定义新标签页的页面样式,因此我们继续如下的流程。
src/newtab/index.html 已经在上面给过,这里不再赘述。
// src/newtab/index.tsx
import { render } from 'solid-js/web';
import { App } from './pages';
import '@root/index.css';
function bootstrap() {
const root = document.getElementById('root');
render(() => <App />, root!);
}
bootstrap();
// src/newtab/pages/index.tsx
import solidLogo from '@root/assets/solid-logo.svg';
import { Counter } from '@root/components/counter.tsx';
import style from './index.module.css';
export function App() {
return (
<div class='w-screen h-screen flex flex-col items-center bg-slate-400'>
<img
class={style.logo + ' w-[15rem] mt-12'}
src={solidLogo}
alt='solid-logo'
/>
<img
class='mt-10 h-20'
src='https://skillicons.dev/icons?i=vite,solidjs,ts,tailwindcss'
alt='skillicons'
/>
<h1 class='text-7xl'>Hello SolidJS!</h1>
<Counter />
</div>
);
}
/** src/newtab/index.module.css */
.logo {
animation: logo-spin 20s linear infinite;
}
@keyframes logo-spin {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
我们看看效果
现在,打开新标签页之后都是这个页面了。
后台脚本
我希望安装这个脚本之后会自动打开一个新的标签页,然后显示的是上面所编写的页面。
我们首先为了能够获取到 chrome 插件的 api 的代码提示,需要安装依赖
pnpm i -D @types/chrome
然后在 tsconfig.json 中添加一下。
// tsconfig.json
{
...
"compilerOptions": {
...
"types": ["vite/client", "chrome"],
...
}
}
然后我们到 src/background/index.ts 中尝试从代码提示中获取一下 chrome 中有什么 api。
有了代码提示,就方便很多了。
现在编写这个安装后的逻辑。
// src/background/index.ts
const action = chrome.action || chrome.browserAction; // 一些插件按钮以及别的浏览器相关上的 API,以后再用到
const runtime = chrome.runtime; // 运行时
const tabs = chrome.tabs; // 标签栏相关的工具
runtime.onInstalled.addListener(() => {
tabs.create({});
});
然后尝试把插件卸载再安装。
这个逻辑好像又过于简单,那我们再实现一个功能:在 popup 中点击一个按钮后标签栏自动整理同属一个域名的标签。
那我们打开src/popup/pages/index.tsx
// src/popup/pages/index.tsx
...
import { sortTabs } from '@root/utils/sort-tab.ts';
export function App() {
return (
<div class='w-[15rem] h-[20rem] bg-slate-400 flex flex-col items-center'>
...
<button
onClick={sortTabs}
class='bg-slate-600 rounded-md px-2 py-1 mt-2 text-xl border-0 outline-1 focus:outline'
>
SORT TABS
</button>
</div>
);
}
// src/utils/sort-tabs.ts
const tabs = chrome.tabs;
const tabGroup = chrome.tabGroups;
export async function sortTabs() {
const _tabs = await tabs.query({});
const pattern = /^(.*)\:\/\/(?:www\.)?(.*?)\/(.*)$/;
const pairs = _tabs.map((t) => [pattern.exec(t.url!)![2] as string, t.id]);
const urlMap = new Map<string, number[]>();
pairs.forEach((p) => {
let arr: number[];
const k = p[0] as string;
if (!urlMap.has(k)) {
urlMap.set(k, []);
}
arr = urlMap.get(k)!;
arr.push(p[1] as number);
});
[...urlMap.keys()].forEach(async (k) => {
const v = urlMap.get(k);
const group = await tabs.group({ tabIds: v });
tabGroup.update(group, { title: k.slice(0, 3) });
});
}
注意,如果要让 tabs 能够访问到 tab 的 url 以及可以使用整理分组的 api,必须在 manifest.ts 中打开 tabs、tabGroups 的权限,来到 manifest.ts。
...
const manifest: Manifest.WebExtensionManifest = {
...
permissions: ['tabs', 'tabGroups'],
host_permissions: ['<all_urls>'],
...
};
然后必须回到浏览器扩展管理中重新加载,否则不能生效。
完成效果如下图
点击 SORT TABS 后,如图
如果希望点击插件的时候整理,也可以在src/background/index.ts中这样加入。这个情况下在没有 popup 的时候才能生效,也就是在manifest.ts中将 actions.default_popup去掉。
// src/background/index.ts
...
import { sortTabs } from '@root/utils/sort-tab.ts';
...
action.onClicked.addListener(sortTabs);
写好后也要自己手动重新加载插件,尝试点击插件生效。
暂时完结
在一开始的介绍中所展示的模板(没有整理 tab 功能) github 地址在chrome-vite-solid-ts-template/ at main · SokuRitszZ/chrome-vite-solid-ts-template · GitHub。
后续可能还会继续开发其他插件。