Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

热更新:Chrome 插件开发提效 #32

Open
Godiswill opened this issue Nov 3, 2022 · 0 comments
Open

热更新:Chrome 插件开发提效 #32

Godiswill opened this issue Nov 3, 2022 · 0 comments

Comments

@Godiswill
Copy link
Owner

原文

Chrome Manifest V3 + Webpack5 + React18 热更新提升开发效率。

解决的问题

开发 Chrome 插件的同学想必都会遇到一个问题:
每次更新代码需要在 chrome://extensions 扩展程序中

  1. 找到对应的插件点击刷新按钮
  2. 重新点击唤起插件查看效果

特别的繁琐,严重影响开发效率。

reload

本文借助 create-react-app reject 后的工程,改造实现:

  1. 支持现代 Web 开发一样的体验,React、TS、热更新(react-refresh)等
  2. 支持修改 popup 时,实时局部热更新
  3. 支持修改 content、background 时,无需手动刷新
  4. 支持静态资源 public 目录文件变动自动更新

实现过程

npx create-react-app crx-app --template typescript

进入工程目录

npm run eject

打包多文件

可能需要输出以下打包文件:

  1. main:主入口,create-react-app 项目主文件,可以用来本地网页开发时预览 popup、tab、panel、devtools 等
  2. popup、tab、panel、devtools 等输出 html,供 Chrome 插件展示页面
  3. content、background 输出 js,用来 Chrome 插件通信

一、 新增 config/pageConf.js,开发只需按需配置需要打包的输出的文件,内部自动会处理。

module.exports = {
  main: { // 必须需要 main 入口
    entry: 'src/pages/index',
    template: 'public/index.html',
    filename: 'index', // 输出为 index.html,默认主入口
  },
  background: {
    entry: 'src/pages/background/index',
  },
  content: {
    entry: 'src/pages/content/index',
  },
  devtools: {
    entry: 'src/pages/devtools/index',
    template: 'public/index.html',
  },
  newtab: {
    entry: 'src/pages/newtab/index',
    template: 'src/pages/newtab/index.html',
  },
  options: {
    entry: 'src/pages/options/index',
    template: 'src/pages/options/index.html',
  },
  panel: {
    entry: 'src/pages/panel/index',
    template: 'public/index.html',
  },
  popup: {
    entry: 'src/pages/popup/index',
    template: 'public/index.html',
  },
};

对应说明

type PageConfType = { 
  [key: string]: { // 输出文件名
    entry: string; // webpack.entry 会转化为绝对路径
    template?: string; // 模板 html,存在会被 HtmlWebpackPlugin 处理;没有表示纯 js 不会触发 webapck HMR
    filename?: string; // 输出到 build 中的文件名,默认是 key 的值
  }
}

二、修改 config/paths.js,处理第一步里的配置路径

+ /** 改动:多入口配置 */
+ const pages = Object.entries(require('./pageConf'));
+ // production entry
+ const entry = pages.reduce((pre, cur) => {
+   const [name, { entry }] = cur;
+   if(entry) {
+     pre[`${name}`] = resolveModule(resolveApp, entry);
+   }
+   return pre;
+ }, {});
+
+ // HtmlWebpackPlugin 处理 entry
+ const htmlPlugins = pages.reduce((pre, cur) => {
+   const [name, { template, filename }] = cur;
+   template && pre.push({
+     name,
+     filename: filename,
+     template: resolveApp(template),
+   });
+   return pre;
+ }, []);
+ 
+ // 检查必须文件是否存在
+ const requiredFiles = pages.reduce((pre, cur) => {
+   const { entry, template } = cur[1];
+   const entryReal = entry && resolveModule(resolveApp,entry);
+   const templateReal =  template && resolveApp(template);
+   entryReal && !pre.includes(entryReal) && pre.push(entryReal);
+   templateReal && !pre.includes(templateReal) && pre.push(templateReal);
+   return pre;
+ }, []);

导出供后续使用

// config after eject: we're in ./config/
module.exports = {
  ...
+  entry,
+  requiredFiles,
+  htmlPlugins,
};

三、修改 config/webpack.config.js,配置文件打包输出,固定打包文件名,因为需要在插件 manifest.json 中配置

- entry: paths.appIndexJs, // 删除默认配置
+ entry: paths.entry, // 换上自定义的 entry
output: {
-  filename: ... // 删除打包输出文件名配置
-  chunkFilename: ...
+  filename: '[name].js', // 固定打包文件名
},

...

