Vue3 开发 Chrome 插件,实用小技巧
本文不介绍chrome 插件开发的基础知识,推荐参考:
【干货】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
不受全局样式影响,所以我们需要单独处理。
- 需要
container
和root
两标签
我们需要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,联网等功能。
欢迎尝试体验 Arvin!
转载自:https://juejin.cn/post/7252283213187104825