Vite dev 启动
yarn start启动执行的核心代码如下
const server = await createServer({
root,
base: options.base,
mode: options.mode,
configFile: options.config,
logLevel: options.logLevel,
clearScreen: options.clearScreen,
optimizeDeps: { force: options.force },
server: cleanOptions(options)
})
if (!server.httpServer) {
throw new Error('HTTP server not available')
}
await server.listen()
核心即 createServer,创建了一个httpService服务,并启动。
接下来看看,这个函数做了哪些事情?
CreateServer
function createServer(
inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
// 初始化配置
const config = await resolveConfig(inlineConfig, 'serve', 'development')
const { root, server: serverConfig } = config
...
// 初始化httpServer服务中间件
const middlewares = connect() as Connect.Server
// 创建httpServer
const httpServer = middlewareMode
? null
: await resolveHttpServer(serverConfig, middlewares, httpsOptions)
// 简历websocket连接
const ws = createWebSocketServer(httpServer, config, httpsOptions)
const { ignored = [], ...watchOptions } = serverConfig.watch || {}
// chokidar: 创建watcher,监听本地文件变化
const watcher = chokidar.watch(path.resolve(root), {
ignored: [
'**/.git/**',
'**/node_modules/**',
'**/test-results/**', // Playwright
...(Array.isArray(ignored) ? ignored : [ignored])
],
ignoreInitial: true,
ignorePermissionErrors: true,
disableGlobbing: true,
...watchOptions
}) as FSWatcher
// 创建插件执行ctx
const container = await createPluginContainer(config, moduleGraph, watcher)
const closeHttpServer = createServerCloseFn(httpServer)
let exitProcess: () => void
// 初始化server
// ## server config start
const server: ViteDevServer = {
config,
middlewares,
httpServer,
watcher,
pluginContainer: container,
ws,
moduleGraph,
resolvedUrls: null, // will be set on listen
ssrTransform(code: string, inMap: SourceMap | null, url: string) {
return ssrTransform(code, inMap, url, code, {
json: { stringify: server.config.json?.stringify }
})
},
transformRequest(url, options) {
return transformRequest(url, server, options)
},
transformIndexHtml: null!, // to be immediately set
async ssrLoadModule(url, opts?: { fixStacktrace?: boolean }) {
if (isDepsOptimizerEnabled(config, true)) {
await initDevSsrDepsOptimizer(config, server)
}
await updateCjsSsrExternals(server)
return ssrLoadModule(
url,
server,
undefined,
undefined,
opts?.fixStacktrace
)
},
ssrFixStacktrace(e) {
if (e.stack) {
const stacktrace = ssrRewriteStacktrace(e.stack, moduleGraph)
rebindErrorStacktrace(e, stacktrace)
}
},
ssrRewriteStacktrace(stack: string) {
return ssrRewriteStacktrace(stack, moduleGraph)
},
async listen(port?: number, isRestart?: boolean) {
await startServer(server, port, isRestart)
if (httpServer) {
server.resolvedUrls = await resolveServerUrls(
httpServer,
config.server,
config
)
}
return server
},
async close() {
if (!middlewareMode) {
process.off('SIGTERM', exitProcess)
if (process.env.CI !== 'true') {
process.stdin.off('end', exitProcess)
}
}
await Promise.all([
watcher.close(),
ws.close(),
container.close(),
closeHttpServer()
])
server.resolvedUrls = null
},
printUrls() {
if (server.resolvedUrls) {
printServerUrls(
server.resolvedUrls,
serverConfig.host,
config.logger.info
)
} else if (middlewareMode) {
throw new Error('cannot print server URLs in middleware mode.')
} else {
throw new Error(
'cannot print server URLs before server.listen is called.'
)
}
},
async restart(forceOptimize?: boolean) {
if (!server._restartPromise) {
server._forceOptimizeOnRestart = !!forceOptimize
server._restartPromise = restartServer(server).finally(() => {
server._restartPromise = null
server._forceOptimizeOnRestart = false
})
}
return server._restartPromise
},
_ssrExternals: null,
_restartPromise: null,
_importGlobMap: new Map(),
_forceOptimizeOnRestart: false,
_pendingRequests: new Map()
}
// ## server config end
server.transformIndexHtml = createDevHtmlTransformFn(server)
// 监听文件变化
watcher.on('change', async (file) => {
file = normalizePath(file)
if (file.endsWith('/package.json')) {
return invalidatePackageData(packageCache, 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)
})
}
}
})
// 监听添加文件
watcher.on('add', (file) => {
handleFileAddUnlink(normalizePath(file), server)
})
watcher.on('unlink', (file) => {
handleFileAddUnlink(normalizePath(file), server)
})
...
// 收集vite+rollup后置插件
const postHooks: ((() => void) | void)[] = []
for (const plugin of config.plugins) {
if (plugin.configureServer) {
postHooks.push(await plugin.configureServer(server))
}
}
// Internal middlewares ------------------------------------------------------
// request timer
if (process.env.DEBUG) {
middlewares.use(timeMiddleware(root))
}
// cors (enabled by default)
const { cors } = serverConfig
if (cors !== false) {
middlewares.use(corsMiddleware(typeof cors === 'boolean' ? {} : cors))
}
// proxy
const { proxy } = serverConfig
if (proxy) {
middlewares.use(proxyMiddleware(httpServer, proxy, config))
}
// base
const devBase = config.base
if (devBase !== '/') {
middlewares.use(baseMiddleware(server))
}
// open in editor support
middlewares.use('/__open-in-editor', launchEditorMiddleware())
// serve static files under /public
// this applies before the transform middleware so that these files are served
// as-is without transforms.
if (config.publicDir) {
middlewares.use(
servePublicMiddleware(config.publicDir, config.server.headers)
)
}
// main transform middleware
middlewares.use(transformMiddleware(server))
// serve static files
middlewares.use(serveRawFsMiddleware(server))
middlewares.use(serveStaticMiddleware(root, server))
// spa fallback
if (config.appType === 'spa') {
middlewares.use(spaFallbackMiddleware(root))
}
// 执行后置插件
// This is applied before the html middleware so that user middleware can
// serve custom content instead of index.html.
postHooks.forEach((fn) => fn && fn())
if (config.appType === 'spa' || config.appType === 'mpa') {
// transform index.html
middlewares.use(indexHtmlMiddleware(server))
// handle 404s
// Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`
middlewares.use(function vite404Middleware(_, res) {
res.statusCode = 404
res.end()
})
}
// error handler
middlewares.use(errorMiddleware(server, middlewareMode))
let initingServer: Promise<void> | undefined
let serverInited = false
const initServer = async () => {
if (serverInited) {
return
}
if (initingServer) {
return initingServer
}
initingServer = (async function () {
await container.buildStart({})
if (isDepsOptimizerEnabled(config, false)) {
// non-ssr
await initDepsOptimizer(config, server)
}
initingServer = undefined
serverInited = true
})()
return initingServer
}
// 重写httpServer listen方法: container.buildStart(), initDepsOptimizer()
if (!middlewareMode && httpServer) {
// overwrite listen to init optimizer before server start
const listen = httpServer.listen.bind(httpServer)
httpServer.listen = (async (port: number, ...args: any[]) => {
try {
await initServer()
} catch (e) {
httpServer.emit('error', e)
return
}
return listen(port, ...args)
}) as any
} else {
await initServer()
}
return server
}
大致流程如下:
- 获取config配置
- 创建 http 服务器httpServer
- 创建 WebSocket 服务器ws
- 通过 chokidar 创建监听器watcher
- 创建一个兼容rollup钩子函数的对象container
- 创建模块图谱实例moduleGraph
- 声明server对象
- 注册watcher回调
- 执行插件中的configureServer钩子函数(注册用户定义的前置中间件),并收集用户定义的后置中间件
- 注册中间件
- 注册用户定义的后置中间件
- 注册转换html文件的中间件和未找到文件的404中间件
- 重写 httpServer.listen
- 返回server对象
rollup plugins执行
createServer中会创建一个container:
const container = await createPluginContainer(config, moduleGraph, watcher)
createPluginContainer
function createPluginContainer(
{ plugins, logger, root, build: { rollupOptions } }: ResolvedConfig,
moduleGraph?: ModuleGraph,
watcher?: FSWatcher
): Promise<PluginContainer> {
// we should create a new context for each async hook pipeline so that the
// active plugin in that pipeline can be tracked in a concurrency-safe manner.
// using a class to make creating new contexts more efficient
class Context implements PluginContext {
meta = minimalContext.meta
ssr = false
_scan = false
_activePlugin: Plugin | null
_activeId: string | null = null
_activeCode: string | null = null
_resolveSkips?: Set<Plugin>
_addedImports: Set<string> | null = null
constructor(initialPlugin?: Plugin) {
this._activePlugin = initialPlugin || null
}
parse(code: string, opts: any = {}) {
return parser.parse(code, {
sourceType: 'module',
ecmaVersion: 'latest',
locations: true,
...opts
})
}
async resolve(
id: string,
importer?: string,
options?: {
custom?: CustomPluginOptions
isEntry?: boolean
skipSelf?: boolean
}
) {
...
return container.resolveId as ResolvedId | null
}
getModuleInfo(id: string) {
return getModuleInfo(id)
}
getModuleIds() {
return moduleGraph
? moduleGraph.idToModuleMap.keys()
: Array.prototype[Symbol.iterator]()
}
addWatchFile(id: string) {
watchFiles.add(id)
;(this._addedImports || (this._addedImports = new Set())).add(id)
if (watcher) ensureWatchedFile(watcher, id, root)
}
getWatchFiles() {
return [...watchFiles]
}
emitFile(assetOrFile: EmittedFile) {
warnIncompatibleMethod(`emitFile`, this._activePlugin!.name)
return ''
}
setAssetSource() {
warnIncompatibleMethod(`setAssetSource`, this._activePlugin!.name)
}
getFileName() {
warnIncompatibleMethod(`getFileName`, this._activePlugin!.name)
return ''
}
warn(
e: string | RollupError,
position?: number | { column: number; line: number }
) {
...
}
error(
e: string | RollupError,
position?: number | { column: number; line: number }
): never {
// error thrown here is caught by the transform middleware and passed on
// the the error middleware.
throw formatError(e, position, this)
}
}
class TransformContext extends Context {
filename: string
originalCode: string
originalSourcemap: SourceMap | null = null
sourcemapChain: NonNullable<SourceDescription['map']>[] = []
combinedMap: SourceMap | null = null
constructor(filename: string, code: string, inMap?: SourceMap | string) {
super()
this.filename = filename
this.originalCode = code
if (inMap) {
this.sourcemapChain.push(inMap)
}
}
_getCombinedSourcemap(createIfNull = false) {
...
let combinedMap = this.combinedMap
for (let m of this.sourcemapChain) {
if (typeof m === 'string') m = JSON.parse(m)
if (!('version' in (m as SourceMap))) {
// empty, nullified source map
combinedMap = this.combinedMap = null
this.sourcemapChain.length = 0
break
}
if (!combinedMap) {
combinedMap = m as SourceMap
} else {
combinedMap = combineSourcemaps(cleanUrl(this.filename), [
{
...(m as RawSourceMap),
sourcesContent: combinedMap.sourcesContent
},
combinedMap as RawSourceMap
]) as SourceMap
}
}
if (!combinedMap) {
return createIfNull
? new MagicString(this.originalCode).generateMap({
includeContent: true,
hires: true,
source: cleanUrl(this.filename)
})
: null
}
if (combinedMap !== this.combinedMap) {
this.combinedMap = combinedMap
this.sourcemapChain.length = 0
}
return this.combinedMap
}
getCombinedSourcemap() {
return this._getCombinedSourcemap(true) as SourceMap
}
}
const container: PluginContainer = {
options: await (async () => {
let options = rollupOptions
for (const plugin of plugins) {
if (!plugin.options) continue
options =
(await plugin.options.call(minimalContext, options)) || options
}
if (options.acornInjectPlugins) {
parser = acorn.Parser.extend(
...(arraify(options.acornInjectPlugins) as any)
)
}
return {
acorn,
acornInjectPlugins: [],
...options
}
})(),
getModuleInfo,
async buildStart() {
await Promise.all(
plugins.map((plugin) => {
if (plugin.buildStart) {
return plugin.buildStart.call(
new Context(plugin) as any,
container.options as NormalizedInputOptions
)
}
})
)
},
async resolveId(rawId, importer = join(root, 'index.html'), options) {
...
const ctx = new Context()
const partial: Partial<PartialResolvedId> = {}
...
for (const plugin of plugins) {
...
const result = await plugin.resolveId.call(
ctx as any,
rawId,
importer,
{
custom: options?.custom,
isEntry: !!options?.isEntry,
ssr,
scan
}
)
if (!result) continue
if (typeof result === 'string') {
id = result
} else {
id = result.id
Object.assign(partial, result)
}
isDebug &&
debugPluginResolve(
timeFrom(pluginResolveStart),
plugin.name,
prettifyUrl(id, root)
)
// resolveId() is hookFirst - first non-null result is returned.
break
}
...
if (id) {
partial.id = isExternalUrl(id) ? id : normalizePath(id)
return partial as PartialResolvedId
} else {
return null
}
},
async load(id, options) {
const ssr = options?.ssr
const ctx = new Context()
ctx.ssr = !!ssr
for (const plugin of plugins) {
if (!plugin.load) continue
ctx._activePlugin = plugin
const result = await plugin.load.call(ctx as any, id, { ssr })
if (result != null) {
if (isObject(result)) {
updateModuleInfo(id, result)
}
return result
}
}
return null
},
async transform(code, id, options) {
const inMap = options?.inMap
const ssr = options?.ssr
const ctx = new TransformContext(id, code, inMap as SourceMap)
ctx.ssr = !!ssr
for (const plugin of plugins) {
if (!plugin.transform) continue
ctx._activePlugin = plugin
ctx._activeId = id
ctx._activeCode = code
const start = isDebug ? performance.now() : 0
let result: TransformResult | string | undefined
try {
result = await plugin.transform.call(ctx as any, code, id, { ssr })
} catch (e) {
ctx.error(e)
}
if (!result) continue
isDebug &&
debugPluginTransform(
timeFrom(start),
plugin.name,
prettifyUrl(id, root)
)
if (isObject(result)) {
if (result.code !== undefined) {
code = result.code
if (result.map) {
if (isDebugSourcemapCombineFocused) {
// @ts-expect-error inject plugin name for debug purpose
result.map.name = plugin.name
}
ctx.sourcemapChain.push(result.map)
}
}
updateModuleInfo(id, result)
} else {
code = result
}
}
return {
code,
map: ctx._getCombinedSourcemap()
}
},
async close() {
if (closed) return
const ctx = new Context()
await Promise.all(
plugins.map((p) => p.buildEnd && p.buildEnd.call(ctx as any))
)
await Promise.all(
plugins.map((p) => p.closeBundle && p.closeBundle.call(ctx as any))
)
closed = true
}
}
return container
}
上述container作为vite整个生命周期的ctx, 在不同时期调用ctx上面不同的方法。
vite httpServer创建时调用:
container.options, 自执行函数container.buildStart, 重写httpServer.listen()
模块请求时调用:
- resolveId: 执行所有插件的resolveId函数,直至返回文件的绝对路径,停止遍历。 调用时机:
执行
transformMiddleware中间件时,调用reloveId对请求模块进行解析
const id =(await pluginContainer.resolveId(url, undefined, { ssr }))?.id || url
- load: 读取,返回文件代码和map信息
const loadResult = await pluginContainer.load(id, { ssr })
- transform: 调用插件上的transform,传入源码和文件路径,返回转换后的代码和sourcemap
const transformResult = await pluginContainer.transform(code, id, {
inMap: map,
ssr
})
服务关闭时调用:
- close: 对应vite,rollup插件buildEnd, closeBundle API;
下面着重看下核心插件:esbuildPlugin
esbuildPlugin
export function esbuildPlugin(options: ESBuildOptions = {}): Plugin {
const filter = createFilter(
options.include || /.(m?ts|[jt]sx)$/,
options.exclude || /.js$/
)
// Remove optimization options for dev as we only need to transpile them,
// and for build as the final optimization is in `buildEsbuildPlugin`
const transformOptions: TransformOptions = {
...options,
...
}
return {
name: 'vite:esbuild',
configureServer(_server) {
server = _server
server.watcher
.on('add', reloadOnTsconfigChange)
.on('change', reloadOnTsconfigChange)
.on('unlink', reloadOnTsconfigChange)
},
async configResolved(config) {
await initTSConfck(config)
},
buildEnd() {
// recycle serve to avoid preventing Node self-exit (#6815)
server = null as any
},
async transform(code, id) {
if (filter(id) || filter(cleanUrl(id))) {
const result = await transformWithEsbuild(code, id, transformOptions)
if (result.warnings.length) {
result.warnings.forEach((m) => {
this.warn(prettifyMessage(m, code))
})
}
if (options.jsxInject && /.(?:j|t)sx\b/.test(id)) {
result.code = options.jsxInject + ';' + result.code
}
return {
code: result.code,
map: result.map
}
}
}
}
}
核心及符合过滤条件的文件,会调用transformWithEsbuild来进行源码的转换,使用了编译速度较快的esbuild, 核心代码如下:
const result = await import('esbuld').transform(code, resolvedOptions)
let map: SourceMap
if (inMap && resolvedOptions.sourcemap) {
const nextMap = JSON.parse(result.map)
nextMap.sourcesContent = []
map = combineSourcemaps(filename, [
nextMap as RawSourceMap,
inMap as RawSourceMap
]) as SourceMap
} else {
map = resolvedOptions.sourcemap
? JSON.parse(result.map)
: { mappings: '' }
}
if (Array.isArray(map.sources)) {
map.sources = map.sources.map((it) => toUpperCaseDriveLetter(it))
}
return {
...result,
map
}
通过以上介绍,可以知道,container作为一个插件容器挂载了许多vite/rollup插件,存在vite的整个生命周期中。
浏览器输入localhost:5173发生了什么?
httpServer,注册了一系列中间件,server启动后,浏览器访问时,服务端会依次执行如下:
// main transform middleware
middlewares.use(transformMiddleware(server))
// serve static files, 静态文件中间件
middlewares.use(serveRawFsMiddleware(server))
middlewares.use(serveStaticMiddleware(root, server))
// spa fallback
if (config.appType === 'spa') {
middlewares.use(spaFallbackMiddleware(root))
}
// run post config hooks
// This is applied before the html middleware so that user middleware can
// serve custom content instead of index.html.
postHooks.forEach((fn) => fn && fn())
if (config.appType === 'spa' || config.appType === 'mpa') {
// transform index.html
middlewares.use(indexHtmlMiddleware(server))
// handle 404s
// Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`
middlewares.use(function vite404Middleware(_, res) {
res.statusCode = 404
res.end()
})
}
transformMiddleware
const knownIgnoreList = new Set(['/', '/favicon.ico'])
return async function viteTransformMiddleware(req, res, next) {
if (req.method !== 'GET' || knownIgnoreList.has(req.url!)) {
return next()
}
// ...
}
由于命中了ignore, 直接跳过执行下一个中间件。 然后静态文件中间件,直接跳过
spaFallbackMiddleware
function spaFallbackMiddleware(
root: string
): Connect.NextHandleFunction {
const historySpaFallbackMiddleware = history({
logger: createDebugger('vite:spa-fallback'),
// support /dir/ without explicit index.html
rewrites: [
{
from: //$/,
to({ parsedUrl }: any) {
const rewritten =
decodeURIComponent(parsedUrl.pathname) + 'index.html'
if (fs.existsSync(path.join(root, rewritten))) {
return rewritten
} else {
return `/index.html`
}
}
}
]
})
// Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`
return function viteSpaFallbackMiddleware(req, res, next) {
return historySpaFallbackMiddleware(req, res, next)
}
}
利用connect-history-api-fallback 将访问路径为‘/’的资源指向/index.html文件。
indexHtmlMiddleware
function indexHtmlMiddleware(
server: ViteDevServer
): Connect.NextHandleFunction {
// Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`
return async function viteIndexHtmlMiddleware(req, res, next) {
if (res.writableEnded) {
return next()
}
const url = req.url && cleanUrl(req.url)
// spa-fallback always redirects to /index.html
if (url?.endsWith('.html') && req.headers['sec-fetch-dest'] !== 'script') {
// 获取html文件名
const filename = getHtmlFilename(url, server)
if (fs.existsSync(filename)) {
try {
// 获取html文件内容
let html = fs.readFileSync(filename, 'utf-8')
// 调用 createDevHtmlTransformFn 执行插件对应transformIndexHtml方法,根据返回的内容,将html进行重新拼接,返回最终html文件
html = await server.transformIndexHtml(url, html, req.originalUrl)
return send(req, res, html, 'html', {
headers: server.config.server.headers
})
} catch (e) {
return next(e)
}
}
}
next()
}
}
小思考: vite官方模版html文件内容,到浏览器端为什么多了一行
<script type="module" src="/@vite/client"></script>
总结
当我们访问localhost:5173/时,会被中间件指向/index.html,并向/index.html中注入热更新相关的代码。最后返回这个HTML。当浏览器加载这个HTML时,通过原生ESM的方式请求js文件;会被transformMiddleware中间件拦截,这个中间件做的事就是将这个被请求文件转换成浏览器支持的文件;并会为该文件创建模块对象、设置模块之前的引用关系。
这也是 Vite 冷启动快的原因之一,Vite在启动过程中不会编译源码,只会对依赖进行预构建。当我们访问某个文件时,会拦截并通过 ESbuild 将资源编译成浏览器能够识别的文件类型最后返回给浏览器。
而且这期间还会设置对比缓存和强制缓存,并缓存编译过的文件代码。