시작하는 중

yarn dev를 하면 vite에서 일어나는 일 본문

react

yarn dev를 하면 vite에서 일어나는 일

싱욱 2023. 5. 2. 17:46

이 글은 2023년 5월 1일자 커밋인 1ee0014caa7ecf91ac147dca3801820020a4b8a0헤드를 기준으로 작성하였습니다.

yarn dev를 하면 vite에서 일어나는 일

자신의 프로젝트에서 yarn dev를 하면 package.json에서 scripts의 "dev"가 실행된다.

 

우리 프로젝트의 package.json

// package.json
...
"scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
...

그렇다면 vite가 실행된다. 이는 vite의 package.json으로 이동된다.

여기서 "bin"이 우리를 반겨준다.

 

vite의 package.json

// vite's package.json
...
  "bin": {
    "vite": "bin/vite.js"
  },
...

"bin"의 "vite"는 우리가 yarn dev에서 vite로 이어져온 결과이다. 그럼 bin/vite.js가 실행된다.

 

vite.js에서 start()라는 메서드가 있다.

// bin/vite.js
...
function start() {
  return import('../dist/node/cli.js')
}
...

여기서 ../dist/node/cli.js가 실행된다.

 

근데? 디렉토리가 조금 다르다.

github상에서는 디렉토리가 ../src/node/cli.ts로 바뀌어있다. 하지만 node_modules상에서는 디렉토리가 정확하다.

코드는 유사해서 진행!

 

cli의 메인 부분

여기서 // dev라는 주석이 달린 줄이 있다. 아마도 밑의 // build가 있는 것을 보면 dev모드에서 실행되는 코드인듯

// cli.ts
...
// dev
cli
  .command('[root]', 'start dev server') // default command
  .alias('serve') // the command is called 'serve' in Vite's API
  .alias('dev') // alias to align with the script name
...

 

쭉 내려가다보면 cli.ts 파일 중 143번째 줄에서 우리가 아는 그 문구가 나온다.

// node/cli.ts
...
      const startupDurationString = viteStartTime
        ? colors.dim(
            `ready in ${colors.reset(
              colors.bold(Math.ceil(performance.now() - viteStartTime)),
            )} ms`,
          )
        : ''
...

그럼 여기가 실행되는 메인인 것 같은데 어떤 로직이 있을까? 를 봐야한다.

 

121번째 줄에서 중요한 것이 나온다.

// node/cli.ts
...
    const { createServer } = await import('./server')
...

 

즉, vite는 우리의 터미널에서 웹 소켓 서버를 연다.

 

소켓 서버의 생성

createServer는 다음과 같다.

// node/server/index.ts
...
export async function createServer(
  inlineConfig: InlineConfig = {},
): Promise<ViteDevServer> {
  return _createServer(inlineConfig, { ws: true })
}
...

_createServer에서 웹 소켓을 여는 부분이 있고, 연결을 여는 부분도 있다.

이를 통해서 우리는 vite가 적어도 소켓통신을 하고 있다는 것을 알 수 있게 되었다.

 

그럼 우리의 프로젝트의 개발자 도구에서 웹 소켓을 봐야한다.

client.ts에서 웹 소켓 연결이 된 모습

여기서 중요한 점은 {type: "connected"}하는 메시지가 온다는 것이다.

{type: 'connected'}를 보내는 부분은 server/ws.ts라는 파일에 있다.

// server/ws.ts
...
    socket.send(JSON.stringify({ type: 'connected' }))
...

 

서버에서 보내는 신호 type

클라이언트는 이를 감지할 수 있는 부분이 있다.

