阅读 4269

Vite 是如何实现的

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

Vite 是由 Vue 作者尤雨溪开发的 Web 开发工具,尤雨溪在微博上推广时对 Vite 做了简短介绍:

Vite,一个基于浏览器原生 ES imports 的开发服务器。利用浏览器去解析 imports,在服务器端按需编译返回,完全跳过了打包这个概念,服务器随起随用。同时不仅有 Vue 文件支持,还搞定了热更新,而且热更新的速度不会随着模块增多而变慢。针对生产环境则可以把同一份代码用 Rollup 打包。虽然现在还比较粗糙,但这个方向我觉得是有潜力的,做得好可以彻底解决改一行代码等半天热更新的问题。

我们可以从这段话中提取一些关键信息

  • Vite 基于 ESM,因此实现了快速启动和即时模块热更新能力;
  • Vite 在服务端实现了按需编译。

所以可以直白一些来讲:Vite 在开发环境下并没有打包和构建过程。

开发者在代码中写到的 ESM 导入语法会直接发送给服务器,而服务器也直接将 ESM 模块内容运行处理后,下发给浏览器。接着,现代浏览器通过解析 script module,对每一个 import 到的模块进行 HTTP 请求,服务器继续对这些 HTTP 请求进行处理并响应。

Vite 实现原理解读

环境搭建

Vite 思想比较容易理解,实现起来也并不复杂。接下来,我们来对 Vite 源码进行分析

首先,我们打造一个学习环境,创建一个基于 Vite 的应用,并启动:

$ yarn global add vite
$ npm init vite-app vite-app

$ cd vite-app

$ yarn

$ yarn dev

复制代码

会得到如下图所示的目录结构:

目录结构

启动项目:

$ yarn dev
复制代码

其中浏览器请求:**http://localhost:3000/**,得到的内容即是我们应用项目中的 index.html 内容。

浏览器效果

入口源码

拉取源码,在 开命令行实现部分

cli
  .command('[root]') // default command
  .alias('serve')
  .option('--host [host]', `[string] specify hostname`)
  .option('--port <port>', `[number] specify port`)
  .option('--https', `[boolean] use TLS + HTTP/2`)
  .option('--open [path]', `[boolean | string] open browser on startup`)
  .option('--cors', `[boolean] enable CORS`)
  .option('--strictPort', `[boolean] exit if specified port is already in use`)
  .option('-m, --mode <mode>', `[string] set env mode`)
  .option(
    '--force',
    `[boolean] force the optimizer to ignore the cache and re-bundle`
  )
  .action(async (root: string, options: ServerOptions & GlobalCLIOptions) => {
    // output structure is preserved even after bundling so require()
    // is ok here
    const { createServer } = await import('./server')
    try {
      const server = await createServer({
        root,
        base: options.base,
        mode: options.mode,
        configFile: options.config,
        logLevel: options.logLevel,
        clearScreen: options.clearScreen,
        server: cleanOptions(options) as ServerOptions
      })
      await server.listen()
    } catch (e) {
      createLogger(options.logLevel).error(
        chalk.red(`error when starting dev server:\n${e.stack}`)
      )
      process.exit(1)
    }
  })
复制代码

通过 createServer 来启动一个 http 服务,来实现对浏览器请求的响应。

const { createServer } = await import('./server')

复制代码

createServer方法的实现,代码如下

export async function createServer(inlineConfig) {
    // 配置文件处理
    const config = await resolveConfig(inlineConfig, 'serve', 'development')
    const root = config.root
    const serverConfig = config.server
    const httpsOptions = await resolveHttpsConfig(config)
    let { middlewareMode } = serverConfig
    // 以中间件模式创建 vite 服务器,不使用 vite 创建的服务器
    if (middlewareMode === true) {
      middlewareMode = 'ssr'
    }
  
    const middlewares = connect()
    // 创建一个 http 实例,注意,这里如果 middlewareMode = 'ssr' 则使用中间件来创建服务器
    const httpServer = middlewareMode
      ? null
        : await resolveHttpServer(serverConfig, middlewares, httpsOptions)
    // HMR 连接
    const ws = createWebSocketServer(httpServer, config, httpsOptions)
  
    const { ignored = [], ...watchOptions } = serverConfig.watch || {}
    // 文件监听
    const watcher = chokidar.watch(path.resolve(root), {
      ignored: ['**/node_modules/**', '**/.git/**', ...ignored],
      ignoreInitial: true,
      ignorePermissionErrors: true,
      disableGlobbing: true,
      ...watchOptions
    }) 
  
    const plugins = config.plugins
    const container = await createPluginContainer(config, watcher)
    const moduleGraph = new ModuleGraph(container)
    const closeHttpServer = createServerCloseFn(httpServer)
  
    // eslint-disable-next-line prefer-const
    let exitProcess
  
    const server = {
      config: config,
      middlewares,
      get app() {
        config.logger.warn(
          `ViteDevServer.app is deprecated. Use ViteDevServer.middlewares instead.`
        )
        return middlewares
      },
      httpServer,
      watcher,
      pluginContainer: container,
      ws,
      moduleGraph,
      transformWithEsbuild,
      transformRequest(url, options) {
        return transformRequest(url, server, options)
      },
      transformIndexHtml: null,
      ssrLoadModule(url) {
        if (!server._ssrExternals) {
          server._ssrExternals = resolveSSRExternal(
            config,
            server._optimizeDepsMetadata
              ? Object.keys(server._optimizeDepsMetadata.optimized)
              : []
          )
        }
        return ssrLoadModule(url, server)
      },
      ssrFixStacktrace(e) {
        if (e.stack) {
          e.stack = ssrRewriteStacktrace(e.stack, moduleGraph)
        }
      },
      listen(port, isRestart) {
        return startServer(server, port, isRestart)
      },
      async close() {
        process.off('SIGTERM', exitProcess)
  
        if (!middlewareMode && process.env.CI !== 'true') {
          process.stdin.off('end', exitProcess)
        }
  
        await Promise.all([
          watcher.close(),
          ws.close(),
          container.close(),
          closeHttpServer()
        ])
      },
      _optimizeDepsMetadata: null,
      _ssrExternals: null,
      _globImporters: {},
      _isRunningOptimizer: false,
      _registerMissingImport: null,
      _pendingReload: null
    }
  
    server.transformIndexHtml = createDevHtmlTransformFn(server)
  
    exitProcess = async () => {
      try {
        await server.close()
      } finally {
        process.exit(0)
      }
    }
  
    // 如果收到终止信号句柄,停止服务
    process.once('SIGTERM', exitProcess)
  
    if (!middlewareMode && process.env.CI !== 'true') {
      process.stdin.on('end', exitProcess)
    }
  
    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)
          })
        }
      }
    })
  
    watcher.on('add', (file) => {
      handleFileAddUnlink(normalizePath(file), server)
    })
  
    watcher.on('unlink', (file) => {
      handleFileAddUnlink(normalizePath(file), server, true)
    })
  
    // 插件处理
    // apply server configuration hooks from plugins
    const postHooks = []
    for (const plugin of 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, config))
    }
  
    // base
    if (config.base !== '/') {
      middlewares.use(baseMiddleware(server))
    }
  
    // open in editor support
    middlewares.use('/__open-in-editor', launchEditorMiddleware())
  
    // hmr reconnect ping
    // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`
    middlewares.use('/__vite_ping', function viteHMRPingMiddleware(_, res) {
      res.end('pong')
    })
  
    //decode request url
    middlewares.use(decodeURIMiddleware())
  
    // 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))
    }
  
    // main transform middleware
    middlewares.use(transformMiddleware(server))
  
    // serve static files
    middlewares.use(serveRawFsMiddleware(server))
    middlewares.use(serveStaticMiddleware(root, config))
  
    // spa fallback
    if (!middlewareMode || middlewareMode === 'html') {
      middlewares.use(
        history({
          logger: createDebugger('vite:spa-fallback'),
          // support /dir/ without explicit index.html
          rewrites: [
            {
              from: /\/$/,
              to({ parsedUrl }) {
                const rewritten = parsedUrl.pathname + 'index.html'
                if (fs.existsSync(path.join(root, rewritten))) {
                  return rewritten
                } else {
                  return `/index.html`
                }
              }
            }
          ]
        })
      )
    }
  
    // 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 (!middlewareMode || middlewareMode === 'html') {
      // 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))
  
    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) {
      // overwrite listen to run optimizer before server start
      const listen = httpServer.listen.bind(httpServer)
      httpServer.listen = (async (port, ...args) => {
        try {
          await container.buildStart({})
          await runOptimize()
        } catch (e) {
          httpServer.emit('error', e)
          return
        }
        return listen(port, ...args)
      }) 
  
      httpServer.once('listening', () => {
        // update actual port since this may be different from initial value
        serverConfig.port = (httpServer.address()).port
      })
    } else {
      await container.buildStart({})
      await runOptimize()
    }
  
    return server
  }
