安裝量終於破千了!聊聊瀏覽器擴充套件開發的相關問題與解決方案

WindrunnerMax發表於2024-07-22

瀏覽器擴充套件開發的相關問題與解決方案

我開發的瀏覽器擴充套件安裝量終於過千了!在 Firefox AddOns 已經有2.1k+安裝,在 Chrome WebStore 已經有2k+安裝。實際上在Firefox的擴充套件市場裡是周平均安裝量,當天的實際安裝量要高出平均值不少,而Chrome的擴充套件市場在超過1k安裝量之後就不精確顯示安裝量了,實際安裝量也會高於1k

實際上在我做擴充套件之前,我是實現了指令碼來處理相關的功能的,指令碼在 GreasyFork 上有 2688k+安裝量,而實現擴充套件的主要原因有兩個: 一個原因是我也想學習一下擴充套件的開發,我發現在工作中真的會有應用場景,特別是要突破瀏覽器限制做一些特殊工作的情況下;另一個原因是我發現有將我打包釋出在GreasyFork上的GPL協議的程式碼直接封裝成外掛並加入了廣告,就這竟然還有400k+安裝量。

因此我也基於指令碼能力實現了瀏覽器擴充,並且是主要為了學習的情況下,我從零搭建了整個開發環境,也在處理了很多相容性方案,接下來我們就來聊聊相關問題與解決方案。專案地址 GitHub ,如果覺得不錯,點個star吧 😁 。

擴充套件打包方案

我們在先前提到了在這裡是從零搭建的開發環境,那麼我們就需要挑選一個擴充套件打包工具,在這裡我選用的是rspack,當然如果我們使用webpack或者是rollup都是沒問題的,只是用rspack比較熟悉且打包速度比較快,無論是哪種打包器都是類似的配置。此外,在這裡實際上我們是使用的build級別的打包方式,類似於devserver的方案在v3中目前並不太適用。

那麼需要注意的是,在瀏覽器擴充套件中我們需要定義多個入口檔案,並且需要單檔案的打包方案,不能出現單入口多個chunk,包括CSS我們也需要打包為單入口單輸出,並且輸出的檔名也不要帶hash的字尾,防止檔案找不到的情況。不過這並不是什麼比較大的問題,在配置檔案中注意即可。

module.exports = {
  context: __dirname,
  entry: {
    popup: "./src/popup/index.tsx",
    content: "./src/content/index.ts",
    worker: "./src/worker/index.ts",
    [INJECT_FILE]: "./src/inject/index.ts",
  },
  // ...
  output: {
    publicPath: "/",
    filename: "[name].js",
    path: path.resolve(__dirname, folder),
  },
}

在這裡可以發現INJECT_FILE的輸出檔名是個動態的,在這裡由於inject指令碼是需要注入到瀏覽器頁面上的,由於注入方案的關係,在瀏覽器頁面上就可能發生衝突,所以在這裡我們每次build生成的檔名都是不一致的,在每次釋出後檔名都會更改,包括模擬的事件通訊方案也是一致隨機名。

const EVENT_TYPE = isDev ? "EVENT_TYPE" : getUniqueId();
const INJECT_FILE = isDev ? "INJECT_FILE" : getUniqueId();

process.env.EVENT_TYPE = EVENT_TYPE;
process.env.INJECT_FILE = INJECT_FILE;
// ...

module.exports = {
  context: __dirname,
  builtins: {
    define: {
      "__DEV__": JSON.stringify(isDev),
      "process.env.EVENT_TYPE": JSON.stringify(process.env.EVENT_TYPE),
      "process.env.INJECT_FILE": JSON.stringify(process.env.INJECT_FILE),
      // ...
    }
  }
  // ...
}

Chrome與Firefox相容

Chrome一直在強推擴充套件的V3版本,也就是manifest_version需要標記為3,而在Firefox中提交manifest_version: 3的版本會得到不建議使用的提示。實際上對於個人而言我也不喜歡使用v3版本,限制特別多,很多功能都沒有辦法正常實現,這點我們後邊再聊。那麼既然Chrome強制性用v3Firefox推薦用v2,那麼我們就需要分別在Chromium核心和Gecko核心中實現相容方案。

實際上我們可以發現這是不是很像多端構建的場景,也就是我們需要將同一份程式碼在多個平臺打包。那麼在處理一些跨平臺的編譯問題時,我最常用的的方法就是process.env__DEV__,但是在用多了之後發現,在這種類似於條件編譯的情況下,大量使用process.env.PLATFORM === xxx很容易出現深層次巢狀的問題,可讀性會變得很差,畢竟我們的Promise就是為了解決非同步回撥的巢狀地獄的問題,如果我們因為需要跨平臺編譯而繼續引入巢狀問題的話,總感覺並不是一個好的解決方案。

C/C++中有一個非常有意思的前處理器,C Preprocessor不是編譯器的組成部分,但其是編譯過程中一個單獨的步驟,簡單來說C Preprocessor相當於是一個文字替換工具,例如不加入識別符號的宏引數等都是原始文字直接替換,可以指示編譯器在實際編譯之前完成所需的預處理。#include#define#ifdef等等都屬於C Preprocessor的前處理器指令,在這裡我們主要關注條件編譯的部分,也就是#if#endif#ifdef#endif#ifndef#endif等條件編譯指令。

