likes
comments
collection
share

Vue3 开发 Chrome 插件,实用小技巧

作者站长头像
站长
· 阅读数 12

本文不介绍chrome 插件开发的基础知识,推荐参考:

chrome 插件开发指南

chrome 插件开发指南(Manifest V3)

【干货】Chrome插件(扩展)开发全攻略 (Manifest V2)

开发插件,我们可以采用原生的js,但是开发效率很低,所以还是推荐使用前端框架来配合。我们用的是anfu 大佬搭建一套基础框架(Vite + Vue3 + ts + unocss) github.com/antfu/vites…

插入插件dom节点

在实际开发中,我们需要在页面的某一个或者多个位置插入一个dom节点,甚至需要插入的位置,是需要用户做一些特定操作(click, hover...)才会出现。

// src/test/index.ts

import Test from './test.vue';

const mountApp = (elm: Element, props: any) => {
    
    const root = document.createElement('div')
    root.id = 'test-btn'
    
    const app = createApp(Test, {
        ...props,
        xxx
    })
    app.mount(root);
    
    elm.appendChild(container)
} 

我们定义一个方法,就可以通过传入一个挂载点elm挂载到不同的位置,还可以传入props实现不同位置可以能需要不同的操作。

对于一些隐藏的位置,我们就通过MutationObserver监听整个body来实现插入。

// src/test/index.ts
const observer = new MutationObserver(() => {
    const mountContainer = document.querSelectorAll('挂载点');
    Array.from(mountContainer).forEach(elm => {
        if(elm.querySelector('test-btn')) return;
        mountApp(elm)
    })
})

observer.observe(document.body, {
    childList: true,
    subtree:true
})

这里挂载点的查找,只能通过审查目标网页的具体元素,情况各异,不一定就找到父节点,有可能是插入兄弟节点等等...,但有一点是都需要注意的就是判断是否已经存在了,你要加入的节点,因为MutationObserver 监听 body 大概率都会让这个方法执行多次,避免重复渲染。

避免dom节点被删除

使用 MutationObserver监听,然后挂载渲染,还有一个好处是,当插件dom节点被删除时,会再次渲染,和网页水印的原理是一样的。所以需要这种业务需求的场景,即使可以直接找到挂载点的情况,我们也可以先嵌套一层MutationObserver

const mountApp = (elm: Element, props: any) => {
    new MutationObser(() => {
        if(!document.querSelector('#test-btn')) { // 避免重复渲染
            const root = document.createElement('div')
            root.id = 'test-btn'

            const app = createApp(Test, {
                ...props,
                xxx
            })
            app.mount(root);

            elm.appendChild(container)
        }
    }).observe(document.body, {
        childList: true,
        subtree: true
    })
} 

全局注入 v-drag

因为是使用vue框架,我们可以全局注入一些方法,指令,过滤器、全球化等等。

// src/logic/common-setup.ts

import type { App } from 'vue';

export function setupApp(app: App) {
  // Inject a globally available `$app` object in template
  app.config.globalProperties.$app = {
    context: ''
  };

  app.directive('drag', {
    mounted(el: HTMLElement, binding: { value: { axis: string; handle: string; boundary: string; clickHandle: () => void; };}) {
      let startX = 0, startY = 0, initStartX = 0, initStartY = 0;
    
      const { axis, handle, boundary, clickHandle } = binding.value;
    
      const dragHandle = (handle ? el.querySelector(handle) : el) as HTMLElement;
      const boundaryEl = boundary === 'body' ? document.body : document.querySelector(boundary) || null;
    
      dragHandle.style.cursor = 'grab';
    
      const dragMouseDown = (e: MouseEvent) => {
        e.preventDefault();
        startX = e.clientX;
        startY = e.clientY;
        initStartX = e.clientX;
        initStartY = e.clientY;
        dragHandle.style.cursor = 'grabbing';
        document.addEventListener('mousemove', elementDrag);
        document.addEventListener('mouseup', stopDragging);
      };
    
      const elementDrag = (e: MouseEvent) => {
        e.preventDefault();
        const offsetX = startX - e.clientX;
        const offsetY = startY - e.clientY;
        startX = e.clientX;
        startY = e.clientY;
        let boundaryRect = boundaryEl ? boundary === 'body' ? {
          bottom: window.innerHeight,
          right: window.innerWidth,
          left: 0,
          top: 0
        } : boundaryEl.getBoundingClientRect() : null;

        if (axis === 'x') {
          if (boundaryRect) {
            const newRight = Math.max(
              boundaryRect.left + el.offsetWidth,
              Math.min(boundaryRect.right, el.offsetLeft + el.offsetWidth - offsetX)
            );
            el.style.right = boundaryRect.right - newRight + 'px';
          } else {
            el.style.right = el.offsetLeft + el.offsetWidth - offsetX + 'px';
          }
        } else if (axis === 'y') {
          if (boundaryRect) {
            const newBottom = Math.max(
              boundaryRect.top + el.offsetHeight,
              Math.min(boundaryRect.bottom, el.offsetTop + el.offsetHeight - offsetY)
            );
            el.style.bottom = boundaryRect.bottom - newBottom + 'px';
          } else {
            el.style.bottom = el.offsetTop + el.offsetHeight - offsetY + 'px';
          }
        } else {
          if (boundaryRect) {
            const newRight = Math.max(
              boundaryRect.left + el.offsetWidth,
              Math.min(boundaryRect.right, el.offsetLeft + el.offsetWidth - offsetX)
            );
            const newBottom = Math.max(
              boundaryRect.top + el.offsetHeight,
              Math.min(boundaryRect.bottom, el.offsetTop + el.offsetHeight - offsetY)
            );
            el.style.right = boundaryRect.right - newRight + 'px';
            el.style.bottom = boundaryRect.bottom - newBottom + 'px';
          } else {
            el.style.right = el.offsetLeft + el.offsetWidth - offsetX + 'px';
            el.style.bottom = el.offsetTop + el.offsetHeight - offsetY + 'px';
          }
        }
      };
    
      const stopDragging = (e: any) => {
        dragHandle.style.cursor = 'grab';
        document.removeEventListener('mousemove', elementDrag);
        document.removeEventListener('mouseup', stopDragging);
        
        if (Math.abs(e.clientY - initStartY) <=3 && Math.abs(e.clientX - initStartX) <=3){
          clickHandle && clickHandle();
        }
      };
    
      dragHandle.addEventListener('mousedown', dragMouseDown);
    
      // 清理事件监听
      const cleanup = () => {
        dragHandle.removeEventListener('mousedown', dragMouseDown);
      };
    
      // 返回清理函数
      return cleanup;
    }
    
  });
  // Provide access to `app` in script setup with `const app = inject('app')`
  app.provide('app', app.config.globalProperties.$app);

  // Here you can install additional plugins for all contexts: popup, options page and content-script.
  // example: app.use(i18n)
  // example excluding content-script context: if (context !== 'content-script') app.use(i18n)
}
// src/test/index.ts

