skip to content
Honwhy Wang

将Web 项目打包成浏览器插件

/ 12 min read

将Web 项目打包成浏览器插件

最近重新思考如何将一个Web项目打包成浏览器插件,利用浏览器插件的options页面来作为Web项目的入口index.html。具体就是,如何不改动doocs/md 项目源码,仅通过WXT 框架及打包命令完成改造。这是一款微信 Markdown 编辑器在线项目,功能非常丰富,为了尽最大的可能迁移所有功能,改造过程比较曲折,这里将遇到的主要问题及解决方案记录如下,那就让我们开始吧!

项目初始化与配置

1.1 项目初始化

采用From Scratch 方式,为已有的项目增加wxt.config.ts 等配置,这一步可以参考官方指引

cd md
npm i -D wxt

为了插件能够基本启动起来,需要至少配置一个WXT 要求的入口点,那么可以学习官方的demo 做法,

// <srcDir>/entrypoints/background.ts
export default defineBackground(() => {
console.log('Hello world!');
});

WXT 的项目结构有点特殊,所以结合doocs/md 项目原有结构调整 wxt.config.ts 配置,主要是调整 srcDir publicDir base 等属性,

import { defineConfig } from 'wxt'
import ViteConfig from './vite.config'
export default defineConfig({
srcDir: `src`,
publicDir: `../public`,
extensionApi: `chrome`,
manifest: {
name: `公众号文章编辑器`,
version: `0.0.6`,
icons: {
256: `/mpmd/icon-256.png`,
},
permissions: [`storage`],
host_permissions: [],
web_accessible_resources: [
{
resources: [`*.png`, `*.svg`],
matches: [`<all_urls>`],
},
],
},
analysis: {
open: true,
},
vite: () => ({
...ViteConfig,
base: `/`,
}),
})

同时也可以看到,在 wxt.config.ts 可以沿用原来项目中的 vite.config.ts 配置。

1.2 配置增加入口点 (EntryPoint)

由于很难同时满足两种项目的结构形式,此时index.html 并不是 WXT 的一个入口点,所以需要动态方式配置,这一个环节需要参考 WXT 框架的 hooks 相关说明。 同时也要了解下 WXT Modules,到时候会用到。

解决办法如下,在 srcDir/modules目录下新增一个WXT Modules 定义,在WXT 解析完整个 wxt.config.ts 后会调用这个模块的方法。

// <srcDir>/modules/build-extension.ts
export default defineWxtModule({
async setup(wxt) {
wxt.config.alias[`/src/main.ts`] = `./src/main.ts`
wxt.config.manifest.options_page = `options.html`
wxt.hook(`entrypoints:grouped`, (_, groups) => {
groups.push([{
type: `options`,
name: `options`,
options: { openInTab: true },
inputPath: path.resolve(wxt.config.root, `./index.html`),
outputDir: wxt.config.outDir,
skipped: false,
}])
})
},
})

CDN资源本地化处理

由于原来项目中的Tex数学公式转svg、一键发布功能,引用了外部CDN js资源,而这个行为是被浏览器插件禁止的。其实包括内联的script内容也是不被允许的。

这一步我通过阅读 WXT 框架自己实现的 devHtmlPrerender vite 插件源码进行模仿解决的。从它的源码中可以找到相关介绍说明

// Replace inline script with virtual module served via dev server.
// Extension CSP blocks inline scripts, so that's why we're pulling them out.

2.1 将CDN JS转换为虚拟文件

这一步的做法是将 CDN js资源下载下来,保存到内存中,然后修改原始CDN js链接,改为引用请求虚拟文件地址。 参考:虚拟模块相关说明

大部分的代码思路是参考了 devHtmlPrerender 插件的。

