昨天追到 createServer
中的 Chokidar
這段,今天來繼續往下看,看能學到什麼新東西!
在 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 這個套件的用途並做一下筆記:
add
、change
、unlink
等檔案異動事件,比起 Node.js 原生 fs.watch / fs.watchFile 原生只有 rename
更好webpack-dev-server
、tailwindcss
、nodemon
、Rsbuild
等 3000 多萬個套件中冰與火之歌中的 Night's Watch 是不是也算是一種 Chokidar? 🤔 (圖片來源)
實際來啟一個專案玩玩看,程式碼也放在 GitHub 上:
pnpm init
pnpm add chokidar
新增 chokidar.js
與 src
資料夾,會像是這樣的檔案結構:
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 的用途,這裡獨立將「用 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 的進入點。
循著 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-bundlediscoverProjectDependencies
:當前面的快取不存在時,進到這段邏輯來開始做 pre-bundling,而這個函式看起來就是在掃描整個專案中用到的依賴套件runOptimizeDeps
:可能就是 esbuild 的執行點今天先追到這,下一篇會繼續把 pre-bundling 的這三個函式追完應該就差不多能看見其中的運作原理了,明天會再針對目前追過的原始碼做個總整理。
從昨天到今天一路從啟 dev server,追到實際偵測檔案異動的 Chokidar,並實際嘗試了一下這個套件怎麼用;最後再一路追到 pre-bundling 的三個關鍵函式,文字版的整理可以先這樣理解:
cli.alias('dev')
createServer
connect
套件建立 middlewaresChokidar
套件來做為 watcher 監聽檔案變化 (HMR 主要邏輯所在)createServer
→ initServer
environment.depsOptimizer?.init()
depsOptimizer
中有兩種環境與設定條件:
createExplicitDepsOptimizer
createDepsOptimizer
createDepsOptimizer
loadCachedDepOptimizationMetadata
discoverProjectDependencies
scanImports
prepareEsbuildOptimizerRun
先整理如上,明天全部追完後再來改成圖片版,若有看不懂或錯誤的地方再麻煩留言一起討論,感謝你的閱讀,那我們就明天見了!