Vite源码解析(v3.0.3)

358 阅读8分钟

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 将资源编译成浏览器能够识别的文件类型最后返回给浏览器。

而且这期间还会设置对比缓存和强制缓存,并缓存编译过的文件代码。