React Fast Refresh

前言

首先介绍一下 Live reloadingHot 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.jsModal.js 同时引入了 Theme.js ,编辑 theme.js 的时候,Button.jsModal.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 会尽可能的在编辑刷新时保留组件的状态。特别是 useStateuseRef,只要你不更改它们的参数或 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/babelreact-refresh/runtime)暴露出来。

可从以下 4 个方面来了解 Fast Refresh 的具体实现:

  1. Babel plugin 在编译时做了什么?
  2. Runtime 在运行时怎么配合的?
  3. React 为此提供了哪些支持?
  4. 包括 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 提供的 createSignatureFunctionForTransformregister

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.

264
0
0
0
关于作者
相关产品
评论
未登录
看完啦,登录分享一下感受吧~
暂无评论