@x3china/miaoda-expo-devkit
v0.1.1-beta.87
Published
Expo 应用开发工具集:Sentry DSN 替换 stub、错误/网络捕获、Metro 符号化
Readme
miaoda-expo-devkit
Expo / React Native 开发环境工具集,通过 Metro 构建层注入以下能力:
- Sentry DSN 替换 — 拦截
@sentry/react-native,将真实 DSN 替换为无害 stub,防止开发期错误事件发送到 Sentry 服务器 - 错误 / 网络捕获 — 内置
SentryCapture,自动调用 Metro/symbolicate还原源码位置并输出到console.warn - Bundle 首部注入 — 在 expo-router 初始化之前执行自定义脚本
- HMR postMessage 控制 — 通过
window.postMessage在运行时启动或停止 Fast Refresh - LogBox 屏蔽 — web 平台禁用 Expo 全屏错误遮罩
- Metro transform 缓存持久化 — 将缓存目录固定到项目根目录(可通过
METRO_CACHE_DIR指定),容器/CI 重启后不丢失 - 构建耗时日志 — 将 bundle 总耗时和每个 cache miss 文件的 transform 耗时写入 JSONL 文件(通过
METRO_TRANSFORM_LOG启用) - esbuild minifier — 生产构建时将 Metro minifier 切换为 esbuild(比 terser 快数十倍)
- WASM 支持 — 将
.wasm加入 assetExts,修复 expo-sqlite web worker 打包失败 - lucide-react-native 路径解析 — 配合 babel 插件,消除图标子路径 exports 未声明的 warning
- workspace node_modules 修复 — 修复沙箱环境中 node_modules 位于祖先目录时 Metro 模块解析和 bundle 请求失败的问题
- expo-notifications stub — Expo Go(Android)中提供 no-op 实现,核心 API 调用时弹出带参数校验的调试 Alert,Dev Build 透传真实模块
- expo-media-library stub — Expo Go / Web 中提供 no-op 实现,
saveToLibraryAsync、createAssetAsync、权限请求等 API 调用时弹出 Alert 提示,Dev Build 原生环境透传真实模块 - expo-calendar stub — Expo Go / Web 中提供 no-op 实现,
getEventsAsync、createEventAsync、getCalendarsAsync、权限请求等核心 API 调用时弹出 Alert 提示并校验参数,Dev Build 原生环境透传真实模块 - expo-file-system stub — Web 中将
expo-file-system和expo-file-system/legacy替换为 no-op stub,核心 API 弹 Dialog 提示,Expo Go / Dev Build 透传真实模块 - expo-haptics stub — Web 中提供 no-op 实现,触觉 API 调用时弹 Dialog 提示参数信息,native 不受影响
- expo-contacts stub — Web / Expo Go 中提供 no-op 实现,联系人 API 调用时弹 Dialog 提示,Dev Build 透传真实模块
- expo-image-picker stub — 桌面 Web 中
launchCameraAsync通过getUserMedia打开摄像头预览弹窗(浏览器原生capture属性在 PC 端被忽略),移动端浏览器透传 expo 原实现,Native 不受影响 - devkit-lint — 集成 Oxlint(18 条自定义规则)、Biome、TypeScript 类型检查、以及
app.json字段合法性校验,一条命令完成项目全量静态检查
安装
在 pnpm workspace 中,将包添加到应用的依赖:
// your-app/package.json
{
"dependencies": {
"miaoda-expo-devkit": "workspace:*"
}
}pnpm install使用
Metro 配置
// metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const { withDevkit } = require('miaoda-expo-devkit/metro');
module.exports = withDevkit(getDefaultConfig(__dirname));withDevkit 已内置所有 wrapper(含 expo-notifications、expo-media-library、expo-calendar stub),无需手动叠加。也可单独使用各 wrapper:
const { withDevStubs, withEntryInjection, withExpoMediaLibraryStub, withExpoCalendarStub } = require('miaoda-expo-devkit/metro');
module.exports = withExpoCalendarStub(withExpoMediaLibraryStub(withEntryInjection(withDevStubs(config))));Sentry 初始化
照常调用 Sentry.init,stub 自动注入内置捕获器,无需额外配置:
// app/_layout.tsx
import * as Sentry from '@sentry/react-native';
Sentry.init({
dsn: 'https://[email protected]/your-project',
// stub 自动拦截 beforeSend / beforeBreadcrumb,错误摘要输出到 console.warn
});自定义回调(可选)
传入自己的 beforeSend / beforeBreadcrumb,会在内置捕获器之后串联执行:
import * as Sentry from '@sentry/react-native';
import { SentryCapture } from 'miaoda-expo-devkit';
import type { ErrorCaptureInfo, NetworkCaptureInfo } from 'miaoda-expo-devkit';
const capture = new SentryCapture({
onError(info: ErrorCaptureInfo) {
console.log('Error at:', info.symbolicatedFrames?.[0]);
},
onNetwork(info: NetworkCaptureInfo) {
console.log('Network:', info.method, info.url, info.statusCode);
},
});
Sentry.init({
dsn: 'https://[email protected]/your-project',
beforeSend: capture.beforeSend,
beforeBreadcrumb: capture.beforeBreadcrumb,
});HMR postMessage 控制
withEntryInjection 会在 bundle 首部注入脚本,该脚本安装一个 window.postMessage 监听器,可在运行时控制 Fast Refresh:
// 停止 HMR(暂停 Fast Refresh,WebSocket 连接保持)
window.postMessage({ type: 'devkit:hmr', action: 'disable' }, '*');
// 恢复 HMR(flush 期间积压的所有更新)
window.postMessage({ type: 'devkit:hmr', action: 'enable' }, '*');当前 HMR 状态可通过全局标记读取:
window.__DEVKIT_HMR_ENABLED__ // true | falseHMR 客户端调用仅在
__DEV__模式下生效。全局标记在静态导出(生产构建)下同样存在,可用于测试验证。
API
withDevStubs(config)
从 miaoda-expo-devkit/metro 引入。
将 Sentry DSN 替换 stub 和 LogBox 屏蔽注入到 Metro config:
- 所有平台:将
@sentry/react-native重定向到内置 stub - web 平台:将
@expo/log-box和ErrorOverlayWebControls重定向到 no-op stub
| 参数 | 类型 | 说明 |
|---|---|---|
| config | MetroConfig | 来自 getDefaultConfig 或 getSentryExpoConfig 的 Metro config |
withEntryInjection(config, options?)
从 miaoda-expo-devkit/metro 引入。
在 expo-router 启动前注入脚本,注入内容(dist/stubs/entry-inject.js):
- 设置
globalThis.__DEVKIT_INJECTED__ = true - 安装 HMR postMessage 控制器(
window.addEventListener('message', ...)) - 打印
[DevKit] Hello World
| 参数 | 类型 | 说明 |
|---|---|---|
| config | MetroConfig | Metro config 对象 |
| options | InjectOptions? | 预留选项(当前无字段) |
new SentryCapture(options)
从 miaoda-expo-devkit 引入。
用于自定义捕获行为。stub 内置了一个默认实例(输出到 console.warn),此类用于叠加自定义处理逻辑。
| 选项 | 类型 | 说明 |
|---|---|---|
| onError | (info: ErrorCaptureInfo) => void | 捕获到错误时触发,携带符号化后的调用栈 |
| onNetwork | (info: NetworkCaptureInfo) => void | 捕获到 HTTP 请求时触发 |
| symbolicator | MetroSymbolicator | 可选,单测时注入 mock 以隔离网络依赖 |
构造完成后,将绑定方法传给 Sentry.init:
Sentry.init({
beforeSend: capture.beforeSend,
beforeBreadcrumb: capture.beforeBreadcrumb,
});ErrorCaptureInfo
| 字段 | 类型 | 说明 |
|---|---|---|
| message | string | 错误信息 |
| symbolicatedFrames | MetroFrame[] \| null | 经 Metro 符号化后还原到源码位置的调用栈帧 |
| componentStack | string \| null | React 组件调用栈(ErrorBoundary 捕获的错误才有值) |
| mechanismType | string \| null | Sentry 捕获方式:'onerror'、'generic'、'auto.function.react.error_boundary' 等 |
NetworkCaptureInfo
| 字段 | 类型 | 说明 |
|---|---|---|
| method | string \| null | HTTP 方法 |
| url | string \| null | 请求 URL |
| statusCode | number \| null | HTTP 状态码(连接级失败为 null) |
new MetroSymbolicator(options?)
从 miaoda-expo-devkit 引入。
通过 Metro /symbolicate 接口将 bundle 行列号还原为源码位置。SentryCapture 内部已默认使用,主要用途是单测时注入 mock。
| 选项 | 类型 | 说明 |
|---|---|---|
| baseUrl | string | Metro server 地址。不传则从 NativeModules.SourceCode.scriptURL 自动推断,fallback 到 localhost:8081。 |
单测示例:
const mockSym = {
parseErrorStack: jest.fn(() => []),
symbolicate: jest.fn(async () => null),
};
const onError = jest.fn();
const capture = new SentryCapture({ onError, symbolicator: mockSym });
await capture.beforeSend(fakeEvent, { originalException: new Error('oops') });
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ message: 'oops' }),
);全局标记
注入脚本在 bundle 执行时(早于任何 React 代码)写入以下全局变量,可用于 E2E 测试断言:
| 变量 | 类型 | 说明 |
|---|---|---|
| window.__DEVKIT_INJECTED__ | true | 注入脚本已执行(由 withEntryInjection 保证) |
| window.__DEVKIT_HMR_ENABLED__ | boolean | 当前 HMR 状态,随 postMessage 消息实时更新 |
环境变量
| 变量 | 默认值 | 说明 |
|---|---|---|
| SENTRY_OVERRIDE_DSN | https://[email protected]/0 | 覆盖 Sentry DSN,可指向本地 relay 等自定义端点 |
| METRO_CACHE_DIR | projectRoot/.metro-cache | Metro transform 缓存目录绝对路径,优先级最高,适合容器/CI 挂载外部持久目录 |
| METRO_TRANSFORM_LOG | (未设置时不记录) | 构建日志输出文件的绝对路径(JSONL 格式),仅开发模式(__DEV__)下生效 |
工作原理
metro.config.js
└─ withDevkit(config)
│
├─ withTransformLogger → unstable_perfLoggerFactory + metro-core Logger
│ ├─ CACHE_CONFIG 条目(启动时由 withPersistentCache 写入,含 cache_root / store_class / source)
│ ├─ BUNDLING_REQUEST 条目(每次 bundle 请求写一条)
│ │ ├─ duration_ms、status、initial_build、graph_node_count
│ │ └─ transform_miss_count(cache miss 文件数;0 = 完全命中)
│ └─ TRANSFORM_FILE 条目(每个 cache miss 文件写一条,命中则不写)
│ └─ file、duration_ms
│ (需设置 METRO_TRANSFORM_LOG 且 __DEV__ 才生效)
│
├─ withPersistentCache → config.cacheStores
│ ├─ 缓存路径优先级:METRO_CACHE_DIR > options.cacheDir > projectRoot/.metro-cache
│ └─ 保留 @expo/metro-config FileStore 子类行为(NativeWind skipCache 标志)
│
├─ withWorkspaceNodeModules → watchFolders + resolver.nodeModulesPaths
│ ├─ 向上查找祖先目录的 node_modules,加入 watchFolders
│ └─ 若 .pnpm 是指向外部路径的 symlink,也将外部真实路径加入 watchFolders
│
├─ withWasmSupport → resolver.assetExts
│ └─ 将 .wasm 加入 assetExts,修复 expo-sqlite web worker 打包
│
├─ withCssInterop → 为 expo-image / expo-camera 等注入 NativeWind cssInterop
│
├─ withEsbuildMinify → transformer.minifierPath(仅生产构建)
│ └─ 切换为 metro-minify-esbuild,清空 terser 专属 minifierConfig
│
├─ withLucideResolver → resolver.resolveRequest
│ └─ lucide-react-native/dist/** 子路径 → 绝对文件路径(绕过 exports 检查)
│ └─ 自动检测 .mjs(>= 1.9)vs .js(1.8.x)扩展名
│
├─ withDevStubs → resolver.resolveRequest
│ ├─ @sentry/react-native → dist/stubs/sentry-react-native-stub.js (全平台)
│ └─ @expo/log-box → dist/stubs/no-op-logbox.js (仅 web)
│
├─ withExpoNotificationsStub → resolver.resolveRequest
│ └─ expo-notifications → dist/stubs/expo-notifications-stub.js (仅 Android)
│ ├─ Expo Go:no-op + 调试 Alert(含参数校验)
│ └─ Dev Build:透传真实 expo-notifications
│
├─ withExpoMediaLibraryStub → resolver.resolveRequest
│ └─ expo-media-library → dist/stubs/expo-media-library-stub.js (全平台)
│ ├─ Expo Go / Web:no-op + Alert 提示(不崩溃)
│ └─ Dev Build(原生):透传真实 expo-media-library
│
├─ withExpoCalendarStub → resolver.resolveRequest
│ └─ expo-calendar → dist/stubs/expo-calendar-stub.js (全平台)
│ ├─ Expo Go / Web:no-op + Alert 提示 + 参数校验(不崩溃)
│ └─ Dev Build(原生):透传真实 expo-calendar
│
├─ withExpoFileSystemStub → resolver.resolveRequest
│ ├─ expo-file-system/legacy → dist/stubs/expo-file-system-stub.js (全平台)
│ └─ expo-file-system → dist/stubs/expo-file-system-next-stub.js
│ ├─ Web:no-op + Dialog 提示(不崩溃)
│ └─ Expo Go / Dev Build(原生):透传真实模块
│
├─ withExpoHapticsStub → resolver.resolveRequest
│ └─ expo-haptics → dist/stubs/expo-haptics-stub.js (仅 web)
│ ├─ Web:no-op + Dialog 提示(不崩溃)
│ └─ native:透传真实 expo-haptics
│
├─ withExpoContactsStub → resolver.resolveRequest
│ └─ expo-contacts → dist/stubs/expo-contacts-stub.js (仅 web)
│ ├─ Web / Expo Go:no-op + Dialog 提示(不崩溃)
│ └─ Dev Build(原生):透传真实 expo-contacts
│
├─ withExpoImagePickerStub → resolver.resolveRequest
│ └─ expo-image-picker → dist/stubs/expo-image-picker-stub.js (仅 web)
│ ├─ 桌面 Web:launchCameraAsync → getUserMedia + #__devkit_camera_overlay__ 弹窗
│ │ ├─ 点"拍照":canvas.toDataURL → ImagePickerResult { canceled:false, assets }
│ │ └─ 点"取消":返回 { canceled:true, assets:null }
│ ├─ 移动端浏览器:透传 expo 原实现(capture 属性正常工作)
│ └─ getUserMedia 不可用:降级透传 expo 原实现(不崩溃)
│
└─ withEntryInjection → resolver.resolveRequest(仅 __DEV__)
└─ expo-router/entry-classic → dist/stubs/expo-router-entry-stub.js
├─ require('./entry-inject') ← 注入脚本(bundle 首部执行)
│ ├─ globalThis.__DEVKIT_INJECTED__ = true
│ ├─ setupHMRMessageControl() ← 安装 postMessage 监听器
│ └─ console.log('[DevKit] Hello World')
└─ require('expo-router/entry-classic') ← 原 expo-router 入口
setupHMRMessageControl()
└─ window.addEventListener('message', handler)
├─ { type: 'devkit:hmr', action: 'disable' }
│ ├─ globalThis.__DEVKIT_HMR_ENABLED__ = false
│ └─ hmrClient.disable() (仅 __DEV__)
└─ { type: 'devkit:hmr', action: 'enable' }
├─ globalThis.__DEVKIT_HMR_ENABLED__ = true
└─ hmrClient.enable() (仅 __DEV__)
sentry-react-native-stub.js
└─ init(options)
└─ RealSentry.init({ ...options, dsn: OVERRIDE_DSN, beforeSend, beforeBreadcrumb })
├─ _capture.beforeSend(event, hint) ← 内置 SentryCapture(自动注入)
│ ├─ MetroSymbolicator.symbolicate() → POST /symbolicate
│ └─ onError({ message, frames, ... }) → console.warn(默认)
└─ options.beforeSend?(event, hint) ← 调用方自定义(可选,串联执行)Package Exports
| 子路径 | 文件 | 内容 |
|---|---|---|
| . | dist/index.js | SentryCapture、MetroSymbolicator、全部类型 |
| ./metro | dist/metro.js | withDevkit、withDevStubs、withEntryInjection、withPersistentCache、withTransformLogger、withExpoNotificationsStub、withExpoMediaLibraryStub、withExpoCalendarStub 等全部 Metro wrapper |
| ./babel-plugin-jsx-source | dist/babel/plugin-jsx-source.js | Babel 插件:为 JSX 注入 source 信息 |
| ./babel-preset | dist/babel/preset.js | Babel Preset:集成 jsx-source 和 lucide 插件 |
| ./sentry-react-native-stub | dist/stubs/sentry-react-native-stub.js | @sentry/react-native 模块替换 stub |
| ./no-op-logbox | dist/stubs/no-op-logbox.js | LogBox no-op stub |
| ./expo-notifications-stub | dist/stubs/expo-notifications-stub.js | expo-notifications Expo Go Android stub |
| ./expo-media-library-stub | dist/stubs/expo-media-library-stub.js | expo-media-library Expo Go / Web stub |
| ./expo-calendar-stub | dist/stubs/expo-calendar-stub.js | expo-calendar Expo Go / Web stub |
| ./expo-file-system-stub | dist/stubs/expo-file-system-stub.js | expo-file-system/legacy Web stub |
| ./expo-file-system-next-stub | dist/stubs/expo-file-system-next-stub.js | expo-file-system 新版 API Web stub |
| ./expo-haptics-stub | dist/stubs/expo-haptics-stub.js | expo-haptics Web stub |
| ./expo-contacts-stub | dist/stubs/expo-contacts-stub.js | expo-contacts Web / Expo Go stub |
| ./expo-image-picker-stub | dist/stubs/expo-image-picker-stub.js | expo-image-picker 桌面 Web stub |
devkit-lint
devkit-lint 是内置的静态检查命令,一次执行涵盖四个阶段:
oxlint → biome → tsc --noEmit → app.json schema 校验所有阶段都会跑完再退出,最终汇总哪个阶段失败。
使用
// package.json
{
"scripts": {
"lint": "devkit-lint"
}
}bun run lintapp.json 字段校验
devkit-lint 在最后一步用 @expo/schemer 对 app.json 做 JSON Schema 校验,拦截字段值错误(枚举值不合法、类型错误等)。
典型场景:"orientation": "all" 不是合法的 Expo 枚举值(合法值为 "default" | "portrait" | "landscape"),这类错误原本只会在 Gradle/AAPT 阶段(构建约 1 分钟后)以晦涩的资源链接报错暴露,现在在 lint 阶段秒级报出:
[app.json schema errors]
/path/to/project/app.json: field 'orientation' — must be equal to one of the allowed values (got: "all")
────────────────────────────────────────────────────────────
RESULT: FAILED — Found errors in: app.jsonSchema 来源与存储
Expo 随每个 SDK 版本在其文档站发布版本化 JSON Schema(位于 docs/public/static/schemas/v{VERSION}/app-config-schema.json)。devkit 将对应版本的 schema 文件复制到 src/schemas/app-config-schema-v55.json,随 CLI bundle(dist/cli/lint.js)一起内联打包。
这意味着:
- 完全离线:不在运行时请求
exp.host或任何外部服务,在无网络的 CI 容器中也能正常工作 - 版本固定:schema 与 Expo SDK 55 对应,校验结果与 Expo 官方行为一致
- 零运行时依赖:
@expo/schemer(及其依赖ajv)在构建时内联进dist/cli/lint.js,消费方无需安装额外依赖
升级 Expo SDK 时的 schema 更新
当项目升级 Expo SDK 版本时,需同步更新 schema 文件:
# 从 Expo 源码仓库复制新版 schema(以升级到 v56 为例)
cp /path/to/expo/docs/public/static/schemas/v56.0.0/app-config-schema.json \
packages/devkit/src/schemas/app-config-schema-v56.json
# 删除旧版 schema 文件
rm packages/devkit/src/schemas/app-config-schema-v55.json并相应修改 src/cli/lint.ts 中的 import 路径:
// 改为
import rawSchema from '../schemas/app-config-schema-v56.json';Stubs 模块说明
entry-inject.js
由 expo-router-entry-stub.js 在 expo-router/entry-classic 执行前 require,因此代码运行时机早于 expo-router 初始化和任何路由渲染。
功能:
- 设置全局标记
globalThis.__DEVKIT_INJECTED__ = true(供测试验证) - 安装 HMR postMessage 控制器
- 安装 LGUI 可视化编辑器控制器
- 安装 CSS 注入控制器
- 安装路由消息控制器
expo-router-entry-stub.js
Metro 的 resolveRequest 将 expo-router/entry-classic 重定向到此文件。
执行顺序:
- entry-inject(注入脚本)
- expo-router/entry-classic(原 expo-router 入口,负责注册 App)
hmr-control.js
HMR postMessage 控制器,监听 window 上的 postMessage 消息来控制 HMR。
消息格式:
window.postMessage({ type: 'devkit:hmr', action: 'enable' | 'disable' }, '*')副作用:
- 设置
globalThis.__DEVKIT_HMR_ENABLED__(初始值 true),随每条消息更新 - 在
__DEV__模式下调用expo/src/async-require/hmr的enable()/disable(),实际暂停或恢复 Fast Refresh
css-control.js
CSS 注入控制器,为 LGUI 编辑器提供可视化高亮样式。
高亮属性:
data-editor-active:选中元素(2px 实线边框)data-editor-hover:悬停元素(1px 实线边框)data-editor-each:同源兄弟元素(1px 虚线边框)data-editor-full-width:全宽元素(使用内缩 offset)
导出函数:
injectSelectorModeStyle():注入选择模式样式,返回移除函数setupCSSInjectionControl():注入编辑器高亮 CSS(页面加载时注入,常驻)
lgui-control.js
LGUI 可视化编辑器 postMessage 控制器,实现可视化编辑器与 iframe 内页面的双向通信。
消息格式(父窗口 → iframe):
window.postMessage({ type: 'editor-inject' }, '*') // 初始化编辑器
window.postMessage({ type: 'editor-destroy' }, '*') // 销毁编辑器消息格式(iframe → 父窗口):
parent.postMessage({ type: 'iframe-target-change', target: ElementInfo }, '*')
parent.postMessage({ type: 'iframe-scroll', target: Rect }, '*')功能:
- 监听鼠标事件(mouseover、mouseleave、click),处理 hover/active 状态
- 与父窗口通信,发送选中元素信息(位置、组件名、源码位置等)
- 观察选中节点属性变化,实时同步信息
- 高亮同源兄弟节点
router-control.js
LGUI 路由控制器,监听父窗口 postMessage 处理路由导航和页面刷新。
消息格式(父窗口 → iframe):
window.postMessage({ type: 'editor-location-update', pageName: string }, '*') // 更新路由
window.postMessage({ type: 'editor-refresh' }, '*') // 刷新页面功能:
editor-location-update:更新路由(使用history.pushState)editor-refresh:刷新页面(使用location.reload)
sentry-react-native-stub.js
@sentry/react-native 模块替换 stub,由 withDevStubs() 在 Metro 层自动注入。
功能:
- 将用户传入的 DSN 替换为无害的覆盖值(默认
https://[email protected]/0) - 自动注入内置 SentryCapture,监听并符号化 Sentry 错误事件
- 串联调用方传入的
beforeSend/beforeBreadcrumb(内置捕获器先执行) - 向父窗口发送
GLOBAL_ERROR事件,携带错误信息
环境变量:
SENTRY_OVERRIDE_DSN:自定义覆盖 DSN(如指向本地 relay)
no-op-logbox.js
LogBox no-op stub,用于 web 平台禁用 Expo 全屏错误遮罩。
由 withDevStubs() 在 Metro 层将 @expo/log-box 和 ErrorOverlayWebControls 重定向到此文件。
expo-notifications-stub.js
expo-notifications 模块替换 stub,由 withExpoNotificationsStub() 在 Metro 层注入(仅 Android 平台)。
背景: Expo SDK 53 起,expo-notifications 的 Android native module 已从 Expo Go 中移除,直接 import 会在 Expo Go 启动时崩溃。
运行时行为:
- Expo Go(Android):提供 no-op 实现,
requestPermissionsAsync、setNotificationChannelAsync、scheduleNotificationAsync等核心 API 调用时弹出带参数校验的调试 Alert - Development Build(Android):透传真实
expo-notifications,功能完全正常 - iOS(任意):不经过此 stub,直接使用真实
expo-notifications
手动验证: 在 devkit-e2e App 中扫码进入「Notification Stub 验证」页面,逐按钮触发并对照期望结果。
expo-media-library-stub.js
expo-media-library 模块替换 stub,由 withExpoMediaLibraryStub() 在 Metro 层注入(全平台)。
背景: expo-media-library 依赖原生相册 API,在 Expo Go 和 Web 环境中不可用,调用 saveToLibraryAsync 等 API 会直接崩溃。
运行时行为:
- Expo Go / Web:提供 no-op 实现,以下 API 调用时弹出 Alert 提示(不崩溃):
usePermissions()— 返回{ status: 'undetermined', granted: false },requestPermission()弹 AlertrequestPermissionsAsync()/getPermissionsAsync()— 前者弹 Alert,后者静默返回 deniedsaveToLibraryAsync(uri)— 弹 Alert 显示操作和 URI(超 60 字符自动截断)createAssetAsync(uri)— 弹 Alert 并返回合法的伪资产对象- 其他未知 API — Proxy 兜底,静默返回
undefined;以PermissionsAsync结尾的 API 返回 denied 结构
- Development Build(原生):透传真实
expo-media-library,功能完全正常
Alert 消息格式:
保存到相册
Expo Go 扫码预览不支持访问手机相册
操作: 图片: file:///tmp/test.jpg
发布为正式 App 后可正常使用手动验证: 在 devkit-e2e App 中扫码进入「Media Library Stub 验证」页面,逐按钮触发并对照期望结果。
expo-calendar-stub.js
expo-calendar 模块替换 stub,由 withExpoCalendarStub() 在 Metro 层注入(全平台)。
背景: expo-calendar 依赖原生系统日历 API,在 Expo Go 和 Web 环境中不可用,调用 getEventsAsync 等 API 会直接崩溃。
运行时行为:
- Expo Go / Web:提供 no-op 实现,以下 API 调用时弹出 Alert / Dialog 提示(不崩溃):
useCalendarPermissions()/useRemindersPermissions()— 初始返回undetermined,requestPermission()弹 AlertrequestCalendarPermissionsAsync()/requestRemindersPermissionsAsync()— 弹 Alert 提示,返回 deniedgetCalendarPermissionsAsync()/getRemindersPermissionsAsync()— 静默返回 deniedgetCalendarsAsync(entityType?)— 弹 Alert 显示查询类型,返回空数组createCalendarAsync(details)— 弹 Alert 含名称和颜色,校验details.title非空updateCalendarAsync(id, details)/deleteCalendarAsync(id)— 弹 Alert 含日历 ID,校验 ID 格式getEventsAsync(calendarIds, startDate, endDate)— 弹 Alert 含时间范围,校验 calendarIds 非空及日期合法性,返回空数组createEventAsync(calendarId, eventData)— 弹 Alert 含标题和时间,Android 下校验 startDate/endDateupdateEventAsync(id, details)/deleteEventAsync(id)— 弹 Alert 含事件 ID,校验 ID 格式- 其他未知 API — Proxy 兜底,静默返回
undefined;以PermissionsAsync结尾的 API 返回 denied 结构
- Development Build(原生):透传真实
expo-calendar,功能完全正常
枚举常量(stub & Dev Build 均可用):
EntityTypes、Frequency、Availability、CalendarType、EventStatus、SourceType、
AttendeeRole、AttendeeStatus、AttendeeType、AlarmMethod、EventAccessLevel、
CalendarAccessLevel、ReminderStatus、DayOfTheWeek、MonthOfTheYear
Alert 消息格式(合规示例):
创建日历事件
秒哒扫码预览不支持访问手机日历
日历 ID: cal1
标题: 团队会议
开始: 2025/1/1 10:00:00
结束: 2025/1/1 11:00:00
✅ 参数合规
发布为正式 App 后可正常使用手动验证: 在 devkit-e2e App 中扫码进入「Calendar Stub 验证」页面,逐按钮触发并对照期望结果。
expo-file-system-stub.js / expo-file-system-next-stub.js
expo-file-system 模块替换 stub,由 withExpoFileSystemStub() 在 Metro 层注入(全平台)。
背景: expo-file-system 的文件 API 在 Web 端不可用:legacy API 底层方法缺失会抛 UnavailabilityError;新版 API(File / Directory)基类缺少 validatePath(),new File(...) 会抛 TypeError: this.validatePath is not a function。
拦截路径:
expo-file-system/legacy→expo-file-system-stub.js(legacy API)expo-file-system→expo-file-system-next-stub.js(新版 File/Directory/Paths API)
运行时行为:
- Web:提供 no-op 实现,核心 API 调用时弹出 Dialog 提示(不崩溃)
- Expo Go / Development Build(原生):透传真实
expo-file-system,功能完全正常
expo-haptics-stub.js
expo-haptics 模块替换 stub,由 withExpoHapticsStub() 在 Metro 层注入(仅 web 平台)。
背景: Web 没有振动/触觉硬件 API,expo-haptics 在 web 会运行时崩溃。
运行时行为:
- Web:提供 no-op 实现,
impactAsync、notificationAsync、selectionAsync调用时弹出 Dialog 提示参数信息(不崩溃);枚举常量(ImpactFeedbackStyle、NotificationFeedbackType等)正常可用 - Expo Go / Development Build(native):透传真实
expo-haptics,功能完全正常
expo-contacts-stub.js
expo-contacts 模块替换 stub,由 withExpoContactsStub() 在 Metro 层注入(仅 web 平台)。
背景: expo-contacts 依赖 native module,在 Web 和 Expo Go 中不可用。
运行时行为:
- Web / Expo Go:提供 no-op 实现,联系人 API 调用时弹出 Dialog 提示(不崩溃)
- Development Build(原生):透传真实
expo-contacts,功能完全正常
expo-image-picker-stub.js
expo-image-picker 模块替换 stub,由 withExpoImagePickerStub() 在 Metro 层注入(仅 web 平台)。
背景: expo-image-picker 的 launchCameraAsync 在 web 上底层使用 <input type="file" capture="environment">。桌面浏览器(Chrome、Firefox、Safari)有意忽略 capture 属性,直接弹出文件选择框而不是摄像头。这是浏览器厂商的硬限制,无法通过任何 HTML 属性或 meta 标签绕过。
运行时行为:
- 桌面 Web(非 mobile UA):
launchCameraAsync调用navigator.mediaDevices.getUserMedia打开摄像头预览弹窗(#__devkit_camera_overlay__),用户点击"📷 拍照"后截取一帧并以data:image/jpeg格式返回,点击"取消"返回{ canceled: true } - 移动端浏览器(Android Chrome / iOS Safari):透传 expo 原实现,
capture属性在移动端正常调起系统相机 getUserMedia不可用(无摄像头或浏览器限制):静默降级,透传 expo 原实现(不崩溃)- Native(iOS / Android):Metro resolver 不拦截,直接使用原生
expo-image-picker,功能完全不变
弹窗 DOM 结构(供 Playwright 定位):
<div id="__devkit_camera_overlay__"> <!-- 全屏遮罩 -->
<div> <!-- 面板 -->
<p>拍照</p>
<video autoplay muted playsinline> <!-- 摄像头预览 -->
<div>
<button>📷 拍照</button>
<button>取消</button>
</div>
<p><!-- 错误信息(getUserMedia 失败时显示) --></p>
</div>
</div>返回值格式(与 expo 原实现兼容):
// 拍照成功
{
canceled: false,
assets: [{
uri: 'data:image/jpeg;base64,...',
width: 640,
height: 480,
type: 'image',
fileName: 'photo_1234567890.jpg',
mimeType: 'image/jpeg',
base64: null,
exif: null,
}]
}
// 取消
{ canceled: true, assets: null }E2E 测试: devkit-e2e/tests/e2e/image-picker-stub.spec.ts,使用 addInitScript mock getUserMedia(注入 canvas stream 替代真实摄像头),覆盖弹窗出现/消失、拍照返回结果、取消、降级等场景。
Babel 插件:jsx-source
babel-plugin-jsx-source 为 JSX 元素注入 source 属性(文件路径、行列号),用于开发调试。通过 dataSet 对象注入,这是 React Web 可识别的数据通道。
配置
// babel.config.js
module.exports = {
plugins: [
['miaoda-expo-devkit/babel-plugin-jsx-source', { rootDir: __dirname }]
]
};转换效果
普通 JSX 元素:
// 转换前
<View style={styles.container} />
// 转换后
<View style={styles.container} dataSet={{"mdId": "path/to/file.tsx:10:4"}} />纯文本节点(会添加 componentContent,使用 URL 编码格式):
// 转换前
<div>hello world</div>
// 转换后
<div dataSet={{"mdId": "path/to/file.tsx:10:4", "componentContent": "%7B%22text%22%3A%22hello%20world%22%7D"}}>hello world</div>componentContent 解码后为:
{"text":"hello world"}选项
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| rootDir | string | - | 项目根目录,用于计算相对路径。不提供则使用绝对路径 |
| excludePaths | string[] | [] | 跳过注入的路径模式列表(相对于 rootDir 的路径片段) |
Babel 插件:lucide-react-native
Metro 没有 tree-shaking,直接写 import { Star } from "lucide-react-native" 会把整个图标库(约 1500 个图标)打进 bundle。babel-plugin-lucide-react-native 在编译阶段将具名导入改写为直接按文件导入,彻底规避这个问题。
转换效果:
// 转换前
import { Star, BookOpen } from "lucide-react-native";
// 转换后(CJS,默认)
import _Star from "lucide-react-native/dist/cjs/icons/star";
import _BookOpen from "lucide-react-native/dist/cjs/icons/book-open";配置
推荐通过内置 Preset 一次性启用所有 Babel 插件:
// babel.config.js
module.exports = {
presets: [['miaoda-expo-devkit/babel-preset', { excludePaths: ['src/components/ui'] }]],
};也可以单独使用:
module.exports = {
plugins: [['miaoda-expo-devkit/babel-plugin-lucide-react-native']],
};选项
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| useES | boolean | false | 使用 ESM 格式(dist/esm/icons/)而非默认的 CJS |
支持的写法
插件可识别所有常见的图标引用写法:
import { BookOpen, Star, Crown } from "lucide-react-native";
// JSX 直接使用
<BookOpen size={24} />
// 赋值给变量
const icon = BookOpen;
// 对象 value
const ITEMS = [{ icon: BookOpen }, { icon: Star }];
// 计算属性 key
const map = { [BookOpen]: 'read' };
// 数组、三元、函数参数
const list = [BookOpen, Star];
const active = flag ? BookOpen : Star;
renderIcon(Crown);
// 导入别名
import { BookOpen as ReadIcon } from "lucide-react-native";
<ReadIcon />
// re-export
export { Star, BookOpen as ReadIcon } from "lucide-react-native";版本兼容性
插件在运行时探测 lucide-react-native 的实际目录结构,自动适配新旧版本的路径差异(>= 1.9 新增 icons/ 子目录;旧版 1.8.x 图标直接位于 dist/cjs/),lucide 升级时无需修改配置。
