Fast Refresh原理剖析

一.Fast Refresh 又是什么?

React Native(v0.6.1)新推出的一项特性,React 组件修改能立即生效

Fast Refresh is a React Native feature that allows you to get near-instant feedback for changes in your React components.

听起来像是…没错,其核心能力就是Hot Reloading

Fast Refresh is a feature that lets you edit React components in a running application without losing their state.

但不同于此前的社区方案(如React Hot Loader),Fast Refresh 由 React 提供官方支持,更加稳定可靠

It is similar to an old feature known as “hot reloading”, but Fast Refresh is more reliable and officially supported by React.

为了彻底解决旧方案稳定性、可靠性、容错性等方面的问题:

It didn’t work reliably for function components, often failed to update the screen, and wasn’t resilient to typos and mistakes. We heard that most people turned it off because it was too unreliable.

概念上,Fast Refresh 相当把 Live Reloading 与 Hot Reloading 合二为一了

In React Native 0.61, We’re unifying the existing “live reloading” (reload on save) and “hot reloading” features into a single new feature called “Fast Refresh”.

二.运作机制

Reload 策略

基本的处理策略分为 3 种情况:

  • 如果所编辑的模块仅导出了 React 组件,Fast Refresh 就只更新该模块的代码,并重新渲染对应的组件。此时该文件的所有修改都能生效,包括样式、渲染逻辑、事件处理、甚至一些副作用

  • 如果所编辑的模块导出的东西不只是 React 组件,Fast Refresh 将重新执行该模块以及所有依赖它的模块

  • 如果所编辑的文件被 React(组件)树之外的模块引用了,Fast Refresh 会降级成整个刷新(Live Reloading)

根据模块导出内容区分纯组件模块、非组件模块和不纯组件模块,对纯组件模块(只导出 React 组件的模块)支持程度最好,完全支持新 React(v16.x)的函数式组件和Hooks

容错处理

