Hot Update: Chrome Plug-in Development Improves Efficiency

Hot Update: Chrome Plug-in Development Improves Efficiency

original

Chrome Manifest V3 + Webpack5 + React18 hot update improves development efficiency.

solved problem

Students who develop Chrome extensions must have encountered a problem:
every time the code is updated, it needs to be in the chrome://extensionsextension

  1. Find the corresponding plugin and click the refresh button
  2. Click again to evoke the plug-in to view the effect

It is particularly cumbersome and seriously affects development efficiency.

insert image description here

With the help of the project after create-react-app reject, this article transforms and implements:

  1. Support the same experience as modern web development, React, TS, hot update (react-refresh), etc.
  2. Support real-time partial hot update when popup is modified
  3. When modifying content and background, there is no need to refresh manually
  4. Support automatic update of static resource public directory file changes

Implementation process

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

Enter the project directory

npm run eject

Pack multiple files

It may be necessary to output the following package files:

  1. main: the main entry, the main file of the create-react-app project, which can be used to preview popup, tab, panel, devtools, etc. during local web development
  2. Popup, tab, panel, devtools, etc. output html for the Chrome plug-in to display the page
  3. content, background output js, used for Chrome plug-in communication

1. Newly added config/pageConf.js, development only needs to configure the output files that need to be packaged as needed, and it will be processed automatically internally.

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',
  },
};

Correspondence description

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

2. Modify config/paths.jsand process the configuration path in the first step

+ /** 改动:多入口配置 */
+ 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;
+ }, []);

Export for later use

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

3. Modify config/webpack.config.js, the configuration file is packaged and output, and the package file name is fixed, because it needs to be configured in the plugin 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,
  }),
]

4. Modification config/webpackDevServer.config.js. In order to improve the efficiency of development, webpack stores the packaged files in memory by default.
We need to pack the files in the hard drive build folder, then load the unpacked build directory in the Chrome management extension.

devMiddleware: {
+  writeToDisk: true,
},

Listen to the public directory

This directory can place some configuration static resources needed by Chrome extensions, such as icons and manifest.json. When the files in the directory change, they are copied to the build in real time.

1. Modify scripts/build.js

// 删除 copy 代码
- copyPublicFolder()

2. Add yarn add copy-webpack-plugin -D
3. Modify config/webpack.config.js, monitor public file changes, copy the latest to 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 hot update configuration

Due to the CSP security issue of the Chrome extension, for example, content hot update is not supported.
You need to modify the default HRM configuration, manually configure the hot update file, and exclude content/background.

1. Modifyconfig/webpackDevServer.config.js

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

Second, scripts/start.jsmodify checkBrowsers().thentheentry

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,
+     ];
+   }
+ });

3. Modification config/webpack.config.js, it is not allowed to generate runtime inline code

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

...

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

CRX content changes are automatically loaded

  • question:

As can be seen from the above, content cannot support automatic loading of hot updates,
but we don’t want to click the refresh button every time the chrome content/background is modified.

  • Ideas:
  1. webpack-dev-sever provides middlewares middleware capabilities to process routing requests and create lightweight Web instant messaging technology SSE (Server Sent Event)
  2. webpack-dev-sever provides a hook compiler.hooks for file change monitoring life cycle, which monitors file changes and generates
  3. webpack-dev-sever communicates with the plugin background using SSE, and triggers the plugin to reload after changing the file
  4. Communication between the background and content of the plug-in, triggering Tab page reload
  • solve:

1. Modification scripts/start.js, when webpack-dev-sever starts, add /reloadrequest monitoring and create a new 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;
+  }
+};

2. Add hooks in devServer.startCallback to send SSE messages when monitoring content/background changes

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

3. Newly added src/pages/background/index.ts, monitor SSE, receive file change notification, first use to send a message chrome.tabs.sendMessageto content,
refresh the current Tab page, and then chrome.runtime.reload()automatically load the plug-in

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

4. Newly added src/pages/content/index.ts, if the content changes and there is communication with the Tab page of the current page, the current page needs to be refreshed.
It can also be implemented automatically, listening to the background message reload Tab page in the content.

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

The lazy man automates the last step, producing and compiling the zip package automatically.

1. Addconfig/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();

2. Modify package.json

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

final effect

insert image description here

I'm too lazy, so I won't engage in dynamic graphics here. Readers, masters, get the code and run it to see the effect.

cra-crx-boilerplate

Guess you like

Origin blog.csdn.net/guduyibeizi/article/details/127684258