一.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 에 의해 직접 지원을 제공하도록 변경했습니다:
[caption id="attachment_2202" align="alignnone" width="560"]
react hot loader vs fast refresh[/caption]
이전에는 컴포넌트 상태를 유지하기 위해, 컴포넌트의 render 부분을 교체하는 Proxy Component 가 필요했지만, _신규 React 는 함수형 컴포넌트, Hooks 의 핫 리플레이스에 네이티브 지원을 제공_하므로, 불필요해졌습니다
四.소스 코드 간이 분석
관련 소스 코드는 Babel 플러그인과 Runtime 의 2 부분으로 나뉘며, 모두 react-refresh 에서 유지보수되며, 다른 엔트리 파일 (react-refresh/babel, react-refresh/runtime) 을 통해 공개됩니다
다음 4 가지 측면에서 Fast Refresh 의 구체적인 구현을 이해할 수 있습니다:
-
Plugin 은 컴파일 시에 무엇을 했는가?
-
Runtime 은 런타임 시에 어떻게 협력했는가?
-
React 는 이를 위해 어떤 지원을 제공했는가?
-
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 快速指南](/articles/babel 快速指南/#articleHeader9) 참조
다음에 다시 순회하여 모든 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 를 마주치면 3 행의 코드가 삽입되며, 모듈 스코프의 var _s = $RefreshSig$(); 와 _s(App, "useState{[foo, setFoo](0)}\\nuseEffect{}");, 및 Hooks 호출과 같은 스코프의 _s(); 입니다
Runtime 은 런타임 시에 어떻게 협력했는가?
Babel 플러그인이 주입한 코드 중에 2 개의 미정의 함수가 나타났습니다:
-
$RefreshSig$:Hooks 시그니처 생성 -
$RefreshReg$:컴포넌트 등록
이 2 개의 함수는 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 가 제공하는 createSignatureFunctionForTransform 와 register 에 해당합니다
createSignatureFunctionForTransform 는 2 개의 단계로 Hooks 의 식별 정보를 채우며, 1 회차는 관련 컴포넌트의 정보를 채우고, 2 회차는 Hooks 를 수집하며, 3 회차 이후의 호출은 무효입니다 (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 로 전달되며, 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 / 참조
五.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.
아직 댓글이 없습니다