首先介绍一下 Live reloading 和 Hot reloading 的区别:
- Live reloading: 修改文件之后,Webpack 重新编译,并强制刷新浏览器,属于全局(整个应用)刷新,相当于
window.location.reload()
; - Hot reloading: 修改文件之后,Webpack 重新编译对应模块,刷新时可以记住应用的状态,从而做到局部刷新。
Fast Refresh
是 React 官方在 React Native(v0.6.1) 推出的模块热替换(HMR)方案,由于其核心实现与平台无关,因而 Fast Refresh
同时也可以适用于 Web。
刷新策略
- 如果你编辑了一个 仅导出 React 组件 的模块文件, Fast Refresh 只会更新该模块的代码,并且重新渲染你的组件。你能够编辑文件里面的任何东西,包括样式,渲染逻辑,事件处理或者 effects。
- 如果你编辑的模块并不导出 React 组件, Fast Refresh 将会重新运行该模块,和其他引入该模块的模块文件。例如,
Button.js
和Modal.js
同时引入了Theme.js
,编辑theme.js
的时候,Button.js
和Modal.js
都会更新。 - 最后,如果你编辑了某个文件,而这个文件被 React 渲染树 之外的模块引入,则
Fast Refresh
将会回退到完全刷新。你可能有一个文件,该文件渲染了一个 React 组件,同时又导出了一个被其他非 React 组件引入的值。例如,你的 React 组件模块同时导出了一个常量,并且在非 React 组件模块引入了它。在这种情况下面,考虑将查询迁移到一个单独的文件并将其导入到两个文件中。这样Fast Refresh
才能重新生效。其他的情况也类似。
容错处理
- 如果在 Fast Refresh 的过程中出现了语法错误,可以在修复错误后重新保存文件。
Redbox
警告会跟着消失。错误语法的模块会被阻止运行,这样你就不需要重载 App。 - 如果出现了在模块初始化过程中的运行时错误(例如,将
StyleSheet.create
打成了Style.create
),在你修复错误之后, Fast Refresh 会话会继续进行。Redbox
警告消失,模块更新。 - 如果出现了组件内部发生的运行时错误,在你修复错误之后,
Fast Refresh
会话也将继续进行。在这种情况下,React 将会使用更新后的代码重新挂载你的应用。 - 如果发生运行时错误的组件在 错误边界(Error Boundaries)内部,
Fast Refresh
将在你修复错误后重新渲染错误边界内的节点。
限制
当你编辑文件的时候,Fast Refresh
会在安全的前提下保持组件里的 state。在以下情况编辑文件之后,组件里的 state 会被重置:
- class 组件的本地 state 不会被保持(仅保持函数组件和 Hooks 的 state)。
- 除了 React 组件外,您正在编辑的模块可能还有其他导出。
- 有时候,一个模块导出的是一个高阶组件,例如
createNavigationContainer(MyScreen)
。如果返回的组件是一个 class 组件,state 将会被重置。
随着函数组件和 Hooks 被应用得更加广泛,从长远来看,Fast Refresh
的编辑体验会变得更好。
提示
- Fast Refresh 默认保持函数组件(和 Hooks)的 state。
- 假设你正在调试一个仅发生在挂载期间的动画,你想要强制重置状态,让这个组件被重新挂载。在这种场景下,你可以在文件的任何地方增加
// @refresh reset
。这个指令会让 Fast Refresh 在每次编辑时重新挂载该文件中定义的组件。
Hooks
Fast Refresh
会尽可能的在编辑刷新时保留组件的状态。特别是 useState
和 useRef
,只要你不更改它们的参数或 Hooks 的调用顺序,就可以保留它们以前的值
有依赖的 Hook —— 比如 useEffect
, useMemo
, 和 useCallback
在 Fast Refresh 期间将始终刷新。在 Fast Refresh 触发时它们的依赖项列表将被忽略。
举个🌰,当你把 useMemo(() => x * 2, [x])
改为 useMemo(() => x * 10, [x])
, 即使Hook 的依赖 x
没有改变,factory 函数也会重新运行。如果 React 没有这样处理,这个修改就不会反映到屏幕上。
有时候这种机制会导致意想不到的结果。例如,即使一个 useEffect
的依赖项是空数组,在 Fast Refresh 期间仍会重新运行一次。然而,即使没有 Fast Refresh
,编写能够适应偶尔重新运行的 useEffect
代码也是一种很好的做法。这使得你以后向其引入新的依赖项时可以更轻松。
要想达到比HMR(module 级)、React Hot Loader(受限的组件级)粒度更细的热更新能力,支持组件级、甚至 Hooks 级的可靠更新,仅靠外部机制(补充的运行时、编译转换)很难做到,需要 React 的深度配合:
Fast Refresh is a reimplementation of “hot reloading” with full support from React.
也就是说,一些之前绕不过去的难题(比如 Hooks),现在可通过 React 配合解决
实现上,Fast Refresh 同样基于 HMR,自底向上依次为:
- HMR 机制:如 webpack HMR
- 编译转换:
react-refresh/babel
- 补充运行时:
react-refresh/runtime
- React 支持:React DOM 16.9+,或 react-reconciler 0.21.0+
与 React Hot Loader 相比,去掉了组件之上的代理,改由 React 直接提供支持:
之前为了保留组件状态,支持替换组件 render 部分的 Proxy Component 都不需要了,因为新版 React 对函数式组件、Hooks 的热替换提供了原生支持。
React Refresh
分为 Babel 插件和 Runtime 两部分,都维护在 react-refresh
包中,通过不同的入口文件(react-refresh/babel
、react-refresh/runtime
)暴露出来。
可从以下 4 个方面来了解 Fast Refresh 的具体实现:
- Babel plugin 在编译时做了什么?
- Runtime 在运行时怎么配合的?
- React 为此提供了哪些支持?
- 包括 HMR 在内的完整机制
Babel plugin 在编译时做了什么?
简单来讲,Fast Refresh 通过 Babel 插件找出所有组件和自定义 Hooks,并在对应的位置插入组件注册和自定义 Hook 签名收集的函数调用。
function useFancyState() {
const [foo, setFoo] = React.useState(0);
useFancyEffect();
return foo;
}
const useFancyEffect = () => {
React.useEffect(() => {});
};
export default function App() {
const bar = useFancyState();
return <h1>{bar}</h1>;
}
var _s = $RefreshSig$(),
_s2 = $RefreshSig$(),
_s3 = $RefreshSig$();
function useFancyState() {
_s();
const [foo, setFoo] = React.useState(0);
useFancyEffect();
return foo;
}
_s(useFancyState, "useState{[foo, setFoo](0)}\nuseFancyEffect{}", false, function () {
return [useFancyEffect];
});
const useFancyEffect = () => {
_s2();
React.useEffect(() => {});
};
_s2(useFancyEffect, "useEffect{}");
export default function App() {
_s3();
const bar = useFancyState();
return <h1>{bar}</h1>;
}
_s3(App, "useFancyState{bar}", false, function () {
return [useFancyState];
});
_c = App;
var _c;
$RefreshReg$(_c, "App");
Runtime 在运行时怎么配合的?
Babel 插件注入的代码中出现了两个未定义的函数:
$RefreshSig$
收集自定义 Hook 签名$RefreshReg$
注册组件
这两个函数来自react-refresh/runtime
,例如:
var RefreshRuntime = require('react-refresh/runtime');
window.$RefreshReg$ = (type, id) => {
// Note module.id is webpack-specific, this may vary in other bundlers
const fullId = module.id + ' ' + id;
RefreshRuntime.register(type, fullId);
}
window.$RefreshSig$ = RefreshRuntime.collectCustomHooksForSignature;
分别对应 RefreshRuntime
提供的 createSignatureFunctionForTransform
和 register
createSignatureFunctionForTransform
分两个阶段填充 Hooks 的标识信息,第一次填充关联组件的信息,第二次收集 Hooks,第三次及之后的调用都无效( resolved
状态,什么也不做):
export function createSignatureFunctionForTransform() {
let savedType;
let hasCustomHooks;
let didCollectHooks = false;
return function<T>(
type: T,
key: string,
forceReset?: boolean,
getCustomHooks?: () => Array<Function>,
): T | void {
if (typeof key === 'string') {
// We're in the initial phase that associates signatures
// with the functions. Note this may be called multiple times
// in HOC chains like _s(hoc1(_s(hoc2(_s(actualFunction))))).
if (!savedType) {
// We're in the innermost call, so this is the actual type.
savedType = type;
hasCustomHooks = typeof getCustomHooks === 'function';
}
// Set the signature for all types (even wrappers!) in case
// they have no signatures of their own. This is to prevent
// problems like https://github.com/facebook/react/issues/20417.
if (
type != null &&
(typeof type === 'function' || typeof type === 'object')
) {
setSignature(type, key, forceReset, getCustomHooks);
}
return type;
} else {
// We're in the _s() call without arguments, which means
// this is the time to collect custom Hook signatures.
// Only do this once. This path is hot and runs *inside* every render!
if (!didCollectHooks && hasCustomHooks) {
didCollectHooks = true;
collectCustomHooksForSignature(savedType);
}
}
};
}
而 register
把组件引用( type
)和组件名标识( id
)存储到一张大表中,如果已经存在加入到更新队列:
export function register(type: any, id: string): void {
// Create family or remember to update it.
// None of this bookkeeping affects reconciliation
// until the first performReactRefresh() call above.
let family = allFamiliesByID.get(id);
if (family === undefined) {
family = {current: type};
allFamiliesByID.set(id, family);
} else {
pendingUpdates.push([family, type]);
}
allFamiliesByType.set(type, family);
}
pendingUpdates
队列中的各项更新在performReactRefresh
时才会生效,加入到updatedFamiliesByType
表中,供 React 查询:
function resolveFamily(type) {
// Only check updated types to keep lookups fast.
return updatedFamiliesByType.get(type);
}
React 为此提供了哪些支持?
注意到 Runtime 依赖 React 的一些函数:
import type {
Family,
RefreshUpdate,
ScheduleRefresh,
ScheduleRoot,
FindHostInstancesForRefresh,
SetRefreshHandler,
} from 'react-reconciler/src/ReactFiberHotReloading';
其中,setRefreshHandler
是 Runtime 与 React 建立联系的关键:
export const setRefreshHandler = (handler: RefreshHandler | null): void => {
if (__DEV__) {
resolveFamily = handler;
}
};
在performReactRefresh
时从 Runtime 传递给 React,并通过ScheduleRoot或scheduleRefresh触发 React 更新:
export function performReactRefresh(): RefreshUpdate | null {
const update: RefreshUpdate = {
updatedFamilies, // Families that will re-render preserving state
staleFamilies, // Families that will be remounted
};
helpersByRendererID.forEach(helpers => {
// 将更新表暴露给React
helpers.setRefreshHandler(resolveFamily);
});
// 并触发React更新
failedRootsSnapshot.forEach(root => {
const helpers = helpersByRootSnapshot.get(root);
const element = rootElements.get(root);
helpers.scheduleRoot(root, element);
});
mountedRootsSnapshot.forEach(root => {
const helpers = helpersByRootSnapshot.get(root);
helpers.scheduleRefresh(root, update);
});
}
之后,React 通过resolveFamily
取到最新的函数式组件和 Hooks:
export function resolveFunctionForHotReloading(type: any): any {
const family = resolveFamily(type);
if (family === undefined) {
return type;
}
// Use the latest known implementation.
return family.current;
}
(摘自react/packages/react-reconciler/src/ReactFiberHotReloading.new.js)
并在调度过程中完成更新:
export function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
switch (workInProgress.tag) {
case IndeterminateComponent:
case FunctionComponent:
case SimpleMemoComponent:
// 更新函数式组件
workInProgress.type = resolveFunctionForHotReloading(current.type);
break;
case ClassComponent:
workInProgress.type = resolveClassForHotReloading(current.type);
break;
case ForwardRef:
workInProgress.type = resolveForwardRefForHotReloading(current.type);
break;
default:
break;
}
}
(摘自react/packages/react-reconciler/src/ReactFiber.new.js)
至此,整个热更新过程都清楚了
但要让整套机制跑起来,还差一块——HMR
包括 HMR 在内的完整机制
以上只是具备了运行时细粒度热更新的能力,要着整运转起来还要与 HMR 接上,这部分工作与具体构建工具(webpack 等)有关
具体如下:
// 1.在应用入口(引react-dom之前)引入runtime
const runtime = require('react-refresh/runtime');
// 并注入GlobalHook,从React中钩出一些东西,比如scheduleRefresh
runtime.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => type => type;
// 2.给每个模块文件前后注入一段代码
window.$RefreshReg$ = (type, id) => {
// Note module.id is webpack-specific, this may vary in other bundlers
const fullId = module.id + ' ' + id;
RefreshRuntime.register(type, fullId);
}
window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform;
try {
// !!!
// ...ACTUAL MODULE SOURCE CODE...
// !!!
} finally {
window.$RefreshReg$ = prevRefreshReg;
window.$RefreshSig$ = prevRefreshSig;
}
// 3.所有模块都处理完之后,接入HMR API
const myExports = module.exports;
if (isReactRefreshBoundary(myExports)) {
module.hot.accept(); // Depends on your bundler
const runtime = require('react-refresh/runtime');
// debounce降低更新频率
let enqueueUpdate = debounce(runtime.performReactRefresh, 30);
enqueueUpdate();
}
其中,isReactRefreshBoundary
是具体的热更新策略,控制走 Hot Reloading 还是降级到 Live Reloading,React Native 的策略具体见metro/packages/metro/src/lib/polyfills/require.js /
Fast Refresh 最初虽然用于 React Native,但其核心实现是平台无关的,也适用于 Web 环境。
It’s originally shipping for React Native but most of the implementation is platform-independent.
将 React Native 的 Metro 换成 webpack 等构建工具,按上述步骤接入即可,例如:
- parcel:官方支持
- webpack:社区插件
甚至 React Hot Loader 已经贴出了退役公告,建议使用官方支持的 Fast Refresh:
React-Hot-Loader is expected to be replaced by React Fast Refresh. Please remove React-Hot-Loader if Fast Refresh is currently supported on your environment.