与 Hot Reloading 相比,Fast Refresh 的容错性更强一些:

  • 语法错误:Fast Refresh 期间的语法错误会被 catch 住,修掉并保存文件即可恢复正常,所以存在语法错误的文件不会被执行,无需手动重刷

  • 运行时错误:模块初始化过程中的运行时报错同样能被 catch 住,不会造成实质影响,而对于组件中的运行时错误,Fast Refresh 会重刷(remount)整个应用(除非有Error Boundary

也就是说,对于语法错误和部分拼写错误(模块加载时的运行时错误),修复后 Fast Refresh 就能恢复正常,而对于组件运行时错误,会降级到整个重刷(Live Reloading)或者局部重刷(有 Error Boundary 的话,重刷 Error Boundary)

限制

然而,有些情况下,维持状态并不十分安全,所以可靠起见,Fast Refresh 遇到这些情况一概不保留状态

  • Class 组件一律重刷(remount),state 会被重置,包括高阶组件返回的 Class 组件

  • 不纯组件模块,所编辑的模块除导出 React 组件外,还导出了其它东西

特殊的,还可以通过// @refresh reset指令(在源码文件中任意位置加上这行注释)强制重刷(remount),最大限度地保证可用性

P.S.长期来看,函数式组件将崛起,Class 组件越来越少,编辑体验会越来越好:

In the longer term, as more of your codebase moves to function components and Hooks, you can expect state to be preserved in more cases.

三.实现原理

要想达到比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 直接提供支持:

react hot loader vs fast refresh

react hot loader vs fast refresh

之前为了保留组件状态,支持替换组件 render 部分的 Proxy Component 都不需要了,因为新版 React 对函数式组件、Hooks 的热替换提供了原生支持

四.源码简析

相关源码分为 Babel 插件和 Runtime 两部分,都维护在react-refresh中,通过不同的入口文件(react-refresh/babelreact-refresh/runtime)暴露出来

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

  1. Plugin 在编译时做了什么?

  2. Runtime 在运行时怎么配合的?

  3. React 为此提供了哪些支持?

  4. 包括 HMR 在内的完整机制

Plugin 在编译时做了什么?

简单来讲,Fast Refresh 通过 Babel 插件找出所有组件和自定义 Hooks,并注册到大表(Map)中

先遍历 AST 收集所有 Hooks 及其签名:

// 语法树遍历一开始先单跑一趟遍历找出所有Hooks调用,记录到 hookCalls Map中
Program: {
  enter(path) {
    // This is a separate early visitor because we need to collect Hook calls
    // and "const [foo, setFoo] = ..." signatures before the destructuring
    // transform mangles them. This extra traversal is not ideal for perf,
    // but it's the best we can do until we stop transpiling destructuring.
    path.traverse(HookCallsVisitor);
  }
}

(摘自react/packages/react-refresh/src/ReactFreshBabelPlugin.js

P.S.以上代码是 Babel 插件中 visitor 的一部分,具体见Babel 快速指南

接着再遍历一次找出所有 React 函数式组件,并插入代码把组件、Hooks 等信息暴露给 Runtime,建立起源文件与运行时模块之间的联系

// 遇到函数声明注册Hooks信息
FunctionDeclaration: {
  exit(path) {
    const node = path.node;
    // 查表,函数中有Hooks调用则继续
    const signature = getHookCallsSignature(node);
    if (signature === null) {
      return;
    }

    const sigCallID = path.scope.generateUidIdentifier('_s');
    path.scope.parent.push({
      id: sigCallID,
      init: t.callExpression(refreshSig, []),
    });

    // The signature call is split in two parts. One part is called inside the function.
    // This is used to signal when first render happens.
    path
      .get('body')
      .unshiftContainer(
        'body',
        t.expressionStatement(t.callExpression(sigCallID, [])),
      );

    // The second call is around the function itself.
    // This is used to associate a type with a signature.

    // Unlike with $RefreshReg$, this needs to work for nested
    // declarations too. So we need to search for a path where
    // we can insert a statement rather than hardcoding it.
    let insertAfterPath = null;
    path.find(p => {
      if (p.parentPath.isBlock()) {
        insertAfterPath = p;
        return true;
      }
    });

    insertAfterPath.insertAfter(
      t.expressionStatement(
        t.callExpression(
          sigCallID,
          createArgumentsForSignature(
            id,
            signature,
            insertAfterPath.scope,
          ),
        ),
      ),
    );
  },
},
Program: {
  exit(path) {
    // 查表,文件中有React函数式组件则继续
    const registrations = registrationsByProgramPath.get(path);
    if (registrations === undefined) {
      return;
    }
    const declarators = [];
    path.pushContainer('body', t.variableDeclaration('var', declarators));
    registrations.forEach(({handle, persistentID}) => {
      path.pushContainer(
        'body',
        t.expressionStatement(
          t.callExpression(refreshReg, [
            handle,
            t.stringLiteral(persistentID),
          ]),
        ),
      );
      declarators.push(t.variableDeclarator(handle));
    });
  },
},

即通过 Babel 插件找到所有的 React 函数式组件定义(包括 HOC 等),并按组件名把组件引用注册到运行时:

// 转换前
export function Hello() {
  function handleClick() {}
  return <h1 onClick={handleClick}>Hi</h1>;
}
export default function Bar() {
  return <Hello />;
}
function Baz() {
  return <h1>OK</h1>;
}
const NotAComp = 'hi';
export { Baz, NotAComp };
export function sum() {}
export const Bad = 42;

// 转换后
export function Hello() {
  function handleClick() {}

  return <h1 onClick={handleClick}>Hi</h1>;
}
_c = Hello;
export default function Bar() {
  return <Hello />;
}
_c2 = Bar;

function Baz() {
  return <h1>OK</h1>;
}

_c3 = Baz;
const NotAComp = 'hi';
export { Baz, NotAComp };
export function sum() {}
export const Bad = 42;

var _c, _c2, _c3;

$RefreshReg$(_c, "Hello");
$RefreshReg$(_c2, "Bar");
$RefreshReg$(_c3, "Baz");

特殊的,Hooks 处理起来稍麻烦一些:

// 转换前
export default function App() {
  const [foo, setFoo] = useState(0);
  React.useEffect(() => {});
  return <h1>{foo}</h1>;
}

// 转换后
var _s = $RefreshSig$();

export default function App() {
  _s();

  const [foo, setFoo] = useState(0);
  React.useEffect(() => {});
  return <h1>{foo}</h1>;
}

_s(App, "useState{[foo, setFoo](0)}\\nuseEffect{}");

_c = App;

var _c;

$RefreshReg$(_c, "App");

遇到一个 Hooks 会插入三行代码,模块作用域的var _s = $RefreshSig$();_s(App, "useState{[foo, setFoo](0)}\\nuseEffect{}");,以及与 Hooks 调用处于相同作用域的_s();

Runtime 在运行时怎么配合的?

Babel 插件注入的代码中出现了两个未定义的函数:

  • $RefreshSig$:创建 Hooks 签名

  • $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.createSignatureFunctionForTransform;

分别对应 RefreshRuntime 提供的createSignatureFunctionForTransformregister

createSignatureFunctionForTransform分两个阶段填充 Hooks 的标识信息,第一次填充关联组件的信息,第二次收集 Hooks,第三次及之后的调用都无效(resolved状态,什么也不做):

export function createSignatureFunctionForTransform() {
  // We'll fill in the signature in two steps.
  // First, we'll know the signature itself. This happens outside the component.
  // Then, we'll know the references to custom Hooks. This happens inside the component.
  // After that, the returned function will be a fast path no-op.
  let status: SignatureStatus = 'needsSignature';
  let savedType;
  let hasCustomHooks;
  return function<T>(
    type: T,
    key: string,
    forceReset?: boolean,
    getCustomHooks?: () => Array<Function>,
  ): T {
    switch (status) {
      case 'needsSignature':
        if (type !== undefined) {
          // If we received an argument, this is the initial registration call.
          savedType = type;
          hasCustomHooks = typeof getCustomHooks === 'function';
          setSignature(type, key, forceReset, getCustomHooks);
          // The next call we expect is from inside a function, to fill in the custom Hooks.
          status = 'needsCustomHooks';
        }
        break;
      case 'needsCustomHooks':
        if (hasCustomHooks) {
          collectCustomHooksForSignature(savedType);
        }
        status = 'resolved';
        break;
      case 'resolved':
        // Do nothing. Fast path for all future renders.
        break;
    }
    return type;
  };
}

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,并通过ScheduleRootscheduleRefresh触发 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 /

五.Web 支持

Fast Refresh 需求虽然来自 React Native,但其核心实现是平台无关的,也适用于 Web 环境

It’s originally shipping for React Native but most of the implementation is platform-independent.

将 React Native 的 Metro 换成 webpack 等构建工具,按上述步骤接入即可,例如:

P.S.甚至 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.

参考资料

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code