You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

214 lines
18 KiB
Markdown

2 years ago
# 09Fast Refresh提高 UI 调试效率神器
你好,我是蒋宏伟。今天我们来讲一讲提高 UI 调试效率的方法。
在开发 UI 时,大家一般都是一边看设计稿,一边写代码,一边调试,三种行为交替进行的。谁的大脑都不是一台编译机,也不能安装真正的 React Native 环境。即使已经思考得很完备了,我们也不能保写完的一段代码里面没有任何 Bug每次写完的代码都能完美符合我们预期的设计。所以我们离不开 UI 调试。
那UI 调试效率重要吗?非常重要。你可以回想一下,是不是我们大部分的业务开发都会涉及到 UI 的开发。而在 UI 开发的过程中,你是不是会花费很多时间在调试代码上,甚至调试时间可能比真正写代码的时间还要多?正是如此,我们才更应该花点时间学一下调试技巧,把 UI 开发整体效率给提上去。
今天这节课,我会先从 React Native 快速刷新的使用讲起,然后再深入核心原理,帮你理解如何更好地使用快速刷新,提高你的 UI 开发效率。
## 使用快速刷新
React Native [快速刷新Fast Refresh](https://reactnative.dev/blog/2019/09/18/version-0.61#fast-refresh)是默认开启的,你不用做任何额外的配置,就能立刻体验到。
快速刷新提效的本质是**及时反馈**。也就是说,你写下代码后就能看到 UI没有其他任何多余步骤。代码完成了UI 就更新了,这就是及时反馈。
假设你正在开发一个商品列表页面。UI 稿中图片左边距为 30px ,你在 Image 的样式中增加了一行 `marginLeft: 30` 的代码。当你按下快捷键`cmd + s`保存代码时,不到 1s 的时间,你就看到你屏幕右侧模拟器中,所有商品图片都移到正确的位置上了。
你能在心中快速验证一下,是对的,然后你又添加一行上下居中的代码,又是不到 1s 的时间,商品图片又位移了一下。嗯,完美居中。每一次的 UI 调试都是,所码即所见,无与伦比的开发体验,让你沉浸在这开发的心流中。
这实际上就是我日常使用 React Native 快速刷新能力开发 UI 界面的感受。
在使用快速刷新时,你应该知道一个提升开发效率的小技巧。我日常开发时习惯把模拟器放在代码编辑器右边,并且会把模拟器勾选`window => stay on top`选项,在把模拟器置顶在编辑器上方。
这样,我们就能在写代码和调试的同时,立刻看到模拟器中的效果。相比真机调试或者多屏来回切换,置顶模拟器可以减少手离开键盘和视野来回切换的次数,提高你的开发效率。
![图片](https://static001.geekbang.org/resource/image/57/f4/57463070f313cb6e0e1425ee3930e8f4.png?wh=1825x1080)
看到这里,你是不是很好奇,快速刷新带来的“所码即所见”能力的原理究竟是什么样的?
## 基础原理:模块热替换
React Native 的快速刷新功能的最早期版本,叫做热重载 Hot Reload是基于 Webpack 的模块热替换[Hot Module Replacement](https://webpack.js.org/guides/hot-module-replacement)的原理开发的。我们写 React Native 之前,都会运行一个 `react-native start` 命令,启动一个 Metro 服务,而 Metro 服务就实现了模块热替换的功能。
Metro 服务会把更新的代码打包发送给 React Native 应用,让应用能够及时更新,那这个过程大概是怎么样的呢?
首先Metro 服务会监听代码文件的变化当你修改完代码保存文件时Metro 服务就会收到通知。在你保存好后Metro 就会编译涉及到的更新文件(③),编译完成后再生成一个用于更新的 bundle。
而 Metro 的模块热替换服务和 React Native 应用中的模块热替换客户端HMR Client在启动时其实已经建立好了 socket 连接。
所以,当新 bundle 生成时,模块热替换服务会通过 socket 通知块热替换客户端,热替换客户端实际就是运行在 React Native 应用中的一段 JavaScript 代码,它一开始就执行了一个 socket 监听事件(④)。
React Native 收到通知后,就会向请求 bundle 服务发起请求。然后bundle 服务会返回一个用于更新的 bundle并使用 JavaScript 引擎,在原来 React Native 应用的 JavaScript 上下文中执行用于更新的 bundle。
这个bundle 是由多个模块组成的,你修改代码文件对应的模块及其依赖模块都是新模块,新模块会把原先的旧模块替换掉。⑥
这就是整个模块热替换的全部过程,这里我放了一张流程图,你可以参考一下:
![](https://static001.geekbang.org/resource/image/2f/15/2fd3716c54b10fe645b9a3d4301cdb15.jpg?wh=1980x711)
但是这里会有一个问题,仅仅只是用新模块替换旧模块,会导致原生视图重新渲染,并且丢失原有状态。
这是因为,新模块的重新执行就意味着,每个新模块中的组件,无论是类组件或者函数组件,都会被重新创建。而 React 在判断是否要更新的时候会判断更新前后的两个组件是否相等。这样一来即便新旧组件的代码完全一样React 也会认为你销毁了原有组件,又创建了一个新的组件。而组件所对应的原生视图,也会发生销毁和重建。
这就好比,你先创建了一个旧的空对象,然后又创建了一个新的空对象。虽然代码完全一样,都是空对象,但是你用全等去判断时,因为对象是引用类型,创建了一个新对象就创建了一个新的引用,新的引用又不等于旧的引用,所以新对象是不等于旧对象的:
```plain
// 新的空对象 ≠ 旧的空对象
{} !== {}
```
同理,当你保存 List 组件时,即便你没有对 List 组件中的代码做任何修改模块热替换后React 也会认为,你保存之前的是旧组件,保存之后的是新组件。而新组件不等于旧组件,那它就会帮你销毁旧的原生视图,并重新创建新的原生视图。这个时候,原有组件状态 state 和原生列表的滚动位置都会丢失:
```plain
// 保存前oldList.js
export default function List {}
// 保存后newList.js
export default function List {}
// 渲染的都是 List 组件
render(){ <List /> }
// 但是,因为 newList ≠ oldList
require('newList').default !== require('oldList').default
// 所以React 会销毁旧的 List 原生视图,创建新的 List 原生视图
```
也就是说,基础的模块热替换功能只能实现组件级别的强制刷新,而组件状态的丢失,会导致开发效率的降低。
你想啊,当你要在商品列表页面中开发一个弹窗时,你修改了弹窗组件,一保存,弹窗组件强制刷新了,然后就消失了。你要又点开弹窗,重来一次。弹窗还稍微好点,如果是层级更新的组件,你要多次操作才能使用,如此反复操作,开发效率会变得很低。
## 进阶能力:复用组件及其状态
那么React Native 的快速刷新功能,是如何实现组件状态不丢失,原生视图不重建的呢?
快速刷新功能复用组件和状态的原理分为两个步骤:
1. 在编译时,修改组件的注册方式;
2. 在运行时,用“代理”的方式管理新旧组件的切换。
在编译时, 快速刷新的 babel 插件 [ReactFreshBabelPlugin](https://github.com/facebook/react/blob/v17.0.2/packages/react-refresh/src/ReactFreshBabelPlugin.js) 修改你的代码,将你的组件转换成可被代理的组件。快速刷新 babel 插件和其他 babel 插件一样,它的功能都是对代码进行转换。正如你使用 babel 可以把 JSX 转换为 JavaScript 一样,快速刷新 babel 插件也可以在你组件源代码中插入一些代码,实现组件的“代理”。
打一个比方,如果我们要对一个自定义的 Counter 函数组件实现代理。那我们要怎么做呢?首先,在 metro 打包时,快速刷新 babel 插件,找到文件中要导出的 Counter 组件;然后,通过它的函数名、文件名生成一个全局唯一的 ID例如 Counter.js#Counter ;最后,生成一行注册代码。这行代码的作用是,将 ID 作为一个不变的对象标识,用这个不变的对象去“代理”,因模块热替换而变化 Counter 组件,具体你可以看下这里:
```plain
// 源代码
export function Counter() {
const [count, setCount] = useState(0);
const handlePress = () => setCount(count + 1)
return <Text onPress={handlePress}>times:{count}</Text>
}
const __exports_default = Counter
export default __exports_default
// 由快速刷新 babel 生成
// 将组件注册到组件管理中心
register('Counter.js#Counter', Counter)
```
有了编译时插入的注册代码,在运行时,我们就可以用“代理”的方式,管理新旧组件的切换了。
无论是初次加载的 Count 组件,还是后续模块热替换不断新建的 Counter 组件,都会放在组件注册中心。而“代理”只会在 Count 组件初次加载时创建,创建之后就作为一个不变的对象放在“代理”注册中心。
在代码保存后,模块热替换会将新的组件代码运行,在新组件被创建的同时,新组件的注册函数就会被执行了。通过唯一的 ID找到对应的不变“代理”并将代理的 current 引用,切换到新组件上,完成新旧组件的切换。
```
// ReactFreshRuntime.js
// “代理”注册中心
const allFamiliesByID = {}
// 组件注册中心
const allFamiliesByType = {}
function register(id, componentType) {
let family = allFamiliesByID[id];
if (family === undefined) {
family = {current: componentType};
// 将不变的“代理”放入“代理”注册中心
allFamiliesByID[id] = family
} else {
// 用不变的“代理”,管理新旧组件的切换
const prevType = family.current;
family.current = componentType;
}
// 将所有组件都放入组件注册中心
allFamiliesByType[componentType] = family;
}
```
这就在保证组件不变的情况下,完成了新旧组件的切换。
因为代理组件是存在全局对象上的,所以当你保存代码引起模块系统更新时,代理组件的引用也不会发生改变。接着,页面开始更新,此时调用的是代理组件的 render 方法,然后代理组件调用的更新后的新模块组件的 render 方法。你每保存一次代码,模块系统都更新一次,代理组件实际 render 也会进行一次切换但是只要你的代码没有变化React 也不会重新创建原生视图。React Native 组件级别的快速刷新,就是通过**代理组件**实现的。
那究竟是如何实现复用组件及其状态的呢?
我们先来说状态复用。在我们前面的示例中,我们把 Counter 函数组件,放在了 Counter.js 的文件中,一个文件就是一个模块,如果里面只有一个函数组件的话,我们就可以把它叫做一个函数组件模块。模块代码是执行在该模块的上下文中的,上下文中有着各种变量,其中就包括状态。通过“代理”组件的方式,就可以实现在同一个组件模块的上下文中,执行不同的函数组件。无论是新函数组件,还是旧函数组件,用的都是相同的状态,这就是状态复用。
那么组件所代表的原始视图的复用又指的是什么呢?
我们同样打个比方,假设现在你改动的组件要开始渲染 render 了。我们前面提到过render 时判断是否要重新创建原生视图,是通过浅对比算法 shallowCompare 实现的。如果新旧组件的类型相等就走 re-update 的逻辑不创建,如果新旧组件的类型相等就走 re-mount 的逻辑重新创建。现在,新旧组件的“代理”是就是同一个对象,状态也不会发生改变,浅对比算法判断肯定相等,所以原生视图不会重新创建,从而实现了原生视图的复用。
**简而言之React Native 的快速刷新功能,就是通过“代理”组件的方式,实现了组件状态不丢失,原生视图不重建。**
这里我放了快速刷新 babel 编译后的复用模型,可以帮助你理解复用的实现原理:
![](https://static001.geekbang.org/resource/image/6e/a9/6eb61b9eb4d62b0519aed1a2a23e22a9.jpg?wh=1980x711)
**当然,并不是所有情况都会复用状态和原生视图。**
这又从何说起呢?我从组件类型的角度来给你解释。组件有两种类型:函数组件和类组件。
对于函数组件来说hooks 的顺序非常重要,相同的状态下,不同的顺序会有不同的结果。如果你修改了 hooks 的顺序,快速刷新时就会重新初始化状态。在其他情况下,函数组件的快速刷新都会为你保留状态。
对于类组件来说,只要是类组件的代码发生更新,组件的内部状态都要重新初始化。关于这点,快速刷新功能的作者 Dan 在博客中解释到,“(保留类组件的状态)热重载是非常不可靠的,最大原因就是类组件的方法是可以被动态替换的。是的,在实例原型链上替换它们很简单,但是根据我的经验,有太多边缘情况了,它根本没有办法可靠地工作”。
> [The simple answer is “replace them on the prototype” but even with Proxies, in my experience there are too many gnarly edge cases for this to work reliably.](https://overreacted.io/my-wishlist-for-hot-reloading)
所以,我给你的建议是,**尽可能地拥抱函数组件,放弃类组件**。这样你在 UI 调试的时候,就能更多的享受函数组件带来的状态保留好处。特别是一些入口很深的组件,需要多次操作后才能调试,一旦导航、蒙层、滚动之类的组件状态丢失了,整个操作就要重新再来一遍,才能重新进行调试。拥抱函数组件,你的调试效率才会更高。
当然,如果项目中已经用到了很多类组件,又要调试一些入口很深的组件,怎么办?方法也很简单,你应该把你要调试的组件,单独拎出来调试。如果拎出来的组件和其他组件有依赖关系,也可以通过 mock 数据的形式对其依赖进行解耦,实现快速调试。
想象你已经理解快速刷新的基本原理,接下来我们会站在一个更高的视角,看一下快速刷新的完整策略。
## 整体策略:逐步降级
在编程调试时,有各式各样的代码,有函数组件、类组件、工具函数和常量等等。那么,是什么样的策略,能让你尽可能地快看到调试结果呢?
快速刷新的整体策略就是**逐步降级**。如果颗粒度最小的更新不能使用,就换成颗粒度大一些的更新:
* 代码块:如果你只修改了函数组件中的一些代码块,并且没有改动 hooks 的顺序。快速刷新在复用状态和原生视图的同时,你对该文件的所有修改都会生效,包括样式、渲染逻辑、事件处理、甚至一些副作用;
* 组件:如果你修改了类组件中的任意代码,快速刷新会使用新的类组件进行重新渲染,原来的状态和原生视图都会被销毁;
* 模块:如果你修改的模块导出的东西不只是 React 组件,快速刷新将重新执行该模块以及所有依赖它的模块;
* React Native 应用:如果你修改的文件被 React 组件树之外的模块引用了,快速刷新将重新渲染整个 React Native 应用。
可以看到,快速刷新的逐步降级策略是,**从更新颗粒度最小代码块开始的,然后是组件、模块,最后是大颗粒度的 React Native 应用**。越小颗粒度的更新,为我们保留了越多原来的状态和环境,我们的开发调试效率也更高。
![](https://static001.geekbang.org/resource/image/a3/05/a3a4d0e37df1a44e1b3fb74b491c3d05.jpg?wh=1222x1080)
在调试的过程中,还会有奇奇怪怪的报错发生,比如语法错误、运行时错误和错误边界,这些错误快速刷新都帮你捕获到了。因此,快速刷新还有很强的鲁棒性。
## 总结
现在,再回到我们最初的话题,如何提高 UI 调试效率?我相信现在你已经有了答案。
调试 UI 最重要的是即使反馈和所码即所见。React Native 的快速刷新能力,会把我们的代码修改,尽可能快地展示出来。
能够实现快速刷新原因是,快速刷新能够通过模块热替换的方式,用我们修改后的新模块替换原来的旧模块。如果,该模块导出的是组件,那么“代理”组件就会将引用从旧组件切到新组件上,实现组件级别的刷新。如果,函数组件且 hooks 顺序没有发生改变,快速刷新时原有的组件状态也会保留。快速刷新时,越小颗粒度的更新,速度越快,调试效率更高。
要用好快速刷新功能,还有三个小技巧:
1. 同屏预览。将模拟器置顶在编辑器上方,减少你视野来回切换频率;
2. 拥抱函数组件。函数组件能保留原有组件状态,减少你操作交互的次数;
3. 单独拎出来调试。单独拎出来先开发独立组件再集成,可能会比在层层嵌套的代码结构中开发效率更高。
## 思考题
今天这一讲介绍的主要介绍的是理论知识,我就不给你留作业了,请你思考一下为什么快速刷新功能的作者 Dan 认为,保留类组件的热重载非常不可靠的,但函数组件却是可行的?
欢迎在留言区写下你的想法。我是蒋宏伟,咱们下节课见。