基于React、Antd、ts开发chrome拓展

1,418 阅读3分钟

基于React、Antd、Ts开发chrome拓展

官方API文档

API Reference - Chrome Developers

版本选择

  • V2 (将支持到2023年)

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e9f3e2b9dc344024abe6870f4514204e~tplv-k3u1fbpfcp-zoom-1.image

  • V3 (≥ chrome88)

优先选择mv3版本

核心部分

  • manifest.json
  • content
  • background
  • devtool-page
  • popup-page
  • options-page

项目配置

可以自定义配置或者通过CRA直接创建,原理一样

  • 创建项目
yarn create react-app my-app --template typescript

cd my-app

yarn add babel-plugin-import less less-loader customize-cra html-webpack-plugin react-app-rewired @types/chrome cross-env -D 

yarn add antd chrome-extension-core -S
  • 修改package.json配置
"scripts": {
    "start": "react-app-rewired start",
    "start:hot": "cross-env CHROME_HOT=true node ./script/start.js start",
    "start:disk": "cross-env CHROME_HOT=false WRITE_TO_DISK=true node ./script/start.js start"
 }
  • 部分目录结构(本质上就是一个多页打包)
├── script
│   ├── reload.js  // reload服务配置
│   ├── start.js  // 启动script
├── src
│   ├── reload // 开发节点reload插件
│   │   ├── background.ts
│   │   ├── event.ts
│   │   └── content.ts
│   ├── background   // background入口文件
│   │   └── index.ts
│   ├── event 
│   │   └── index.ts
│   ├── store
│   │   └── index.ts
│   ├── contentScript // contentScript 入口文件,将插入到页面dom结构里
│   │   ├── index.ts
│   │   └── visionContent.ts
│   ├── renderContent // 渲染content的react文件
│   │   ├── views
│   │   │   └── Home
│   │   │       └── index.tsx
│   │   └── index.tsx
│   └── pages  
│       ├── popup // popup-pages
│       │   ├── index.less
│       │   ├── App
│       │   │   └── index.tsx
│       │   └── index.tsx
│       └── options // options-pages
│           ├── index.css
│           ├── index.tsx
│           └── App
│               └── index.tsx
├── public 
│   ├── logo192.png
│   ├── options.html
│   ├── popup.html
│   └── manifest.json
  • manifest配置
{
  "action": {
    "default_icon": "icon192.png",
    "default_popup": "popup.html"
  },
  "background": {
    "service_worker": "background.js"
  },
  "description": "chromeExtensionsTemplate",
  "host_permissions": ["<all_urls>"],
  "manifest_version": 3,
  "name": "chromeExtensionsTemplate",
  "offline_enabled": false,
  "options_page": "options.html",
  "permissions": [
    "tabs",
    "activeTab",
    "storage",
    "scripting"
  ],
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "css": [],
      "js": ["visionContentScript.js"],
      "run_at": "document_end"
    }
  ],
  "version": "0.0.1",
  "web_accessible_resources": [
    {
      "matches": ["<all_urls>"],
      "resources": []
    }
  ]
}
  • webpack部分配置(通过customize-cra进行修改) 完整配置
// 检测是否热更新插件
const isHotReloadChrome = process.env.CHROME_HOT === 'true';
// 是否仅写入硬盘
const writeToDisk = process.env.WRITE_TO_DISK === 'true';

const removePlugin = (plugins, name) => {
  const list = plugins.filter(
    (it) => !(it.constructor && it.constructor.name && name === it.constructor.name),
  );
  if (list.length === plugins.length) {
    throw new Error(`Can not found plugin: ${name}.`);
  }
  return list;
};

const getWebpackConfig = (webpackEnv) => {
  const isEnvProduction = webpackEnv === 'production';
  const htmlWebpackPluginConfig = isEnvProduction
    ? {
      minify: {
        removeComments: true,
        collapseWhitespace: true,
        removeRedundantAttributes: true,
        useShortDoctype: true,
        removeEmptyAttributes: true,
        removeStyleLinkTypeAttributes: true,
        keepClosingSlash: true,
        minifyJS: true,
        minifyCSS: true,
        minifyURLs: true,
      },
    }
    : {};

  return [
    addWebpackPlugin(
      new HtmlWebpackPlugin({
        template: path.resolve(__dirname, 'public/popup.html'),
        chunks: ['popup'],
        filename: `popup.html`,
        ...htmlWebpackPluginConfig,
      }),
    ),
    addWebpackPlugin(
      new HtmlWebpackPlugin({
        template: path.resolve(__dirname, 'public/options.html'),
        chunks: ['options'],
        filename: `options.html`,
        ...htmlWebpackPluginConfig,
      }),
    ),
  ].filter(Boolean);
};

