avatar
threadsinstagram

Vite 原始碼 (2) - Chokidar、pre-bundling 初探

Table of Contents

前言

昨天追到 createServer 中的 Chokidar 這段,今天來繼續往下看,看能學到什麼新東西!

Vite 怎麼做到監聽檔案異動變化

createServer 這個五百多行的 function 中,先大致看一下這方法中的結構方便分析:

// packages/vite/src/node/server/index.ts
// ... 略 ...
export async function _createServer(...) {
  // ... 設定檔前置 ...
 
  // ... 宣告 middlewares、WebSocket、httpServer 等 ...
 
  // 用 Chokidar 來做為 watcher 監聽檔案變化
  const watcher = chokidar.watch(...)
 
  // 用前面建立好的設定組合成一個 dev server 的物件供後面使用
  let server: ViteDevServer = {
    config,
    middlewares,
    httpServer,
    watcher,
    ws,
    // ... 略 ...
  }
  // ... 利用 watcher 做檔案變化監聽 ...
 
  // ... 各種 middleware.use 來添加中間需要被插入的邏輯 ...
}

Chokidar 介紹

這裡我們先來查查 Chokidar 這個套件的用途並做一下筆記:

  • 主要用途是可以拿來做跨平台的檔案異動監聽器
  • 唸法用中文的話可唸做「丘奇打」,原文是印度語中的守望者、監看者的意思
  • 提供具體的 addchangeunlink 等檔案異動事件,比起 Node.js 原生 fs.watch / fs.watchFile 原生只有 rename 更好
  • 廣泛被用在 webpack-dev-servertailwindcssnodemonRsbuild3000 多萬個套件

冰與火之歌中的 Night's Watch 是不是也算是一種 Chokidar? 🤔 (圖片來源)

實際來試試 Chokidar 理解一下用途

實際來啟一個專案玩玩看,程式碼也放在 GitHub 上:

pnpm init
pnpm add chokidar

新增 chokidar.jssrc 資料夾,會像是這樣的檔案結構:

ironman-lab/
├── src/
│   └── chokidar.js
└── package.json

新增檔案內容:

// src/chokidar.js
const chokidar = require('chokidar');
const fs = require('fs');
const path = require('path');
 
// 處理路徑,此處的 `__dirname` 指的是 `src`
const watchDir = path.join(__dirname, 'watch_dir');
 
const log = console.log.bind(console);
const toRelativePath = (absolutePath) => {
  path.relative(process.cwd(), absolutePath);
}
 
// 當 src/watch_dir 不存在時,建立一個新的目錄
if (!fs.existsSync(watchDir)) {
  fs.mkdirSync(watchDir);
  log(`Created directory: ${toRelativePath(watchDir)}`);
}
 
// 初始化 chokidar 的watcher
const watcher = chokidar.watch(watchDir, {
  // 設定後第一次啟動時才不會觸發事件
  ignoreInitial: true
});
 
// 修改 watcher 的檔案異動事件監聽,使用相對路徑
watcher
  .on('add', (path) => log(`File ${toRelativePath(path)} has been added`))
  .on('change', (path) => log(`File ${toRelativePath(path)} has been changed`))
  .on('unlink', (path) => log(`File ${toRelativePath(path)} has been removed`))
  .on('addDir', (path) =>
    log(`Directory ${toRelativePath(path)} has been added`)
  )
  .on('unlinkDir', (path) =>
    log(`Directory ${toRelativePath(path)} has been removed`)
  )
  .on('error', (error) => log(`Watcher error: ${error}`))
  .on('ready', () => log('Initial scan complete. Ready for changes'));
 
console.log(`[Chokidar] watching ${toRelativePath(watchDir)}...`);

此時可以用 node ./src/chokidar.js 執行檔案,並嘗試在 watch_dir 底下去新增、修改、刪除檔案或資料夾,可以看到對應的 log 被印出來,有趣的是可以觀察到改名時,行為會被判定為刪除 (unlink)新增

chokidar demo

回到原始碼中的 createServer 中

看過簡單範例後,我們可以理解 Chokidar 的用途,這裡獨立將「用 watcher 做檔案變化監聽」的部份單獨拉出來看:

