Skip to main content

Fast Refresh Principle Analysis

Free2020-06-07#Tool#React Native Hot Reload#React Native HMR#React Native Fast Refresh原理#RN热重载#React Native Live Editing

Unlike React Hot Loader which struggled hard for 6 years, Fast Refresh was born in the React family

1. What is Fast Refresh?

A new feature launched by React Native (v0.6.1), React component modifications take effect immediately:

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

Sounds like... yes, its core capability is Hot Reloading:

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

But different from previous community solutions (such as React Hot Loader), Fast Refresh is officially supported by React, more stable and reliable:

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

To thoroughly solve problems with old solutions in stability, reliability, fault tolerance, etc.:

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.

Conceptually, Fast Refresh is equivalent to combining Live Reloading and Hot Reloading into one:

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".

2. Operating Mechanism

Reload Strategy

Basic processing strategy is divided into 3 situations:

  • If the edited module only exports React components, Fast Refresh only updates the module's code and re-renders the corresponding components. At this time all modifications to this file can take effect, including styles, rendering logic, event handling, and even some side effects

  • If the edited module exports things other than React components, Fast Refresh will re-execute the module and all modules that depend on it

  • If the edited file is referenced by modules outside the React (component) tree, Fast Refresh will degrade to full refresh (Live Reloading)

Distinguish pure component modules, non-component modules and impure component modules based on module export content, best support for pure component modules (modules that only export React components), fully supports new React (v16.x) function components and Hooks

Fault Tolerance

Compared to Hot Reloading, Fast Refresh has stronger fault tolerance:

  • Syntax errors: Syntax errors during Fast Refresh will be caught, fix and save file to restore normal, so files with syntax errors won't be executed, no need to manually refresh

  • Runtime errors: Runtime errors during module initialization can also be caught, won't cause substantial impact, and for runtime errors in components, Fast Refresh will remount the entire application (unless there's Error Boundary)

That is, for syntax errors and some typos (runtime errors during module loading), Fast Refresh can restore to normal after fixing, and for component runtime errors, will degrade to full remount (Live Reloading) or partial remount (if there's Error Boundary, remount Error Boundary)

Limitations

However, in some situations, maintaining state is not very safe, so for reliability, Fast Refresh doesn't preserve state in these situations at all:

  • Class components are always remounted, state will be reset, including Class components returned by higher-order components

  • Impure component modules, the edited module exports other things besides React components

Specially, can also use // @refresh reset directive (add this comment line anywhere in source file) to force remount, maximizing availability

P.S. Long term, function components will rise, Class components will become fewer, editing experience will become better:

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.

3. Implementation Principle

To achieve finer-grained hot update capability than HMR (module level), React Hot Loader (limited component level), supporting component-level, even Hooks-level reliable updates, relying only on external mechanisms (supplementary runtime, compilation transformation) is very difficult, needs React's deep cooperation:

Fast Refresh is a reimplementation of "hot reloading" with full support from React.

That is, some previously insurmountable problems (such as Hooks), can now be solved through React cooperation

In implementation, Fast Refresh is also based on HMR, from bottom to top:

  • HMR mechanism: such as webpack HMR

  • Compilation transformation: react-refresh/babel

  • Supplementary runtime: react-refresh/runtime

  • React support: React DOM 16.9+, or react-reconciler 0.21.0+

Compared to React Hot Loader, removed proxy above components, changed to React directly providing support:

[caption id="attachment_2202" align="alignnone" width="560"]react hot loader vs fast refresh react hot loader vs fast refresh[/caption]

Previously to preserve component state, Proxy Component supporting replacing component render part are no longer needed, because new React provides native support for function components, Hooks hot replacement

4. Source Code Analysis

Related source code is divided into Babel plugin and Runtime two parts, both maintained in react-refresh, exposed through different entry files (react-refresh/babel, react-refresh/runtime)

Can understand Fast Refresh's specific implementation from following 4 aspects:

  1. What did Plugin do at compile time?

  2. How does Runtime cooperate at runtime?

  3. What support did React provide for this?

  4. Complete mechanism including HMR

What did Plugin do at compile time?

Simply put, Fast Refresh finds all components and custom Hooks through Babel plugin, and registers them to a large table (Map)

First traverse AST to collect all Hooks and their signatures:

// 语法树遍历一开始先单跑一趟遍历找出所有 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);
  }
}

