Elinia / Notes

没法当blog的小笔记
0 stars 0 forks source link

通过 Vite 实现 React 浏览器扩展开发的 HMR(模块热替换) #1

Closed Elinia closed 1 year ago

Elinia commented 1 year ago

注:该文撰写于 2021 年 11 月 25 日,因此有部分表述和方案已经过时,仅留做参考。

1 预热

1.1 HMR(模块热替换)

最早听到 HMR 这个词,是在使用前端构建工具 webpack 的时候。在 HMR 出现之前,我们需要在每次修改代码之后,完全重新构建代码并刷新页面以反映最新结果,这是一件十分费时费力地工作。而 HMR 允许在运行时更新所有类型的模块,无需完全刷新,极大地提升了前端开发效率。打包器在开发过程中会检测代码的更新并自动根据改动重新打包代码,以打补丁或直接替换的方式更新网页中运行的代码,且在这个过程中尽量不造成页面的完全刷新。

image.png 图片来源:Webpack's Hot Module Replacement Feature Explained

然而当构建的应用越来越庞大后,具有海量模块的代码极大地拖慢了基于全量构建的 webpack 等构建工具启动开发服务器以及 HMR 的速度。

1.2 Vite

Vite 作为新一代的前端构建工具,在发布之初便备受关注,在今年发布 Vite 2.0 后更是如日中天,广受吹捧。Vite 使用 esbuild 实现高速的依赖预构建,并借助现代浏览器对 ES 模块的原生支持提供单个模块级别的按需加载和 HMR,提高首次构建以及迭代构建的速度,带来更加优秀的前端开发体验。

image.png esbuild

我们也已在之前几个线上前端项目中在使用 create-react-app (使用 webpack)构建的基础上,集成了 Vite 以解决开发服务器启动慢,热更新慢的问题,显著地改善了开发体验,间接提升了迭代速度。

1.3 浏览器扩展开发

近期,在新的 POC 项目中,我们需要在开发网页应用的同时,开发一个浏览器扩展以完成与网页应用地对接和与后端地交互。然而浏览器扩展的社区生态相比网页应用差了许多个数量级,开源配套工具少之又少,自己摸索出现了问题也鲜能找到解决方案。

这篇文章就将把作者通过将 Vite 集成到现有的基于 create-react-app + parcel 的浏览器扩展项目上摸着石头过河的经验分享出来,希望能够让大家少走一些弯路,还可以稍微理解一下实现的机制原理。

1.4 正文前

本文灵感来源和实现大量参考开源模板 vitesse-webext,根据实际情况筛掉了不必要的部分,同时解决了一些原项目未考虑的问题如 React 框架适配,兼容 manifest v3 等。如果你想使用其他框架如 Vue 或 Svelte 来开发浏览器扩展,同时也对如 WindiCSS、pnpm 等感兴趣的话,不妨直接以此开源模板为支点来进行研究或开发,本文仅当作理解这种解决方案原理的参考即可。

另外声明,本人才疏学浅,可能会有理解错误的地方,若大家对文章观点有不同见解或发现文章描述情况与事实不符,欢迎指正,共同进步,谢谢。

2 支持开发模式 HMR

这章主要介绍开发模式相关的实现,为了让理解曲线相对平缓,会尽量忽略掉支持生产构建的部分,因此读者可能会感觉到代码不完整。先别急,我们将在后面的章节改写成支持生产构建的完全体代码。

另外由于我们的项目目前只考虑支持(较新版本的) Chrome 浏览器,因此只使用 chrome.runtime.Port 相关 API 进行网页应用与浏览器扩展的通信,暂时没有用到 content scripts,所以本文也没有包含这部分的实现。还希望大家见谅。

2.1 为啥浏览器扩展不好做 HMR

各大构建工具,无论 webpack 还是 Vite,都是通过以下手段来达成 HMR 的:

  1. 把写出来的代码进行编译转化(打包)变成浏览器可以直接运行的代码
  2. 启动一个开发服务器,让浏览器能够通过服务器获取打包后的代码
  3. 监听本地代码变化,在发生改变时根据影响范围重新打包代码
  4. 提供一个 Web Socket 接口,每当发生代码变化后,通知浏览器获取更新代码并替换