module.exports = {
  webpack: (config, webpackEnv) => {
		// 多入口
    config.entry = {
      popup: './src/pages/popup/index.tsx',
      options: './src/pages/options/index.tsx',
      background: [
        './src/background/index.ts',
        isHotReloadChrome && './src/reload/background.ts',
      ].filter(Boolean),
      visionContentScript: [
        './src/contentScript/index.ts',
        isHotReloadChrome && './src/reload/content.ts',
      ].filter(Boolean),
    };
    config.output = {
      filename: '[name].js',
      path: __dirname + '/build',
      publicPath: './',
    };

    // 删除这几个默认插件
    config.resolve.plugins = removePlugin(config.resolve.plugins, 'ModuleScopePlugin');
    config.plugins = removePlugin(config.plugins, 'WebpackManifestPlugin');
    config.plugins = removePlugin(config.plugins, 'HtmlWebpackPlugin');

    return override(...getWebpackConfig(webpackEnv))(config, webpackEnv);
  },
  devServer: overrideDevServer((config) => {
    return {
      ...config,
      devMiddleware: {
        ...config.devMiddleware,
        writeToDisk: writeToDisk || isHotReloadChrome || false, // 开发阶段实时生成文件
      },
      // 获取devServer,配置热更新
      onBeforeSetupMiddleware: (devServer) => {
        isHotReloadChrome && reload(devServer);
        config.onBeforeSetupMiddleware(devServer);
      },
    };
  }),
  paths: function (paths, env) {
    paths.appIndexJs = `${basePaths.appSrc}/pages/popup/index.tsx`;
    paths.appHtml = `${basePaths.appPath}/public/popup.html`;
    return paths;
  },
};

热更新配置

对于popup、options、devtool静态页面来说,只需要配置webpack将开发阶段生成的文件写入磁盘即可。对于background来说则需要重新reload一遍才能生效,chrome正好提供了API[chrome.runtime.reload]可以使background重新reload。

  • reload-server

本质就是通过启动的devServer配置一个长链接接口,SSE或者WS都可以,这里使用的是SSE,在webpack每次打包结束后hooks的done事件时发送消息到客户端

function ReloadServer({ app, compiler }) {
  app.get('/reload', (request, response, next) => {
    response.setHeader('Content-Type', 'text/event-stream');
    response.setHeader('Connection', 'keep-alive');
    response.setHeader('Cache-Control', 'no-cache');
    response.setHeader('Access-Control-Allow-Origin', '*');
    response.writeHead(200);

    let isEnd = false;
    compiler.hooks.done.tap('chrome reload plugin', () => {
      if (isEnd) return;
      response.flushHeaders();
      const data = `data: ${JSON.stringify({ time: new Date() })}\n\n`;
      response.write(data);
      response.end();
      isEnd = true;
    });

    request.on('close', () => {
      isEnd = true;
      response.end();
    });
  });
}

module.exports = ReloadServer;
  • reload-client

background接收到更新消息之后再通过chrome.runtime.sendMessage发送消息到content,这里通过封装好的事件包 www.npmjs.com/package/chr…

// /reload/background.ts
import { debounce } from '@/common/utils/function';
import { getTab } from 'chrome-extension-core';
import { hotReloadEvent } from './event';

const { DEV_PORT } = devConfig || {};
if (DEV_PORT) {
// 添加ssh长链接
  const eventSource = new EventSource(`http://127.0.0.1:${DEV_PORT}/reload/`);
  const onMessage = async () => {
    const tabInfo = await getTab({ active: true });
    
    // 发送消息给当前激活中的content,content进行reload
    hotReloadEvent.emit('chromeHotReload', true, { type: 'tab', id: tabInfo?.id }).finally(() => {
      chrome.runtime?.reload?.();
    });
  };
  eventSource.onmessage = debounce(onMessage, 1000);
}

// /reload/content.ts
import { hotReloadEvent } from './event';
const { DEV_PORT } = devConfig || {};

hotReloadEvent.on('chromeHotReload', () => {
  setTimeout(() => {
    // 延迟500ms,避免后台更新时前台也刷新,开启header劫持时会出现crash
    window.location.reload();
  }, 500);
});

Content渲染

  • renderContent

透出mount和unmount方法,可以在contentScript中控制何时渲染

import React from 'react';
import ReactDOM from 'react-dom';
import Home from '@/renderContent/views/Home';

type Props = {
  container?: Element | null;
};
const render = (props: Props) => {
  const { container } = props;
  if (container) {
    ReactDOM.render(
      <Home></Home>,
      container,
    );
  }
};

export async function mount(props: Props) {
  render(props);
}

export async function unmount(props: Props) {
  const { container } = props;
  container && ReactDOM.unmountComponentAtNode(container);
}
  • contentScript

自定义决定何时渲染和卸载,demo是由popup控制

