npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 | false

HMR 客户端调用仅在 __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):

  1. 设置 globalThis.__DEVKIT_INJECTED__ = true
  2. 安装 HMR postMessage 控制器(window.addEventListener('message', ...))
  3. 打印 [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 lint

app.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.json

Schema 来源与存储

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 重定向到此文件。

执行顺序:

  1. entry-inject(注入脚本)
  2. 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() 弹 Alert
    • requestPermissionsAsync() / getPermissionsAsync() — 前者弹 Alert,后者静默返回 denied
    • saveToLibraryAsync(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() 弹 Alert
    • requestCalendarPermissionsAsync() / requestRemindersPermissionsAsync() — 弹 Alert 提示,返回 denied
    • getCalendarPermissionsAsync() / getRemindersPermissionsAsync() — 静默返回 denied
    • getCalendarsAsync(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/endDate
    • updateEventAsync(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 升级时无需修改配置。