这些操作都有个前提是浏览器不是通过直接访问文件系统获得静态代码,而是通过访问构建工具建立的开发服务器去获取打包后代码。然而对于浏览器扩展,根据规范,开发者需要提供一个包含所有页面入口点的 manifest.json,这些入口点的路径必须是扩展文件夹下的静态路径。所以现有构建工具通过本地服务器提供的打包后代码就无法直接被指定为扩展入口点,加大了扩展做 HMR 的难度。

2.2 如何绕过浏览器扩展只能访问静态代码的难题

解决方案其实很直接,既然你不能直接指定开发服务器作为入口点,那么我就写一个静态的入口点,然后在静态入口点里请求开发服务器获取脚本就行了。

这里以 popup/index.html 为例介绍如何制作一个可以访问开发服务器的扩展入口点。

<!-- src/ext/popup/index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Popup</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="app"></div>
    <!-- <script type="module" src="./main.tsx"></script> -->
    <script type="module" src="http://localhost:xxxx/hmr.js"></script>
    <script type="module" src="http://localhost:xxxx/popup/main.tsx"></script>
  </body>
</html>

这里我们修改了两行代码,其中第二行很好理解,就是将原本的相对路径引入改成了开发服务器的路径。

那么第一行是干什么用的呢?其实这跟 Vite 的 React 插件的实现细节有一点关系。

2.3 在扩展 HMR 中适配 React 框架

Vite 在实现 React HMR 与 Vue/Svelte 的 HMR 上是有些许不同的。Vue/Svelte 插件的实现中只需要全局引入 /@vite/client 就可以完成 HMR 的工作了,但是 React 的实现中则需要额外注入一些方法和变量到浏览器全局的 window 中,并在每个 .jsx 文件中使用它们。在通常的网页应用开发中,这个注入是 Vite 的 React 插件来帮我们完成的。插件会在入口点的 HTML 文件(即使是库模式,在开发过程中也需要提供一个 HTML 的入口点)中添加一个 <script> 标签并将注入脚本作为行内脚本插入进去并通过开发服务器提供给浏览器。

然而我们为了解决扩展只能访问静态代码的问题时,需要提供一个静态的 HTML 代码,注入自然也就无法在 HTML 中动态完成了。对于这个问题,我的解决方案是把需要注入的代码保存在一个 hmr.js 文件中,并在每个入口点引入它已达到与动态注入行内脚本同样的效果。

这里相比于直接把行内脚本写在 HTML 文件中,通过文件引入更好,理由有以下几点:

  1. 更易于维护。对于浏览器扩展来说,通常拥有多个入口点(popup, options, background等),考虑到今后这段代码可能被更新,把它复用起来是更加明智的。
  2. 通过本地服务器引入可以自动进行依赖的路径解析,若使用行内脚本,我们需要为脚本中的所有依赖提供开发服务器中的路径。
  3. 行内脚本的运行需要制定额外的内容安全策略(Content Security Policy 或 CSP),使其允许行内脚本的运行。虽然我们可以仅在开发版本中制定它,但这仍会增加额外的心智负担。
// src/ext/hmr.js

// only on dev mode
if (import.meta.hot) {
  import('/@vite/client');
  const RefreshRuntime = import('/@react-refresh');

  RefreshRuntime.then(r => {
    r.default.injectIntoGlobalHook(window);
    window.$RefreshReg$ = () => {};
    window.$RefreshSig$ = () => type => type;
    window.__vite_plugin_react_preamble_installed__ = true;
  });
}

2.4 编写 Vite 配置文件

关于 Vite + React 通用的配置问题社区已经有很多相关文档文章教程了,作者团队之前也写过一篇文章记录这些问题,这里就不做过多赘述了。这一小节主要记录下做浏览器扩展开发模式相关的配置。

首先,为了方便编写和维护,我们先准备一些通用配置和方法:

// scripts/utils.ts
import { resolve } from 'path';

