写一个基于 SolidJS + TS + Vite + TailwindCSS 的浏览器插件开发代码模板

1,270 阅读3分钟

前言

近来实习公司给了个开发浏览器插件的需求,因此开始研究了一下浏览器插件开发。接这个需求的时候还在学习SoildJS,因此这个插件会使用 SolidJS来开发(看到后面就能明白怎么用其他框架了)。为了能够实现一个简单的工程化,还要使用Vite + TS + nodemon进行一个简单的配置。样式编写上希望能够方便点,希望能用Tailwind CSS

本文亦从此仓库抄袭学习并做简化,推荐先查看此仓库,能分析清楚就毋需再看本文章了。

本文不注重讲解原理,重在如何实操,上手即学

若有疑问可评论区提问,若有不足望不吝指正。

实现完成的效果如图

以下都是先前完成的,这一次再带着实现一次,因此最后实际产出会与如下的图上有所出入。

插件 popup image.png

新标签页 image.png

涉及

  • 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.jsondist 目录下,需要自行编写一个 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目录,即为插件的文件夹

image.png

image.png

我们在浏览器中引入这个插件试试看

image.png

image.png

image.png

image.png

成功!

至此,环境搭建完成。

配置 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);
  }
}

效果如图

image.png

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

我们看看效果

image.png

现在,打开新标签页之后都是这个页面了。

后台脚本

我希望安装这个脚本之后会自动打开一个新的标签页,然后显示的是上面所编写的页面。

我们首先为了能够获取到 chrome 插件的 api 的代码提示,需要安装依赖

pnpm i -D @types/chrome

然后在 tsconfig.json 中添加一下。

// tsconfig.json
{
    ...
    "compilerOptions": {
        ...
        "types": ["vite/client", "chrome"],
        ...
    }
}

然后我们到 src/background/index.ts 中尝试从代码提示中获取一下 chrome 中有什么 api。

image.png

有了代码提示,就方便很多了。

现在编写这个安装后的逻辑。

// src/background/index.ts
const action = chrome.action || chrome.browserAction; // 一些插件按钮以及别的浏览器相关上的 API,以后再用到
const runtime = chrome.runtime; // 运行时

const tabs = chrome.tabs; // 标签栏相关的工具

runtime.onInstalled.addListener(() => {
  tabs.create({});
});

然后尝试把插件卸载再安装。

image.png

这个逻辑好像又过于简单,那我们再实现一个功能:在 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>'],
  
  ...
};

然后必须回到浏览器扩展管理中重新加载,否则不能生效。

image.png

完成效果如下图

image.png

image.png

点击 SORT TABS 后,如图

image.png

如果希望点击插件的时候整理,也可以在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

后续可能还会继续开发其他插件。