原文链接:www.chmal.it/blog/buildi…
作者:Szymon Chmal
你是否好奇过,在 Metro 终端按下 j 键的瞬间会发生什么?一个 Chrome 窗口应声弹出,调试控制台完成连接,你便能调试自己的 JavaScript 代码。
这过程恍若魔法,也可能早已成为你开发流程中习以为常的一环。自从 Flipper 被弃用后,这个 Chrome DevTools 实例就成了我们窥探 React Native 运行时的主要窗口。背后的开发团队为其搭建了坚如磐石的高性能底层架构,扛起了与 JS 引擎建立连接的核心重任。
但从设计初衷来看,它只是一款通用工具。它展示的是 JavaScript 最原始的运行状态,而非我们开发时依赖的框架层抽象概念—— 比如它无法原生可视化 React Navigation 导航栈、Reanimated 共享值,或是特定原生模块的状态。
我一直希望能打造一些专属调试面板:让调试工具能 “读懂” 我的应用,相当于给这个调试仪表盘新增自定义的监控指标。但问题在于,这个调试器是以预打包产物的形式分发的,其设计初衷根本不支持终端用户进行扩展。
所以,我必须找到一种方式,靠自己实现对它的扩展。
关键发现
第一步是搞清楚这个调试窗口的本质。当调试器窗口打开时,它看起来就像一个专属的原生应用 —— 你甚至看不到地址栏,完全是原生应用的视觉体验。
但只要你将 DevTools 窗口分离(没错,你可以在 DevTools 窗口上再打开 DevTools 调试它),这种 “原生假象” 就会被戳破。检查这个窗口会发现:它本质上只是一个运行在 localhost 上的网页而已。
我进一步深挖后发现:这个调试界面的 UI 其实是通过 @react-native/debugger-frontend 这个包分发的。该包包含了调试界面的打包产物,而 Metro 会通过 @react-native/dev-middleware 将这些文件作为静态资源对外提供。
这个前端的源码托管在 facebook/react-native-devtools-frontend 仓库中 —— 这是一个体量庞大、逻辑复杂的仓库,还搭配了重型构建系统。如果为了添加一个调试面板就去 Fork 这个仓库,后续的维护工作会变成一场噩梦:我将不得不无休止地合并上游的代码变更。
但 “它只是本地服务器提供的静态 HTML 文件” 这个发现,给了我一个灵感:如果能拦截这个 HTML 文件的请求,就能在它发送到浏览器之前,把自己的代码注入进去。
竞态条件问题
我的第一想法很简单:写一个 Metro 中间件来拦截 /debugger-frontend/ 路径的请求。但问题来了 ——Metro 会先执行其内部中间件(包括提供调试器资源的那个),然后才会运行用户自定义的中间件。
这是一场我赢不了的竞态条件:等我的代码感知到请求时,响应早已发送完毕。这就像试图拦截一封已经送达的信件,根本来不及。
唯一的办法,是在 “信件寄出前修改信封上的地址”—— 我必须通过猴子补丁(monkey-patch) 的方式,篡改 CLI 中负责打开浏览器 URL 的函数。
import path from "node:path";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
// 1. Locate the internal module that opens the browser
const getDevToolsFrontendUrlModulePath = path.dirname(
require.resolve("@react-native/dev-middleware/package.json"),
);
const getDevToolsFrontendUrlModule = require(
path.join(getDevToolsFrontendUrlModulePath, "/utils/getDevToolsFrontendUrl"),
);
// 2. Save the original function
const getDevToolsFrontendUrl = getDevToolsFrontendUrlModule.default;
// 3. Monkey-patch it
getDevToolsFrontendUrlModule.default = (
experiments,
webSocketDebuggerUrl,
devServerUrl,
options,
) => {
const originalUrl = getDevToolsFrontendUrl(
experiments,
webSocketDebuggerUrl,
devServerUrl,
options,
);
// Redirect to our custom endpoint so Metro passes it to us
return originalUrl.replace("/debugger-frontend/", "/rozenite/");
};
现在,当你按下 j 键时,Chrome 会打开 http://localhost:8081/rozenite/.... 这个地址。你猜发生了什么?页面直接报出 404 页面未找到 错误。
但这其实是最理想的结果 —— 这意味着我们成功绕开了 Metro 的内部中间件。Metro 无法识别这个自定义路径,因此会让请求 “穿透” 到我们早已准备好的自定义中间件层进行处理。
拦截逻辑实现
我们在 Metro 配置中通过 server.enhanceMiddleware 方法注册自定义处理器,这里就是我们的 “拦截主战场”。
const { getDefaultConfig } = require("@react-native/metro-config");
const config = getDefaultConfig(__dirname);
config.server.enhanceMiddleware = (middleware, server) => {
return (req, res, next) => {
// 1. Listen for our custom path
if (req.url.startsWith("/rozenite/")) {
// 2. Rewrite the URL to match the file on disk
req.url = req.url.replace("/rozenite", "");
// 3. If it's the HTML entry point, we need to inject our code
if (req.url.includes("rn_fusebox.html")) {
const originalHtml = fs.readFileSync(
path.join(rnDevToolsFrontendPath, "rn_fusebox.html"),
"utf8",
);
const injectedHtml = injectRuntime(originalHtml);
res.setHeader("Content-Type", "text/html");
return res.end(injectedHtml);
}
}
// Fallback to default Metro middleware
return middleware(req, res, next);
};
};
module.exports = config;
注入实现
此时调试器的 HTML 内容已经加载到内存中,接下来就是我们的 “关键一步”—— 插入一个指向自定义运行时脚本的 <script> 标签,相当于在调试器里 “插上我们的专属标识”:
<script type="module" src="./host.js"></script>
type="module" 这个属性,是通往浏览器原生模块系统的 “通关密钥”。
借助它,我们的自定义运行时能够直接从 React Native DevTools 内部动态导入其内置模块。通过共享执行上下文和模块系统,我们可以复用 DevTools 自带的 UI 组件和 SDK 实例,完全无需处理复杂的版本不兼容问题。本质上,我们相当于实现了 “无需配置文件的运行时模块联邦”。
但问题在于,浏览器的安全策略(这也无可厚非)向来十分严苛。调试器页面配置了严格的内容安全策略(CSP) —— 如果我们只是简单插入一个脚本标签,Chrome 会立刻拦截它。
因此,我们需要解析页面中已有的 CSP meta 标签,生成一个唯一的 nonce 值,并将其添加到 CSP 允许的资源源列表中:
const nonce = crypto.randomUUID();
// Extract the original CSP string from the HTML
// It looks like: default-src 'self'; script-src 'self' ...
const cspRegex =
/<meta[^>]*http-equiv="Content-Security-Policy"[^>]*content="([^"]*)"[^>]*>/;
const cspMatch = html.match(cspRegex);
const originalCSP = cspMatch[1];
// Add our nonce to the allowed script sources
const updatedCSP = originalCSP.replace(
/script-src\s+([^;]+)/,
`script-src $1 'nonce-${nonce}'`,
);
// Inject the script with the matching nonce
const script = `<script nonce="${nonce}" type="module" src="./host.js"></script>`;
运行时逻辑
在 host.js 文件中,我们的代码如今已能和官方调试器代码并肩运行—— 共享同一个全局作用域。
正因为我们是以模块的形式注入自身代码,现在只需直接导入调试器的内部 UI 对象即可。我们使用的,正是主应用所依赖的那个完全相同的实例。
// This is the key: we import directly from the running DevTools application
// Note the absolute path – we are importing from the browser's context
import * as UI from "/rozenite/ui/legacy/legacy.js";
export const createPanel = (title, url) => {
// 1. Create the view using the internal DevTools API
const view = new UI.InspectorView.SimpleView(title);
// 2. Add it to the tab bar
UI.InspectorView.InspectorView.instance().addPanel(view);
};
// Usage: createPanel('Rozenite', 'panel.html');
总结
这算不算一种 “奇技淫巧”?毫无疑问是的。它看起来像、闻起来也像 —— 而且还依赖于那些理论上可能随时变更的内部路径。
但精妙的 “黑科技” 往往自带一种独特的巧思:我们没有对抗宿主(调试器)的底层架构(HTML + HTTP + ESM),而是选择顺应它,最终打造出一套异常稳健的解决方案。这套方案经受住了时间的考验:即便最近 React Native 推出了全新的、基于 Electron 构建的 DevTools 外壳,我们的方案也无需任何修改就能正常工作。
至此,我们已成功 “攻入” 调试器内部 —— 不仅能在界面上渲染自定义内容,还能让代码运行在调试器的执行上下文中。在下一篇文章中,我们会进一步深入 DevTools 底层,把自定义消息注入到它与应用的通信链路中。