#if VERBOSE >= 2
  print("trace message");
#endif

#ifdef __unix__ /* __unix__ is usually defined by compilers targeting Unix systems */
# include <unistd.h>
#elif defined _WIN32 /* _WIN32 is usually defined by compilers targeting 32 or 64 bit Windows systems */
# include <windows.h>
#endif

那麼我們同樣也可以將類似的方式藉助構建工具來實現,首先C Preprocessor是一個預處理工具,不參與實際的編譯時的行為,那麼是不是就很像webpack中的loader,而原始文字的直接替換我們在loader中也是完全可以做到的,而類似於#ifdef#endif我們可以透過註釋的形式來實現,這樣就可以避免深層次的巢狀問題,而字串替換的相關邏輯是可以直接修改原來來處理,例如不符合平臺條件的就可以移除掉,符合平臺條件的就可以保留下來,這樣就可以實現類似於#ifdef#endif的效果了。此外,透過註釋來實現對某些複雜場景還是有幫助的,例如我就遇到過比較複雜的SDK打包場景,對內與對外以及對本體專案平臺的行為都是不一致的,如果在不構建多個包的情況下,跨平臺就需要使用者自己來配置構建工具,而使用註釋可以在不配置loader的情況下同樣能夠完整打包,在某些情況下可以避免使用者需要改動自己的配置,當然這種情況還是比較深地耦合在業務場景的,只是提供一種情況的參考。

// #IFDEF CHROMIUM
console.log("IS IN CHROMIUM");
// #ENDIF

// #IFDEF GECKO
console.log("IS IN GECKO");
// #ENDIF

最開始的時候我想使用正則的方式直接進行處理的,但是發現處理起來比較麻煩,尤其是存在巢狀的情況下,就不太容易處理邏輯,那麼再後來我想反正程式碼都是 一行一行的邏輯,按行處理的方式才是最方便的,特別是在處理的過程中因為本身就是註釋,最終都是要刪除的,即使存在縮排的情況直接去掉前後的空白就能直接匹配標記進行處理了。這樣思路就變的簡單了很多,預處理指令起始#IFDEF只會置true,預處理指令結束#ENDIF只會置false,而我們的最終目標實際上就是刪除程式碼,所以將不符合條件判斷的程式碼行返回空白即可,但是處理巢狀的時候還是需要注意一下,我們需要一個棧來記錄當前的處理預處理指令起始#IFDEF的索引即進棧,當遇到#ENDIF再出棧,並且還需要記錄當前的處理狀態,如果當前的處理狀態是true,那麼在出棧的時候就需要確定是否需要標記當前狀態為false從而結束當前塊的處理,並且還可以透過debug來實現對於命中模組處理後檔案的生成。

// CURRENT PLATFORM: GECKO

// #IFDEF CHROMIUM
// some expressions... // remove
// #ENDIF

// #IFDEF GECKO
// some expressions... // retain
// #ENDIF

// #IFDEF CHROMIUM
// some expressions... // remove
// #IFDEF GECKO
// some expressions... // remove
// #ENDIF
// #ENDIF

// #IFDEF GECKO
// some expressions... // retain
// #IFDEF CHROMIUM
// some expressions... // remove
// #ENDIF
// #ENDIF

// #IFDEF CHROMIUM|GECKO
// some expressions... // retain
// #IFDEF GECKO
// some expressions... // retain
// #ENDIF
// #ENDIF
// ...
// 迭代時控制該行是否命中預處理條件
const platform = (process.env[envKey] || "").toLowerCase();
let terser = false;
let revised = false;
let terserIndex = -1;
/** @type {number[]} */
const stack = [];
const lines = source.split("\n");
const target = lines.map((line, index) => {
// 去掉首尾的空白 去掉行首註釋符號與空白符(可選)
const code = line.trim().replace(/^\/\/\s*/, "");
// 檢查預處理指令起始 `#IFDEF`只會置`true`
if (/^#IFDEF/.test(code)) {
  stack.push(index);
  // 如果是`true`繼續即可
  if (terser) return "";
  const match = code.replace("#IFDEF", "").trim();
  const group = match.split("|").map(item => item.trim().toLowerCase());
  if (group.indexOf(platform) === -1) {
    terser = true;
    revised = true;
    terserIndex = index;
  }
  return "";
}
// 檢查預處理指令結束 `#IFDEF`只會置`false`
if (/^#ENDIF$/.test(code)) {
  const index = stack.pop();
  // 額外的`#ENDIF`忽略
  if (index === undefined) return "";
  if (index === terserIndex) {
    terser = false;
    terserIndex = -1;
  }
  return "";
}
// 如果命中預處理條件則擦除
if (terser) return "";
  return line;
});
// ...

那麼在實際使用的過程中,以呼叫註冊Badge為例,透過if分支將不同端的程式碼分別執行即可,當然如果有類似的定義,也可以方便地直接重新定義變數即可。

