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
クラスを作成します。
import { Compiler, Configuration } from 'webpack';
export class ReactRefreshPlugin {
async apply(compiler: Compiler) {}
}
export default {
entry: {
bundle: [
'webpack-hot-middleware/client?timeout=10000',
'src/index.ts'
],
},
plugins: [
new ReactRefreshPlugin(),
],
}
つぎに、以下で作成する runtime ファイルを webpack のエントリーに追加します。また (j|t)sx?
ファイルに対して refresh 用のローダーを追加します。
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 更新などの処理を行うヘルパー関数をまとめたものです。
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
を追加します。
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.ts
で window
に追加した $RefreshHelpers$
関数を呼んでいます。
実際には registerExportsForReactRefresh
で module を react-refresh
に登録したり、webpack-hot-middleware
経由で module を更新したり、scheduleUpdate
で react-refresh
の更新処理を実行したりしています。
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 をつくります。
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(),
};
動作確認
ホットリロードされています。
今回は最低限の部分のみを実装して react-refresh
を導入してみましたが、@pmmmwh/react-refresh-webpack-plugin
を使うと手軽に react-refresh
が導入できます。
おしまい。
参考
- https://github.com/facebook/react/issues/16604#issuecomment-528663101
- https://github.com/pmmmwh/react-refresh-webpack-plugin
- https://github.com/vercel/next.js/tree/canary/packages/react-refresh-utils
- https://gist.github.com/maisano/441a4bc6b2954205803d68deac04a716
- https://github.com/shooontan/bokusai-react/pull/3