从一次巧合谈谈 React Devtool

写在之前

这其实是篇拖了很久的博客(倒不如说其实有很多可以写的题材但是最后都没有成文)。回头发现自从不打比赛以后博客频次少了很多,甚至几乎可以算是没有了。

所以一直想找个机会把博客捡起来,这几年也看到很多优秀的个人博客以及各种在努力更新的周刊。之前不写博客很大的原因是觉得学习笔记性质的文章可能我会更愿意存在自己的文档库中,比如 Notion。既然选择拿出来肯定要有一些独特之处(至少要自我感觉)。

但是这也导致很多 idea 拖着拖着就不了了之了,要么就是觉得题材比较简单,内容比较普通。要不是就是涉及到不太熟悉的领域,有很多需要调研和搜集的资料。

但在看到很多坚持更新的个人博客以后,我的观点有了改变,到底还是现有输出,才能有不断提升的质量。先有总结和调研才会机会去发现可以成文的原创性内容。甚至坚持做汇集新闻资料的周刊这本身就是一件意义非凡的事。

虽然这是一篇不是很复杂的博客,但总算是个好的开始,希望后面可以保持几周一篇的节奏。

巧合起因

Figma 风波

事情还要从这则新闻[1]说起,一夜之间国内对标 Figma 的软件都纷纷出现,并都在陆续支持 Figma 文件的导入,包括蓝湖、墨刀,以及本文的源头:即时设计。

大疆 figma

关于这件事本身以及后续的影响我没有过多的兴趣讨论。

不过后续很多论坛一旦发布介绍 Figma 特性的文章就有一些类似的反对声音,表示 Figma 所谓政治倾向,不考虑使用等等。还有一些评论则直接表明这种设计软件有很强的可替代性,没有产品壁垒,且不说 Figma 作为 SaaS 巨头本身的市值[2],这种傲慢本身就是站不住脚的。至少从前端的角度来说,Figma 作为一个成熟的巨型应用让人们重新认识 Web 的能力与边界。后续也陆续出现了一些成功的尝试[3]

意外体验

2022.07.05 UPDATE

在发布这篇这篇博客后不久偶然发现即时设计官网的 devtool 已经正常了,看起来是只保留了 react-dom。介于几个月前发过邮件但没有任何回应,这应该是一个巧合。不过并不影响阅读,如果想要还原当前的情况可以使用 Web Archive 等工具访问,能够看到当时的状态。

在这件事之前其实我就有用过蓝湖和墨刀,不过还没有体验过即时设计,点开官网体验了一番,是设计软件比较经典的落地页模式,但是插件栏突然变得意外的显眼起来。

诶,我红灯怎么亮了

Jsdesign Landing Page

用过主流前端框架的同学都知道这是 DEV 开发者模式下,在这种模式下 React 和 Vue、Angular 的 Devtool 都会以醒目的颜色提醒当前模式不能用于生产,在部署线上之前需要进行 BUILD 构建。

但是在现代前端的开发模式下有完善的框架和脚手架,这是不可能出错的环节(yarn build 似乎很难打错成 yarn dev 😂)。

抱着好奇心我查看了 Source,很明显是经过 Webpack 打包的产物: js.design source 从 React Devtool 的 Component Panel 去观察元素也能看出明显是生产环境代码,误会解除。

探索原因

那么接下的问题就是明明是生产构建代码,为什么 React Devtool 会当作 DEV 模式并发出红色警告呢?

How Development Mode Works

首先我检索到的是 Dan Abramov 写的博客[4]。这篇文章主要介绍了我们为什么需要区分前端环境以及怎样构建不同的环境。

首先区分生产环境和开发环境是很有必要的,因为在开发环境下会有很多额外的 logging 以及未经优化方便 debug 的内容。而生产环境也需要构建多种类型的产物并在 package.json 正确配置[5]进行引导,当然 UMD 产物[6]也可以直接通过浏览器 <script /> 进行消费。

事实标准是通过 process.env.NODE_ENV 来进行区分 developmentproduction 环境。在构建时会通过 boolean 值来进行区分并直接替换,之后处于另一个分支的代码称为 dead code,构建工具一般会自动清除这一区块的代码进行优化[7]