plugins: [
  // Generates an `index.html` file with the <script> injected.
-  new HtmlWebpackPlugin(...)
  /** 改动:多页改造 */
+  ...paths.htmlPlugins.map(({ name, template, filename }) => new HtmlWebpackPlugin(
    Object.assign(
      {},
      {
        inject: true,
-        template: paths.appHtml,
+        template: template,
+        filename: `${filename || name}.html`,
+        chunks: [name],
+        cache: false,
      },
      ...
    )
+  )),
  new MiniCssExtractPlugin({
-    filename: 'static/css/[name].[contenthash:8].css',
-    chunkFilename: 'static/css/[name].[contenthash:8].chunk.css',
+    /** 改动:CSS 文件名写死,不需要运行时 CSS */
+    filename: '[name].css',
+    runtime: false,
  }),
]

四、修改 config/webpackDevServer.config.js,webpack 为了提升开发时效率,默认打包文件存储在内存中。
我们需要把文件打包在硬盘 build 文件夹中,然后在 Chrome 管理扩展程序中加载已解压的 build 目录。

devMiddleware: {
+  writeToDisk: true,
},

监听 public 目录

此目录可以放置一些 Chrome 插件需要的配置静态资源,如图标,manifest.json。目录下文件变动时,实时复制到 build 中。

一、修改 scripts/build.js

// 删除 copy 代码
- copyPublicFolder()

二、新增 yarn add copy-webpack-plugin -D
三、修改 config/webpack.config.js,监听 public 文件改动,复制最新到 build

plugins: [
+  new CopyPlugin({
+    patterns: [
+      {
+        context: paths.appPublic,
+        from: '**/*',
+        to: path.join(__dirname, '../build'),
+        transform: function (content, path) {
+          if(path.includes('manifest.json')) {
+            return Buffer.from(
+              JSON.stringify({
+                // version: process.env.npm_package_version,
+                // description: process.env.npm_package_description,
+                ...JSON.parse(content.toString()),
+              })
+            );
+          }
+          return content;
+        },
+        // filter: (resourcePath) => {
+        //   console.log(resourcePath);
+        //   return !resourcePath.endsWith('.html');
+        // },
+        globOptions: {
+          dot: true,
+          gitignore: true,
+          ignore: ['**/*.html'], // 过滤 html 文件
+        },
+      },
+    ],
+  }),
]

HRM 热更新配置

由于 Chrome 插件的 CSP 安全问题,不支持例如 content 热更新。
需要修改默认 HRM 配置,手动配置热更新文件,排除 content/background。

一、修改 config/webpackDevServer.config.js

+ hot: false, 
+ client: false,
- client: ...,

二、修改 scripts/start.jscheckBrowsers().then 里的 entry

const config = configFactory('development');
+ /** 改动:手动 HRM,在 crx 中必须带上 hostname、port 否则无法热更新,坑了很久。。。 */
+ const pages = Object.entries(require('../config/pageConf'));
+ pages.forEach((cur) => {
+   const [name, { template }] = cur;
+   const url = config.entry[name];
+   if(url && template) {
+     // https://webpack.js.org/guides/hot-module-replacement/#via-the-nodejs-api
+     config.entry[name] = [
+       'webpack/hot/dev-server.js',
+       `webpack-dev-server/client/index.js?hot=true&live-reload=true&hostname=${HOST}&port=${port}`,
+       url,
+     ];
+   }
+ });

三、修改 config/webpack.config.js,不允许产生运行时内联代码

- const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== 'false';

...

plugins: [
  ...
-  isEnvProduction &&
-    shouldInlineRuntimeChunk &&
-    new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/runtime-.+[.]js/]),
]

CRX content 变更自动加载

  • 问题:

上述可知,content 是无法支撑热更新自动加载的,
但 chrome content/background 被修改我们又不希望每次去插件管理界面点击刷新按钮。

  • 思路:
  1. webpack-dev-sever 提供了 middlewares 中间件能力处理路由请求,创建轻量级 Web 即时通讯技术 SSE(Server Sent Event)
  2. webpack-dev-sever 提供了文件变动监听生命周期的钩子 compiler.hooks,监听文件变更、生成
  3. webpack-dev-sever 与插件 background 使用 SSE 通信,变更文件后触发插件重新加载
  4. 插件 background、content 之间通信,触发 Tab 页 reload
  • 解决:

一、修改 scripts/start.js,webpack-dev-sever 启动时,新增 /reload 请求监听,并新建 SSE

+ const SSEStream = require('ssestream').default;
+ let sseStream;
const serverConfig = {
  ...createDevServerConfig(proxyConfig, urls.lanUrlForConfig),
  host: HOST,
  port,
+  setupMiddlewares: (middlewares, _devServer) => {
+    if (!_devServer) {
+      throw new Error('webpack-dev-server is not defined');
+    }
+    middlewares.unshift({
+      name: 'handle_content_change',
+      path: '/reload', // 监听路由
+      middleware: (req, res) => {
+        console.log('sse reload');
+        sseStream = new SSEStream(req);
+
+        sseStream.pipe(res);
+        res.on('close', () => {
+          sseStream.unpipe(res);
+        });
+      },
+    });
+
+    return middlewares;
+  }
+};