// 可以是任意可用端口
export const port = 3303;
// 用于获得对于项目根目录的相对路径,这在脚本目录不唯一的时候保持一致性有很大帮助,前提是提供这个工具方法的模块不会随意改变位置
export const r = (...args: string[]) => resolve(__dirname, '..', ...args);
export const isDev = process.env.NODE_ENV !== 'production';

// 开发服务器启动时的服务路径
export const devFetchPath = `http://localhost:${port}`;
// 扩展的源码路径
export const extSrcPath = 'src/ext';
// 扩展的生成后代码路径
export const extBuildPath = 'extension/dist';

然后就可以编写 vite.config.ext.ts 了:

// vite.config.ext.ts

import { defineConfig } from 'vite';
import {
  devFetchPath,
  extBuildPath,
  extSrcPath,
  isDev,
  port,
  r,
} from './scripts/utils';
import { sharedConfig } from './vite.config';

export default defineConfig({
  // Vite + React + Typescript 的一些通用配置
  ...sharedConfig,
  // 把扩展源码所在目录指定为根目录
  root: r(extSrcPath),
  // 指定静态资源的路径
  publicDir: 'extension',
  // 为了防止在同时开发网页应用和浏览器扩展时发生缓存的依赖项冲突,我们需要把缓存的路径分开
  cacheDir: r('node_modules/.vite.ext'),
  server: {
    port,
    hmr: {
      host: 'localhost',
    },
    // 指定开发阶段生成资源的路径
    origin: `${devFetchPath}/`,
  },
  // 代码中引入的资源基础路径
  // https://cn.vitejs.dev/guide/build.html#public-base-path
  base: `${devFetchPath}/`,
});

这里为啥不直接叫 vite.config.ts 呢?因为我们这个项目是把网页应用和浏览器扩展包含在一个代码仓库中,同时又没有使用像 monorepo 这样的项目管理策略,因此会同时需要多个 Vite 配置文件来管理不同项目,自然就需要不同的配置文件名了。如果你的项目是一个纯粹的浏览器扩展,那么包括一部分配置在内的代码都可以有一定程度上的简化,请读者根据实际情况进行调整。

2.5 生成 manifest.json

建立 manifest.ts 脚本用于生成 manifest.json 并将其写入扩展目录。可能有一些配置只针对 Chrome 扩展生效,请酌情参考:

// scripts/manifest.ts
// manifest.json 中的路径都是相对于扩展根目录(即 extension 目录)的路径

import fs from 'fs-extra';
import { devFetchPath, r } from '../scripts/utils';

const EXT_KEY = '[key]'; // 用于生成固定的 extension id
const LOGO_PATH = `./assets/logo.png`;

async function getManifest() {
  const common = {
    name: '[extension name]',
    version: '[extension version]',
    description: '[extension description]',
    icons: {
      '128': LOGO_PATH,
    },
    // 允许哪些外部网页应用与扩展做交互
    externally_connectable: {
      matches: [
        'http://localhost:*/*',
      ],
    },
    key: EXT_KEY,
  };

  const manifest = {
    ...common,
    manifest_version: 2,
    browser_action: {
      default_icon: LOGO_PATH,
      default_popup: './dist/popup/index.html',
    },
    options_ui: {
      page: './dist/options/index.html',
      open_in_tab: true,
    },
    background: {
      page: './dist/background/index.html',
      persistent: false,
    },
    // 允许扩展运行开发服务器提供的代码(对于扩展本身来说是外部代码,因此需要授权)
    content_security_policy: `script-src 'self' ${devFetchPath}; object-src 'self'`,
  };

  return manifest;
}

async function writeManifest() {
  // 生成 manifest.json 并写入到扩展根目录中
  await fs.ensureDir(r('extension'));
  await fs.writeJSON(r('extension/manifest.json'), await getManifest(), {
    spaces: 2,
  });
  console.log('write manifest.json');
}

writeManifest();

2.6 将 HTML 和 manifest.json 放入扩展目录

创建 watch.ts 的脚本用于管理开发模式下的静态代码(HTML 和 manifest.json):

// scripts/watch.ts

