使用 webpack 構建 chrome 擴展的熱更新問題

前不久我寫了一個 chrome 擴展,做爲一個前端弄潮兒,我固然想用上各類前端界最 fashion 的開發工具。因而乎,折騰到最後使用了 webpack + TypeScript + react 這麼一套技術棧。在 github 上研究了幾個模板項目以後,發現大多數都太初級了,功能太簡單,並且有一個我覺應當提供的很基礎的功能始終沒發現有哪一個項目支持,也就是當修改了 content script 以後自動 reload 擴展和刷新注入了 content script 的頁面這個問題。這個問題若是解決了,你就不須要每次修改了擴展代碼還去 chrome 擴展列表頁面點下刷新按鈕刷新擴展。最後在研究了 webpack 的熱更新機制和查閱了 webpack 和 chome 擴展官方文檔以後解決了這個問題。在開發完我那個擴展以後,我便將其提取成了一個模板項目 awesome-chrome-extension-boilerplatejavascript

其實我在使用 webpack + TypeScript + react 這套技術棧開發 chrome 擴展時碰到的問題真很多,若是全拿出來說沒個兩萬字我估計是寫不完。這篇文章主要聊聊 webpack 開發 chrome 擴展的熱更新問題,並重點講解我是如何實現修改了 content script 以後自動 reload 擴展和刷新注入了 content script 的頁面的,這也是我那個模板的最大特點,也算是給它的 README 作個補充。css

在閱讀文章以前,但願讀者對 webpack 和 chrome 擴展開發有基本的瞭解。本文的主要內容分爲:前端

  1. 我對 chrome 擴展的理解
  2. 各類頁面的熱更新問題分析
  3. 如何實現修改了 content script 以後自動 reload 擴展和刷新注入了 content script 的頁面

我對 chrome 擴展的理解

chrome 擴展其實本質就是一個包含了 manifest.json 文件的文件夾。java

其實就像 npm 包同樣,包含 package.json 文件的文件夾咱們就能夠將其視爲一個 npm 包。這是從靜態的角度也就從文件的角度來講的。node

從擴展運行時來看,chrome 擴展是一個被 chrome 以 chrome:// 協議託管的一個靜態服務器。當咱們訪問了 chrome 擴展種的各類資源,其實就是向這個服務器請求了以 chrome:// 協議頭開始的某個 URL 。例如請求 background 頁面其實就是訪問了下圖中 URL 對應的 HTML 文件:react

background

擴展中有各類各樣的頁面,最多見的例如 background 頁面,也就是上面這張圖中的頁面,options 頁面也就是選項頁面,popup 頁面也就是點擊圖標後的彈窗頁面,其實還有不少其它的如書籤頁面,瀏覽歷史頁面,新標籤頁面等這些頁面本質上就是 chrome 擴展中的一個 HTML 文件,固然有些頁面你不指定一個自定義的 HTML 文件,chrome 擴展會爲你自動生成。既然它們都是由 HTML 文件渲染的,那咱們其實能夠直接用開發 SPA 的方式來渲染它們。webpack

SPA 也就是 Single Page Application 單頁面應用,如今的前端三大框架其實都是主要用於開發 SPA。經過前端路由,咱們能夠在一個 HTML 文件上就能像 App 同樣訪問不一樣的頁面,藉助於組件化和框架的能力實現切換不一樣的頁面,而且只會局部刷新,不像之前 jsp,asp 的時候切個頁面整個網頁都刷新一遍,從性能和體驗上都是進步。而熟悉前端路由的人都知道,前端路由也有好多種,最經常使用也就是 BrowserRouter 和 HashRouter。而 BrowserRouter 它是須要服務器配合的,將全部的 HTML URL 重定向到一個 HTML 文件。咱們使用 webpack 開發的時候訪問的是 webpack-dev-server,若是你須要使用 BrowserRouter,那麼就要修改 webpack 配置:git

// webpack.config.js
module.exports = {
  //...
  devServer: {
    historyApiFallback: true,
  },
};
複製代碼

可是,chrome 擴展這個靜態服務器咱們無法給它設置重定向啊,chrome 自己咱們是無法編程的。好在咱們還有其它的 Router 方案,使用 HashRouter 就剛恰好,既不會由於 URL 帶 hash 值比較醜(由於看不到),又實現了前端路由的功能。github

