최근 새로 만들어지는 리액트 프로젝트들은 create-react-app
대신 vite
를 많이 이용하는 추세인 것 같습니다. npm init vite
나 yarn create vite
를 통해 리액트 프로젝트를 생성하고 개발하면, 상상 이상으로 빠른 속도에 놀라게 됩니다.
엄밀히는 리액트 프로젝트 보다는 CSR SPA 렌더링을 하는 정적 리액트 웹사이트 프로젝트라고 해야 정확한 표현이고, vite 보다는
create-vite
패키지를 이용해 생성한template-react-ts
보일러플레이트 라고 해야 정확한 표현입니다만, 편의상 이렇게 표현하겠습니다.
설정이 워낙 많고 케이스가 다양하기 때문에, 모든 설정은
yarn create vite
를 하고React
,Typescript
를 선택하여 생성한 것을 기준으로 하겠습니다. vite 버전은 2023년 4월 12일 기준vite
git 메인 브랜치 헤드인 cdd9c2320650f34c46e02f3777239e595cf6543d 를 기준으로 하겠습니다.
이번 글에서는 vite 에서 App.tsx
에 있는 App 이라는 리액트 컴포넌트를 수정했을 때 HMR이 무슨 과정을 거치며, 그 과정에서 vite가 무슨 일을 해 주는지를 브라우저 개발자 도구와 vite 소스코드를 함께 뒤적거리며 알아보겠습니다.
먼저 devserver 와 브라우저 사이에 웹소켓이 연결된단 걸 확인하고, 파일이 수정되었을 때 devserver 가 브라우저에게 메시지를 쏘는 걸 확인하고, 그 다음에는 브라우저가 모듈을 불러와서 react-refresh 에게 넘기는 것까지 확인해 보겠습니다.
💡 vite 개발서버를 열고 접속하면 브라우저와 개발서버 사이에 웹소켓이 하나 연결된다
yarn dev
를 통해 vite devserver 를 실행하면, devserver는 웹소켓을 받을 준비를 해 둡니다. (소스코드)
// 서버
const ws = createWebSocketServer(httpServer, config, httpsOptions)
또한 우리가 브라우저에서 http://localhost:5173
에 접속하면, 개발자 도구를 보면 아래와 같이 첫 번째 js 파일로 @vite/client
를 불러오는 것을 확인할 수 있는데요,
이 @vite/client
가 자바스크립트를 수행하여 개발서버와 웹소켓을 연결합니다. (소스코드)
// 브라우저
let socket: WebSocket;
...
socket = setupWebSocket(socketProtocol, socketHost, fallback)
...
function setupWebSocket(/* ... */) {
const socket = new WebSocket(`${protocol}://${hostAndPath}`, 'vite-hmr')
...
그리고 다시 개발자 도구를 통해 웹소켓이 연결되고 {"type": "connected"}
라는 메세지가 전송되는 것을 확인할 수 있습니다. 이 메세지는 당연히 서버에서 보내는 거겠죠? (소스코드)
좋아요, 이제 vite 개발서버를 열고 접속하면 브라우저와 개발서버 사이에 웹소켓이 하나 연결된다는 걸 확인했습니다.
💡 파일을 수정하면 chokidar 이 파일 수정을 감지하여 vite 가 아까 연결해둔 웹소켓을 통해 update 타입의 메세지를 쏜다
/src/App.tsx
파일을 간단하게 수정하여 콘솔로그를 하나 추가하고 저장해 보았더니, 아까 설정해둔 웹소켓을 타고 { type: 'update', ... }
이벤트가 들어오는 걸 확인할 수 있었습니다. 이 부분 로직을 좀더 자세히 알아볼까요?
파일 수정 | 개발자 도구 |
---|---|
개발서버 소스코드를 보면, 파일 수정을 감지하는 로직이 있습니다. vite는 파일 변경을 감지할 때 chokidar
이라는 라이브러리를 이용합니다. (소스코드)
그리고 파일이 수정되었을 때, onHMRUpdate
를 수행하도록 등록합니다.
// 서버
watcher.on('change', async (file) => {
file = normalizePath(file)
// invalidate module graph cache on file change
moduleGraph.onFileChange(file)
await onHMRUpdate(file, false)
})
코드를 보면 onHMRUpdate
는 당연하게도 파일 수정뿐 아니라 파일 추가나 파일 제거 상황에도 수행되도록 등록됩니다. 우리는 리액트 컴포넌트를 수정하는 케이스만 확인할 거니까, 자세히 알아보지는 않겠습니다.
아무튼 onHMRUpdate
를 타고 들어가면 handleHMRUpdate
가 나오고, 여기저기 있는 config 대응 로직이나 디버깅 로직들 등을 무시하고 지나가면 updateModules
에 도착하게 됩니다. 이 함수를 요약하면 아래와 같습니다.
// 서버
export function updateModules(
file: string,
modules: ModuleNode[],
timestamp: number,
{ config, ws, moduleGraph }: ViteDevServer,
afterInvalidation?: boolean,
): void {
const updates: Update[] = []
let needFullReload = false
for (const mod of modules) {
const hasDeadEnd = propagateUpdate(mod, traversedModules, boundaries)
if (hasDeadEnd) {
needFullReload = true
continue
}
updates.push(현재 모듈을 가공한 객체)
}
if (needFullReload) {
ws.send({ type: 'full-reload' })
return
}
ws.send({ type: 'update', updates })
}
오, 아까 App.tsx
를 수정했을 때 type이 update
로 발송된 걸 보니 needFullReload
는 false
였나 봅니다. 이 부분에 대한 미스테리는 지금 다뤄 버리면 내용이 산으로 가기에, 다음 섹션에서 알아보겠습니다. 참고로 미리 스포를 하자면, 리액트 컴포넌트가 아닌 대부분의 js 수정사항들 (가령 constant 수정 등) 은 full-reload
를 유발합니다.
좋아요, 이제 파일을 수정하면 chokidar 이 파일 수정을 감지하여 vite 가 아까 연결해둔 웹소켓을 통해 update 타입의 메세지를 쏜다는 걸 확인했습니다.
💡
@vite/client
가 웹소켓을 받아서 모듈을 다시 불러온다
💡 불러온 모듈은 "외부의 누군가" 덕분에 HMR된다
아까 /src/App.tsx
를 수정했죠? 이때 브라우저 개발자 도구 js 섹션을 보면, /src/App.tsx
만 다시 불러오는 걸 확인할 수 있습니다. 아래 사진을 보면, waterfall 이 오른쪽 끝 구석에 초록색으로 살짝 떠있는 걸로 보아 /src/App.tsx
만 다시 불러온 것입니다.
웹소켓 메세지를 받아서 해당 파일을 다시 요청하는 건 브라우저의 역할이라고 추측할 수 있겠죠? 다시 @vite/client
를 보겠습니다.
@vite/client
는 message 를 받으면 handleMessage
를 호출하도록 등록합니다. handleMessage 는 message의 type
필드를 기준으로 switch문을 돌리는데요, 그중 우리가 살펴보고 있는 update
타입에 대한 로직은 아래와 같습니다. 마찬가지로 조금 길다 보니 요약해서 작성하겠습니다. (원본 소스코드)
// 브라우저
await Promise.all(
payload.updates.map(async (update): Promise<void> => {
if (update.type === 'js-update') {
return queueUpdate(fetchUpdate(update))
}
// 이 밑에는 50줄 가량의 css update 대응 로직
})
)
다행히도 우리의 업데이트는 js-update
니까, fetchUpdate
와 queueUpdate
만 살펴보면 되겠네요! (참고: update.type
은 여기서 확인할 수 있듯이 js-update
와 css-update
두가지 타입만 있습니다.)
queueUpdate
는 update 가 여러 개일 때 비동기를 처리하기 위한 장치로, 넘겨받은 콜백을 순서대로 수행합니다. 우리가 살펴보는 케이스는 update 가 한개이므로, 위의 코드는 사실상 아래 코드나 다름없습니다.
const callback = fetchUpdate(payload.updates[0]) // update 가 한개라서 Promise.all 을 날려도 똑같다
callback(); // update 가 한개라서 queueUpdate 도 사실상 콜백을 수행하는 역할만 한다
그럼 fetchUpdate 를 확인해볼까요? 이전까지는 명확하게 타고 들어가고 타고 들어가서 확인할 수 있는 쉬운 코드였는데, 갑자기 난이도가 확 올라갔습니다. 그래서 이번에는 요약을 하고 우리 케이스에 맞지 않는 분기문을 제거해서, 우리의 케이스에 맞게 읽기 쉬운 버전으로 바꿔서 데려왔습니다. 원본 소스코드도 같이 확인해보시는 걸 추천드립니다.
// 우리의 케이스 (/src/App.tsx 수정) 에서는
// isSelfUpdate 는 true 이므로 제거
// qualifiedCallbacks 는 길이가 1인 배열이므로 quealifiedCallback 으로 변경.
// qualifiedCallbacks.deps 도 길이가 1인 배열임
async function fetchUpdate({
path,
acceptedPath,
timestamp,
explicitImportRequired,
}: Update) {
const mod = hotModulesMap.get(path)
let fetchedModule: ModuleNamespace | undefined
const qualifiedCallback = mod.callbacks.filter(({ deps }) => deps.includes(acceptedPath))[0]
fetchedModule = await import(`${acceptedPath}?t=${timestamp}`)
return () => {
qualifiedCallback.fn(fetchedModule)
}
}
빼고 나니 생각보다 별게 없습니다. 그런데.. 너무 없습니다. 중간에 await import()
를 하는 걸 보니 동적 임포트를 통해 변경된 모듈을 다시 받아온다는 건 이해했습니다. 하지만 모듈을 다시 불러온다고 해서 HMR이 일어날 리는 없습니다. HMR은 언제 되는 걸까요? 우리의 개발모드 리액트 웹사이트는 어떻게 업데이트되는 걸까요?
저기 굉장히 수상하게 생긴 qualifiedCallback
에 해답이 있습니다. 그리고, 지금까지에 비해 꽤나 쫓아가기 어렵습니다. qualifiedCallback
은 mod
에서 가져온 거고, mod
는 hotModulesMap
에서 가져온 값입니다. hotModulesMap
은 빈 Map 으로 초기화되며, 여기에 set 을 수행하는 곳은 acceptDeps
한 곳 뿐입니다. 그리고 다시, 이 acceptDeps
는 hot.accept
와 hot.acceptExports
두 곳에서만 수행됩니다. 이중 우리의 HMR을 담당해준 건 accept
입니다.
function acceptDeps(deps: string[], callback: HotCallback['fn'] = () => {}) {
// ...
hotModulesMap.set(ownerPath, mod)
}
const hot: ViteHotContext = { // 👈 얘가 범인
// ...
accept(deps?: any, callback?: any) {
if (typeof deps === 'function' || !deps) {
acceptDeps([ownerPath], ([mod]) => deps?.(mod))
} else if (typeof deps === 'string') {
// ...
},
hot
는 import.meta.hot
을 통해 외부에 노출됩니다. 공식문서 를 보면 (원문 / 번역), 플러그인은 import.meta.hot.accept
를 수행하여 "HMR된다고 등록" 함과 동시에 모듈의 HMR을 자체적으로 수행할 수 있습니다.
모듈 자신에 대한 HMR을 확인하기 위해서는
import.meta.hot.accept
를 사용하고 업데이트된 모듈을 받는 콜백을 전달합니다: (중략) 이렇게 Hot updates를 "허용한" 모듈은 HMR 범위로 간주됩니다.
어? "HMR 범위로 간주" 된다고? 아까의 미스테리 - 왜 리액트 컴포넌트는 update
타입이고 일반 상수파일은 full-reload
가 날라가는가 - 가 해결됩니다. vite 가 리액트 컴포넌트를 HMR 대상이라고 판단했기 때문에, 리액트 컴포넌트는 HMR이 되니까 아까 앞에서 봤던 isFullReload
가 false
여서 update
타입이 날아가고, 다른 것들은 HMR이 안 되니까 full-reload
가 날아갑니다. 이 부분에 대한 판단 로직은 propagateUpdate
함수에서 찾아볼 수 있습니다. (리액트 컴포넌트에 대해 node.isSelfAccepting
이 true
입니다.)
너무 길어졌네요. 정리하자면 이런 상황입니다.
import.meta.hot.accept
를 실행해서 HMR 콜백을 등록했다full-reload
가 아닌 update
타입으로 전송된다좋아요. 이제 @vite/client
가 웹소켓을 받아서 모듈을 다시 불러온다는 것과, 불러온 모듈은 "외부의 누군가" 덕분에 HMR된다는 것까지 왔습니다. 마지막으로 그 "외부의 누군가"를 찾으러 가 볼까요?
@vitejs/plugin-react
💡
@vitejs/plugin-react
가 리액트 컴포넌트가 수정되면react-refresh
에게 넘기도록 등록한다
HMR은 아무튼 브라우저에 있는 소스코드가 진행할 거예요. 아까 본 개발자 도구에 누가 봐도 "내가 범인이다!" 라고 소리치는 친구가 있습니다.
react-refresh
는 리액트에서 공식적으로 지원하는 HMR 도구입니다. 아마 누군가가 플러그인을 통해 react-refresh 를 수행하도록 지정했겠군요. 플러그인은 모두 컨피그 파일에 들어있으니, 제 프로젝트의 vite.config.ts
파일을 보겠습니다.
플러그인이 단 하나밖에 없군요? @vitejs/plugin-react
. 이 친구 소스코드에서 드디어 범인을 찾았습니다.
const footer = `
if (import.meta.hot) {
window.$RefreshReg$ = prevRefreshReg;
window.$RefreshSig$ = prevRefreshSig;
RefreshRuntime.__hmr_import(import.meta.url).then((currentExports) => {
RefreshRuntime.registerExportsForReactRefresh(__SOURCE__, currentExports);
import.meta.hot.accept((nextExports) => {
if (!nextExports) return;
const invalidateMessage = RefreshRuntime.validateRefreshBoundaryAndEnqueueUpdate(currentExports, nextExports);
if (invalidateMessage) import.meta.hot.invalidate(invalidateMessage);
});
});
}`
중간에 보면, import.meta.hot.accept
를 수행하면서 모듈을 받아서 react-refresh
에게 넘기는 코드가 있는 것을 확인할 수 있습니다.
좋아요. 이제 우리는 react-refresh 에게 모듈을 넘기는 친구까지 찾아냈습니다.
이렇게 react vite 프로젝트에서 컴포넌트를 수정했을 때 발생하는 HMR 과정에서 vite 가 어떤 역할을 담당하는지 알아봤습니다.
정리하면,
@vitejs/plugin-react
를 등록해뒀습니다.@vitejs/plugin-react
에 의해 해당 컴포넌트는 HMR 대상이라고 판단되어 소켓을 통해 update
타입이 전송됩니다. 브라우저는 update
메세지를 받아서 해당 파일을 다시 요청하고, 넘겨받은 모듈을 react-refresh
에게 넘기면 react-refresh 가 컴포넌트 HMR을 진행합니다.full-refresh
타입이 전송됩니다. 브라우저는 full-refresh
메세지를 받아서 페이지를 리로드합니다.
좋은 글 감사합니다! 도움 많이 받고 갑니다 👊👊