Chrome 扩展脚手架中的共享代码包:@extension/shared 依赖注入与 React 工具集实战指南

原创2026-10-01 15:06:14450 阅读
文章标签:前端示例工程

Chrome 扩展脚手架中的共享代码包:@extension/shared 依赖注入与 React 工具集实战指南

@extension/shared 是 chrome-extension-boilerplate-react-vite 这一 pnpm monorepo 中的核心共享包,集中承载了扩展各页面(Popup、Options、New Tab、DevTools Panel、Content Script)之间复用的 React Hook、高阶组件、工具函数与类型定义。本文以 packages/shared/README.md 为骨架,结合包内全部源码与各页面模块的真实调用点,完整讲解如何把它引入自己的页面包、每个导出 API 的底层实现原理,以及在 Shadow DOM 注入、存储订阅等场景下的实战用法。

包定位:@extension/shared 在脚手架中扮演什么角色

在 chrome-extension-boilerplate-react-vite 中,整个仓库被组织为多包 workspace(根目录存在 pnpm-workspace.yaml),每个页面(popup、options、new-tab、devtools-panel、side-panel、content、content-runtime、content-ui)都是独立 npm 包,而跨页面复用的代码被集中抽取到 packages/shared 中。

README 对它的定位只有一句话:"This package contains code shared with other packages"(本包包含与其他包共享的代码)。这句话背后对应的是脚手架的一个关键设计决策——凡是两个以上页面都会用到的 React 能力、样式隔离工具、日志与类型工具,都统一收敛到 @extension/shared,而不是在各页面中各自复制一份。

从入口文件 index.mts 可以看到包的对外导出面,共四个来源:

export * from './lib/hooks/index.js';
export * from './lib/hoc/index.js';
export * from './lib/utils/index.js';
export * from './const.js';

对应四个能力分区:

导出来源 提供内容 典型用途
lib/hooks/ useStorage 响应式订阅扩展 Storage 数据
lib/hoc/ withErrorBoundary、withSuspense 为组件注入错误边界与 Suspense
lib/utils/ initAppWithShadow、colorfulLog、excludeValuesFromBaseArray、sleep、COLORS Shadow DOM 注入、彩色日志、数组工具、类型工具
const.ts PROJECT_URL_OBJECT 项目地址常量对象

在 package.json 中引入共享包:核心实操

原文档给出了使用 @extension/shared 的唯一前置条件——在目标包的 package.json 中添加依赖:

{
  "dependencies": {
    "@extension/shared": "workspace:*"
  }
}

workspace:* 是 pnpm workspace 协议:它让本地包引用自动解析到仓库内 packages/shared 目录下的源码包,而不是从 npm registry 下载外部版本,从而保证所有页面共享同一份代码、同一次类型检查与同一轮热更新。

在 packages/shared/package.json 中可以看到该包自身的元数据:

  • "name": "@extension/shared"、"version": "0.5.0"、"type": "module";
  • "private": true——仅限仓库内部使用,不会被发布到公共 registry;
  • "sideEffects": false——打包器可安全做 tree-shaking,只保留实际用到的导出;
  • "types": "index.mts"、"main": "dist/index.mjs"、"files": ["dist/**"]——对外暴露编译产物与类型声明。

值得注意的是它的构建脚本:

"scripts": {
  "ready": "tsc -b",
  "postinstall": "pnpm ready"
}

postinstall 会在依赖安装后自动执行 tsc -b(TypeScript 工程引用模式构建),确保 dist 产物在页面包编译前就已就绪。因此在实际工程中,首次安装依赖后通常无需手动构建共享包即可直接引用。

以页面包为例,pages/popup/package.json、pages/options/package.json、pages/new-tab/package.json、pages/devtools-panel/package.json 以及 pages/content/package.json 等均已声明 "@extension/shared": "workspace:*",新增页面包时照此添加即可。

React Hook 深度解析:useStorage 如何同步订阅 Storage

useStorage 是 @extension/shared 中复用面最广的导出(见 lib/hooks/use-storage.tsx),它把 @extension/storage 包的存储实例转换为 React 响应式数据。完整实现如下:

import { useRef, useSyncExternalStore } from 'react';
import type { BaseStorageType } from '@extension/storage';

const storageMap: Map<BaseStorageType<any>, WrappedPromise> = new Map();

const wrapPromise = <R,>(promise: Promise<R>) => {
  let status = 'pending';
  let result: R;
  const suspender = promise.then(
    r => { status = 'success'; result = r; },
    e => { status = 'error'; result = e; },
  );
  return {
    read() {
      switch (status) {
        case 'pending': throw suspender;
        case 'error': throw result;
        default: return result;
      }
    },
  };
};

export const useStorage = <
  Storage extends BaseStorageType<Data>,
  Data = Storage extends BaseStorageType<infer Data> ? Data : unknown,
>(
  storage: Storage,
) => {
  const initializedRef = useRef(false);
  const _data = useSyncExternalStore<Data | null>(storage.subscribe, storage.getSnapshot);

  if (!storageMap.has(storage)) {
    storageMap.set(storage, wrapPromise(storage.get()));
  }
  if (_data || initializedRef.current) {
    storageMap.set(storage, { read: () => _data });
    initializedRef.current = true;
  }
  return (_data ?? storageMap.get(storage)!.read()) as Exclude<Data, PromiseLike<unknown>>;
};

其运行机制可以分为三层:

  1. 同步快照:useSyncExternalStore(storage.subscribe, storage.getSnapshot) 是 React 18 官方的外部 store 订阅 API,存储变更时组件自动重渲染,拿到最新快照 _data;
  2. 首帧数据兜底:首次挂载时存储值尚未就绪,此时通过 storageMap 缓存 wrapPromise(storage.get()) 的"已包装 Promise"——pending 状态抛出的 Promise 会被上层的 Suspense 捕获,等存储读取完成后再恢复渲染,避免首屏空白与"闪旧数据"问题;
  3. 数据就绪后直读:一旦 _data 有值或已初始化过,就把缓存替换为 { read: () => _data },后续渲染直接返回快照值。

类型层面,Storage extends BaseStorageType<Data> 结合条件类型 Storage extends BaseStorageType<infer Data>,使返回数据自动推断为存储实例对应的数据类型,并借助 Exclude<Data, PromiseLike<unknown>> 抹除可能的 Promise 形态。

典型用法可见于 pages/popup/src/Popup.tsx 等页面:

import { useStorage } from '@extension/shared';
// exampleThemeStorage 定义于 packages/storage/lib/impl/example-theme-storage.ts
const { isLight } = useStorage(exampleThemeStorage);

同样的模式还出现在 pages/options/src/Options.tsx、pages/new-tab/src/NewTab.tsx、pages/devtools-panel/src/Panel.tsx 中,主题开关(明暗色)即通过这一 Hook 在多个页面间保持一致。

高阶组件:withErrorBoundary 与 withSuspense

withErrorBoundary

lib/hoc/with-error-boundary.tsx 基于 react-error-boundary 库封装:

export const withErrorBoundary = <T extends Record<string, unknown>>(
  Component: ComponentType<T>,
  FallbackComponent: ComponentType<FallbackProps>,
) =>
  function WithErrorBoundary(props: T) {
    return (
      <ErrorBoundary FallbackComponent={FallbackComponent}>
        <Component {...props} />
      </ErrorBoundary>
    );
  };

它接收一个业务组件与一个 FallbackComponent(接收 react-error-boundary 的 FallbackProps,内含 error、resetKeys、resetErrorBoundary 等能力),返回包了一层 ErrorBoundary 的新组件。业务组件抛错时,页面不再白屏,而是渲染兜底 UI。react-error-boundary 被声明为 packages/shared/package.json 中唯一的运行时依赖(^6.0.0)。

脚手架自带的 ErrorDisplay 组件(packages/ui/lib/components/error-display/ErrorDisplay.tsx)即典型兜底组件,包含错误标题、堆栈列表与重置按钮。

withSuspense

lib/hoc/with-suspense.tsx 的实现更简洁:

export const withSuspense =
  <T extends Record<string, unknown>>(Component: ComponentType<T>, SuspenseComponent: ReactElement) =>
  (props: T) => (
    <Suspense fallback={SuspenseComponent}>
      <Component {...props} />
    </Suspense>
  );

它把组件包进 <Suspense>,并接收一个 ReactElement 作为 fallback(如加载动画)。它和 useStorage 的 wrapPromise 机制天然配合:存储未就绪时抛出的 Promise 会让 Suspense 显示 loading 占位(如 packages/ui/lib/components/LoadingSpinner.tsx),就绪后自动渲染真实内容。

两个 HOC 在页面中的组合用法,见 pages/new-tab/src/NewTab.tsx:

import { PROJECT_URL_OBJECT, useStorage, withErrorBoundary, withSuspense } from '@extension/shared';
const { isLight } = useStorage(exampleThemeStorage);
export default withErrorBoundary(withSuspense(NewTab, <LoadingSpinner />), ErrorDisplay);

工具函数与常量

initAppWithShadow:把 React 应用注入 Shadow DOM

lib/utils/init-app-with-shadow.ts 是 Content Script 场景的核心工具,用于将 React 应用挂载到宿主页面 DOM,同时用 Shadow DOM 隔离样式,避免污染宿主页面:

export const initAppWithShadow = ({ id, app, inlineCss }: { id: string; inlineCss: string; app: ReactElement }) => {
  const root = document.createElement('div');
  root.id = id;
  document.body.append(root);

  const rootIntoShadow = document.createElement('div');
  rootIntoShadow.id = `shadow-root-${id}`;
  const shadowRoot = root.attachShadow({ mode: 'open' });

  if (navigator.userAgent.includes('Firefox')) {
    // Firefox 环境下 adoptedStyleSheets 存在 bug(Bugzilla 1770592),
    // 退化为向 shadowRoot 注入 <style> 元素;这可能导致与宿主页面样式冲突
    const styleElement = document.createElement('style');
    styleElement.innerHTML = inlineCss;
    shadowRoot.appendChild(styleElement);
  } else {
    // 其他浏览器:构造 CSSStyleSheet 并挂到 adoptedStyleSheets
    const globalStyleSheet = new CSSStyleSheet();
    globalStyleSheet.replaceSync(inlineCss);
    shadowRoot.adoptedStyleSheets = [globalStyleSheet];
  }

  shadowRoot.appendChild(rootIntoShadow);
  createRoot(rootIntoShadow).render(app);
};

调用点集中在 Content Script 入口,例如 pages/content-runtime/src/matches/all/index.tsx、pages/content-ui/src/matches/all/index.tsx,以及各自的 example 匹配目录。使用方式大致为:

import { initAppWithShadow } from '@extension/shared';
initAppWithShadow({
  id: 'your-app-root',
  inlineCss: '', // 通常是 vite 注入的内联样式字符串
  app: <YourApp />,
});

其中 Firefox 分支的设计值得注意:由于 Firefox 对 adoptedStyleSheets 支持存在缺陷(源码注释指向 Bugzilla 1770592),该工具在 Firefox 下改用 <style> 元素注入 CSS,代价是可能与宿主页面产生样式冲突——这属于代码中明确说明的已知权衡。

colorfulLog 与 COLORS 颜色表

lib/utils/colorful-logger.ts 提供终端彩色日志:

export const colorfulLog = (message: string, type: ColorType) => {
  let color: ValueOf<typeof COLORS>;
  switch (type) {
    case 'success': color = COLORS.FgGreen; break;
    case 'info':    color = COLORS.FgBlue;  break;
    case 'error':   color = COLORS.FgRed;   break;
    case 'warning': color = COLORS.FgYellow; break;
    default:        color = COLORS[type];   break;
  }
  console.info(color, message);
  console.info(COLORS['Reset']);
};

ColorType 定义于 lib/utils/types.ts:'success' | 'info' | 'error' | 'warning' | keyof typeof COLORS,即除四个语义别名外,还可直接传 COLORS 表中的任意键。颜色常量表见 lib/utils/const.ts,包含 Reset、Bright、Dim、Underscore、Blink、Reverse、Hidden 以及 16 色前景/背景 ANSI 转义码。