// Stored outside the plugin to effect all instances of the htmlScriptToVirtual plugin.
const inlineScriptContents: Record<number, string> = {}
export function htmlScriptToVirtual(
config: wxt.ResolvedConfig,
getWxtDevServer: () => wxt.WxtDevServer | undefined,
): vite.PluginOption {
const virtualInlineScript = `virtual:md-inline-script`
const resolvedVirtualInlineScript = `\0${virtualInlineScript}`
const server = getWxtDevServer?.()
return [
{
name: `md:dev-html-prerender`,
apply: `build`,
async transform(code, id) {
if (
server == null
|| !id.endsWith(`.html`)
) {
return
}
const { document } = parseHTML(code)
// Replace inline script with virtual module served via dev server.
// Extension CSP blocks inline scripts, so that's why we're pulling them out.
const promises: Promise<void>[] = []
const inlineScripts = document.querySelectorAll(`script[src^=http]`)
inlineScripts.forEach(async (script) => {
promises.push(new Promise<void>((resolve) => {
const url = script.getAttribute(`src`) ?? ``
doFetch(url).then((textContent) => {
const hash = murmurHash(textContent)
inlineScriptContents[hash] = textContent
script.setAttribute(`src`, `${server.origin}/@id/${virtualInlineScript}?${hash}`)
if (script.hasAttribute(`id`)) {
script.setAttribute(`type`, `module`)
}
resolve()
})
}))
})
await Promise.all(promises)
const newHtml = document.toString()
config.logger.debug(`transform ${id}`)
config.logger.debug(`Old HTML:\n${code}`)
config.logger.debug(`New HTML:\n${newHtml}`)
return newHtml
},
},
{
name: `md:virtualize-react-refresh`,
apply: `serve`,
resolveId(id) {
// Resolve inline scripts
if (id.startsWith(virtualInlineScript)) {
return `\0${id}`
}
// Ignore chunks during HTML file pre-rendering
if (id.startsWith(`/chunks/`)) {
return `\0noop`
}
},
load(id) {
// Resolve virtualized inline scripts
if (id.startsWith(resolvedVirtualInlineScript)) {
// id="virtual:md-inline-script?<hash>"
const hash = Number(id.substring(id.indexOf(`?`) + 1))
return inlineScriptContents[hash]
}
// Ignore chunks during HTML file pre-rendering
if (id === `\0noop`) {
return ``
}
},
},
]
}

然后需要把这个插件引入到 wxt 中,同样是刚才的 build-extension.ts 模块加入这个插件,

addViteConfig(wxt, () => ({
plugins: [
htmlScriptToVirtual(wxt.config, () => wxt.server),
],
}))

2.2 解决Vue DevTools引用地址错误的问题

在此之前以为浏览器插件形式下是无法使用Vue DevTools 的,也无从下手解决这个问题,一般是屏蔽掉这个插件。

import vueDevTools from 'vite-plugin-vue-devtools'
export default defineConfig({
plugins: [
vueDevTools()
]
})

自从了解了vite虚拟模块虚拟文件后,观察Vue DevTools引入后在html中的引用形式,

<script type="module" src="/@id/virtual:vue-devtools-path:overlay.js"></script>
<script type="module" src="/@id/virtual:vue-inspector-path:load.js"></script>

解决方案就比较明显了,调整script引用链接,在url前加${server.origin} , 调整后html中的这两个脚本就是

<script type="module" src="http://localhost:3000/@id/virtual:vue-devtools-path:overlay.js"></script>
<script type="module" src="http://localhost:3000/@id/virtual:vue-inspector-path:load.js"></script>

这样子才能发起http请求,走vite server的虚拟模块匹配逻辑。要不然,其实请求的最终地址是,类似

chrome-extension://loflmllnppnghdegodmmlpdfkgjabkge/@id/virtual:vue-devtools-path:overlay.js

当然就无法处理的。

解决方案:用vite 插件做一个hack,

export function vueDevtoolsHack(
config: wxt.ResolvedConfig,
getWxtDevServer: () => wxt.WxtDevServer | undefined,
): vite.Plugin {
const server = getWxtDevServer?.()
return {
name: `md:vue-devtools-hack`,
apply: `build`,
transformIndexHtml: {
order: `post`,
handler(html) {
const { document } = parseHTML(html)
const inlineScripts = document.querySelectorAll(`script[src^='/@id/virtual:']`)
inlineScripts.forEach((script) => {
const src = script.getAttribute(`src`)
const newSrc = `${server?.origin}${src}`
script.setAttribute(`src`, newSrc)
})
const newHtml = document.toString()
config.logger.debug(`Old HTML:\n${html}`)
config.logger.debug(`New HTML:\n${newHtml}`)
return newHtml
},
},
}
}

相关讨论,how to use vue-devtools in chrome extension options page #727

打包构建方案

3.1 虚拟文件持久化

经过同事的提醒,刚开始的调研方向是将虚拟文件打包进最终的产物,但是忽略了dev和build模式的区别。

在dev模式下,html页面中的script引用地址被修改为http请求虚拟文件地址,但是build模式下并不会直接沿用dev的这个过程,而且进一步分析,build模式下,script引用地址也不应该修改成http地址形式,应该是本地地址或者相对地址。

3.2 将CDN JS转换为本地文件

经过这一番分析后,还是决定针对build模式再重新写一个vite插件来处理,同时一并考虑保存地址及html引用地址,插件如下,

