react開発環境にreact-refreshを導入する

React の web 開発には基本的にホットリロードが導入されています。これまでのホットリロードには react-hot-loader を用いた実装が多いかと思いますが、他にも同様の機能を持つ react-refresh という React 公式ライブラリを使ったホットリロード実装も可能です。

そこで今回は react-refresh を webpack プラグインとして開発環境に導入し、React のホットリロード環境を構築します。

react-refresh

2020 年 6 月現在、react-refresh のドキュメントはありませんが、rough guide によると React 16.9.0+react-refresh が使えるようです。また、react-refresh/babel を Babel プラグインに追加する必要があります。

webpackプラグイン

さっそく webpack プラグインを作っていきます。サーバー側には webpack-hot-middleware が導入されている前提です。

ReactRefreshPlugin

まずはプラグインとなる ReactRefreshPlugin クラスを作成します。

react-refresh-plugin/index.ts
import { Compiler, Configuration } from 'webpack';

export class ReactRefreshPlugin {
  async apply(compiler: Compiler) {}
}
webpack.config.ts
export default {
  entry: {
    bundle: [
      'webpack-hot-middleware/client?timeout=10000',
      'src/index.ts'
    ],
  },
  plugins: [
    new ReactRefreshPlugin(),
  ],
}

つぎに、以下で作成する runtime ファイルを webpack のエントリーに追加します。また (j|t)sx? ファイルに対して refresh 用のローダーを追加します。

react-refresh-plugin/index.ts
import { Compiler, Configuration } from 'webpack';

export class ReactRefreshPlugin {
  // add runtime.ts to the top of the entry
  addEntry(entry: Configuration['entry']): Configuration['entry'] {
    // ...
  }

  async apply(compiler: Compiler) {
    compiler.options.entry = this.addEntry(compiler.options.entry);

    const isUserResource = (resource: string) =>
      !/node_modules/.test(resource) && /(j|t)sx?/.test(resource);

    compiler.hooks.normalModuleFactory.tap(this.constructor.name, nmf => {
      nmf.hooks.afterResolve.tap(this.constructor.name, data => {
        if (isUserResource(data.resource)) {
          // add a loader for react-refresh
          data.loaders.unshift({
            loader: require.resolve('./loader'),
          });
        }
        return data;
      });
    });
  }
}

runtime エントリーが追加されると、webpack の entry はこのようになります。

bundle: [
  'path/to/react-refresh-plugin/runtime.ts',
  'webpack-hot-middleware/client?timeout=10000',
  'src/index.ts'
]

つぎは runtime エントリーファイルを作成します。

runtime

window$RefreshReg$$RefreshSig$ を追加しています。これらは react-refresh が使う関数です。$RefreshHelpers$ は、module 更新などの処理を行うヘルパー関数をまとめたものです。

react-refresh-plugin/runtime.ts
import RefreshRuntime from 'react-refresh/runtime';
import RefreshHelpers from './helpers';

export type RefreshRuntimeGlobals = {
  $RefreshReg$: (type: unknown, id: string) => void;
  $RefreshSig$: () => (type: unknown) => unknown;
  $RefreshInterceptModuleExecution$: (moduleId: string) => () => void;
  $RefreshHelpers$: typeof RefreshHelpers;
};

declare global {
  interface Window extends RefreshRuntimeGlobals {}
}

// Hook into ReactDOM initialization
RefreshRuntime.injectIntoGlobalHook(window);

// noop fns to prevent runtime errors during initialization
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => type => type;

// Register global helpers
window.$RefreshHelpers$ = RefreshHelpers;

loader

webpack の loader を作ります。この loader は読み込む source ファイルの最後に ReactRefreshModule を追加します。

react-refresh-plugin/loader.ts
import { loader } from 'webpack';
import { ReactRefreshModule } from './ReactRefreshModule';

let refreshModuleRuntime = ReactRefreshModule.toString();

// remove the 'function ReactRefreshModule() {' and '}' of ReactRefreshModule
  refreshModuleRuntime.indexOf('{') + 1,
  refreshModuleRuntime.lastIndexOf('}')
);

const ReactRefreshLoader: loader.Loader = function ReactRefreshLoader(
  source,
  inputSourceMap
) {
  this.callback(null, `${source}\n\n${refreshModuleRuntime}`, inputSourceMap);
};

export default ReactRefreshLoader;

ReactRefreshModule

loader で (j|t)sx? に追加される内容を作ります。runtime.tswindow に追加した $RefreshHelpers$ 関数を呼んでいます。

実際には registerExportsForReactRefresh で module を react-refresh に登録したり、webpack-hot-middleware 経由で module を更新したり、scheduleUpdatereact-refresh の更新処理を実行したりしています。

react-refresh-plugin/ReactRefreshModule.ts
import { RefreshRuntimeGlobals } from './runtime';
import { Module } from './helpers';

declare global {
  interface Window extends RefreshRuntimeGlobals {}
}

declare const module: Module;

export function ReactRefreshModule() {
  if (typeof window !== 'undefined' && window.$RefreshHelpers$) {
    window.$RefreshHelpers$.registerExportsForReactRefresh(module);

    if (module.hot && window.$RefreshHelpers$.isReactRefreshBoundary(module)) {
      module.hot.dispose(data => {
        data.module = module;
      });
      module.hot.accept();

      if (module.hot.data) {
        if (
          !module.hot.data.module ||
          window.$RefreshHelpers$.shouldInvalidateReactRefreshBoundary(
            module.hot.data.module as Module,
            module
          )
        ) {
          module.hot.invalidate();
        }
        window.$RefreshHelpers$.scheduleUpdate();
      }
    }
  }
}

helpers

さいごに helpers をつくります。