let env = chrome;
// #IFDEF GECKO
if (typeof browser !== "undefined") {
  env = browser;
}
// #ENDIF
export const cross = env;

// ...
let action: typeof cross.action | typeof cross.browserAction = cross.action;
// #IFDEF GECKO
action = cross.browserAction;
// #ENDIF
action.setBadgeText({ text: payload.toString(), tabId });
action.setBadgeBackgroundColor({ color: "#4e5969", tabId });

先於頁面Js程式碼執行

瀏覽器擴充套件的一個重要功能就是document_start,也就是瀏覽器注入的程式碼要先於網站本身的Js程式碼執行,這樣就可以為我們的程式碼留予充分的Hook空間,試想一下如果我們能夠在頁面實際載入的時候就執行我們想執行的Js程式碼的話,豈不是可以對當前的頁面為所欲為了。雖然我們不能夠Hook自面量的建立,但是我們總得呼叫瀏覽器提供的API,只要用API的呼叫,我們就可以想辦法來劫持掉函式的呼叫,從而拿到我們想要的資料,例如可以劫持Function.prototype.call函式的呼叫,而這個函式能夠完成很大程度上就需要依賴我這個劫持函式在整個頁面是要最先支援的,否則這個函式已經被呼叫過去了,那麼再劫持就沒有什麼意義了。

Function.prototype.call = function (dynamic, ...args) {
  const context = Object(dynamic) || window;
  const symbol = Symbol();
  context[symbol] = this;
  args.length === 2 && console.log(args);
  try {
    const result = context[symbol](...args);
    delete context[symbol];
    return result;
  } catch (error) {
    console.log("Hook Call Error", error);
    console.log(context, context[symbol], this, dynamic, args);
    return null;
  }
};

那麼可能我們大家會想這個程式碼的實現意義在何處,舉一個簡單的實踐,在某某文庫中所有的文字都是透過canvas渲染的,因為沒有DOM那麼如果我們想獲得文件的整篇內容是沒有辦法直接複製的,所以一個可行的方案是劫持document.createElement函式,當建立的元素是canvas時我們就可以提前拿到畫布的物件,從而拿到ctx,而又因為實際繪製文字總歸還是要呼叫context2DPrototype.fillText方法的,所以再劫持到這個方法,我們就能將繪製的文字拿出來,緊接著就可以自行建立DOM畫在別處,想複製就可以複製了。

那麼我們回到這個問題的實現上,如果能夠保證指令碼是最先執行的,那麼我們幾乎可以做到在語言層面上的任何事情,例如修改window物件、Hook函式定義、修改原型鏈、阻止事件等等等等。其本身的能力也是源自於瀏覽器擴充,而如何將瀏覽器擴充套件的這個能力暴露給Web頁面就是指令碼管理器需要考量的問題了。那麼我們在這裡假設使用者指令碼是執行在瀏覽器頁面的Inject Script而不是Content Script,基於這個假設,首先我們大機率會寫過動態/非同步載入JS指令碼的實現,類似於下面這種方式:

const loadScriptAsync = (url: string) => {
    return new Promise<Event>((resolve, reject) => {
        const script = document.createElement("script");
        script.src = url;
        script.async = true;
        script.onload = e => {
            script.remove();
            resolve(e);
        };
        script.onerror = e => {
            script.remove();
            reject(e);
        };
        document.body.appendChild(script);
    });
};

那麼現在就有一個明顯的問題,我們如果在body標籤構建完成也就是大概在DOMContentLoaded時機再載入指令碼肯定是達不到document-start的目標的,即使是在head標籤完成之後處理也不行,很多網站都會在head內編寫部分JS資源,在這裡載入同樣時機已經不合適了,實際上最大的問題還是整個過程是非同步的,在整個外部指令碼載入完成之前已經有很多JS程式碼在執行了,做不到我們想要的“最先執行”。

那麼下載我們就來探究具體的實現,首先是v2的擴充套件也就是在gecko核心的瀏覽器上,對於整個頁面來說,最先載入的必定是html這個標籤,那麼很明顯我們只要將指令碼在html標籤級別插入就好了,配合瀏覽器擴充套件中backgroundchrome.tabs.executeScript動態執行程式碼以及Content Script"run_at": "document_start"建立訊息通訊確認注入的tab,這個方法是不是看起來很簡單,但就是這麼簡單的問題讓我思索了很久是如何做到的。

// Content Script --> Background
// Background -> chrome.tabs.executeScript
chrome.tabs.executeScript(sender.tabId, {
  frameId: sender.frameId,
  code: `(function(){
    let temp = document.createElementNS("http://www.w3.org/1999/xhtml", "script");
        temp.setAttribute('type', 'text/javascript');
        temp.innerHTML = "${script.code}";
        temp.className = "injected-js";
        document.documentElement.appendChild(temp);
        temp.remove();
    }())`,
  runAt,
});