// generate stub index.html files for dev entry
import { execSync } from 'child_process';
import fs from 'fs-extra';
import chokidar from 'chokidar';
import { r, isDev, devFetchPath, extBuildPath, extSrcPath } from './utils';

if (isDev) {
  // 将源码目录中的 html 文件拷贝到扩展目录对应路径下
  /**
   * Stub index.html to use Vite in development
   */
  async function stubIndexHtml() {
    const views = ['options', 'popup', 'background'];

    for (const view of views) {
      await fs.ensureDir(r(`${extBuildPath}/${view}`));
      await fs.copy(
        r(`${extSrcPath}/${view}/index.html`),
        r(`${extBuildPath}/${view}/index.html`)
      )
      console.log(`stub ${view}`);
    }
  }
  stubIndexHtml();
  // 每当 html 文件变化时就重新拷贝一次
  chokidar.watch(r(`${extSrcPath}/**/*.html`)).on('change', () => {
    stubIndexHtml();
  });
  // 每当 manifest.ts 变化时就重新生成一次 manifest.json
  chokidar.watch(r('scripts/manifest.ts')).on('change', () => {
    execSync('npm run dev:ext:manifest', { stdio: 'inherit' });
  });
}

2.7 启动开发模式

// package.json
{
 "scripts": {
    "dev:ext": "npm run ext:clear && run-p dev:ext:manifest dev:ext:watch dev:ext:code",
    "dev:ext:manifest": "esno scripts/manifest.ts",
    "dev:ext:code": "vite --config vite.config.ext.ts",
    "dev:ext:watch": "esno scripts/watch.ts",
    "ext:clear": "rimraf extension/dist extension/manifest.json"
  }
}