具體在使用 webpack 將 background,options,popup 頁面看成 SPA 開發的時候還會有一些小問題。咱們在以開發者模式加載測試的擴展時是要加載磁盤上的文件,因此咱們須要配置 webpack devServer 將 bundle 寫到磁盤上。除此以外,咱們還要配置 CORS 跨域,由於咱們訪問的 chrome 擴展頁面協議時是: chrome:// 和咱們 devServer HTTP 協議不一樣,會有跨域問題:web

import { Express } from 'express';
import { Compiler } from 'webpack';
import webpackDevMiddleware from 'webpack-dev-middleware';
import webpackHotMiddleware from 'webpack-hot-middleware';
import devConfig from '../configs/webpack.dev';

export default (app: Express, compiler: Compiler): void => {
  const devMiddlewareOptions: webpackDevMiddleware.Options = {
    publicPath: devConfig!.output!.publicPath!,
    headers: {
      // 配置 cors 跨域
      'Access-Control-Allow-Origin': '*',
    },
    lazy: false,
    stats: 'minimal',
    // 將 bundle 寫到磁盤而不是內存
    writeToDisk: true,
  };
  app.use(webpackDevMiddleware(compiler, devMiddlewareOptions));
  app.use(webpackHotMiddleware(compiler, { path: '/__webpack_HMR__' }));
};
複製代碼

上面是我模板項目中使用到的 devServer 中間件配置,配置 webpack devServer 我總結有四種方式:

  1. webpack-dev-server 命令行工具
  2. webpack-dev-server node API
  3. express + webpack devServer 中間件
  4. koa + webpack devServer 中間件

我最終採用的是第三種,不選前二者是由於靈活度不夠,由於我須要配置 content scripts 的 webpack entry 不加載 hot reload 腳本以及修改默認拉取熱更新補丁的 path,只有經過 webpack devServer 中間件才能配置。又由於 koa 配置 devServer 資料很少,感受不如 express 成熟穩定,因此不選擇第四種。至於爲何須要配置 content scripts 的 webpack entry 不加載 hot reload 腳本以及修改默認拉取熱更新補丁的 path,後面會提到。

各類頁面的熱更新問題分析

咱們從非content scriptscontent scripts 這兩類腳原本討論。

非 content scripts 的熱更新問題

前面一部份內容我就講了,bacngound, options, popup 等頁面其實咱們能夠直接看成普通的 SPA 頁面來開發,也就是說能夠直接使用 webpack devServer 自身提供的熱更新能力。所以咱們還能夠配置 react 的 hot reload 等。從配置 webpack 的角度來講,相對與普通的前端項目的區別就是配置多入口,跨域,devServer bundle 寫到磁盤等,有必定的複雜度。不喜歡折騰的能夠考慮直接用我那個模板,並且我那個模板項目還作了各方面的優化。

前面提到須要修改默認拉取熱更新補丁的 path,這是由於:默認狀況下,在向 webpack devServer 拉取熱更新補丁的 path 是 /__webpack_hmr

webpack-hot-middleware.png

若是你不設置 path 爲你的 devServser 地址就會出現下面的問題,也就是直接向 chrome 擴展請求熱更新數據了:

webpack-hmr.png

因此仍是要使用 node API 配置 devServer 的 webpack-hot-middleware 中間件才行:

// 部分 webpack entry 配置
import { resolve } from 'path';
import serverConfig from '../configs/server.config';
import { isProd } from './env';

const src = resolve(__dirname, '../../src');
const HMRSSEPath = encodeURIComponent(`http://${serverConfig.HOST}:${serverConfig.PORT}/__webpack_HMR__`);
// 指定 path 爲咱們的 devServer 的地址
const HMRClientScript = `webpack-hot-middleware/client?path=${HMRSSEPath}&reload=true`;

const devEntry: Record<string, string[]> = {
  background: [HMRClientScript, resolve(src, './background/index.ts')],
  options: [HMRClientScript, 'react-hot-loader/patch', resolve(src, './options/index.tsx')],
};
複製代碼

content scripts 的熱更新問題

首先我下個結論:使用 webpack devServer 自帶的熱更新機制是不可能對 content scripts 起做用的。

根本緣由是由於:因爲 chrome 限制了 content script 內沒法訪問其它 content script,inject script,以及網頁自己的 js 腳本內的變量。又由於 webpack devServer 熱更新是以 jsonp 的方式來拉取更新補丁的,注入網頁的 content scrpit 中包含的實現熱更新機制的代碼會調用 jsonp 插入頁面的熱更新補丁中的更新函數,可是因爲 chrome 限制,沒法調用,也就沒法應用熱更新補丁。

