Chrome插件迁移至Manifest V3

Chrome插件商店即将全面停止对 manifest v2版本清单的支持,也就促使开发者们将逐渐将插件向MV3迁移。但迁移中会遇到的一些问题,比如清单格式的改变,由数组变成对象,或者由对象变成数组,不过这些问题也只是算格式上的不兼容而已,很好修改。而一些大麻烦比如MV3中CSP的策略更加严格,不仅限制了脚本注入,更是完全禁止使用eval(),new Function()之类的动态函数生成。这就导致一些框架如Vue等不再正常工作了。

让注入的脚本(unsafe-inline)重新工作

我在content.js中生成了一个script标签,并手动将其注入到页面中,如下:

// content.js
const content = "alert('injected script')"
const script = document.createElement('script')
script.innerHTML += content
document.body.appendChild(script)

作为结果,你会发现:

对于这种情况,我们只需要将上面的js代码单独写到一个文件中,并在清单中声明此文件,方便Google进行代码审查,这样就满足MV3的要求了

// inject.js
alert('injected script')
// content.js
const script = document.createElement('script')
script.setAttribute('src', chrome.runtime.getURL('inject.js'))
document.body.appendChild(script)
// manifest.json
{
  "manifest_version": 3,
  ...
  "web_accessible_resources": [
    {
      "resources": ["inject.js"],
      "matches": ["http://a.com/*", "https://a.com/*"]
    }
  ]
  ...
}

使用沙盒(sandbox)让Vue重新工作

由于Vue使用了 New Function(),被CSP策略禁止,要么放弃使用动态代码,要么使用沙盒把Vue和插件隔离。

使用沙盒的好处是很明显的,比如原先使用Vue写的页面不需要修改就可继续使用,减少迁移的工作量。但是坏处也很明显,比如说在沙盒中无法调用插件相关的api,但其实这个问题也非常好解决,我们只需要在原来的插件环境和隔离出的沙盒环境中,添加一个数据交换的中间层就可以了。

<!-- sandbox.html -->
...
<head>
    ...
    <script src="scripts/vue-dev.js"></script>
</head>
<body>
    <div id="app">
        ...
    </div>
    <script src="scripts/sandbox.js"></script>
</body>
...
<!-- warpper.html -->
...
<body>
    <iframe id="sandbox" src="sandbox.html" frameborder="0"> </iframe>
    <script src="scripts/wrapper.js"></script>
</body>
// manifest.json
{
  "manifest_version": 3,
  ...
  "sandbox": {
    "pages": [
      "sandbox.html"
    ]
  }
  ...
}

接下来,我们在需要添加一个中间层,让 sandbox.html 和 wrapper.html 之间通信

沙盒和父级通信

// sandbox.js


// 接收数据
window.addEventListener('message', function (event) {
    const data = event.data
    console.log(data)
})

// 发送数据
window.top.postMessage({ ... }, '*')

父级和沙盒通信

// wrapper.js


//接收数据
window.addEventListener('message',  function (e) {
    const data = e.data
    console.log(data)
}

// 发送数据
const f = document.querySelector('#sandbox') as HTMLIFrameElement
f.contentWindow.postMessage({ ... }, '*')

由于发送和接收数据是异步进行的,所以我们要对上面的方法按照发布-订阅的设计模式进行封装,以便让业务代码不需要大幅度修改即可使用

譬如在MV2中的代码:

// sandbox.js

async mounted() {
    const manifestData = chrome.runtime.getManifest()
    ...
}

对象chrome 为 undefined,要使用此对象,只能在沙盒外调用,修改上面的代码为:

// sandbox.js


class MessageHub {
    static map = {} as { [key: number]: (data?: MessageHubData) => void }

    static seed = 0

    static getMsgId() {
        if (this.seed > 1000 * 1000) {
            this.seed = 0
        }
        this.seed++
        return this.seed
    }

    static sendMessage(id: number, message: { [key: string]: any }, fn?: (data?: MessageHubData) => void) {
        if (id in this.map) {
            throw Error('MessageHub id conflict')
        }
        this.map[id] = fn
        window.top.postMessage({ ...message, id }, '*')
    }

    static {
        window.addEventListener('message', function (event) {
            const data = event.data as MessageHubData
            const fn = MessageHub.map[data.id]
            if (fn) {
                fn(data)
            }
            delete MessageHub.map[data.id]
        })
    }
}

function getManifestData() {
    return new Promise((resolve, reject) => {
        MessageHub.sendMessage(MessageHub.getMsgId(), { fn: 'getManifestData' }, (data) => {
            resolve(data.data)
        })
    })
}

new Vue({
    ...
    async mounted() {
        const manifestData = await getManifestData() as chrome.runtime.Manifest
        ...
    }
})

// warpper.js


const f = document.querySelector('#sandbox') as HTMLIFrameElement

window.addEventListener('message', async function (e) {
    const data = e.data
    if (!data.fn) return

    switch (data.fn) {
        case 'getManifestData':
            f.contentWindow.postMessage({ data: chrome.runtime.getManifest(), id: data.id }, '*')
            break
        ...
        default:
            f.contentWindow.postMessage({ id: data.id }, '*')
            break
    }
})

如此,利用一个订阅–发布的中间件,我们不需要对代码进行大幅度修改,即可将插件迁移至MV3