// 做檔案內修改後存檔的監聽
watcher.on('change', async (file) => {
  // 處理 windows 的反斜線路徑問題
  file = normalizePath(file)
 
  await pluginContainer.watchChange(file, { event: 'update' })
  // invalidate module graph cache on file change
  for (const environment of Object.values(server.environments)) {
    environment.moduleGraph.onFileChange(file)
  }
  // 看起來是處理 HMR 相關邏輯
  await onHMRUpdate('update', file)
})
 
getFsUtils(config).initWatcher?.(watcher)
 
// 做檔案新增、刪除的監聽
watcher.on('add', (file) => {
  onFileAddUnlink(file, false)
})
watcher.on('unlink', (file) => {
  onFileAddUnlink(file, true)
})

先來看看這個 onFileAddUnlink

const onFileAddUnlink = async (file: string, isUnlink: boolean) => {
  file = normalizePath(file)
 
  // 主要是用來處理不同環境的 plugin 系統,先不管
  await pluginContainer.watchChange(file, {
    event: isUnlink ? 'delete' : 'create',
  })
 
  if (publicDir && publicFiles) {
    // ... 處理 public 資料夾底下異動,略 ...
  }
  if (isUnlink) {
    // invalidate module graph cache on file change
    for (const environment of Object.values(server.environments)) {
      environment.moduleGraph.onFileDelete(file)
    }
  }
  // 觸發 HMR 邏輯
  await onHMRUpdate(isUnlink ? 'delete' : 'create', file)
}

從新增、刪除、變更檔案時,看到都會觸發 onHMRUpdate 這個方法,追進去會看到 handleHMRUpdate 裡面有兩百多行,可能之後另外寫一篇直接順便研究一下 HMR 的原理。這裡還有另一個比較不懂的東西是 moduleGraph,研究了下應該可以理解為這個模組圖會用來描述所有模組間的依賴關係,而在使用者在瀏覽器上訪問不同路徑頁面時,在前面提過的按需編譯有可能就與這個有關。

發現這整段應該都是在處理 HMR 的部分,因此統一都後面回頭再來看,這裡先專心找到 pre-bundling 的進入點。

pre-bundling 進入點

循著 createServer 繼續往下找後會看到一堆的 middlewares.use,會看到其中有一段 initingServer,顧名思義這裡就是啟動 dev server 的邏輯所在,其中會到有一個 depsOptimizer 應該就是我們的主角:

let initingServer: Promise<void> | undefined
let serverInited = false
const initServer = async () => {
  if (serverInited) return
  if (initingServer) return initingServer
 
  initingServer = (async function () {
    // For backward compatibility, we call buildStart for the client
    // environment when initing the server. For other environments
    // buildStart will be called when the first request is transformed
    await environments.client.pluginContainer.buildStart()
 
    await Promise.all(
      Object.values(server.environments).map((environment) =>
        environment.depsOptimizer?.init(),
      ),
    )
 
    // TODO: move warmup call inside environment init()
    warmupFiles(server)
    initingServer = undefined
    serverInited = true
  })()
  return initingServer
}

這邊有區分 environments 往定義追進去會是在這個位置,在猜這應該是為了支援 SSR 的用途,我們這邊就關注 createDepsOptimizer 就好:

// packages/vite/src/node/server/environment.ts
this.depsOptimizer = (
  optimizeDeps.noDiscovery || options.consumer !== 'client'
    ? createExplicitDepsOptimizer
    : createDepsOptimizer
)(this)

追到 createDepsOptimizer 後,會找到以下的位置與內容,裡面有 700 多行,這個有點硬但可以先從下面這樣關鍵的結構下手,以下有簡化過原始碼內容方便閱讀:

// packages/vite/src/node/optimizer/optimizer.ts
export function createDepsOptimizer(environment): DepsOptimizer {
  // ... 初始化各種變數省略 ...
 
  // 這看起來是前幾天看到的 `node_modules/.vite/deps/_metadata.json`
  let metadata: DepOptimizationMetadata = initDepsOptimizerMetadata(
    environment,
    sessionTimestamp,
  )
 
  const depsOptimizer: DepsOptimizer = {/* 各種狀態分別在以下方法去添加 */}
 
  // 偵測監聽期間有新的依賴套件被新增,有的話印出
  const logNewlyDiscoveredDeps = () => {
    colors.green(
      `✨ new dependencies optimized: ${depsLogString(newDepsToLog)}`,
    )
  }
 
  let inited = false
  async function init() {...}
 
  // ... 略 ...
 
  return depsOptimizer
}