不过这并不能解决我们的问题,要探索这个问题需要进入 React 代码仓库一探究竟。

React Devtool Architecture

React 本身是以 monorepo 形式组织的,Devtool 相关有这样几个 package[8](基于 4.24.7 version)

- react-devtools-core
- react-devtools-extensions
- react-devtools-inline
- react-devtools-shared
- react-devtools-shell
- react-devtools-timeline
- react-devtools

下面简单介绍一下这几个 devtool 相关的模块:

react-devtools-shared 包含一些公共模块,被 core 和 inline 等内部使用

react-devtools-shell 这个模块主要用于一些 e2e 测试等用途

react-devtools-core 可以理解为 devtool 模块,但是 devtool 一般不会单独出现,往往会以浏览器插件、Electron Debug App 等形式出现,所以需要通过其他模块集成这部分能力。包含 frontendbackend 两个部分,其中 backend 用于在 React Native 等环境中连接 Devtool UI,而 frontend 则用于将生成的 Devtool DOM 嵌入到不同的容器中(比如分别嵌入到浏览器开发者工具和网页元素中)

react-devtools-extensions 是基于 core 提供 Devtool 浏览器插件的模块

react-devtools 是一个基于 Electron 的 Devtool 封装

react-devtools-inline

这个看起来比较有意思[9],官方的描述是

This package can be used to embed React DevTools into browser-based tools like CodeSandbox, StackBlitz, and Replay.

不过目前依赖 React 很多 experimental APIs,另外估计对浏览器版本也有比较高的要求,目前似乎还没有到 CodeSandbox 和 StackBlitz 提供类似的集成,也许等逐渐稳定以后可以期待一下。

react-devtools-timeline

也是个比较有趣的模块,新内容比较多,可以单独介绍一下。据 README 这是面向 React18 的一个实验性特性,随着后续稳定这个模块也可能会迁移到 shared 模块中。

新的 Timeline 主要为 React Profiler 的 Timeline 模块提供了全新的支持,包括更好的显示 scheduler 的调度情况以及对 Suspense 和 Transition 等新 API 对于性能影响的支持。

同时也实验性的加入了性能优化意见,比如对于占用过长时间的 useLasyoutEffect 和对于更新意外的 Suspend 标注警告。更多相关内容可以关注 React 18 工作组的介绍[10]

如下是我简单的体验,可以看到会根据不同的 Lanes(一种 React 内部根据不同来源调整优先级调度的分类)进行分组。先是一个输入触发更新,之后进行了 Render Stage(蓝色标注),接着触发了 Commit Stage(紫色标注),这个阶段还有一个深紫的区块,这应该是我用的组件库会使用 useLayoutEffect 导致的。在时间线下方还能看到对应具体的组件名称。 Timeline Profiler

同时可以调整观察的尺度,我刚刚把粒度调整为了一次调度,实际上这是一个虚拟列表,所以把时间线拉长可以看到一个比较直观的调度状态。

List scorll

另外介绍博客中还提到现在支持观察非 React 代码的 JS 调度情况,可以在 flamechart 里观察,下面是官方介绍视频的截图:

Other Javascript

但是我在实际体验的时候并没有发现 flamechart 这个模块,Devtool 应该是最新版本,react 和 react-dom 也都是比较新的 18.0.1,浏览器是 Chrome Canary,所以这块可能还有一些问题,等稳定后应该就能实现演示的效果了。

Flamegraph

火焰图是 Profiler 中比较经典的模块,同时也一直在更新以方便开始者有比较好的调试体验。当前的火焰图与一开始的介绍[11]相比也有不小的调整。

最开始会直接显示当前组件的 FiberNode 和 State 情况:

Old Flamegraph

而现在改为了更为简洁的描述,同时会显示本次更新的 Priority(Concurrent 下的特性),以及本次更新的原因(比如路由改变,hook 更新)

Flamegraph Information

另外还可以在 React Devtool 中的 Setting -> Profiler 可以开启 Record why each component rendered while profiling,之后对于火焰图中的每个组件都可以显示被触发更新的原因,虽然比较模糊,但其实还是挺有趣的。

Flamegraph record

How to determine the different modes