查看 wbpack 源碼,能夠看到 webpack devServer 用的就是 jsonp 進行熱更新的:

webpack-json.png

如何實現修改了 content script 以後自動 reload 擴展和刷新注入了 content script 的頁面

這是本文的重點,其實思路仍是很清晰的:

  1. 監聽 webpack 修改 content script 事件
  2. devServer 經過 SSE 主動推送事件給 background
  3. background 監聽推送事件
  4. 調用擴展 API reload 擴展,併發送消息給全部注入了 content script 的頁面讓它們刷新頁面

下面咱們來一步一步實現;

監聽 webpack 修改 content script 事件

查閱 webpack 官方文檔發如今使用 node API 的時候能夠經過 compiler.hooks.done 這個鉤子在 webpack 每次編譯結束時執行一些操做。本質上 webpack 插件也是經過給 webpack 各類事件掛鉤子來實現各類功能。

// plugin 包含咱們的處理編譯完成這一事件的邏輯
compiler.hooks.done.tap('extension-auto-reload-plugin', plugin);
複製代碼

監聽到編譯成功還不夠,咱們還須要判斷是否編譯成功,以及經過這次編譯的統計信息 stats 拿到這次編譯涉及到的 chunks 來判斷是否修改了 content scripts 的 chunks:

const contentScriptsChunks = fs.readdirSync(resolve(__dirname, '../../src/contents'));
const plugin = (stats: Stats) => {
  const { modules } = stats.toJson({ all: false, modules: true });
  const shouldReload =
    !stats.hasErrors() &&
    modules &&
    modules.length === 1 &&
    contentScriptsChunks.includes(modules[0].chunks[0] as string);

  if (shouldReload) {
    // 發送消息給 background
  }
};
複製代碼

devServer 經過 SSE 主動推送事件給 background

服務器端實時主動推送如今最經常使用應該是 WebSocket 協議了。不過既然咱們已經有了一個 express 服務器,就不必再啓動一個 WebSocket 服務器,利用 SSE(Server Side Event) 的話只須要給 express 加個路由便可。整合上一節內容,咱們能夠實現一個 express 中間件在監聽到修改了 content script 時推送 compiled-successfully 事件給 background:

import fs from 'fs';
import { resolve } from 'path';
import chalk from 'chalk';
import { debounce } from 'lodash';
import { RequestHandler } from 'express';
import { Compiler, Stats } from 'webpack';
import SSEStream from 'ssestream';

export default function(compiler: Compiler) {
  const extAutoReload: RequestHandler = (req, res, next) => {
    console.log(chalk.yellow('Received a SSE client connection!'));

    res.header('Access-Control-Allow-Origin', '*');
    const sseStream = new SSEStream(req);
    sseStream.pipe(res);
    let closed = false;

    const contentScriptsModules = fs.readdirSync(resolve(__dirname, '../../src/contents'));
    const compileDoneHook = debounce((stats: Stats) => {
      const { modules } = stats.toJson({ all: false, modules: true });
      const shouldReload =
        !stats.hasErrors() &&
        modules &&
        modules.length === 1 &&
        contentScriptsModules.includes(modules[0].chunks[0] as string);

      if (shouldReload) {
        console.log(chalk.yellow('Send extension reload signal!'));

        sseStream.write(
          {
            event: 'compiled-successfully',
            data: {
              action: 'reload-extension-and-refresh-current-page',
            },
          },
          'UTF-8',
          error => {
            if (error) {
              console.error(error);
            }
          }
        );
      }
    }, 1000);

    // 斷開連接後以前的 hook 就不要執行了
    const plugin = (stats: Stats) => !closed && compileDoneHook(stats);
    compiler.hooks.done.tap('extension-auto-reload-plugin', plugin);

    res.on('close', () => {
      closed = true;
      console.log(chalk.yellow('SSE connection closed!'));
      sseStream.unpipe(res);
    });

    next();
  };

  return extAutoReload;
}
複製代碼

background 監聽推送事件

監聽 devServer 推送的 compiled-successfully 事件很簡單,使用 SSE 客戶端對 express 服務器也就是咱們的 devServer 上指定的 SSE 路由創建 SSE 連接便可:

const source = new EventSource('http://127.0.0.1:3000/__extension_auto_reload__');