二、在 devServer.startCallback 中新增 hooks 监听 content/background 变化时发送 SSE 消息

+ let contentOrBackgroundIsChange = false;
+ compiler.hooks.watchRun.tap('WatchRun', (comp) => {
+   if (comp.modifiedFiles) {
+     const changedFiles = Array.from(comp.modifiedFiles, (file) => `\n  ${file}`).join('');
+     console.log('FILES CHANGED:', changedFiles);
+     if(['src/pages/background/', 'src/pages/content/'].some(p => changedFiles.includes(p))) {
+       contentOrBackgroundIsChange = true;
+     }
+   }
+ });
+ 
+ compiler.hooks.done.tap('contentOrBackgroundChangedDone', () => {
+   if(contentOrBackgroundIsChange) {
+     contentOrBackgroundIsChange = false;
+     console.log('--------- 发起 chrome reload 更新 ---------');
+     sseStream?.writeMessage(
+       {
+         event: 'content_changed_reload',
+         data: {
+           action: 'reload extension and refresh current page'
+         }
+       },
+       'utf-8',
+       (err) => {
+         sseStream?.unpipe();
+         if (err) {
+           console.error(err);
+         }
+       },
+     );
+   }
+ });
+ 
+ compiler.hooks.failed.tap('contentOrBackgroundChangeError', () => {
+   contentOrBackgroundIsChange = false;
+ });

三、新增 src/pages/background/index.ts,监听 SSE,收到文件变更通知,先利用 chrome.tabs.sendMessage 给 content 发消息,
刷新当前 Tab 页,然后 chrome.runtime.reload() 自动加载插件

if (process.env.NODE_ENV === 'development') {
    const eventSource = new EventSource(
        `http://${process.env.REACT_APP__HOST__}:${process.env.REACT_APP__PORT__}/reload/`
    );
    console.log('--- 开始监听更新消息 ---');
    eventSource.addEventListener('content_changed_reload', async ({ data }) => {
        const [tab] = await chrome.tabs.query({
            active: true,
            lastFocusedWindow: true,
        });
        const tabId = tab.id || 0;
        console.log(`tabId is ${tabId}`);
        await chrome.tabs.sendMessage(tabId, {
            type: 'window.location.reload',
        });
        console.log('chrome extension will reload', data);
        chrome.runtime.reload();
    });
}

四、新增 src/pages/content/index.ts,如果 content 变动且跟当前页 Tab 页有通信,需要刷新当前页。
同样可以自动化实现,在 content 中监听 background 消息 reload Tab 页。

chrome.runtime.onMessage.addListener(
    (
        msg: MessageEventType,
        sender: chrome.runtime.MessageSender,
        sendResponse: (response: string) => void
    ) => {
        console.log('[content.js]. Message received', msg);
        sendResponse('received');
        if (process.env.NODE_ENV === 'development') {
            if (msg.type === 'window.location.reload') {
                console.log('current page will reload.');
                window.location.reload();
            }
        }
    }
);

build zip

懒人自动化最后一步,生产编译后自动 zip 包。

一、新增 config/scripts/zip.js

const fs = require('fs');
const path = require('path');
const zipFolder = require('zip-folder');

const manifestJson = require('../build/manifest.json');

const SrcFolder = path.join(__dirname, '../build');
const ZipFilePath = path.join(__dirname, '../release');

const makeDestZipDirIfNotExists = () => {
  if (!fs.existsSync(ZipFilePath)) {
    fs.mkdirSync(ZipFilePath);
  }
};

function removeSpace(str, str2) {
  return str?.replace(/\s+/g, str2 || '');
}

const main = () => {
  const { name, version } = manifestJson;
  const zipFilename = path.join(
    ZipFilePath,
    `${removeSpace(name, '_')}-v${removeSpace(version)}.zip`
  );

  makeDestZipDirIfNotExists();

  console.info(`Zipping ${zipFilename}...`);
  zipFolder(SrcFolder, zipFilename, (err) => {
    if (err) {
      return console.err(err);
    }
    console.info('Zip is OK');
  });
};

main();

二、修改 package.json

+ "build": "node scripts/build.js && node scripts/zip.js",

最终效果

overview

太懒了,这里就不搞动态图了,各位看官老爷自行获取代码运行查看效果。

cra-crx-boilerplate

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant