正如上一篇文章: Vite 实践:Vue 旧项目迁移结尾所说,学习源码比学会配置价值更大。所以工作之余抽空学习了一下 Vite 的源码。
Vite 源码的版本为:2.7.0-beta.3
前言
如为什么选 Vite所说,Vite 的优化主要在开发服务器上。其通过浏览器支持的 ESM 模块导入,省了开发时应用打包的时间,使得开发服务器启动更为快速。同时实现了 ESM 的 HMR(模块热重载),这样更新不会随着应用体积增大而增加时间,大大改进了开发体验。
要实现上述的功能,需要解决以下的问题:
一是兼容 CJS 和 UMD 规范的模块;
二是处理裸模块导入的问题;
import Vue from 'vue'
像上面的例子,vue 模块的解析是由打包工具处理的,浏览器无法查找 vue 模块的地址,因此需要把 vue 转换成真实的地址 /node_modules/vue/dist/vue.esm.js
;
三是像 lodash-es 这样的模块,从中导出一个方法,通过模块之间的层层的依赖和引用,最终可能会发起 600 多个请求,可能造成网络堵塞。
为了解决上面的问题,Vite 的做法是依赖预构建。
依赖预构建做了哪些事呢?Vite 将代码分为依赖和源码。可以理解依赖为node_modules,源码为自己写的项目代码。对于依赖,Vite 会用 esbuild 进行依赖预构建(打包),以兼容 CJS 和 UMD 规范的模块;同时将有许多内部模块的依赖转换为单个模块,解决上述第三点的问题。并且会缓存预构建的结果。
而当浏览器请求源码时,Vite 会通过插件对非 ESM 的模块进行转换,同时将模块内导入其他模块路径的代码转换为真实的地址,解决上述第二点的问题。
接下来就对上面所说的相关代码进行一些简略的分析。
值得一说的是,上面所说的内容官方文档都有,具体参考以下章节:为什么选 Vite,NPM 依赖解析和预构建,依赖预构建。可以说官方文档是非常用心了。
源码浅析
开启开发服务器
运行 vite serve
后,在 packages/vite/src/node/cli.ts
可以找到,运行了以下代码:
const { createServer } = await import('./server')
const server = await createServer({
root,
base: options.base,
mode: options.mode,
configFile: options.config,
logLevel: options.logLevel,
clearScreen: options.clearScreen,
server: cleanOptions(options)
})
await server.listen()
进到 packages/vite/src/node/server/index.ts
,就是开启开发服务器的整体流程,下面只截取了部分关键代码。
export async function createServer(
inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
// 解析配置
const config = await resolveConfig(inlineConfig, 'serve', 'development')
// ...
// connect 可以让 http 服务器使用中间件
const middlewares = connect() as Connect.Server
// 定义 http 服务器
const httpServer = middlewareMode
? null
: await resolveHttpServer(serverConfig, middlewares, httpsOptions)
// 定义 websocket 服务器,用于 HMR
const ws = createWebSocketServer(httpServer, config, httpsOptions)
// ...
// chokidar 可以监听文件的变化,用于 HMR
const watcher = chokidar.watch(path.resolve(root), {
// ...
}) as FSWatcher
// ...
// 插件容器,用于执行插件钩子
const container = await createPluginContainer(config, watcher)
// ...
const server: ViteDevServer = {
// ...
}
// ...
// 注册文件变化的回调函数
watcher.on('change', async (file) => {
// ...
})
watcher.on('add', (file) => {
// ...
})
watcher.on('unlink', (file) => {
// ...
})
// ...
// 下面注册一些中间件,用来处理开发服务器接收到的请求
if (cors !== false) {
middlewares.use(corsMiddleware(typeof cors === 'boolean' ? {} : cors))
}
const { proxy } = serverConfig
if (proxy) {
middlewares.use(proxyMiddleware(httpServer, config))
}
if (config.base !== '/') {
middlewares.use(baseMiddleware(server))
}
// ...
if (config.publicDir) {
middlewares.use(servePublicMiddleware(config.publicDir))
}
// 主要关注这个中间件,其会触发插件的 transform 方法,对文件及导入语句进行转换
middlewares.use(transformMiddleware(server))
middlewares.use(serveRawFsMiddleware(server))
middlewares.use(serveStaticMiddleware(root, server))
const runOptimize = async () => {
if (config.cacheDir) {
server._isRunningOptimizer = true
try {
server._optimizeDepsMetadata = await optimizeDeps(config)
} finally {
server._isRunningOptimizer = false
}
server._registerMissingImport = createMissingImporterRegisterFn(server)
}
}
if (!middlewareMode && httpServer) {
let isOptimized = false
// 代理了listen方法,在开启监听之前触发了插件的buildStart钩子,并进行了依赖预构建
const listen = httpServer.listen.bind(httpServer)
httpServer.listen = (async (port: number, ...args: any[]) => {
if (!isOptimized) {
try {
await container.buildStart({})
// 依赖预构建
await runOptimize()
isOptimized = true
} catch (e) {
httpServer.emit('error', e)
return
}
}
return listen(port, ...args)
}) as any
} else {
await container.buildStart({})
await runOptimize()
}
return server
}
从上面代码可以看出,开启开发服务器的主要流程是这样的:
- 解析配置
- 初始化一些关键对象,如 connect 中间件,http 服务器,websocket 服务器,chokidar 文件监听,pluginContainer 插件容器等,然后把注册的对象都挂载到
server:ViteDevServer
上(上面没有这一步)。 - 注册文件变化的回调事件
- 注册一些中间件,主要关注
transformMiddleware
这个中间件,其会触发插件的 transform 方法,对文件及导入语句进行转换 - 在开启监听之前触发了插件的 buildStart 钩子,并进行了依赖预构建
- 开启监听
配置解析
packages/vite/src/node/config.ts
文件的 resolveConfig
方法。主要进行参数的混合,初始化等。但是要注意这里调用了 resolvePlugins
方法,这个方法会在 plugins 加入一些内置的插件,其中就包括重写 import 裸模块导入的 importAnalysisPlugin
插件。resolvePlugins
实现在 packages/vite/src/node/plugins/index.ts
文件。
依赖预构建
从上面可以看出,依赖预构建调用的是 optimizeDeps
函数。这个函数在 packages/vite/src/node/optimizer/index.ts
中定义。
export async function optimizeDeps(
config: ResolvedConfig,
force = config.server.force,
asCommand = false,
newDeps?: Record<string, string>,
ssr?: boolean
): Promise<DepOptimizationMetadata | null> {
// ...
const dataPath = path.join(cacheDir, '_metadata.json')
const mainHash = getDepHash(root, config)
const data: DepOptimizationMetadata = {
hash: mainHash,
browserHash: mainHash,
optimized: {}
}
if (!force) {
let prevData: DepOptimizationMetadata | undefined
try {
prevData = JSON.parse(fs.readFileSync(dataPath, 'utf-8'))
} catch (e) {}
// hash is consistent, no need to re-bundle
if (prevData && prevData.hash === data.hash) {
log('Hash is consistent. Skipping. Use --force to override.')
return prevData
}
}
// 缓存目录初始化
if (fs.existsSync(cacheDir)) {
emptyDir(cacheDir)
} else {
fs.mkdirSync(cacheDir, { recursive: true })
}
// a hint for Node.js
// all files in the cache directory should be recognized as ES modules
writeFile(
path.resolve(cacheDir, 'package.json'),
JSON.stringify({ type: 'module' })
)
// ...
// 扫描依赖,获得需要预构建的依赖,以及查找不到的依赖
let deps: Record<string, string>, missing: Record<string, string>
if (!newDeps) {
;({ deps, missing } = await scanImports(config))
} else {
deps = newDeps
missing = {}
}
// ...
// 查找不到的依赖打印日志报错
const missingIds = Object.keys(missing)
if (missingIds.length) {
throw new Error(
`The following dependencies are imported but could not be resolved:\n\n ${missingIds
.map(
(id) =>
`${chalk.cyan(id)} ${chalk.white.dim(
`(imported by ${missing[id]})`
)}`
)
.join(`\n `)}\n\nAre they installed?`
)
}
// 合并 optimizeDeps 配置
const include = config.optimizeDeps?.include
if (include) {
const resolve = config.createResolver({ asSrc: false })
for (const id of include) {
// normalize 'foo >bar` as 'foo > bar' to prevent same id being added
// and for pretty printing
const normalizedId = normalizeId(id)
if (!deps[normalizedId]) {
const entry = await resolve(id)
if (entry) {
deps[normalizedId] = entry
} else {
throw new Error(
`Failed to resolve force included dependency: ${chalk.cyan(id)}`
)
}
}
}
}
// ...
for (const id in deps) {
const flatId = flattenId(id)
const filePath = (flatIdDeps[flatId] = deps[id])
// ...
}
// ...
// esbuild 打包
const result = await build({
absWorkingDir: process.cwd(),
entryPoints: Object.keys(flatIdDeps),
bundle: true,
format: 'esm',
target: config.build.target || undefined,
external: config.optimizeDeps?.exclude,
logLevel: 'error',
splitting: true,
sourcemap: true,
outdir: cacheDir,
ignoreAnnotations: true,
metafile: true,
define,
plugins: [
...plugins,
esbuildDepPlugin(flatIdDeps, flatIdToExports, config, ssr)
],
...esbuildOptions
})
const meta = result.metafile!
// the paths in `meta.outputs` are relative to `process.cwd()`
const cacheDirOutputPath = path.relative(process.cwd(), cacheDir)
for (const id in deps) {
const entry = deps[id]
data.optimized[id] = {
file: normalizePath(path.resolve(cacheDir, flattenId(id) + '.js')),
src: entry,
needsInterop: needsInterop(
id,
idToExports[id],
meta.outputs,
cacheDirOutputPath
)
}
}
// 写入 _metadata.json 文件
writeFile(dataPath, JSON.stringify(data, null, 2))
// ...
return data
}
依赖预构建的流程大致是:
- 从
_meta.json
中读取hash
,以此判断是否需要重新进行依赖预构建 - 对缓存目录进行初始化,默认是
node_modules/.vite
- 调用
scanImports
方法,获得需要预构建的依赖以及查找不到的依赖 - 查找不到的依赖打印日志报错
- 合并
optimizeDeps
配置 esbuild
进行依赖预构建- 更新
_metadata.json
文件
先看看 scanImports
是怎么获取需要预构建的依赖的。
export async function scanImports(config: ResolvedConfig): Promise<{
deps: Record<string, string>
missing: Record<string, string>
}> {
const start = performance.now()
let entries: string[] = []
const explicitEntryPatterns = config.optimizeDeps.entries
const buildInput = config.build.rollupOptions?.input
if (explicitEntryPatterns) {
entries = await globEntries(explicitEntryPatterns, config)
} else if (buildInput) {
const resolvePath = (p: string) => path.resolve(config.root, p)
if (typeof buildInput === 'string') {
entries = [resolvePath(buildInput)]
} else if (Array.isArray(buildInput)) {
entries = buildInput.map(resolvePath)
} else if (isObject(buildInput)) {
entries = Object.values(buildInput).map(resolvePath)
} else {
throw new Error('invalid rollupOptions.input value.')
}
} else {
entries = await globEntries('**/*.html', config)
}
// ...
const deps: Record<string, string> = {}
const missing: Record<string, string> = {}
const container = await createPluginContainer(config)
const plugin = esbuildScanPlugin(config, container, deps, missing, entries)
const { plugins = [], ...esbuildOptions } =
config.optimizeDeps?.esbuildOptions ?? {}
await Promise.all(
entries.map((entry) =>
build({
absWorkingDir: process.cwd(),
write: false,
entryPoints: [entry],
bundle: true,
format: 'esm',
logLevel: 'error',
plugins: [...plugins, plugin],
...esbuildOptions
})
)
)
return {
deps,
missing
}
}
简而言之,这个方法会用应用入口进行 esbuild 构建,并在 esbuildScanPlugin
插件中将扫描到的依赖写入 deps
和 missing
两个数组。当然,这次构建只是查找需要预构建的依赖,构建结果不会写入硬盘。然后再看看 esbuildScanPlugin
,这是一个 esbuild 插件。下面直接截取关键代码。
// bare imports: record and externalize ----------------------------------
build.onResolve(
{
// avoid matching windows volume
filter: /^[\w@][^:]/
},
async ({ path: id, importer }) => {
if (moduleListContains(exclude, id)) {
return externalUnlessEntry({ path: id })
}
if (depImports[id]) {
return externalUnlessEntry({ path: id })
}
const resolved = await resolve(id, importer)
if (resolved) {
if (shouldExternalizeDep(resolved, id)) {
return externalUnlessEntry({ path: id })
}
if (resolved.includes('node_modules') || include?.includes(id)) {
// dependency or forced included, externalize and stop crawling
if (OPTIMIZABLE_ENTRY_RE.test(resolved)) {
depImports[id] = resolved
}
return externalUnlessEntry({ path: id })
} else {
const namespace = htmlTypesRE.test(resolved) ? 'html' : undefined
// linked package, keep crawling
return {
path: path.resolve(resolved),
namespace
}
}
} else {
missing[id] = normalizePath(importer)
}
}
)
depImports
即是 deps
。结合注释,我们可以明白,Vite 会将代码中使用裸模块导入,并在 node_modules (或 optimizeDeps
配置)中的模块当成需要预构建的模块。为什么一定要在 node_modules 中呢?官网对此也有解释,这是为了防止 monorepo 项目中别的 package 的代码被当成依赖进行预构建,Monorepo 和链接依赖。
然后再来看看 esbuild 预构建的相关配置。
const result = await build({
absWorkingDir: process.cwd(),
entryPoints: Object.keys(flatIdDeps),
bundle: true,
format: 'esm',
target: config.build.target || undefined,
external: config.optimizeDeps?.exclude,
logLevel: 'error',
splitting: true,
sourcemap: true,
outdir: cacheDir,
ignoreAnnotations: true,
metafile: true,
define,
plugins: [
...plugins,
esbuildDepPlugin(flatIdDeps, flatIdToExports, config, ssr)
],
...esbuildOptions
})
entryPoints
:入口文件配置,注意这里是把每个依赖当成构建入口,而不是应用入口。
bundle
:配置为 true
,则所有依赖会被打包成一个文件,即每个入口文件里面的 import 同步依赖都会被打包成内联代码,最后的结果就是每个入口都会打包成一个文件。esbuild 官网也有详细的描述。
To bundle a file means to inline any imported dependencies into the file itself. This process is recursive so dependencies of dependencies (and so on) will also be inlined. By default esbuild will not bundle the input files.
Note that bundling is different than file concatenation. Passing esbuild multiple input files with bundling enabled will create multiple separate bundles instead of joining the input files together. To join a set of files together with esbuild, import them all into a single entry point file and bundle just that one file with esbuild.
splitting
:代码拆分,多个入口之间的公共代码会被合并抽离。所以预构建的结果中有很多 chunk-xxxx.js
这样的文件。
以上,就是依赖预构建的内容。
重写裸模块导入
按上面所说,这部分逻辑是 importAnalysisPlugin
插件实现的。Vite 的插件原理其实就是在特定的阶段会通过插件容器遍历所有插件,调用插件的钩子方法。比如在模块在被请求时,会调用 resolveId
,load
,transform
三个钩子,重写 import
就是在 transform
阶段触发的。所以这部分逻辑可以直接定位到 packages/vite/src/node/plugins/importAnalysis.ts
的 transform
方法。
Vite 会是先识别代码中的导入语句。这个功能是由 es-module-lexer
模块实现的。该模块可以进行词法分析,返回导入语句的许多参数。
n: 模块名称
s: 模块名称的开始位置
e: 模块名称的结束位置
ss: 导入语句的开始位置
se: 导入语句的结束位置
d: 是否为动态导入(import()),是则返回开始位置,否则返回-1
然后可以看看 Vite 的源码:
async transform(source, importer, options) {
// ...
imports = parseImports(source)[0]
// ...
const str = () => s || (s = new MagicString(source))
// ...
for (let index = 0; index < imports.length; index++) {
const {
s: start,
e: end,
ss: expStart,
se: expEnd,
d: dynamicIndex,
n: specifier
} = imports[index]
// normalize
const [normalizedUrl, resolvedId] = await normalizeUrl(
specifier,
start
)
let url = normalizedUrl
// rewrite
if (url !== specifier) {
// ...
str().overwrite(start, end, isDynamicImport ? `'${url}'` : url)
}
}
}
source
为文件内容(源码),importer
为文件地址。parseImports
为 es-module-lexer
的 parse
方法,imports
为解析出来的导入语句信息的数组。通过 normalizeUrl
方法获得真正的模块地址之后,对源码内容进行替换。MagicString
是一个用于代码字符串替换的库。
当然,源文件远不止这些内容,还有很多情况的处理,以及 normalizeUrl
方法的实现,这里就不多讲。
这里总结一下从依赖预构建,开启开发服务器之后请问文件的流程:开发服务器接到请求,从系统读取文件后(返回之前)会对文件内容进行转换。在经过 transformMiddleware
中间件的时候,中间件会触发插件容器的 transfrom
钩子,插件容器会按顺序执行所有插件的 transform
方法。
这个转换文件一般是处理以下几种情况:
- 对于 js 文件,会重写 import,处理裸模块导入的问题
- 对于一些浏览器处理不了的文件,比如
sass
,vue
等,会有对应的插件进行转换。比如将 sass 转换成 css;而 vue 文件则会将 js 抽离出来返回,并在其中增加导入模板和样式的语句,因此你会发现一个 vue 文件一般会有三个请求。
因为文件的转换(编译)是在请求发生之后,所以你会发现开发服务器启动非常快,但在请求一些新文件的时候会慢一些。但总体的开发体验还是有很大的提升。
HMR
模块热重载的功能分为客户端(client)和服务端(server)两部分。
先看看服务端的代码。前面说到,Vite 用 chokidar
监听模文件的变化,在回调函数这里调用了 handleHMRUpdate
方法。
// packages/vite/src/node/server/index.ts
watcher.on('change', async (file) => {
file = normalizePath(file)
// invalidate module graph cache on file change
moduleGraph.onFileChange(file)
if (serverConfig.hmr !== false) {
try {
await handleHMRUpdate(file, server)
} catch (err) {
ws.send({
type: 'error',
err: prepareError(err)
})
}
}
})
回调函数入参 file
为文件的路径。然后跳到 handleHMRUpdate
方法所在的文件 packages/vite/src/node/server/hmr.ts
。直接看 updateModules
方法。经过一系列处理之后,如果是正常的的 HMR,最后会通过 websocket(后面简写为 ws)给客户端发一个 type = 'update'
消息,告知客户端有哪些资源更新了,需要重新请求了。告知内容包括需要更新的资源的路径,更新的时间戳等。
function updateModules(
file: string,
modules: ModuleNode[],
timestamp: number,
{ config, ws }: ViteDevServer
) {
const updates: Update[] = []
const invalidatedModules = new Set<ModuleNode>()
let needFullReload = false
for (const mod of modules) {
invalidate(mod, timestamp, invalidatedModules)
if (needFullReload) {
continue
}
const boundaries = new Set<{
boundary: ModuleNode
acceptedVia: ModuleNode
}>()
const hasDeadEnd = propagateUpdate(mod, boundaries)
if (hasDeadEnd) {
needFullReload = true
continue
}
updates.push(
...[...boundaries].map(({ boundary, acceptedVia }) => ({
type: `${boundary.type}-update` as Update['type'],
timestamp,
path: boundary.url,
acceptedPath: acceptedVia.url
}))
)
}
if (needFullReload) {
config.logger.info(chalk.green(`page reload `) + chalk.dim(file), {
clear: true,
timestamp: true
})
ws.send({
type: 'full-reload'
})
} else {
config.logger.info(
updates
.map(({ path }) => chalk.green(`hmr update `) + chalk.dim(path))
.join('\n'),
{ clear: true, timestamp: true }
)
ws.send({
type: 'update',
updates
})
}
}
当然有些时候也会发送 type = 'full-reload'
的消息,告知客户端直接刷新页面,比如 index.html 文件修改;而 vite 配置的修改则会重启开发服务器,这时客户端也需要刷新页面。
接下来看看客户端如何处理 ws 的消息的。
首先,我们可以从 chrome devtool 的 network 中看到 index.html 的返回结果,里面会插入一个 client 的脚本 <script type="module" src="/@vite/client"></script>
。至于是哪个插件处理的我没去找,有兴趣可以找找源码。
现在直接在 devtool 中或者源码中看这个 client.ts
文件的内容都可以,这个文件没有压缩。源码的位置在 packages/vite/src/client/client.ts
。直接定位到对 type='update'
类型消息的处理。
case 'update':
notifyListeners('vite:beforeUpdate', payload)
// if this is the first update and there's already an error overlay, it
// means the page opened with existing server compile error and the whole
// module script failed to load (since one of the nested imports is 500).
// in this case a normal update won't work and a full reload is needed.
if (isFirstUpdate && hasErrorOverlay()) {
window.location.reload()
return
} else {
clearErrorOverlay()
isFirstUpdate = false
}
payload.updates.forEach((update) => {
if (update.type === 'js-update') {
queueUpdate(fetchUpdate(update))
} else {
// css-update
// this is only sent when a css file referenced with <link> is updated
let { path, timestamp } = update
path = path.replace(/\?.*/, '')
// can't use querySelector with `[href*=]` here since the link may be
// using relative paths so we need to use link.href to grab the full
// URL for the include check.
const el = (
[].slice.call(
document.querySelectorAll(`link`)
) as HTMLLinkElement[]
).find((e) => e.href.includes(path))
if (el) {
const newPath = `${base}${path.slice(1)}${
path.includes('?') ? '&' : '?'
}t=${timestamp}`
el.href = new URL(newPath, el.href).href
}
console.log(`[vite] css hot updated: ${path}`)
}
})
break
这里把需要热替换的资源分为 js 和 css。如果是 css 的话,比较简单,找到页面上对应资源的 link 标签,将其 href 属性改为资源地址并加上时间戳。这样就会重新发起这个样式文件的请求。如果是 js 的话,要看看 fetchUpdate
方法。
async function fetchUpdate({ path, acceptedPath, timestamp }: Update) {
const mod = hotModulesMap.get(path)
if (!mod) {
// In a code-splitting project,
// it is common that the hot-updating module is not loaded yet.
// https://github.com/vitejs/vite/issues/721
return
}
const moduleMap = new Map()
const isSelfUpdate = path === acceptedPath
// make sure we only import each dep once
const modulesToUpdate = new Set<string>()
if (isSelfUpdate) {
// self update - only update self
modulesToUpdate.add(path)
} else {
// dep update
for (const { deps } of mod.callbacks) {
deps.forEach((dep) => {
if (acceptedPath === dep) {
modulesToUpdate.add(dep)
}
})
}
}
// determine the qualified callbacks before we re-import the modules
const qualifiedCallbacks = mod.callbacks.filter(({ deps }) => {
return deps.some((dep) => modulesToUpdate.has(dep))
})
await Promise.all(
Array.from(modulesToUpdate).map(async (dep) => {
const disposer = disposeMap.get(dep)
if (disposer) await disposer(dataMap.get(dep))
const [path, query] = dep.split(`?`)
try {
const newMod = await import(
/* @vite-ignore */
base +
path.slice(1) +
`?import&t=${timestamp}${query ? `&${query}` : ''}`
)
moduleMap.set(dep, newMod)
} catch (e) {
warnFailedFetch(e, dep)
}
})
)
return () => {
for (const { deps, fn } of qualifiedCallbacks) {
fn(deps.map((dep) => moduleMap.get(dep)))
}
const loggedPath = isSelfUpdate ? path : `${acceptedPath} via ${path}`
console.log(`[vite] hot updated: ${loggedPath}`)
}
}
可以看到,这个方法会先检测是否只有模块自身需要更新,然后回收集所有需要更新的模块。最后用 import()
动态加载新的模块代码。最后这一步和 webpack 没什么区别,不过 webpack 是用它自己定义的加载函数 __webpack_require__
。
为什么加载新的代码就会对原来的代码进行替换呢?个人觉得,加载新的代码之后,新的代码会执行,那么就会对原来的变量,函数等进行覆盖,形成一个替换的效果。大家开发的时候也会发现,打开一个弹窗,对弹窗相关代码进行修改,热替换之后,弹窗自动关闭了。这就是重新执行代码,重置了弹窗状态导致的。
总结
Vite 的优势在于其开发服务器冷启动,模块热替换(HMR)的速度非常快。因为它基于浏览器支持的 ESM,不需要打包。但在这种情况下,Vite 需要解决 CJS,UMD 模块的兼容问题;裸模块导入的问题以及大量内建模块导致大量 http 请求的问题。Vite 的方案是进行依赖预构建,使用 esbuild 将内建模块打包成内联代码,同时兼容 ESM 规范。在发起请求之后,对模块中的裸模块导入语句进行替换。最后也对基于 ESM 的模块热替换原理进行简要分析。