// client/client.ts
...
  switch (payload.type) {
    case 'connected':
      ...
    case 'update':
      ...

보면, connected라는 부분 말고도 update라는 부분이 있다. client.ts에서 update.type을 분기하는 처리가 있다.

// client/client/ts
...
      await Promise.all(
        payload.updates.map(async (update): Promise<void> => {
          if (update.type === 'js-update') {
            return queueUpdate(fetchUpdate(update))
          }

          // css-update
          // this is only sent when a css file referenced with <link> is updated
...

업데이트가 단순 js-update냐는 것도 분기하고 css-update냐는 것도 분기한다.

그럼, 이는 어디서 update type을 달아주냐는 것인가와 js와 css를 따로 분기한다는 것인지는 어디서 볼까?

 

서버 디렉토리에 hmr.ts라는 파일이 있다. vite는 HMR을 지원한다. vite 레퍼런스

HMR은 간단하게 소스가 변경된 부분만 업데이트하는 것이다.

 

hmr.ts 여기서 이를 구분하고 타입을 지정해주는 코드가 있다.

// server/hmr.ts
// js-update와 css-update를 구분하는 부분
...
    updates.push(
      ...boundaries.map(({ boundary, acceptedVia }) => ({
        type: `${boundary.type}-update` as const,
        timestamp,
        path: normalizeHmrUrl(boundary.url),
        explicitImportRequired:
          boundary.type === 'js'
...
// 웹 소켓으로 update를 보내는 부분
  config.logger.info(
    colors.green(`hmr update `) +
      colors.dim([...new Set(updates.map((u) => u.path))].join(', ')),
    { clear: !afterInvalidation, timestamp: true },
  )
  ws.send({
    type: 'update',
    updates,
  })
...

 

위에는 js와 css 업데이트인지를 구분하는 코드이고,

 

그 밑에는 {type: update, updates}를 보내주는 부분이 있다. -> ws이 연결되었을 때 {type: "connected"}가 연결된 것과 유사한 형태이다.


정리

여기까지를 정리하면 다음과 같다.

  1. vite는 웹소켓으로 우리의 vscode를 서버로 두고 브라우저를 클라이언트로하여 소켓통신을 연다.
  2. 서버는 { type: ? } 을 통해 브라우저에게 보내는 요청의 타입을 분기한다.
  3. 이 타입은 여러가지가 있지만, 대표적으로 연결이 성공한 connected와 파일을 업데이트를 해야하는 update가 있다.
  4. 이 요청은 ws에서 보내는 것이며, update는 또 js-update와 css-update로 나뉜다.
  5. 이는 HMR에서 구분하는 것이다.

서버에서 보내는 요청

이제는 이 요청을 어떻게 감지하냐는 것이다. 고맙게도 vite의 코드명은 상당히 좋다. 따라서 server/index.ts에서 ViteDevServer 타입의 server를 발견할 수 있다.

 

이 코드 중에서 설정과 관련된 로직과 터미널에 프린트해주는 부분을들 지니가다보면 watcher라는 것이 보인다.
watcher.on에 여러 옵션들이 있다.

// server/index.ts
...
  watcher.on('change', async (file) => {
    file = normalizePath(file)
    // invalidate module graph cache on file change
    moduleGraph.onFileChange(file)

    await onHMRUpdate(file, false)
  })

  watcher.on('add', onFileAddUnlink)
  watcher.on('unlink', onFileAddUnlink)
...

 

여기서 watcher의 change 부분이다. 반가운 글씨인 onHMRUpdate가 있다. 여기서 try 부분의 handleHMRUPdate로 가면 긴~코드가 있다. 여기서 config나 기타 예외에 대한 처리를 빼다보면 updateModules에 도착한다.

// node/server/hmr.ts
...
export function updateModules(
  file: string,
  modules: ModuleNode[],
  timestamp: number,
  { config, ws, moduleGraph }: ViteDevServer,
  afterInvalidation?: boolean,
): void {
  const updates: Update[] = []

  for (const mod of modules) {
    moduleGraph.invalidateModule(mod, invalidatedModules, timestamp, true)
    const boundaries: { boundary: ModuleNode; acceptedVia: ModuleNode }[] = []

    updates.push(
      ...boundaries.map(({ boundary, acceptedVia }) => ({
        type: `${boundary.type}-update` as const,
        timestamp,
        path: normalizeHmrUrl(boundary.url),
        explicitImportRequired:
          boundary.type === 'js'
            ? isExplicitImportRequired(acceptedVia.url)
            : undefined,
        acceptedPath: normalizeHmrUrl(acceptedVia.url),
      })),
    )
  }
  ...
  ws.send({
    type: 'update',
    updates,
  })
}
...

 

결국 이 함수는 클라이언트로 보낼 updates 배열을 만드는 함수였던 것이다.

  1. 서버는 watcher라는 것을 통해서 우리 프로젝트의 변화를 감지하고
  2. 이 변화는 HMR에 의해 어떤 변화인지 감지한 결과를 handleHMRUppdate함수에서 처리하고 배열로 저장하고
  3. 웹소켓으로 클라이언트에게 보낸다.

클라이언트에서 확인해보기

Home.tsx만 변경해서 보냈더니, 다음과 같이 클라이언트에서 신호를 받았다.

우리가 본 대로, {"type": "update", "update": [...]} 형태로 보내졌다. 정확하게 Home.tsx가 온 모습

index.css는 tailwind css를 사용중이라 같이 보내진 것이다.

그럼 클라이언트는 이를 어떻게 처리할까? 바로 handleMessage이다.

 

클라이언트의 handleMessage

client/client.ts에서 handleMessage가 있다. 여기서 case 'update'를 볼 것이다.

// client/client.ts
...
async function handleMessage(payload: HMRPayload) {
  switch (payload.type) {
    case 'connencted':
      ...
    case 'update':
      ...
      await Promise.all(
        payload.updates.map(async (update): Promise<void> => {
          if (update.type === 'js-update') {
            return queueUpdate(fetchUpdate(update))
          }
       ...
}
...

 

여기서 queueUpdate는 다음과 같은데,

// client/client.ts
let pending = false
let queued: Promise<(() => void) | undefined>[] = []

async function queueUpdate(p: Promise<(() => void) | undefined>) {
  queued.push(p)
  if (!pending) {
    pending = true
    await Promise.resolve()
    pending = false
    const loading = [...queued]
    queued = []
    ;(await Promise.all(loading)).forEach((fn) => fn && fn())
  }
}

 

단순하게 하나의 큐를 만들고 순서대로 실행하기 위한 코드이다. 이유는, 하나를 처리하는 동안 여러 요청이 올 수도 있기 때문인 듯 하다.

그럼 fetchUpdate로 가야한다. fetchUpdate는 다음과 같은데 하나씩 보려고 한다.

// client/client.ts
...
async function fetchUpdate({
  path,
  acceptedPath,
  timestamp,
  explicitImportRequired,
}: Update) {
  const mod = hotModulesMap.get(path)            // path는 아까 hmr에서 만든 path이다. 위의 사진에서 /src/Pages/Home.tsx
  const isSelfUpdate = path === acceptedPath     // path와 acceptedPath가 같은지 보는 것. 나의 경우에는 같다.

  const qualifiedCallbacks = mod.callbacks.filter(({ deps }) =>
    deps.includes(acceptedPath),
  )

  ...

  if (isSelfUpdate) {
    ...
    try {
      fetchedModule = await import(
        /* @vite-ignore */
        base +
          acceptedPathWithoutQuery.slice(1) +
          `?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${
            query ? `&${query}` : ''
          }`
      )
    } catch (e) {
      warnFailedFetch(e, acceptedPath)
    }
  }

  return () => {
    for (const { deps, fn } of qualifiedCallbacks) {
      fn(deps.map((dep) => (dep === acceptedPath ? fetchedModule : undefined)))
    }
    ...
  }
}
...

 

fetchedModule에서 경로를 import해오는 것을 await로 기다리는 코드를 볼 수 있다. 근데 임포트를 해오면 붙여야하는 것 아닌가? 싶었다.

 

더 따라가보려면 솔직히 qualifiedCallbacks가 뭔지는 알아야하는 것 같다.

 

qualifiedCallbacks가 뭔지를 찾아서

코드를 따라가면,

 

qualifiedCallbacksmod에서 온 것이고,

  const qualifiedCallbacks = mod.callbacks.filter(({ deps }) =>

 

modhotModulesMap에서 온 것이다. 이 hotModulesMap은 단지 빈 맵이다.

const hotModulesMap = new Map<string, HotModule>()

 

이 빈 Map 객체를 get이 아닌 set을 하는 곳은 acceptDeps이다.

// client/client.ts
...
  function acceptDeps(deps: string[], callback: HotCallback['fn'] = () => {}) {
    const mod: HotModule = hotModulesMap.get(ownerPath) || {
      id: ownerPath,
      callbacks: [],
    }
    mod.callbacks.push({
      deps,
      fn: callback,
    })
    // 여기가 우리가 찾는 빈 맵 객체를 set하는 부분이다.
    hotModulesMap.set(ownerPath, mod)
  }
...

 

여기서도 이는 함수인데, 이를 불러오는 곳을 찾아야 한다. 근데 찾아보니 바로 밑에 있음! 바로 hot에서 불러온다.

// client/client.ts
...
  const hot: ViteHotContext = {
    get data() {
      return dataMap.get(ownerPath)
    },

    accept(deps?: any, callback?: any) {
      if (typeof deps === 'function' || !deps) {
        // self-accept: hot.accept(() => {})
        acceptDeps([ownerPath], ([mod]) => deps?.(mod))
      } else if (typeof deps === 'string') {
        // explicit deps
        acceptDeps([deps], ([mod]) => callback?.(mod))
      } else if (Array.isArray(deps)) {
        acceptDeps(deps, callback)
      } else {
        throw new Error(`invalid hot.accept() usage.`)
      }
    },

    // export names (first arg) are irrelevant on the client side, they're
    // extracted in the server for propagation
    acceptExports(_, callback) {
      acceptDeps([ownerPath], ([mod]) => callback?.(mod))
    },
    ...

 

hot은 더이상 어디서 쓰이는지를 찾아야하는데.. 이 hotcreateHotContext에 속해있고 이는 export되어 어디선가 또 쓰인다..

그래서 vite를 클론받고 vscode의 폴더를 정규식으로 검색하는 것을 이용했다.

하이 ㅋㅋ

importAnalysis.ts에서 쓰인다. 바로 출동

// node/plugins/importAnalysis.ts
...
        str().prepend(
          `import { createHotContext as __vite__createHotContext } from "${clientPublicPath}";` +
            `import.meta.hot = __vite__createHotContext(${JSON.stringify(
              normalizeHmrUrl(importerModule.url),
            )});`,
        )
...

 

import.meta.hot으로 사용된다?? import.meta는 뭘까?

 

mdn import meta

mdn의 import meta에 대한 문서이다. 컨텍스트별 메타데이터를 JS 모듈에 노출시킨다는 것

 

이걸 왜 쓸까??는 vite 공식문서에 있다.

vite의 HMR API docs

Vite는 HMR API 설정을 import.meta.hot 객체를 통해 노출합니다

즉, HMR API를 javascript 모듈에 노출시켜서 어디서든 사용할 수 있게 한다는 것


클라이언트가 udate 타입의 메시지를 받고 처리하는 과정

  1. 웹 소켓을 통해 메시지를 받으면, type에 따라서 client.ts에서 이를 처리하고 fetchUpdate가 실행된다.
  2. fetchUpdate는 path를 await를 붙여서 우리가 업데이트해서 updates 배열에 담긴 path의 파일을 import 해오고 있다.
  3. 그 함수의 리턴으로 함수 리턴하는데 그 함수안에서는 qualifiedCallbacks를 순회를 하고 있다.
  4. qualifiedCallbacks는 사실 빈 맵이며 이는 hot이라는 플러그인에서 set할 수 있으며
  5. 이 hot은 HMR API를 import.meta.hot을 통해서 다른 js 모듈에서 전역적으로 이 플러그인에 접근할 수 있게 되었다.

총 정리

  1. yarn dev -> vite가 실행되며 vite의 package.json에서 bin이 실행되어 bin/vite.js가 실행된다.
  2. vite.js에서 start()라는 메서드가 실행되며, node/cli.js가 실행된다.
  3. cli.js에서 // dev라는 주석 줄의 코드가 실행된다. 이 코드에서 터미널을 깔끔하게 해주며, createServer를 통해 서버를 연다.
  4. createServer는 웹 소켓을 여는 부분과 연결하는 부분이 있다.
  5. 터미널에서 서버가 열리며 브라우저는 클라이언트가 된다. 즉, vite는 터미널과 브라우저가 소켓통신을 하게 한다.
  6. 클라이언트인 브라우저에서 이를 확인할 수 있으며 처음 연결시 {type:"connected'}라는 메시지가 있으며, 이는 server/ws.ts에 있다.
  7. 클라이언트는 이를 분기처리할 수 있는 부분이 client/client.ts에 존재하며, 이 타입은 update 등등 더 존재한다.
  8. update 타입은 다시 js-update와 css-update로 나뉘며 이는 hmr.ts에서 만들어진다.
  9. hmr.ts는 updates라는 배열을 만들고 클라이언트에 "type": "update"와 함께 보낸다.
  10. 서버는 코드의 변화를 watcher.on("change")를 통해 감지하며 여기서 onHMRUpdate가 실행된다.
  11. onHMRUpdate는 updateModules로 이어지며, 이는 브라우저에서 받는 메시지의 9번의 updates 배열을 만드는 함수이다.
  12. client/client.ts에서 handleMessage는 type을 확인하고 분기처리하며, update는 fetchUpdate를 실행한다.
  13. fetchUpdate는 path와 acceptedPath를 기반으로 실행되는데, 여기서 path의 모듈을 await를 통해 천천히 import해온다.
  14. return으로 qualifiedCallbacks를 순회하는 함수를 호출하는데, 이는 hotModulesMap라는 빈 맵인데, 이는 hot이라는 플러그인의 accept에서만 추가할 수 있다.
  15. 이 hot은 import.meta.hot을 통해 JS 모듈 전체에서 호출 할 수 있으며 이는 HMR API를 사용할 수 있다.
  16. 결국 vite는 리액트의 변경된 부분들을 모듈로써 접근하여 필요한 부분만 수정된 것을 업데이트할 수 있다.

 

vite가 어떻게 처리하는지 너무 궁금해서, 그리고 오픈소스를 읽는 역량을 위해서 해봤는데 아직 부족하다.

 

js가 진심이라고 포폴로 밀고나가고 있으니깐, node.js랑 웹팩이랑 esbuild도 찾아봐야겠다.

 

 

reference

https://github.com/vitejs/vite/tree/1ee0014caa7ecf91ac147dca3801820020a4b8a0

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import.meta

https://vitejs-kr.github.io/guide/api-hmr.html#hmr-api

https://vitejs-kr.github.io/guide/why.html#slow-updates

'react' 카테고리의 다른 글

리액트에서 ajax 요청 다루기  (0) 2023.05.08
react router의 원리  (0) 2023.05.04
useState(함수)???  (0) 2023.04.20
내 코드 리팩토링 하기  (0) 2023.04.14
react router v6.4에 생긴 기능들  (0) 2023.04.12