数组与延时工具

lib/utils/helpers.ts 提供两个小而实用的函数:

export const excludeValuesFromBaseArray = <B extends string[], E extends (string | number)[]>(
  baseArray: B,
  excludeArray: E,
) => baseArray.filter(value => !excludeArray.includes(value)) as ExcludeValuesFromBaseArrayType<B, E>;

export const sleep = async (time: number) => new Promise(r => setTimeout(r, time));
  • excludeValuesFromBaseArray:从 baseArray 中剔除 excludeArray 里的值,并利用类型工具 ExcludeValuesFromBaseArrayType(基于 type-fest 的 TupleToUnion 做 Exclude)在编译期保留被剔除后的精确字面量类型;
  • sleep:Promise 化延时,常用于模拟异步等待或控制重试节奏。

类型工具与项目常量

lib/utils/types.ts 除定义了上述 ColorType、ExcludeValuesFromBaseArrayType 外,还通过 export type * from 'type-fest' 整体再导出 type-fest 的类型工具集(type-fest 以 ^4.41.0 声明在 devDependencies 中),并定义了 ManifestType = chrome.runtime.ManifestV3,可直接在 MV3 相关代码中复用。

const.ts 仅导出一个 PROJECT_URL_OBJECT 常量对象,被 pages/devtools-panel/src/Panel.tsx 等页面用于在界面中展示项目地址;本文不展开其具体 URL。

在页面模块中的真实应用场景

综合各页面对 @extension/shared 的引用,可以归纳出两条清晰的使用链路:

  1. 普通页面(popup / options / new-tab / devtools-panel / side-panel):同时引入 useStorage(订阅主题存储 exampleThemeStorage)、withErrorBoundary、withSuspense 与 PROJECT_URL_OBJECT。参考 pages/popup/src/Popup.tsx、pages/options/src/Options.tsx、pages/new-tab/src/NewTab.tsx、pages/devtools-panel/src/Panel.tsx,统一用 withErrorBoundary(withSuspense(App, <LoadingSpinner />), ErrorDisplay) 包裹根组件;
  2. Content Script 页面(content-runtime / content-ui):仅引入 initAppWithShadow,以 Shadow DOM 方式把 React 应用挂载进宿主页面。参考 pages/content-runtime/src/matches/all/index.tsx 与 pages/content-ui/src/matches/all/index.tsx。

这种"普通页面用 Hooks + HOC,内容脚本用 Shadow DOM 注入"的分工,正是 @extension/shared 设计边界的体现:它不关心具体业务,只沉淀跨页面通用的 React 基础设施。

构建与维护:scripts 一览

packages/shared/package.json 内置了完整的开发脚本,可用于单独维护该包:

命令 作用
pnpm ready 执行 tsc -b,以工程引用方式生成 dist 产物
pnpm lint / pnpm lint:fix ESLint 检查与自动修复
pnpm format 以根目录 .prettierignore 为忽略规则执行 Prettier 格式化
pnpm type-check 执行 tsc --noEmit 做纯类型检查
pnpm clean 依次清理 dist、node_modules、.turbo 缓存

由于 postinstall 已绑定 ready,正常依赖安装流程即可完成构建;需要单独重建时再手动执行 pnpm ready 即可。

小结

@extension/shared 虽然 README 篇幅极简,但其实际价值由包内源码支撑:useStorage 以 useSyncExternalStore + Promise 包装实现了存储的响应式订阅与首帧兜底;withErrorBoundary / withSuspense 提供了统一的异常与异步渲染编排;initAppWithShadow 解决了内容脚本场景最棘手的样式隔离问题,并妥善处理了 Firefox 的兼容分支;colorfulLog、excludeValuesFromBaseArray、sleep 与类型工具则补齐了日常开发的高频需求。在 chrome-extension-boilerplate-react-vite 中,新增任何页面包时只需在 package.json 声明 "@extension/shared": "workspace:*",即可完整复用这套基础设施——这正是共享包模式在扩展脚手架中的最佳实践。

登录后查看全文
chrome-extension-boilerplate-react-vite