import { mount, unmount } from '@/renderContent';
class Content {
  container: HTMLDivElement | undefined;
  id: string;
  constructor() {
    this.id = `chrome-extensions-template-${VERSION.split('.')?.join?.('-')}`;
  }
  create() {
    if (!this.container && !this.getContainer()) {
      this.container = document.createElement('div');
      this.container.id = this.id;
      document.body.appendChild(this.container);
      mount({ container: this.container });
    }
  }

  destroy() {
    const container = this.container || this.getContainer();
    if (container) {
      unmount({ container });
      this.container?.parentNode?.removeChild(container);
      this.container = undefined;
    }
  }

  private getContainer() {
    return document.querySelector(`#${this.id}`);
  }
}

function listener(content: Content) {
  chromeEvent.on('createContent', (needSet) => {
    if (needSet) {
      content.create();
    } else {
      content.destroy();
    }
  });
}

const content = new Content();
listener(content);

样式隔离

content将会直接插入到dom结构中,必然会出现样式污染,使用styleModule也无法解决组件库的样式污染。

引入antd组件会插入影响全局样式的style,可能会导致样式错乱。

Untitled.png 极端情况可能会出现,整个背景被影响

Untitled 1.png

隔离样式主要有两个方案:

  1. iframe隔离,chrome提供了API可以直接嵌入iframe page,或者也可以手动写入iframe节点来完成样式隔离。

    优点:完全隔离,兼容性较好

    缺点:受限于iframe的视窗限制,较难实现message或者dialog等弹窗。

  2. shadowDom隔离

    优点:样式隔离,没有视窗限制,和正常dom一样

    缺点:某些富文本无法使用,需要支持shadowDom的富文本。css需要额外处理插入到shadom节点里。

以下是基于shadowDom的隔离方案

  1. 配置less-loader

shadowDom需要把css嵌入shadow节点里,故此需要获取到css

Untitled 2.png

// 复制addLessLoader进行修改
// 添加resourceQuery
resourceQuery: /toString/,

// 针对toString类型的less去除style-loader 
const loaders = [
        needStyleLoader && isEnvDevelopment && require.resolve('style-loader'),
        needStyleLoader &&
          isEnvProduction && {
            loader: MiniCssExtractPlugin.loader,
            options: shouldUseRelativeAssetPaths ? { publicPath: '../../' } : {},
          }
]

全部代码参考:

chrome-extensions-template/addLessLoader.js at main · crywolfx/chrome-extensions-template

  1. 安装react-shadow
yarn add react-shadow -S
  1. 配置Style组件
export default function Style(props: { styles: any[] }) {
  return (
    <>
      {props.styles?.map?.((item, key) => {
        return item ? (
          // eslint-disable-next-line react/no-array-index-key
          <style type="text/css" key={key}>
            {item?.toString?.()}
          </style>
        ) : null;
      })}
    </>
  );
}
  1. 配置Shadow组件
import type { ReactNode } from 'react';
import { useEffect, useRef, useMemo } from 'react';
import root from 'react-shadow';
import Style from '@/components/Shadow/Style';

export default function ShadowRoot(props: {
  id: string;
  antdComponentUsed?: string[];  // 传入用到的antd组件名,用于动态引入组件css
  css?: string; // 自定义css
  children?: ReactNode;
  mode?: 'open' | 'closed';
  onCreated?: (s?: ShadowRoot | null) => void;
}) {
  const { antdComponentUsed = [], css, mode = 'open', onCreated } = props;
  const antdCss = useMemo(
    () =>
      antdComponentUsed.map(
        (name) =>
          require('antd/es/' + name.toLocaleLowerCase() + '/style/index.less?toString')?.default,
      ),
    [antdComponentUsed],
  );
  // 额外引入组件的动画样式
  let animate = '';
  if (antdCss.length) {
    animate = require('antd/es/style/index.less?toString')?.default;
  }

  const ref = useRef<HTMLElement>(null);
  const onCreatedRef = useRef(onCreated);
  useEffect(() => {
    onCreatedRef.current = onCreated;
  }, [onCreated]);

  useEffect(() => {
    const shadowRoot = ref.current?.shadowRoot;
    onCreatedRef.current?.(shadowRoot);
  }, []);

  return (
    <root.div ref={ref} id={props.id} mode={mode}>
      <Style styles={[animate, ...antdCss, css]} />
      {props.children}
    </root.div>
  );
}
  1. 外层组件
// Root.tsx
import ShadowRoot from '@/components/Shadow';
import css from '@/renderContent/style/root.less?toString';
import Home from '@/renderContent/views/Home';

export default function Root () {
  return (
    <ShadowRoot
      id="chromeExtensionTemplateRoot"
      css={css}
      antdComponentUsed={[
        'button',
      ]}
      mode="open"
    >
      <Home />
    </ShadowRoot>
  )
}
  1. css文件
// root.less
@import url('@/renderContent/views/Home/index.less');

完整仓库地址:

github.com/crywolfx/ch…