這個看起來其實已經還不錯了,能夠基本做到document-start,但既然都說了是基本,說明還有些情況會出問題,我們仔細看這個程式碼的實現,在這裡有一個通訊也就是Content Script --> Background,既然是通訊那麼就是非同步處理的,既然是非同步處理就會消耗時間,一旦消耗時間那麼使用者頁面就可能已經執行了大量的程式碼了,所以這個實現會偶現無法做到document-start的情況,也就是實際上是會出現指令碼失效的情況。

那麼有什麼辦法解決這個問題呢,在v2中我們能夠明確知道的是Content Script是完全可控的document-start,但是Content Script並不是Inject Script,沒有辦法訪問到頁面的window物件,也就沒有辦法實際劫持頁面的函式,那麼這個問題看起來很複雜,實際上想明白之後解決起來也很簡單,我們在原本的Content Script的基礎上,再引入一個Content Script,而這個Content Script的程式碼是完全等同於原本的Inject Script,只不過會掛在window上,我們可以藉助打包工具寫個外掛來完成這件事。

compiler.hooks.emit.tapAsync("WrapperCodePlugin", (compilation, done) => {
  Object.keys(compilation.assets).forEach(key => {
    if (!isChromium && key === process.env.INJECT_FILE + ".js") {
      try {
        const buffer = compilation.assets[key].source();
        let code = buffer.toString("utf-8");
        code = `window.${process.env.INJECT_FILE}=function(){${code}}`;
        compilation.assets[key] = {
          source() {
            return code;
          },
          size() {
            return this.source().length;
          },
        };
      } catch (error) {
        console.log("Parse Inject File Error", error);
      }
    }
  });
  done();
});

這段程式碼表示了我們在同樣的Content Scriptwindow物件上掛了一個隨機生成的key,在這裡也就是我們之前提到的可能會引起衝突的地方,而內容就是我們實際想要注入到頁面的指令碼,但是現在雖然我們能夠拿到這個函式了,怎麼能夠讓其在使用者頁面上執行呢,這裡實際上是用到了同樣的document.documentElement.appendChild建立指令碼方法,但是在這裡的實現非常非常巧妙,我們透過兩個Content Script配合toString的方式拿到了字串,並且將其作為程式碼直接注入到了頁面,從而做到了真正的document-start

const fn = window[process.env.INJECT_FILE as unknown as number] as unknown as () => void;
// #IFDEF GECKO
if (fn) {
  const script = document.createElementNS("http://www.w3.org/1999/xhtml", "script");
  script.setAttribute("type", "text/javascript");
  script.innerText = `;(${fn.toString()})();`;
  document.documentElement.appendChild(script);
  script.onload = () => script.remove();
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  // @ts-ignore
  delete window[process.env.INJECT_FILE];
}
// #ENDIF

前邊也提到了由於實際上Chrome瀏覽器不再允許v2的擴充套件程式提交,所以我們只能提交v3的程式碼,但是v3的程式碼有著非常嚴格的CSP內容安全策略的限制,可以簡單的認為不允許動態地執行程式碼,所以我們上述的方式就都失效了,於是我們只能寫出類似下面的程式碼。

const script = document.createElementNS("http://www.w3.org/1999/xhtml", "script");
script.setAttribute("type", "text/javascript");
script.setAttribute("src", chrome.runtime.getURL("inject.js"));
document.documentElement.appendChild(script);
script.onload = () => script.remove();

雖然看起來我們也是在Content Script中立即建立了Script標籤並且執行程式碼,而他能夠達到我們的document-start目標嗎,很遺憾答案是不能,在首次開啟頁面的時候是可以的,但是在之後因為這個指令碼實際上是相當於拿到了一個外部的指令碼,因此Chrome會將這個指令碼和頁面上其他的頁面同樣處於一個排隊的狀態,而其他的指令碼會有強快取在,所以實際表現上是不一定誰會先執行,但是這種不穩定的情況我們是不能夠接受的,肯定做不到document-start目標。實際上光從這點來看v3並不成熟,很多能力的支援都不到位,所以在後來官方也是做出了一些方案來處理這個問題,但是因為我們並沒有什麼辦法決定使用者客戶端的瀏覽器版本,所以很多相容方法還是需要處理的。

export const implantScript = () => {
  /**  RUN INJECT SCRIPT IN DOCUMENT START **/
  // #IFDEF CHROMIUM
  // https://bugs.chromium.org/p/chromium/issues/detail?id=634381
  // https://stackoverflow.com/questions/75495191/chrome-extension-manifest-v3-how-to-use-window-addeventlistener
  if (cross.scripting && cross.scripting.registerContentScripts) {
    logger.info("Register Inject Scripts By Scripting API");
    // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/registerContentScripts
    cross.scripting
      .registerContentScripts([
        {
          matches: [...URL_MATCH],
          runAt: "document_start",
          world: "MAIN",
          allFrames: true,
          js: [process.env.INJECT_FILE + ".js"],
          id: process.env.INJECT_FILE,
        },
      ])
      .catch(err => {
        logger.warning("Register Inject Scripts Failed", err);
      });
  } else {
    logger.info("Register Inject Scripts By Tabs API");
    // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/onUpdated
    cross.tabs.onUpdated.addListener((_, changeInfo, tab) => {
      if (changeInfo.status == "loading") {
        const tabId = tab && tab.id;
        const tabURL = tab && tab.url;
        if (tabURL && !URL_MATCH.some(match => new RegExp(match).test(tabURL))) {
          return void 0;
        }
        if (tabId && cross.scripting) {
          cross.scripting.executeScript({
            target: { tabId: tabId, allFrames: true },
            files: [process.env.INJECT_FILE + ".js"],
            injectImmediately: true,
          });
        }
      }
    });
  }
  // #ENDIF
  // #IFDEF GECKO
  logger.info("Register Inject Scripts By Content Script Inline Code");
  // #ENDIF
};