(From react/packages/react-refresh/src/ReactFreshBabelPlugin.js)

P.S. Above code is part of visitor in Babel plugin, specifically see [Babel Quick Guide](/articles/babel 快速指南/#articleHeader9)

Then traverse again to find all React function components, and insert code to expose component, Hooks etc. information to Runtime, establishing connection between source file and runtime module:

// 遇到函数声明注册 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));
    });
  },
},

That is through Babel plugin find all React function component definitions (including HOC etc.), and register component references to runtime by component name:

// 转换前
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");

Specially, Hooks handling is slightly more troublesome:

// 转换前
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");

When encountering a Hook, three lines of code are inserted, module scope's var _s = $RefreshSig$(); and _s(App, "useState{[foo, setFoo](0)}\\nuseEffect{}");, and _s(); in same scope as Hook call

How does Runtime cooperate at runtime?

Two undefined functions appear in code injected by Babel plugin:

  • $RefreshSig$: Create Hooks signature

  • $RefreshReg$: Register component

These two functions come from react-refresh/runtime, for example:

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;

Correspond to createSignatureFunctionForTransform and register provided by RefreshRuntime respectively

createSignatureFunctionForTransform fills Hooks identification information in two phases, first fill associated component information, second collect Hooks, third and subsequent calls are invalid (resolved state, does nothing):

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;
  };
}

And register stores component reference (type) and component name identifier (id) to a large table, if already exists add to update queue:

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);
}

Updates in pendingUpdates queue only take effect during performReactRefresh, added to updatedFamiliesByType table, for React to query:

function resolveFamily(type) {
  // Only check updated types to keep lookups fast.
  return updatedFamiliesByType.get(type);
}

What support did React provide for this?

Notice Runtime depends on some React functions:

import type {
  Family,
  RefreshUpdate,
  ScheduleRefresh,
  ScheduleRoot,
  FindHostInstancesForRefresh,
  SetRefreshHandler,
} from 'react-reconciler/src/ReactFiberHotReloading';

Among them, setRefreshHandler is key for Runtime to establish connection with React:

export const setRefreshHandler = (handler: RefreshHandler | null): void => {
  if (__DEV__) {
    resolveFamily = handler;
  }
};

Passed from Runtime to React during performReactRefresh, and trigger React update through ScheduleRoot or scheduleRefresh:

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);
  });
}

Afterwards, React gets latest function components and Hooks through resolveFamily:

export function resolveFunctionForHotReloading(type: any): any {
  const family = resolveFamily(type);
  if (family === undefined) {
    return type;
  }
  // Use the latest known implementation.
  return family.current;
}

(From react/packages/react-reconciler/src/ReactFiberHotReloading.new.js)

And completes update during scheduling process:

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;
  }
}

(From react/packages/react-reconciler/src/ReactFiber.new.js)

So far, the entire hot update process is clear

But to make the whole mechanism run, still missing one piece—HMR

Complete mechanism including HMR

Above only has runtime fine-grained hot update capability, to actually run needs to connect with HMR, this part of work is related to specific build tools (webpack etc.)

Specifically:

// 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();
}

Among them, isReactRefreshBoundary is specific hot update strategy, controls whether to go Hot Reloading or degrade to Live Reloading, React Native's strategy specifically see metro/packages/metro/src/lib/polyfills/require.js /

5. Web Support

Although Fast Refresh requirement comes from React Native, its core implementation is platform-independent, also applicable to Web environment:

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

Replace React Native's Metro with webpack etc. build tools, connect according to above steps, for example:

P.S. Even React Hot Loader has posted retirement announcement, recommends using officially supported 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.

Reference Materials

Comments

No comments yet. Be the first to share your thoughts.

Leave a comment