Chrome 扩展脚手架中的共享代码包:@extension/shared 依赖注入与 React 工具集实战指南
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>>;
};
其运行机制可以分为三层:
- 同步快照:
useSyncExternalStore(storage.subscribe, storage.getSnapshot)是 React 18 官方的外部 store 订阅 API,存储变更时组件自动重渲染,拿到最新快照_data; - 首帧数据兜底:首次挂载时存储值尚未就绪,此时通过
storageMap缓存wrapPromise(storage.get())的"已包装 Promise"——pending状态抛出的 Promise 会被上层的 Suspense 捕获,等存储读取完成后再恢复渲染,避免首屏空白与"闪旧数据"问题; - 数据就绪后直读:一旦
_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 的引用,可以归纳出两条清晰的使用链路:
- 普通页面(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)包裹根组件; - 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:*",即可完整复用这套基础设施——这正是共享包模式在扩展脚手架中的最佳实践。