Chrome V109之後支援了chrome.scripting.registerContentScriptsChrome 111支援了直接在Manifest中宣告world: 'MAIN'的指令碼,但是這其中的相容性還是需要開發者來做,特別是如果原來的瀏覽器不支援world: 'MAIN',那麼這個指令碼是會被當作Content Script處理的,關於這點我覺得還是有點難以處理。

靜態資源處理

設想一下我們的很多資源引用是以字串的形式處理的,例如在manifest.json中的icons引用,是字串引用而並不像我們的Web應用中會根據實際路徑引用資源,那麼在這種情況下資源是不會作為打包工具實際引用的內容的,具體表現是當我們修改資源時並不會觸發打包工具的HMR

因此,對於這部分內容我們需要手動將其併入打包的依賴中,此外還需要將相關檔案複製到打包的目標資料夾中。這實際上並不是個複雜的任務,只需要我們實現外掛來完成這件事即可,在這裡我們需要處理的除了圖片等靜態資源外,還有locales作為語言檔案處理。

exports.FilesPlugin = class FilesPlugin {
  apply(compiler) {
    compiler.hooks.make.tap("FilesPlugin", compilation => {
      const resources = path.resolve("public/static");
      !compilation.contextDependencies.has(resources) &&
        compilation.contextDependencies.add(resources);
    });

    compiler.hooks.done.tapPromise("FilesPlugin", () => {
      const locales = path.resolve("public/locales/");
      const resources = path.resolve("public/static/");

      const folder = isGecko ? "build-gecko" : "build";
      const localesTarget = path.resolve(`${folder}/_locales/`);
      const resourcesTarget = path.resolve(`${folder}/static/`);

      return Promise.all([
        exec(`cp -r ${locales} ${localesTarget}`),
        exec(`cp -r ${resources} ${resourcesTarget}`),
      ]);
    });
  }
};

生成Manifest

在前邊提到的處理靜態資源的問題上,對於manifest.json檔案的生成上同樣存在,我們也需要將其作為contextDependencies註冊到打包工具上。此外,還記得之前我們需要相容的ChromiumGecko嘛,我們在處理manifest.json時同樣需要對其進行相容處理,那麼我們肯定不希望有兩份配置檔案來完成這件事,因此我們可以藉助ts-node來動態生成manifest.json,這樣我們就可以透過各種邏輯來動態地將配置檔案寫入了。

exports.ManifestPlugin = class ManifestPlugin {
  constructor() {
    tsNode.register();
    this.manifest = path.resolve(`src/manifest/index.ts`);
  }

  apply(compiler) {
    compiler.hooks.make.tap("ManifestPlugin", compilation => {
      const manifest = this.manifest;
      !compilation.fileDependencies.has(manifest) && compilation.fileDependencies.add(manifest);
    });

    compiler.hooks.done.tapPromise("ManifestPlugin", () => {
      delete require.cache[require.resolve(this.manifest)];
      const manifest = require(this.manifest);
      const version = require(path.resolve("package.json")).version;
      manifest.version = version;
      const folder = isGecko ? "build-gecko" : "build";
      return writeFile(path.resolve(`${folder}/manifest.json`), JSON.stringify(manifest, null, 2));
    });
  }
};
const __URL_MATCH__ = ["https://*/*", "http://*/*", "file://*/*"];

// Chromium
const __MANIFEST__: Record<string, unknown> = {
  manifest_version: 3,
  name: "Force Copy",
  version: "0.0.0",
  description: "Force Copy Everything",
  default_locale: "en",
  icons: {
    32: "./static/favicon.128.png",
    96: "./static/favicon.128.png",
    128: "./static/favicon.128.png",
  },
  // ...
  permissions: ["activeTab", "tabs", "scripting"],
  minimum_chrome_version: "88.0",
};

// Gecko
if (process.env.PLATFORM === "gecko") {
  __MANIFEST__.manifest_version = 2;
  // ...
  __MANIFEST__.permissions = ["activeTab", "tabs", ...__URL_MATCH__];
  __MANIFEST__.browser_specific_settings = {
    gecko: { strict_min_version: "91.1.0" },
    gecko_android: { strict_min_version: "91.1.0" },
  };

  delete __MANIFEST__.action;
  delete __MANIFEST__.host_permissions;
  delete __MANIFEST__.minimum_chrome_version;
  delete __MANIFEST__.web_accessible_resources;
}

module.exports = __MANIFEST__;