好的,简单分析了下 React Devtool 的几个模块,回到正题,那怎样找到 Devtool 这块的判断逻辑呢,首先可以从 issue 里找找线索,最后找到一个类似的[12],文中给出了调试意见:

Could it be possible that you have some other extension installed that's doing something unexpected here? Can you run the following code in your developer console on one of these pages and paste the result here for me?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
console.log(
  JSON.stringify(
    Array.from(__REACT_DEVTOOLS_GLOBAL_HOOK__.renderers.values()).map(
      (renderer) => ({
        rendererPackageName: renderer.rendererPackageName,
        version: renderer.version,
      })
    )
  )
);

从讨论中可以看出 Devtool 内部会通过 renderers 的 metadata 来判断各种信息。接着在即时设计官网打印一下:

devtool renderer

可以看到官网框架已经换到了 React 18,另外还有一个额外的渲染器 react-canvaskit,那看起来大概率是这里的问题。

接着我们找寻一下源码内部的逻辑,这里我也没什么很好的方法,可以在 Sourcegraph 内锁定范围为几个 devtool 的 package,之后搜索关键字 renderer.,好在文件并不多,很快就定位到了相关逻辑[13]。判断函数内部注释写的很详细,有兴趣的可以仔细读读。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
if (typeof renderer.version === 'string') {
  // React DOM Fiber (16+)
  if (renderer.bundleType > 0) {
    // This is not a production build.
    // We are currently only using 0 (PROD) and 1 (DEV)
    // but might add 2 (PROFILE) in the future.
    return 'development';
  }

  // React 16 uses flat bundles. If we report the bundle as production
  // version, it means we also minified and envified it ourselves.
  return 'production';
  // Note: There is still a risk that the CommonJS entry point has not
  // been envified or uglified. In this case the user would have *both*
  // development and production bundle, but only the prod one would run.
  // This would be really bad. We have a separate check for this because
  // it happens *outside* of the renderer injection. See `checkDCE` below.
}

renderer.version 为 string 的情况下(从注释看应该是换 Fiber 架构后对 version 做的调整),会直接通过 bundleType 判断环境类型,接下来让我们把 renderer 的结构直接输出:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{
  "bundleType": 1,
  "version": "0.0.1",
  "rendererPackageName": "react-canvaskit",
  "overrideHookState": null,
  "overrideProps": null,
  "setSuspenseHandler": null,
  "scheduleUpdate": null,
  "currentDispatcherRef": {
    "current": {
      "unstable_isNewReconciler": false
    }
  },
  "findHostInstancesForRefresh": null,
  "scheduleRefresh": null,
  "scheduleRoot": null,
  "setRefreshHandler": null,
  "getCurrentFiber": null
}

可以看到果然是 bundleType 出了问题,不过运气不错的是 canvaskit 是在 Github 开源的,所以可以方便看到相关逻辑。检索后可以看到这个 renderer 的 reconciler 在配置 devtool 支持的时候直接 hard code 了相关信息。

1
2
3
4
5
6
const canvaskitReconciler = ReactReconciler(hostConfig)
canvaskitReconciler.injectIntoDevTools({
  bundleType: 1, // 0 for PROD, 1 for DEV
  version: '0.0.1', // version for your renderer
  rendererPackageName: 'react-canvaskit', // package name
})

P.S. 如果你不太熟悉 renderer 和 reconciler 的概念,可以看一下 medium 上的经典文章[14]

总结

最终到这里就破案了,虽然解决方案比较简单,但是从一个现象一步步探索到根因的过程还是非常有趣的,所以本文也写的比较详细,尽可能把思考和检索过程体现出来,因为判断开发模式这本身是个不太被非 React 开发者关心的点,所以基本也没什么资料,如果直接指出这是 bundleType 出错导致的其实作为读者也很难有收获,这中间顺着各种信息检索 issue 和 commit 的过程还是蛮有趣的。

发现这个问题以后我也给即时设计发了邮件,不过几个月过去了也没有回复,这篇文章拖了很久以后最近想补全打开官网发现这个问题依然没有解决。这一点还挺奇怪的,虽然无伤大雅,不会对实际功能造成影响,但是如果挂着 React Devtool 发现忽然红灯亮了确实比较尴尬😂。