這裡可以看到比較關鍵的地方在 init 這個方法,這就是前面 environment.depsOptimizer?.init() 的初始化函式,從裡面會看到這一段:

// 這就是前面 environment.depsOptimizer?.init() 的初始化函式
async function init() {
  if (inited) return
  inited = true
 
  // 分析目前 metadata 與 hash 中的快取資料是否需要重新 pre-bundle
  const cachedMetadata = await loadCachedDepOptimizationMetadata(environment)
 
  firstRunCalled = !!cachedMetadata
 
  metadata = depsOptimizer.metadata =
    cachedMetadata || initDepsOptimizerMetadata(environment, sessionTimestamp)
 
  // 快取資料不存在則執行
  if (!cachedMetadata) {
    // ... 略 ...
 
    // 這看起來是文件上的 optimizeDeps.noDiscovery 控制
    if (noDiscovery) {
      // We don't need to scan for dependencies or wait for the static crawl to end
      // Run the first optimization run immediately
      runOptimizer()
    } else {
      // Important, the scanner is dev only
      depsOptimizer.scanProcessing = new Promise((resolve) => {
        // Runs in the background in case blocking high priority tasks
        ;(async () => {
          try {
            debug?.(colors.green(`scanning for dependencies...`))
 
            // 爬專案中有用到哪些依賴套件
            discover = discoverProjectDependencies(
              devToScanEnvironment(environment),
            )
 
            // For dev, we run the scanner and the first optimization
            // run on the background
            optimizationResult = runOptimizeDeps(environment, knownDeps)
 
            // ... 略 ...
          } catch (e) {
            // ... 略 ...
          } finally {
            // ... 略 ...
          }
        })()
      })
    }
  }
}

init 這個初始化函式可以看到有三個關鍵函式:

  • loadCachedDepOptimizationMetadata:這裡面會根據目前的 metadata 與 hash 來判斷快取資料是否需要重新 pre-bundle
  • discoverProjectDependencies :當前面的快取不存在時,進到這段邏輯來開始做 pre-bundling,而這個函式看起來就是在掃描整個專案中用到的依賴套件
  • runOptimizeDeps:可能就是 esbuild 的執行點

今天先追到這,下一篇會繼續把 pre-bundling 的這三個函式追完應該就差不多能看見其中的運作原理了,明天會再針對目前追過的原始碼做個總整理。

小結

從昨天到今天一路從啟 dev server,追到實際偵測檔案異動的 Chokidar,並實際嘗試了一下這個套件怎麼用;最後再一路追到 pre-bundling 的三個關鍵函式,文字版的整理可以先這樣理解:

  • 啟 dev server
    • cli.alias('dev')
    • createServer
      • → 用 connect 套件建立 middlewares
      • → 建立 WebSocket server
      • → 用 Chokidar 套件來做為 watcher 監聽檔案變化 (HMR 主要邏輯所在)
  • pre-bundling 的進入點
    • createServerinitServer
      • environment.depsOptimizer?.init()
      • 其中的 depsOptimizer 中有兩種環境與設定條件:
        • 一個是 createExplicitDepsOptimizer
        • 一個是 createDepsOptimizer
  • 前置作業
    • createDepsOptimizer
      • 用來做 optimizer 的初始化
    • loadCachedDepOptimizationMetadata
      • 算 metadata 與 hash
    • discoverProjectDependencies
      • scanImports
      • 專案中用了哪些第三方套件,有的話會補上 flattenId
    • prepareEsbuildOptimizerRun
      • 準備啟動 esbuild 的相關設定

先整理如上,明天全部追完後再來改成圖片版,若有看不懂或錯誤的地方再麻煩留言一起討論,感謝你的閱讀,那我們就明天見了!

參考資料