事件通訊方案

在瀏覽器擴充中有很多模組,常見的模組有background/workerpopupcontentinjectdevtools等,不同的模組對應著不同的作用,協作構成了外掛的擴充套件功能。那麼顯然由於存在各種模組,每個模組負責不同的功能,我們就需要完成關聯模組的通訊能力。

由於整個專案都是由TS構建的,因此我們更希望實現型別完備的通訊方案,特別是在功能實現複雜的時候靜態的型別檢查能夠幫我們避免很多問題,那麼在這裡我們就以PopupContent為例對資料通訊做統一的方案,在擴充套件中我們需要為每個需要通訊的模組設計相關的類。

首先我們需要定義通訊的key值,因為我們需要透過type來決定本次通訊傳遞的資訊型別,而為了防止值衝突,我們透過reduce為我們的key值增加一些複雜度。

const PC_REQUEST_TYPE = ["A", "B"] as const;
export const POPUP_TO_CONTENT_REQUEST = PC_REQUEST_TYPE.reduce(
  (acc, cur) => ({ ...acc, [cur]: `__${cur}__${MARK}__` }),
  {} as { [K in typeof PC_REQUEST_TYPE[number]]: `__${K}__${typeof MARK}__` }
);

如果我們用過redux的話,可能會遇到一個問題,就是type如何跟payload攜帶的型別對齊,例如我們希望當typeA的時候,TS能夠自動推斷出來payload的型別是{ x: number },而如果typeB的時候,TS能夠自動推斷型別為{ y: string },那麼這個例子比較簡單的宣告式方案如下:

type Tuple =
  | {
      type: "A";
      payload: { x: number };
    }
  | {
      type: "B";
      payload: { y: string };
    };
    
const pick = (data: Tuple) => {
  switch (data.type) {
    case "A":
      return data.payload.x; // number
    case "B":
      return data.payload.y; // string
  }
};

這麼寫起來實際上並不優雅,我們可能更希望對於型別的宣告可以優雅一些,那麼當然我們可以藉助範型來完成這件事。不過我們可能並不能一步將其處理完成,需要分開把型別宣告做好,首先我們可以實現type -> payload的型別Map,將對映關係表達出來,之後將其轉換為type -> { type: T, payload: Map[T] }的結構,然後取Tuple即可。

type Map = {
  A: { x: number };
  B: { y: string };
};

type ToReflectMap<T extends string, M extends Record<string, unknown>> = {
  [P in T]: { type: unknown extends M[P] ? never : P; payload: M[P] };
};

type ReflectMap = ToReflectMap<keyof Map, Map>;

type Tuple = ReflectMap[keyof ReflectMap];

那麼我們現在可以將其封裝到一個namespace中,以及一些基本的型別資料轉換方法來方便我們呼叫。

export namespace Object {
  export type Keys<T extends Record<string, unknown>> = keyof T;

  export type Values<T extends Record<symbol | string | number, unknown>> = T[keyof T];
}

export namespace String {
  export type Map<T extends string> = { [P in T]: P };
}

export namespace EventReflect {
  export type Array<T, M extends Record<string, unknown>> = T extends string
    ? [type: unknown extends M[T] ? never : T, payload: M[T]]
    : never;

  export type Map<T extends string, M extends Record<string, unknown>> = {
    [P in T]: { type: unknown extends M[P] ? never : P; payload: M[P] };
  };

  export type Tuple<
    T extends Record<string, string>,
    M extends Record<string, unknown>
  > = Object.Values<Map<Object.Values<T>, M>>;
}

type Tuple = EventReflect.Tuple<String.Map<keyof Map>, Map>;

實際上為了方便我們的函式呼叫,我們也可以對引數做處理,在函式內部將其重新as為需要的引數型別即可。

type Map = {
  A: { x: number };
  B: { y: string };
};

type Args = EventReflect.Array<keyof Map, Map>;

declare function post(...args: Args): null;

post("A", { x: 2 });
post("B", { y: "" });

為了明確我們的型別表達,在這裡我們暫時不用函式引數的形式來表達,依然使用物件type -> payload的形式標註型別。那麼既然在這裡我們已經將請求的型別定義好,我們接著需要將返回響應的資料型別定義出來,為了方便於資料的表達與嚴格的型別,我們同樣將返回的資料表示為type -> payload的形式,當然這裡的響應type和請求時的type是一致的。

type EventMap = {
  [POPUP_TO_CONTENT_REQUEST.A]: { [K in PCQueryAType]: boolean };
};

export type PCResponseType = EventReflect.Tuple<String.Map<keyof EventMap>, EventMap>;

接下來我們就來定義整個事件通訊的Bridge,由於此時我們是PopupContent傳送資料,那麼我們就必須要明確向當前的哪個Tab傳送資料,所以在這裡需要查詢當前活躍的Tab。資料通訊的方式則是使用的cross.tabs.sendMessage方法,在接收訊息的時候則需要cross.runtime.onMessage.addListener。並且由於可能存在的多種通訊通道,我們還需要判斷這個訊息源,在這裡我們透過傳送的key判斷即可。