import Test from './test.vue';
import { setupApp } from '~/logic/common-setup';

const mountApp = (elm: Element, props: any) => {
    
    const root = document.createElement('div')
    root.id = 'test-btn'
    
    const app = createApp(Test, {
        ...props,
        xxx
    })
    app.mount(root);
    setupApp(app); // 新增
    elm.appendChild(container)
} 

webComponent 实现隔离

对于我们直接把节点挂载到页面上,难免会被目标网页的全局样式影响,所以我们可以采用webComponent 实现隔离。 关于webComponent 的入门,可以参考阮一峰老师的Web Components 入门实例教程.

export function createShadowRoot(tagName: string, styleUrl: string) {
  const container = document.createElement(tagName || 'div');
  const shadowDOM = container.attachShadow?.({ mode: 'open' }) || container;
  const root = document.createElement('div');
  const styleEl = document.createElement('link');
  const darkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
  if (darkMode) {
    root.classList.add('dark');
  }
  styleEl.setAttribute('rel', 'stylesheet');
  styleEl.setAttribute('href', browser.runtime.getURL(styleUrl));
  shadowDOM.appendChild(styleEl);
  shadowDOM.appendChild(root);
  return { container, root };
}
  const mountApp = (elm:Element, options?: any) => {
    const { container, root } = createShadowRoot('test-btn', 'dist/test/style.css');

    const app = createApp(App, {
      ...options,
    });
    setupApp(app);
    app.mount(root);
    elm.appendChild(container);
};

这里有几个注意事项:

  • 通过vite 打包我们是把css单独提取出来的,所以需要把css引入到webCompoent中。

如果不提取css,直接打入js中,它也是通过create style标签插入head标签中,但是webCompoent不受全局样式影响,所以我们需要单独处理。

  • 需要containerroot两标签

我们需要container和root两个标签,因为如果只有root,在 moutApp时,root会被重新覆盖,style标签就没了。

Css 加载

我们这样做,做了样式隔离,但也是动态加载css样式,所以在网速不稳定时,页面元素也闪一下,让用户看到最原始的dom节点样式,体验不好,所以这部分得优化。

方案:我们手动加载css样式,把css内容直接写入style,然后再渲染

export const createShadowRoot = async (tagName: string, styleUrl: string) => {
    const container = document.createElement(tagName || 'div');
    const shadowDOM = container.attachShadow?.({ mode: 'open' }) || container;
    const root = document.createElement('div');
    const darkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
    if (darkMode) {
      root.classList.add('dark');
    }
    const styleEl = document.createElement('style');
    const cssUrl = browser.runtime.getURL(styleUrl); // 获取css 链接
    const cssContent = await (await fetch(cssUrl)).text();  // 获取css 具体文本内容
    styleEl.textContent = cssContent;
    shadowDOM.appendChild(styleEl);
    shadowDOM.appendChild(root);
    return { container, root };
  };
});

缓存css

这样虽然渲染延迟了一点,但是节点的样式不会闪烁,如果我们再加上一个样式的缓存,在使用过程中的多次渲染,就又快又好了。


export const createShadowRoot = (() => {
  const cacheCss = new Map<string, string>();
  return async (tagName: string, styleUrl: string) => {
    const container = document.createElement(tagName || 'div');
    const shadowDOM = container.attachShadow?.({ mode: 'open' }) || container;
    const root = document.createElement('div');
    const darkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
    if (darkMode) {
      root.classList.add('dark');
    }
    const styleEl = document.createElement('style');
    let cssContent = '';
    if (cacheCss.has(styleUrl)) {
      cssContent = cacheCss.get(styleUrl) || '';
    } else {
      const cssUrl = browser.runtime.getURL(styleUrl);
      cssContent = await (await fetch(cssUrl)).text();  
      cacheCss.set(styleUrl, cssContent);
    }
    styleEl.textContent = cssContent;
    shadowDOM.appendChild(styleEl);
    shadowDOM.appendChild(root);
    return { container, root };
  };
})();

最后

我们开发的插件是一款基于chatgpt的工具插件,可以快速写邮件,总结网页内容,快速发推特,总结油管视频内容,gpt3.5聊天等功能,后面还会上gpt4,联网等功能。 欢迎尝试体验 ArvinVue3 开发 Chrome 插件,实用小技巧