export function htmlScriptToLocal(
wxt: wxt.Wxt,
): vite.Plugin {
return {
name: `md:build-html-prerender`,
apply: `build`,
transformIndexHtml: {
order: `pre`,
async handler(html) {
const { document } = parseHTML(html)
const promises: Promise<void>[] = []
const httpScripts = document.querySelectorAll(`script[src^=http]`)
if (httpScripts.length > 0) {
httpScripts.forEach(async (script) => {
/* eslint-disable no-async-promise-executor */
promises.push(new Promise<void>(async (resolve) => {
const url = script.getAttribute(`src`) ?? ``
if (url?.startsWith(`http://localhost`)) {
resolve()
return
}
const textContent = await doFetch(url)
const hash = murmurHash(textContent)
const jsName = url.match(/\/([^/]+)\.js$/)?.[1] ?? `.js`
const fileName = `${jsName.split(`.`)[0]}-${hash}.js`
// write to file
const outFile = path.resolve(wxt.config.outDir, `./${fileName}`)
await writeFile(outFile, textContent, `utf8`)
script.setAttribute(`src`, `/${fileName}`)
// script.setAttribute(`type`, `module`)
resolve()
}))
})
}
// Replace inline script with virtual module served via dev server.
// Extension CSP blocks inline scripts, so that's why we're pulling them
// out.
const inlineScripts = document.querySelectorAll(`script:not([src])`)
if (inlineScripts.length > 0) {
inlineScripts.forEach(async (script) => {
promises.push(new Promise<void>(async (resolve) => {
// Save the text content for later
const textContent = script.textContent ?? ``
const hash = murmurHash(textContent)
const fileName = `md-inline-${hash}.js`
// write to file
const outFile = path.resolve(wxt.config.outDir, `./${fileName}`)
await writeFile(outFile, textContent, `utf8`)
// Replace unsafe inline script
const virtualScript = document.createElement(`script`)
// virtualScript.type = `module`
virtualScript.src = `/${fileName}`
script.replaceWith(virtualScript)
resolve()
}),
)
})
}
await Promise.all(promises)
const newHtml = document.toString()
wxt.config.logger.debug(`Old HTML:\n${html}`)
wxt.config.logger.debug(`New HTML:\n${newHtml}`)
return newHtml
},
},
}
}

相关讨论:how to deal with cdn js files #1255

最终引入自实现的插件配置如下,

addViteConfig(wxt, () => ({
plugins: [
htmlScriptToVirtual(wxt.config, () => wxt.server),
vueDevtoolsHack(wxt.config, () => wxt.server),
wxt.config.command === `build`
? htmlScriptToLocal(wxt)
: undefined,
],
}))

解决打包体积过大问题

这是一个遗留了大半年的问题,通过vite等官方指引配置build?.rollupOptions.output.manualChunks 并没有效果,而且还可能导致build 出错。。 相关讨论file of zip output is too large that expend firefox 4MB limit #765

其中打包后单文件体积超过4MB的是options这个chunk,意思就是 option entrypoint 把所有依赖都打包一起了。 本次研究出来cdn js本地化的方案过程中,对WXT框架源码增加了不少认识,经过一番hacking,算是找到了改变打包配置的WXT hook。

解决方案,选择哪些依赖单独分包,可以通过npx wxt build --analyze命令得到报告。

wxt.hook(`vite:build:extendConfig`, (_, config) => {
if (config.build?.rollupOptions?.input && config.build?.rollupOptions?.input) {
const input = config.build?.rollupOptions.input as Record<string, string>
if (input.options) {
const output = config.build?.rollupOptions.output as FakeRollupOptions
output.manualChunks = (id) => {
if (id.includes(`prettier`)) {
return `prettier-chunk`
}
if (id.includes(`highlight.js`)) {
return `highlight-chunk`
}
}
}
}
})

之所以不能在全局配置manualChunks,是由于WXT是多模块的打包方式,在background这个entrypoint打包的时候配置分包方式会由于不支持而报错。而针对option entrypoint单独修改,在WXT调用vite#build方法前修改(请在WXT源码中搜索关键方法:buildEntrypoints),就可以顺利完成分包。分包的效果是options chunk主包体积减少,其他分包/chunks目录,被options chunk引入。

结语

本次的改造保留了原有项目100%的功能,非常不容易,主要的举措是动态配置Entrypoint入口文件,利用Vite插件处理cdn js资源,hack Vue DevTools插件地址引用错误问题。对于dev和build两种模式区别处理cdn js资源的做法或许可以合并成一种,有待后续研究改进。

这款插件的最大好处就是引入了公众号素材库(图床)功能,在撰写公众号文章的时候不用使用其他图床,而且通过openapi形式与公众号素材库接口打通,粘贴图片即可上传并使用,包括预览效果都是支持的。

相关使用教程,请参考公众号文章编辑器插件教程。新版本插件即将发布审核上架,敬请期待!