在這裡需要注意的是即使擴充套件的定義中sendResponse是響應非同步資料,但是在實際測試的過程中發現這個函式是不能非同步呼叫的,也就是說這個函式必須要在響應的回撥中立即執行,其說的非同步指的是整個事件通訊的過程是非同步的,所以在這裡我們就以資料返回響應的形式來定義。

export class PCBridge {
  public static readonly REQUEST = POPUP_TO_CONTENT_REQUEST;

  static async postToContent(data: PCRequestType) {
    return new Promise<PCResponseType | null>(resolve => {
      cross.tabs
        .query({ active: true, currentWindow: true })
        .then(tabs => {
          const tab = tabs[0];
          const tabId = tab && tab.id;
          const tabURL = tab && tab.url;
          if (tabURL && !URL_MATCH.some(match => new RegExp(match).test(tabURL))) {
            resolve(null);
            return void 0;
          }
          if (!isEmptyValue(tabId)) {
            cross.tabs.sendMessage(tabId, data).then(resolve);
          } else {
            resolve(null);
          }
        })
        .catch(error => {
          logger.warning("Send Message Error", error);
        });
    });
  }

  static onPopupMessage(cb: (data: PCRequestType) => void | PCResponseType) {
    const handler = (
      request: PCRequestType,
      _: chrome.runtime.MessageSender,
      sendResponse: (response: PCResponseType | null) => void
    ) => {
      const response = cb(request);
      response && response.type === request.type && sendResponse(response);
    };
    cross.runtime.onMessage.addListener(handler);
    return () => {
      cross.runtime.onMessage.removeListener(handler);
    };
  }

  static isPCRequestType(data: PCRequestType): data is PCRequestType {
    return data && data.type && data.type.endsWith(`__${MARK}__`);
  }
}

此外,在content中與inject通訊需要比較特殊的封裝,在Content Script中的DOM和事件流是與Inject Script共享的,那麼實際上我們就可以有兩種方式實現通訊:

  • 首先我們常用的方法是window.addEventListener + window.postMessage,只不過這種方式很明顯的一個問題是在Web頁面中也可以收到我們的訊息,即使我們可以生成一些隨機的token來驗證訊息的來源,但是這個方式畢竟能夠非常簡單地被頁面本身截獲不夠安全。
  • 另一種方式即document.addEventListener + document.dispatchEvent + CustomEvent自定義事件的方式,在這裡我們需要注意的是事件名要隨機,透過在注入框架時於background生成唯一的隨機事件名,之後在Content ScriptInject Script都使用該事件名通訊,就可以防止使用者截獲方法呼叫時產生的訊息了。

這裡需要注意的是,所有傳輸的資料型別必須要是可序列化的,如果不是可序列化的話在Gecko核心的瀏覽器中會被認為是跨域的物件,畢竟實際上確實是跨越了不同的Context了,否則就相當於直接共享記憶體了。

// Content Script
document.addEventListener("xxxxxxxxxxxxx" + "content", e => {
    console.log("From Inject Script", e.detail);
});

// Inject Script
document.addEventListener("xxxxxxxxxxxxx" + "inject", e => {
    console.log("From Content Script", e.detail);
});

// Inject Script
document.dispatchEvent(
    new CustomEvent("xxxxxxxxxxxxx" + "content", {
        detail: { message: "call api" },
    }),
);

// Content Script
document.dispatchEvent(
    new CustomEvent("xxxxxxxxxxxxx" + "inject", {
        detail: { message: "return value" },
    }),
);

熱更新方案

在前邊我們一直提到了谷歌強推的v3有很多限制,這其中有一個很大的限制是其CSP - Content Security Policy不再允許動態執行程式碼,那麼諸如我們DevServerHMR工具則都無法正常發揮其作用了,但是熱更新是我們實際需要的功能,所以只能採用並沒有那麼完善的解決方案。

我們可以編寫一個打包工具的外掛,利用ws.Server啟動一個WebSocket伺服器,之後在worker.js也就是我們將要啟動的Service Worker來連線WebSocket伺服器。然後可以透過new WebSocket來連結並且在監聽訊息,當收到來自服務端的reload訊息之後,我們就可以執行chrome.runtime.reload()來實現外掛的重新載入了。

那麼在開啟的WebSocket伺服器中需要在每次編譯完成之後例如afterDone這個hook向客戶端傳送reload訊息,這樣就可以實現一個簡單的外掛重新載入能力了。但是實際上這引入了另一個問題,在v3版本的Service Worker不會常駐,所以這個WebSocket連結也會隨著Service Worker的銷燬而銷燬,是比較坑的一點,同樣也是因為這一點大量的Chrome擴充套件無法從v2平滑過渡到v3,所以這個能力後續還有可能會被改善。

exports.ReloadPlugin = class ReloadPlugin {
  constructor() {
    if (isDev) {
      try {
        const server = new WebSocketServer({ port: 3333 });
        server.on("connection", client => {
          wsClient && wsClient.close();
          wsClient = client;
          console.log("Client Connected");
        });
      } catch (error) {
        console.log("Auto Reload Server Error", error);
      }
    }
  }
  apply(compiler) {
    compiler.hooks.afterDone.tap("ReloadPlugin", () => {
      wsClient && wsClient.send("reload-app");
    });
  }
};
export const onReceiveReloadMsg = () => {
  if (__DEV__) {
    try {
      const ws = new WebSocket("ws://localhost:3333");
      // 收到訊息即過載
      ws.onmessage = () => {
        try {
          CWBridge.postToWorker({ type: CWBridge.REQUEST.RELOAD, payload: null });
        } catch (error) {
          logger.warning("SEND MESSAGE ERROR", error);
        }
      };
    } catch (error) {
      logger.warning("CONNECT ERROR", error);
    }
  }
};

export const onContentMessage = (data: CWRequestType, sender: chrome.runtime.MessageSender) => {
  logger.info("Worker Receive Content Message", data);
  switch (data.type) {
    case CWBridge.REQUEST.RELOAD: {
      reloadApp(RELOAD_APP);
      break;
    }
    // ...
  }
  return null;
};

Popup多語言

比較有趣的一件事情是,瀏覽器提供的多語言方案實際上並不好用,我們在locals中儲存的檔案實際上只是佔位,是為了讓擴充套件市場認識我們的瀏覽器擴充套件支援的語言,而實際上的多語言則在我們的Popup中自行實現,例如在packages/force-copy/public/locales/zh_CN中的資料如下:

{
  "name": {
    "message": "Force Copy"
  }
}

那麼實際上前端的多語言解決方案有很多,在這裡因為我們的擴充套件程式不會有太多需要關注的多語言的內容,畢竟只是一個Popup層,如果需要獨立一個index.html的頁面的話,那採用社群的多語言方案還是有必要的。不過在這裡我們就簡單實現即可。

首先是型別完備,在我們的擴充中我們是以英文為基準語言,所以配置也是以英文為基準的設定。而由於我們希望有更好的分組方案,所以在這裡可能會存在比較深層次的巢狀結構,因此型別上也必須完整將其拼接出來,用以支援我們的多語言。

export const en = {
  Title: "Force Copy",
  Captain: {
    Modules: "Modules",
    Start: "Start",
    Once: "Once",
  },
  Operation: {
    Copy: "Copy",
    Keyboard: "Keyboard",
    ContextMenu: "ContextMenu",
  },
  Information: {
    GitHub: "GitHub",
    Help: "Help",
    Reload: "Reload",
  },
};
export type DefaultI18nConfig = typeof en;

export type ConfigBlock = {
  [key: string]: string | ConfigBlock;
};
type FlattenKeys<T extends ConfigBlock, Key = keyof T> = Key extends string
  ? T[Key] extends ConfigBlock
    ? `${Key}.${FlattenKeys<T[Key]>}`
    : `${Key}`
  : never;
export type I18nTypes = Record<FlattenKeys<DefaultI18nConfig>, string>;

緊接著我們定義I18n類以及語言的全域性快取,在I18n類中實現了函式呼叫、多語言配置按需生成、多語言配置獲取的函式,在呼叫的時候直接例項化new I18n(cross.i18n.getUILanguage());,取i18n.t("Information.GitHub")即可。

const cache: Record<string, I18nTypes> = {};

export class I18n {
  private config: I18nTypes;
  constructor(language: string) {
    this.config = I18n.getFullConfig(language);
  }

  t = (key: keyof I18nTypes, defaultValue = "") => {
    return this.config[key] || defaultValue || key;
  };

  private static getFullConfig = (key: string) => {
    if (cache[key]) return cache[key];
    let config;
    if (key.toLowerCase().startsWith("zh")) {
      config = this.generateFlattenConfig(zh);
    } else {
      config = this.generateFlattenConfig(en);
    }
    cache[key] = config;
    return config;
  };

  private static generateFlattenConfig = (config: ConfigBlock): I18nTypes => {
    const target: Record<string, string> = {};
    const dfs = (obj: ConfigBlock, prefix: string[]) => {
      for (const [key, value] of Object.entries(obj)) {
        if (isString(value)) {
          target[[...prefix, key].join(".")] = value;
        } else {
          dfs(value, [...prefix, key]);
        }
      }
    };
    dfs(config, []);
    return target as I18nTypes;
  };
}

最後

瀏覽器擴充套件的開發還是比較複雜的一件事,特別是在需要相容v2v3的情況下,很多設計都需要思考是否能夠正常在v3上實現,在v3的瀏覽器擴充套件上失去了很多靈活性,但是相對也獲取了一定的安全性。不過瀏覽器擴充套件本質上的許可權還是相當高的,例如即使是v3我們仍然可以在Chrome上使用CDP - Chrome DevTools Protocol來實現很多事情,擴充套件能做的東西實在是太多了,如果不瞭解或者不開源的話根本不敢安裝,因為擴充套件許可權太高可能會造成很嚴重的例如使用者資訊洩漏等問題,即使是比如像Firefox那樣必須要上傳原始碼的方式來加強稽核,也很難杜絕所有的隱患。

相關文章