复制代码

代码很长,简单来说做了这几件事:

  • 创建一个服务器,用于作为一个静态服务器,响应应用的请求
  • 创建一个 webSocket,提供 HMR
  • 使用 chokidar 启用文件监听,并对文件修改进行处理
  • 插件处理
  • 监听句柄,如遇到停止信号则停止服务

启动服务器的作用

浏览器在访问在访问了 http://localhost:3000/ 后,得到了下面的内容:

<body>

  <di v id="app"></div>

  <script type="module" src="/src/main.js"></script>

</body>

复制代码

依据 ESM 规范在浏览器 script 标签中的实现,对于 <script type="module" src="./bar.js"></script> 内容:当出现 script 标签 type 属性为 module 时,浏览器会发出 HTTP 请求模块相应内容。经过 Vite Server 处理。

经过处理的 main.js

我们可以看到,经过 Vite Server 处理 http://localhost:3000/src/main.js 请求后,最终返回了上面图片的内容。不过这个内容和我们项目中的 ./src/main.js 有差别

源代码是这样

import { createApp } from 'vue'
import App from './App.vue'
import './index.css'

createApp(App).mount('#app')

复制代码

经过 Vite 后变成这样了

import { createApp } from '/@modules/vue.js'
import App from '/src/App.vue'
import '/src/index.css?import'

createApp(App).mount('#app')

复制代码

这里我们拆成两部分来看。

其中 import { createApp } from 'vue' 改为 import { createApp } from '/@modules/vue.js',原因很明显:import 对应的路径只支持 "/""./"或者 "../" 开头的内容,直接使用模块名 import,会立即报错。

所以在 Vite Server 处理请求时,通过 resolve 这个插件来给 import from 'A' 的 A 添加 /@module/ 前缀为 from '/@modules/A'源码部分对应

整个过程和调用链路较长,我对 Vite 处理 import 方法做一个简单总结:

  • 在 createServer 里获取请求 path 对应的 body 内容;

  • 通过 es-module-lexer 解析资源 AST,并拿到 import 的内容;

  • 如果判断 import 的资源是绝对路径,即可认为该资源为 npm 模块,并返回处理后的资源路径。比如上述代码中,vue → /@modules/vue

对于形如:import App from './App.vue'import './index.css' 的处理,与上述情况类似:

  • 在 createSercer 里获取请求 path 对应的 body 内容;

  • 通过 es-module-lexer 解析资源 AST,并拿到 import 的内容;

  • 如果判断 import 的资源是相对路径,即可认为该资源为项目应用中资源,并返回处理后的资源路径。比如上述代码中,./App.vue → /src/App.vue

接下来浏览器根据 main.js 的内容,分别请求:

/@modules/vue.js
/src/App.vue
/src/index.css?import
复制代码

对于 /@module/ 类请求较为容易,我们只需要完成下面三步:

  • 在 createServer 中间件里获取请求 path 对应的 body 内容;

  • 判断路径是否以 /@module/ 开头,如果是,取出包名(这里为 vue.js);

  • 去 node_modules 文件中找到对应的 npm 库,并返回内容。

上述步骤在 Vite 中使用 resolve 中间件实现。

接着,就是对 /src/App.vue 类请求进行处理,这就涉及 Vite 服务器的编译能力了。

我们先看结果,对比项目中的 App.vue,浏览器请求得到的结果显然出现了大变样:

image

实际上,App.vue 这样的单文件组件对应 script、styletemplate,在经过 Vite Server 处理时,服务端对 script、style 和 template 三部分分别处理,对应中间件为 @vitejs/plugin-vue。这个插件的实现很简单,即对 .vue 文件请求进行处理,通过 parseSFC 方法解析单文件组件,并通过 compileSFCMain 方法将单文件组件拆分为形如上图内容,对应中间件关键内容可在源码 vuePlugin 中找到。源码中,涉及 parseSFC 具体所做的事情,是调用 @vue/compiler-sfc 进行单文件组件解析。精简为我自己的逻辑,帮助你理解:

总的来说,每一个 .vue 单文件组件都被拆分成多个请求。比如对应上面场景,浏览器接收到 App.vue 对应的实际内容后,发出 HelloWorld.vue 以及 App.vue?type=template 的请求(通过 type 这个 query 来表示是 template 还是 style)。createServer 进行分别处理并返回,这些请求依然分别被上面提到的 @vitejs/plugin-vue 插件处理:对于 template 的请求,服务使用 @vue/compiler-dom 进行编译 template 并返回内容。

对于上面提到的 http://localhost:3000/src/index.css?import 请求稍微特殊,在 css 插件中, 通过 cssPostPlugin 对象的 transform 来实现解析:

    transform(css, id, ssr) {
      if (!cssLangRE.test(id) || commonjsProxyRE.test(id)) {
        return
      }

      const modules = cssModulesCache.get(config)!.get(id)
      const modulesCode =
        modules && dataToEsm(modules, { namedExports: true, preferConst: true })

      if (config.command === 'serve') {
        if (isDirectCSSRequest(id)) {
          return css
        } else {
          // server only
          if (ssr) {
            return modulesCode || `export default ${JSON.stringify(css)}`
          }
          return [
            `import { updateStyle, removeStyle } from ${JSON.stringify(
              path.posix.join(config.base, CLIENT_PUBLIC_PATH)
            )}`,
            `const id = ${JSON.stringify(id)}`,
            `const css = ${JSON.stringify(css)}`,
            `updateStyle(id, css)`,
            // css modules exports change on edit so it can't self accept
            `${modulesCode || `import.meta.hot.accept()\nexport default css`}`,
            `import.meta.hot.prune(() => removeStyle(id))`
          ].join('\n')
        }
      }

      // build CSS handling ----------------------------------------------------

      // record css
      styles.set(id, css)

      return {
        code: modulesCode || `export default ${JSON.stringify(css)}`,
        map: { mappings: '' },
        // avoid the css module from being tree-shaken so that we can retrieve
        // it in renderChunk()
        moduleSideEffects: 'no-treeshake'
      }
    },
复制代码

调用 cssPostPlugin 中的 transform 方法:

该方法会在浏览器中执行 updateStyle 方法,就像是 http://localhost:3000/src/index.css?import 的源码如下:

import { createHotContext as __vite__createHotContext } from "/@vite/client";import.meta.hot = __vite__createHotContext("/src/components/HelloWorld.vue?vue&type=style&index=0&scoped=true&lang.css");import { updateStyle, removeStyle } from "/@vite/client"
const id = "/Users/study/vite-app/src/components/HelloWorld.vue?vue&type=style&index=0&scoped=true&lang.css"
const css = "\nh1[data-v-469af010] {\n   font-size:18px;\n}\n"
updateStyle(id, css)
import.meta.hot.accept()
export default css
import.meta.hot.prune(() => removeStyle(id))
复制代码

最终完成在浏览器中插入样式。

至此,我们解析并列举了较多源码内容。以上内容需要跟着思路,一步步梳理,也强烈建议你打开 Vite 源码自己动手剖析。如果看到这里你仍然也有些“云里雾里”,不要心急,结合我下面这个图示,再次进行阅读,相信会更有收获。

和 webpack 对比

webpack bundleless 的思路

webpack 思路

打包思路

Vite bundleless 的思路:

Vite 的 思路

打包思路

总结

  • Vite 利用浏览器原生支持 ESM 这一特性,省略了对模块的打包,也就不需要生成 bundle,因此初次启动更快,HMR 特性友好。

  • Vite 开发模式下,通过启动 Node 服务器,在服务端完成模块的改写(比如单文件的解析编译等)和请求处理,实现真正的按需编译。

  • Vite Server 所有逻辑基本都依赖中间件实现。这些中间件,拦截请求之后,完成了如下内容:

    • 处理 ESM 语法,比如将业务代码中的 import 第三方依赖路径转为浏览器可识别的依赖路径;

    • 对 .ts、.vue 等文件进行即时编译;

    • 对 Sass/Less 的需要预编译的模块进行编译;

    • 和浏览器端建立 socket 连接,实现 HMR。

文章分类
前端
文章标签