run-p 是 CLI 工具库 [npm-run-all](https://github.com/mysticatea/npm-run-all) 中提供的脚本,指并行运行列出的 npm 脚本,我们也可以使用类似 run-p dev:ext:* 的 wild card 形式来运行所有满足特定格式的 npm 脚本。

esno 是 CLI 工具库 [esno](https://github.com/antfu/esno) 中提供的脚本,它的基本作用是执行 Node.js 脚本,但它提供了对 TypeScript 以及 ESNext 脚本的运行时支持。这里也有对它的简单介绍。

开发时只需要运行 npm run dev:ext 就 OK 啦。

image.png

3 支持生产模式构建

在上一章,我们已经能够在支持 HMR 的环境下进行浏览器扩展开发了。在实现过程中,我们把原本的生产模式构建流程破坏了一部分,接下来我们再来把生产模式的构建修补好。

3.1 使 HTML 文件支持生产模式构建

在上一章中,我们将使用相对路径导入脚本的 HTML 改成了开发服务器的路径。现在我们需要让它们能够根据是开发模式还是生产模式自动生成对应的路径。

首先我们把 HTML 中的路径改回能够支持生产模式构建的相对路径,这里仍以 popup 举例:

<!-- src/ext/popup/index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Popup</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="app"></div>
    <script type="module" src="../hmr.js"></script>
    <script type="module" src="./main.tsx"></script>
  </body>
</html>

然后修改仅在开发模式下执行的脚本 watch.ts 使其支持将 HTML 魔改为开发模式版本。只需修改其中的 stubIndexHtml() 方法即可:

// scripts/watch.ts

/* ... */

async function stubIndexHtml() {
  const views = ['options', 'popup', 'background'];

  for (const view of views) {
    await fs.ensureDir(r(`${extBuildPath}/${view}`));
    let data = await fs.readFile(
      r(`${extSrcPath}/${view}/index.html`),
      'utf-8'
    );
    // 把 HTML 中的相对路径替换为开发服务器的路径
    data = data
      .replace('"./main.ts"', `"${devFetchPath}/${view}/main.ts"`)
      .replace('"./main.tsx"', `"${devFetchPath}/${view}/main.tsx"`)
      .replace('"../hmr.js"', `"${devFetchPath}/hmr.js"`)
    // 提供一个友好提示告诉开发者仍在读取脚本或未启动开发服务器
      .replace(
      '<div id="app"></div>',
      '<div id="app">Loading assets or Vite server did not start</div>'
    );
    await fs.writeFile(
      r(`${extBuildPath}/${view}/index.html`),
      data,
      'utf-8'
    );
    console.log(`stub ${view}`);
  }
}

3.2 使 Vite 配置文件支持生产模式构建

上一章中我们只为 Vite 配置了开发模式的配置项,这里我们补全生产模式的配置。将 vite.config.ext.ts 修改如下:

// vite.config.ext.ts

import { defineConfig } from 'vite';
import {
  devFetchPath,
  extBuildPath,
  extSrcPath,
  isDev,
  port,
  r,
} from './scripts/utils';
import { sharedConfig } from './vite.config';

export default defineConfig(({ command }) => {
  return {
    ...sharedConfig,
    root: r(extSrcPath),
    publicDir: 'extension',
    cacheDir: r('node_modules/.vite.ext'),
    server: {
      port,
      hmr: {
        host: 'localhost',
      },
      origin: `${devFetchPath}/`,
    },
    // 开发模式与生产模式使用不同的基础路径
    base: command === 'serve' ? `${devFetchPath}/` : '/dist/',
    build: {
      outDir: r(extBuildPath),
      emptyOutDir: false, // 每次构建不清空目录,我们已经有专门的脚本做这件事
      sourcemap: isDev ? 'inline' : false,
      // 根据 Chrome 网页商店的要求,不允许对程代码进行混淆或功能性隐藏(正常压缩是允许的)
      terserOptions: {
        mangle: false,
      },
      rollupOptions: {
        // 指定代码的多个入口点
        input: {
          background: r(`${extSrcPath}/background/index.html`),
          options: r(`${extSrcPath}/options/index.html`),
          popup: r(`${extSrcPath}/popup/index.html`),
        },
      },
    },
  };
});

3.3 使 manifest.json 支持生产模式构建

实际上 manifest 部分不改也支持生产模式构建,不过由于开发模式使用了一些生产模式用不到的权限,因此可以在生产模式种去除。修改 manifest.ts 如下:

// scripts/manifest.ts

import fs from 'fs-extra';
import { devFetchPath, r } from '../scripts/utils';

// 区分开发模式和生产模式
const HMR = process.argv.includes('--hmr');

const EXT_KEY = '[key]';
const LOGO_PATH = './assets/logo.png';

async function getManifest() {
  const common = {
    name: '[extension name]',
    version: '[extension version]',
    description: '[extension description]',
    icons: {
      '128': LOGO_PATH,
    },
    externally_connectable: {
      matches: [
        'http://localhost:*/*',
      ],
    },
    key: EXT_KEY,
  };

  const manifest = {
    ...common,
    manifest_version: 2,
    browser_action: {
      default_icon: LOGO_PATH,
      default_popup: './dist/popup/index.html',
    },
    options_ui: {
      page: './dist/options/index.html',
      open_in_tab: true,
    },
    background: {
      page: './dist/background/index.html',
      persistent: false,
    },
  };

  // 仅开发模式需要连接到 localhost
  if (HMR) {
    manifest.content_security_policy = `script-src 'self' ${devFetchPath}; object-src 'self'`;
  }

  return manifest;
}

async function writeManifest() {
  await fs.ensureDir(r('extension'));
  await fs.writeJSON(r('extension/manifest.json'), await getManifest(), {
    spaces: 2,
  });
  console.log('write manifest.json');
}

writeManifest();

3.4 进行生产模式构建

// package.json
{
  // 添加 --hmr 区分开发模式
  "dev:ext:manifest": "esno scripts/manifest.ts --hmr",
 "build:ext": "run-s ext:clear build:ext:manifest build:ext:code",
  "build:ext:manifest": "esno scripts/manifest.ts",
  "build:ext:code": "cross-env REACT_APP_REPO_TYPE=ext vite build --config vite.config.ext.ts",
  "ext:clear": "rimraf extension/dist extension/manifest.json"
}

执行 npm run build:ext 即可进行生产模式构建啦。

4 支持 Manifest V3(MV3)下的开发和构建

眼尖的同学可能已经发现了,我们在上面章节介绍的流程使用的 Manifest 版本是 V2(MV2),而 MV3 已经在2020年发布,且最早可能在2022年1月份,Chrome 扩展商店就不接受新的 MV2 扩展上线了。我们为什么不直接使用 MV3 呢?这是因为 MV3 为了提高扩展的安全性,禁止了直接修改 script-src 允许外部脚本的配置项,而上文正是使用这个配置项来实现 HMR 的。

image.png

Chrome 对 MV2 的支持时间线

好在 MV3 和 MV2 在 API 上的改动不大,因此我们可以通过平时基于 MV2 开发(虽然不接受新的 MV2 扩展上线,但是仅在本地 MV2 开发在短期内还是会继续支持的),仅在构建时使用 MV3,同时提供不支持 HMR 的 MV3 开发环境来作为调查 MV3 相关问题的环境来尽量保持开发体验。这样即使在 MV2 被完全弃用后,我们仍然可以退化成一个不包含 HMR 的开发和构建环境继续迭代。

另外,关于 MV3 在这方面的改动,仍然有争议和 bug。也就是说,这一块还没有完全定稿,接下来可能还会有改动。因此在完全定稿之前,我们也不会花太大精力在研究如何做迁移上。

4.1 将 background 相关脚本转换成 service worker 模式

MV3 有一个重要改动就是将原本可以作为页面运行的 background 改为只能使用 service worker 模式运行了。

页面脚本和 service worker 脚本在支持的 API 上有少许不同,因此我们需要把 background 脚本修改成 service worker 所支持的版本。这通常来说不是什么问题,background 只负责一些核心的交互逻辑就可以了,这些逻辑基本上都是 service worker 所支持的。

好,以下假设我们的 background 脚本已经是完全兼容 service worker 模式了。

4.2 修改 Vite 配置文件把 background 的入口点 html 改为 js 形式

Vite 原生提供对库模式项目的支持,我们只要按照库模式就可以生成以 js 脚本作为入口点的打包代码。

但是这里也有个问题就是 Vite 是为单项目设计的,虽然提供了多入口模式,但是却不能在一份配置中同时打包库模式和多页面应用模式的代码。因此我们在这里把 background 脚本的打包配置从扩展打包配置中单独拿出来叫做 vite.config.ext.bg.ts

// vite.config.ext.ts

import { defineConfig, UserConfig } from 'vite';
import {
  devFetchPath,
  extBuildPath,
  extSrcPath,
  isDev,
  port,
  r,
} from './scripts/utils';
import { sharedConfig } from './vite.config';

export const extConfig: UserConfig = {
  ...sharedConfig,
  root: r(extSrcPath),
  publicDir: 'extension',
  cacheDir: r('node_modules/.vite.ext'),
  server: {
    port,
    hmr: {
      host: 'localhost',
    },
    origin: `${devFetchPath}/`,
  },
  build: {
    outDir: r(extBuildPath),
    emptyOutDir: false,
    sourcemap: isDev ? 'inline' : false,
    terserOptions: {
      mangle: false,
    },
  },
};

export default defineConfig(({ command }) => {
  return {
    ...extConfig,
    base: command === 'serve' ? `${devFetchPath}/` : '/dist/',
    build: {
      ...extConfig.build,
      rollupOptions: {
        input: {
          options: r(`${extSrcPath}/options/index.html`),
          popup: r(`${extSrcPath}/popup/index.html`),
        },
      },
    },
  };
});
// vite.config.ext.bg.ts

import { defineConfig } from 'vite';
import { extBuildPath, extSrcPath, r } from './scripts/utils';
import { extConfig } from './vite.config.ext';

export default defineConfig({
  ...extConfig,
  base: '/dist/',
  build: {
    ...extConfig.build,
    // 库模式
    lib: {
      entry: r(`${extSrcPath}/background/main.ts`),
      name: 'background',
      formats: ['es'],
    },
    rollupOptions: {
      output: {
        dir: `${extBuildPath}/background`,
        entryFileNames: 'index.js',
      },
    },
  },
});

4.3 支持生成 MV3 的 manifest.json

还是只要修改 manifest.ts 就行了:

// scripts/manifest.ts

import fs from 'fs-extra';
import { devFetchPath, r } from '../scripts/utils';

const HMR = process.argv.includes('--hmr');

const EXT_KEY = '[key]';
const LOGO_PATH = './assets/logo.png';

async function getManifest() {
  const common = {
    name: '[extension name]',
    version: '[extension version]',
    description: '[extension description]',
    icons: {
      '128': LOGO_PATH,
    },
    externally_connectable: {
      matches: [
        'http://localhost:*/*',
      ],
    },
    key: EXT_KEY,
  };

  const manifest = HMR
   ? {
        ...common,
        manifest_version: 2,
        browser_action: {
          default_icon: LOGO_PATH,
          default_popup: './dist/popup/index.html',
        },
        options_ui: {
          page: './dist/options/index.html',
          open_in_tab: true,
        },
        background: {
          page: './dist/background/index.html',
          persistent: false,
        },
        content_security_policy: `script-src 'self' ${devFetchPath}; object-src 'self'`,
      }
   : {
        ...common,
        manifest_version: 3,
        action: {
          default_icon: LOGO_PATH,
          default_popup: './dist/popup/index.html',
        },
        options_page: './dist/options/index.html',
        background: {
          service_worker: './dist/background/index.js',
          type: 'module',
        },
      };

  return manifest;
}

async function writeManifest() {
  await fs.ensureDir(r('extension'));
  await fs.writeJSON(r('extension/manifest.json'), await getManifest(), {
    spaces: 2,
  });
  console.log('write manifest.json');
}

writeManifest();

4.4 修改生产构建脚本并添加 MV3 环境开发模式脚本

生产构建脚本的改动主要是将 vite build 部分的代码从一条拆分成了两条。

MV3 环境开发模式脚本也很简单,就是在 vite build --watch 模式的基础上将模式设为 development 而已(覆写 process.env.NODE_ENV 这个环境变量)。

// package.json
{
 "watch:ext": "run-s ext:clear build:ext:manifest watch:ext:code",
  "watch:ext:code": "run-p watch:ext:bg watch:ext:web",
  "watch:ext:bg": "vite build --mode development -w --config vite.config.ext.bg.ts",
  "watch:ext:web": "vite build --mode development -w --config vite.config.ext.ts",
  "build:ext": "run-s ext:clear build:ext:manifest build:ext:code",
  "build:ext:manifest": "esno scripts/manifest.ts",
  "build:ext:code": "run-p build:ext:bg build:ext:web",
  "build:ext:bg": "vite build --config vite.config.ext.bg.ts",
  "build:ext:web": "vite build --config vite.config.ext.ts",
  "ext:clear": "rimraf extension/dist extension/manifest.json"
}

这样就可以通过 npm run watch:ext 来启动 MV3 环境下的开发模式了。当然,这个开发模式是不包含 HMR 功能的,调试的时候就需要手动刷新扩展页面(对于 popup 或 options 页面)或是点击扩展管理中的刷新按钮(对于 background 脚本)了。

5 结语

其实在这个环境搭建过程中,还遇到过很多小问题,有些已经包含在本文的解决方案中,还有一些和本文的主题无关,是各个工具使用的问题就没有记录在其中。为了防止篇幅过长造成阅读负担,这里就尽量不记录不必要的内容了,相信大家遇到这些小问题都有自己解决的能力。

本来还想把解决问题的思路总结成图画出来的,后来发现自己的画图功底实在是太烂,画出来自己都看不下去,就变成几乎纯文字的描述了。希望自己以后能把自己的画图技能点多加几点。

Elinia commented 1 year ago

贴出来主要是因为看到 chromium MV3 unpacked extension 不能执行 localhost 代码的 issue 已经被解决了,因此这个方案的这部分修改之后应该是能够正常在 MV3 使用 HMR 的,不用像文章中一样使用 build --watch 作为 fallback 方案。