react-refresh-plugin/helpers.ts
import RefreshRuntime from 'react-refresh/runtime';

type Data = Record<string, unknown>;

export type Module = {
  id: string;
  __proto__: { exports: Data | null };
  hot: {
    accept: () => void;
    dispose: (onDispose: (data: Data) => void) => void;
    invalidate: () => void;
    data?: Data;
  };
};

/**
 * Extracts exports from a webpack module object.
 */
function getModuleExports(module: Module) {
  return module.__proto__.exports || module.__proto__;
}

/**
 * Calculates the signature of a React refresh boundary.
 * If this signature changes, it's unsafe to accept the boundary.
 *
 * This implementation is based on the one in [Metro](https://github.com/facebook/metro/blob/907d6af22ac6ebe58572be418e9253a90665ecbd/packages/metro/src/lib/polyfills/require.js#L795-L816).
 */
function getReactRefreshBoundarySignature(
  moduleExports: ReturnType<typeof getModuleExports>
) {
  const signature = [];
  signature.push(RefreshRuntime.getFamilyByType(moduleExports));

  if (moduleExports == null || typeof moduleExports !== 'object') {
    // Exit if we can't iterate over exports.
    return signature;
  }

  for (let key in moduleExports) {
    if (key === '__esModule') {
      continue;
    }

    signature.push(key);
    signature.push(RefreshRuntime.getFamilyByType(moduleExports[key]));
  }

  return signature;
}

/**
 * Creates a helper that performs a delayed React refresh.
 */
function scheduleUpdate() {
  let refreshTimeout: number | NodeJS.Timeout | undefined = undefined;
  return () => {
    if (refreshTimeout === undefined) {
      refreshTimeout = setTimeout(() => {
        refreshTimeout = undefined;
        RefreshRuntime.performReactRefresh();
      }, 30);
    }
  };
}

/**
 * Checks if all exports are likely a React component.
 *
 * This implementation is based on the one in [Metro](https://github.com/facebook/metro/blob/febdba2383113c88296c61e28e4ef6a7f4939fda/packages/metro/src/lib/polyfills/require.js#L748-L774).
 */
function isReactRefreshBoundary(module: Module) {
  const moduleExports = getModuleExports(module);

  if (RefreshRuntime.isLikelyComponentType(moduleExports)) {
    return true;
  }

  if (moduleExports == null || typeof moduleExports !== 'object') {
    // Exit if we can't iterate over exports.
    return false;
  }

  let hasExports = false;
  let areAllExportsComponents = true;

  for (let key in moduleExports) {
    hasExports = true;

    // This is the ES Module indicator flag set by Webpack
    if (key === '__esModule') {
      continue;
    }

    // We can (and have to) safely execute getters here,
    // as Webpack manually assigns harmony exports to getters,
    // without any side-effects attached.
    // Ref: https://github.com/webpack/webpack/blob/b93048643fe74de2a6931755911da1212df55897/lib/MainTemplate.js#L281
    const exportValue = moduleExports[key];
    if (!RefreshRuntime.isLikelyComponentType(exportValue)) {
      areAllExportsComponents = false;
    }
  }

  return hasExports && areAllExportsComponents;
}

/**
 * Checks if exports are likely a React component and registers them.
 *
 * This implementation is based on the one in [Metro](https://github.com/facebook/metro/blob/febdba2383113c88296c61e28e4ef6a7f4939fda/packages/metro/src/lib/polyfills/require.js#L818-L835).
 */
function registerExportsForReactRefresh(module: Module) {
  const moduleExports = getModuleExports(module);
  const moduleId = module.id;

  if (RefreshRuntime.isLikelyComponentType(moduleExports)) {
    // Register module.exports if it is likely a component
    RefreshRuntime.register(moduleExports, moduleId + ' %exports%');
  }

  if (moduleExports == null || typeof moduleExports !== 'object') {
    // Exit if we can't iterate over exports.
    return;
  }

  for (const key in moduleExports) {
    // Skip registering the Webpack ES Module indicator
    if (key === '__esModule') {
      continue;
    }
    const exportValue = moduleExports[key];
    if (RefreshRuntime.isLikelyComponentType(exportValue)) {
      const typeID = moduleId + ' %exports% ' + key;
      RefreshRuntime.register(exportValue, typeID);
    }
  }
}

/**
 * Compares previous and next module objects to check for mutated boundaries.
 *
 * This implementation is based on the one in [Metro](https://github.com/facebook/metro/blob/907d6af22ac6ebe58572be418e9253a90665ecbd/packages/metro/src/lib/polyfills/require.js#L776-L792).
 */
function shouldInvalidateReactRefreshBoundary(
  prevModule: Parameters<typeof getModuleExports>[0],
  nextModule: Parameters<typeof getModuleExports>[0]
) {
  const prevSignature = getReactRefreshBoundarySignature(
    getModuleExports(prevModule)
  );
  const nextSignature = getReactRefreshBoundarySignature(
    getModuleExports(nextModule)
  );

  if (prevSignature.length !== nextSignature.length) {
    return true;
  }

  for (let i = 0; i < nextSignature.length; i += 1) {
    if (prevSignature[i] !== nextSignature[i]) {
      return true;
    }
  }

  return false;
}

export default {
  getModuleExports,
  registerExportsForReactRefresh,
  isReactRefreshBoundary,
  shouldInvalidateReactRefreshBoundary,
  getReactRefreshBoundarySignature,
  scheduleUpdate: scheduleUpdate(),
};

動作確認

ホットリロードされています。

webpack-react-refresh-demo

今回は最低限の部分のみを実装して react-refresh を導入してみましたが、@pmmmwh/react-refresh-webpack-plugin を使うと手軽に react-refresh が導入できます。

おしまい。

参考