source.addEventListener('compiled-successfully', (event: EventSourceEvent) => {
    const shouldReload = JSON.parse(event.data).action === 'reload-extension-and-refresh-current-page';

    if (shouldReload) {
    	// 刷新擴展等後續步驟
    }
)
複製代碼

調用擴展 API reload 擴展,併發送消息給全部注入了 content script 的頁面讓它們刷新頁面

查閱 chrome extension 官方文檔,發現官方提供了一個 reload API 能夠在擴展中直接讓擴展重載。在 background 的 webpack entry 數組添加 autoReloadPatch.ts :

if (!isProd) {
  entry.background.unshift(resolve(__dirname, './autoReloadPatch.ts'));
}
複製代碼

autoReloadPatch.ts 這個腳本在監聽到服務器推送的指定消息時會先發送刷新頁面的消息給全部 tab 頁面,在接收到任意一個來自 content script 的響應後再 reload 擴展(若是咱們的擴展都沒有 content script 就不必刷新了)。

import tiza from 'tiza';

const source = new EventSource('http://127.0.0.1:3000/__extension_auto_reload__');

source.addEventListener(
  'compiled-successfully',
  (event: EventSourceEvent) => {
    const shouldReload = JSON.parse(event.data).action === 'reload-extension-and-refresh-current-page';

    if (shouldReload) {
      tiza
        .color('green')
        .bold()
        .text(`Receive the signal to reload chrome extension as modify the content script!`)
        .info();
      chrome.tabs.query({}, tabs => {
        tabs.forEach(tab => {
          if (tab.id) {
            let received = false;
            chrome.tabs.sendMessage(
              tab.id,
              {
                from: 'background',
                action: 'refresh-current-page',
              },
              ({ from, action }) => {
                if (!received && from === 'content script' && action === 'reload extension') {
                  source.close();
                  tiza
                    .color('green')
                    .bold()
                    .text(`Reload extension`)
                    .info();
                  chrome.runtime.reload();
                  received = true;
                }
              }
            );
          }
        });
      });
    }
  },
  false
);
複製代碼

上面的代碼會發送刷新頁面的消息給全部頁面,爲了實現自動刷新頁面,咱們必須想辦法注入一段監聽刷新頁面的代碼到全部注入了 content script 的頁面。我採起的作法是:默認添加一個 content script 也就是 all.ts,這個 all.ts 的代碼必須注入到全部的其餘 content script 注入到的頁面。因此須要咱們在配置 manifest.json 的時候注意一下,將 all.ts 的 matches 設置爲其它全部 content scripts 的 matches 的父集。例如:

"content_scripts": [
    {
        "matches": ["https://github.com/*"],
        "css": ["css/all.css"],
        "js": ["js/all.js"]
    },
    {
        "matches": ["https://github.com/pulls"],
        "css": ["css/pulls.css"],
        "js": ["js/pulls.js"]
    }
]
複製代碼

all.ts 中的代碼很簡單,就是監聽 background 發送的刷新頁面的消息刷新當前頁面:

import tiza from 'tiza';

chrome.runtime.onMessage.addListener((request, sender, sendResp) => {
  const shouldRefresh = request.from === 'background' && request.action === 'refresh-current-page';

  if (shouldRefresh) {
    tiza
      .color('green')
      .bold()
      .text('Receive signal to refresh current page as modify the content script!')
      .info();
    setTimeout(() => {
      window.location.reload();
    }, 500);
    sendResp({
      from: 'content script',
      action: 'reload extension',
    });
  }
});
複製代碼

我本人平時很喜歡收集各類實用的擴展,也看過很多擴展的源碼。發現其實它們大可能是仍是使用最原始的開發方式。沒有模塊化,很容易致使後期維護性很是差,並且還不能方便得調用 npm 包。人生苦短,直接用輪子很差嗎?固然其實我是知道有一些工具例如 crx-hotreload 能夠實現自動刷新擴展,不過相比於個人模板來講功能上太簡陋了。

實際上使用 webpack + react + TypeScript 開發 chrome 擴展,你可能還會遇到各類問題。這個時候不妨打開個人這個模板項目,去看看我是怎麼配置項目去解決這些問題的。其實我學習編程歷來就沒人帶過,都是靠本身慢慢摸索。我碰到問題也常常去 github 去搜索技術棧相似的項目,直接分析源碼來找到解決辦法。或者你也能夠在評論區提問,通常來講我都會積極回覆。若是本文內容對你有所幫助或者以爲這個模板項目可能用得上,請不要吝嗇你的 star 😂。

最後,快過年了,祝你們新年快樂,2020 事業更上一層樓。

本文爲原創內容,首發於我的博客,轉載請註明出處。

相關文章
相關標籤/搜索