使用 webpack 构建 chrome 扩展的热更新问题

前不久我写了一个 chrome 扩展,作为一个前端弄潮儿,我肯定是想用上各种前端界最 fashion 的开发工具。于是乎,折腾到最后是使用了 webpack + TypeScript + react 这么一套技术栈。在 github 上研究了几个模板项目之后,我感觉大多数都太初级了,功能太简单,而且有一个功能我始终没发现有哪个项目解决了,也就是当修改了 content script 之后自动 reload 扩展和刷新注入了 content script 的页面这个问题。在开发完我那个扩展之后,我便将其提取成了一个模板项目 awesome-chrome-extension-boilerplate

其实我在使用 webpack + TypeScript + react 这套技术栈开发 chrome 扩展时碰到的问题真不少,如果全拿出来写没个两万字我估计是写不完。这篇文章主要聊聊 webpack 开发 chrome 扩展的热更新问题,并重点讲解我是如何实现修改了 content script 之后自动 reload 扩展和刷新注入了 content script 的页面的,这也是我那个模板的最大特色,也算是给它的 README 做个补充。

在阅读文章之前,希望读者对 webpack 和 chrome 扩展开发有基本的了解。本文的主要内容分为:

  1. 我对 chrome 扩展的理解
  2. 各种页面的热更新问题分析
  3. 如何实现修改了 content script 之后自动 reload 扩展和刷新注入了 content script 的页面

我对 chrome 扩展的理解

chrome 扩展其实本质就是一个包含了 manifest.json 文件的文件夹。

其实就像 npm 包一样,包含 package.json 文件的文件夹我们就可以将其视为一个 npm 包。这是从静态的角度来说的。

从扩展运行时来看,chrome 扩展是一个被 chrome 以 chrome:// 协议托管的一个静态服务器。当我们访问了 chrome 扩展种的各种资源,其实就是向这个服务器请求了以 chrome:// 协议头开始的某个 URL 。例如 background 页面:

扩展中有各种各样的页面,最常见的例如 background 页面,也就是上面这张图,options 页面也就是选项页面,popup 页面也就是点击图标后的弹窗页面,其实还有很多其它的如自定义的书签页面,浏览历史页面,新标签页面等这些页面本质上就是 chrome 扩展中的一个 html 文件,当然有些页面你不指定一个自定义的 html 文件,chrome 扩展会为你自动生成。既然它们都是由 HTML 文件渲染的,那我们其实可以直接用开发 SPA 的方式来渲染它们。

SPA 也就是 Single Page Application 单页面应用,前端三大框架其实都是在搞 SPA。通过前端路由,让我们实现了在一个 HTML 文件上就能像 App 一样访问不同的页面,借助于组件化和框架的能力实现切换不同的页面,并且只会局部刷新,不像以前 jsp,asp 的时候切个页面整个网页都刷新一遍,从性能和体验上都是进步。而熟悉前端路由的人都知道,前端路由也有好多种,最常用也就是 BrowserRouter 和 HashRouter。而 BrowserRouter 它是需要服务器配合的,将所有的 HTML URL 重定向到首页。我们使用 webpack 开发的时候访问的是 webpack-dev-server,如果你需要使用 BrowserRouter,那么就要修改 webpack 配置:

// webpack.config.js
module.exports = {
  //...
  devServer: {
    historyApiFallback: true,
  },
};
复制代码

但是,chrome 扩展这个静态服务器我们没法给它设置重定向啊,chrome 本身我们是没法编程的。好在我们还有其它的 Router 方案,使用 HashRouter 就刚刚好,又不会因为 URL 带 hash 值比较丑(因为看不到),又实现了前端路由的功能。

具体在使用 webpack 将 background,options,popup 页面当作 SPA 开发的时候还会有一些小问题。我们在以开发者模式加载测试的扩展时是要加载磁盘上的文件,所以我们需要配置 webpack devServer 将 bundle 写到磁盘上,以及还有 CORS 跨域:

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-dev-server 中间件
  4. koa + webpack-dev-server 中间件

我最终采用的是第三种,不选前两者是因为灵活度不够,因为我需要配置 content scripts 的 webpack entry 不加载 hot reload 脚本,只有通过 webpack devServer 中间件才能配置。又因为 koa 配置 devServer 资料不多,感觉不如 express 成熟稳定,所以不选择第四种。至于为什么需要配置 content scripts 的 webpack entry 不加载 hot reload 脚本,后面会提到。

各种页面的热更新问题分析

我们从 非content scriptscontent scripts 两方面来讨论。

非 content scripts 的热更新问题

前面一部分内容我就讲了,bacngound, options, popup 等页面其实我们可以直接当作普通的 SPA 页面来开发。从配置 webpack 的角度来说,相对与普通的前端项目的区别就是配置多入口,跨域,以及 devServer 写到磁盘,其实都不是很复杂,而且我那个模板项目还做了各方面的优化,不喜欢折腾的可以考虑直接用我那个模板。

这里提一个需要注意的地方,默认情况下,webpack devServer 抓取热更新补丁的 path 是 /__webpack_hmr

如果你不设置 path 为你的 devServser 地址就会出现下面的问题,也就是直接向 chrome 扩展请求热更新数据了:

所以还是要使用 devServer 中间件来配置 devServer 才行:

// 部分 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 进行热更新的:

如何实现修改了 content script 之后自动 reload 扩展和刷新注入了 content script 的页面

这是本文的重点,其实思路是很清晰的:

  1. 监听 webpack 编译成功且修改了 content script 这一事件
  2. devServer 通过 SSE 主动推送消息给 background
  3. background 调用扩展 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 contentScriptsModules = 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 &&
    contentScriptsModules.includes(modules[0].chunks[0] as string);

  if (shouldReload) {
    // 发送消息给 background
  }
};
复制代码

devServer 通过 SSE 主动推送消息给 background

服务器端实时主动推送现在最常用应该是 WebSocket 了,既然我们已经有了一个 express 服务器,就没必要再启动一个 WebSocket 服务器,利用 SSE(Server Side Event) 只需要给 express 加个路由即可。整合上一节内容,我们可以实现一个 express 中间件:

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 调用扩展 API reload 扩展,并发送消息给所有注入了 content script 的页面让他们刷新页面

查阅 chrome extension 官方文档,发现官方提供了一个 reload API 。在 background 的 webpack entry 里同时注入我们用于监听服务器端推送的修改了 content scrript 事件以及后续处理逻辑的代码:

if (!isProd) {
  entry.background.unshift(resolve(__dirname, './autoReloadPatch.ts'));
}
复制代码
import tiza from 'tiza';

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

source.addEventListener(
  'open',
  () => {
    tiza
      .color('green')
      .bold()
      .text('Connected to extension auto reload SSE server!')
      .info();
  },
  false
);

source.addEventListener(
  'message',
  event => {
    console.info('Received a no event name message, data:', event.data);
  },
  false
);

source.addEventListener(
  'pause',
  () => {
    tiza
      .color('yellow')
      .bold()
      .text('Received pause message from server, ready to close connection')
      .info();
    source.close();
  },
  false
);

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
);

source.addEventListener(
  'error',
  (event: EventSourceEvent) => {
    if (event.target!.readyState === 0) {
      console.error('You need to open devServer first!');
    } else {
      console.error(event);
    }
  },
  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 :joy:。

最后,快过年了,祝大家新年快乐,2020 事业更上一层楼。

本文为原创内容,首发于个人博客,转载请注明出处。

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章