下一代前端构建工具 - Vite 2.x 源码级分析

avatar
前端工程师 @公众号:ELab团队

业务背景

笔者所负责的业务 X (之后都以 “X 项目” 代表此业务)对接众多业务线,如果在业务线不断增多,而人员又无法快速补充的前提下,有必要给开发提效。

为何要给开发提效?

说到这里,有人就会问了,我 Webpack 开项目 “只要几十秒” 就能开起来,它不香吗?就算我项目再大,改一行代码热更新好几秒我也能忍受啊,大家不都是这么过来的吗?

我。。。当场就祭出下面这张图:

当然,公说公有理婆说婆有理,那么我就来讲讲我的道理。

笔者每周会对接很多的需求,需求池爆棚的同时,排期表也是层峦叠嶂的。。。

有时候一堆需求突然要一起上线,而且很急,为了稳妥,你需要事先测试一下有没有问题,然后才能 PR/跑流水线啥的;还有的时候,你突然来了点小想法想要做个技术优化,这个时候,打开 X 项目,在命令行开启项目,然后看着下面这张图,心都凉了:

1.2021-06-30 17_28_36.gif

不仅开的巨慢,而且 ctrl+c 还关不掉。。。

然后你改小小的一行,变成了这样:

2.2021-06-30 17_29_43.gif

当然你可以跟我说,这是你优化的问题,只要我 node 版本更新/多线程/拆包。。(这里可以 Google 搜:如何优化 Webpack 打包速度

但是我想让你静下心来看看下面的画面:

3.2021-06-30 17_30_44.gif

上述项目是使用 Vite 重构之后的 X 项目,同样多的文件,同样多的依赖,可以看到第一次 2000+ ms,之后都是 600+ ms 就可以跑起来,而且即开即关,没有心理负担,快到飞起,DX 无敌。

Vite 是什么?

好,讲完了业务背景之后,我们再来看看我们今天的猪脚:“Vite”,为什么会有它,它是个啥,又做了啥,业务项目现在可以用吗?生态如何?

先找个知乎截个图:

再搬一下 Github 的仓库图:

上面的知乎、Github 链接都可打开,感兴趣的同学可以自己去看看。

接下来来回答一下这一节开篇提出的几个问题。

为什么会有 Vite ?

我想最直观的回答就是:“程序员都爱折腾吧”。

但其实是,任何一个工具/产品的诞生并流行,其实都和当下所处的时机有关。

而出发 Vite 诞生的几个必要条件我总结如下:

  1. 传统的构建工具如 Webpack/Rollup/Parcel 等都太慢了,动辄十几秒、几十秒的
  2. ES 标准普及越来越快,现代浏览器支持了原生的 ES Module 使用,类似这样的语法 <script type="module" src="/src/main.js" />,就可以在 main.js 里面使用标准的 ES 模块语法如 import/export 等,然后浏览器会根据 main.jsimport 依赖,自动发起 http 请求依赖的模块,可以通过下面这个视频直观的看出来:

4.2021-06-30 17_33_00.gif

  1. 跨语言(基于 Go)、更快的构建工具已经诞生并趋于成熟,最直观的就是 esbuild

可以看 esbuild 给出的 benchmark 表:

最直观的对比,相比第二名的 rollup + terser ,提升了约 100 倍,不讲武德。。。

  1. 当然第四点,也是一个比较致命的一点,已经有个现成的模板可以 “抄”,比如 snowpack 🌚,它同时是去年 2020 年 JavaScript 最具创新力的打包工具:

但是 snowpack 开发生产是一致的,都使用 esbuild + Browser Native ESM 的概念,这就导致很多浏览器尤其是低版本的 IE 是不支持这些最新的 ES 和浏览器特性的,压根就用不了,相对使用场景就比较有限,明显是一个超前于市场的产品。

而尤大最厉害的一点就是,我开发用 snowpack 的那套概念,生产打包用 Rollup 来做,还搞个开发时的插件机制也兼容 Rollup,这就厉害了,开发很快,生产也照样可以普及的用,这个应用场景就很大了,自然获得了更多的追捧,这从 Github 的 Star 就可以看出来:

snowpack

Vite

而在 2 个多月前,Vite 的 Star 还没有 snowpack 高。。。

Vite 是啥?

上面已经提到了,Vite 是一个基于浏览器原生 ESM 的开发服务器/打包工具等,特点就是一个字 “快”,用尤大的话说就是:

Vite 有多快?在 Repl.it 上从零启动一个基于 Vite 的 React 应用,浏览器页面加载完毕的时候,CRA(create-react-app)甚至还没有装完依赖。

至于 Vite 做了啥,生态如何,在业务项目中是否可以使用,我留一点悬念,留在后续讲解。(避免你看到这里就不看了,我的重头戏还没来呢。。。)

“快” 背后运行的原理是什么?(Vite 做了啥)

先把调试环境搭好

好了,说了这么多可能没什么体感,有些人可能就不满了,你个技术分享,搞了半天没有一行代码。。。

Talk is cheap,show me the code!

讲解一门技术最后的方式就是 “Learn by doing”,下面我们就以这种方式来讲解 Vite 源码。

首先初始化一个 Vite 项目:

yarn create @vitejs/app app-vue2 # 选中模板为 Vue,语言为 Javascript

cd app-vue2 && yarn

yarn dev

闪电起项目 ⚡️,不要几十秒,也不用几秒,只需 851ms,只需 851ms!

我们先将 Vite 源码拷贝到本地,然后在 app-vue2 项目中 link vite 依赖,开始调试源码:

git clone git@github.com:vitejs/vite.git

cd vite && yarn

yarn build # 构建 vite 包

cd packages/vite && yarn link

接着去到 app-vue2 下面,关闭服务器,然后执行如下命令:

yarn link vite

yarn dev

打开浏览器可以看到如下界面:

可以看到,我们的 network 面板里面加载了如下几个模块:

  1. localhost
  2. client
  3. main.js
  4. env.js
  5. vue.js?v=92bdfa16
  6. App.vue
  7. HelloWorld.vue
  8. App.vue?vue&type=style&index=0&lang.css
  9. HelloWorld.vue?vue&type=style&index=0&scoped=true&lang.css
  10. logo.png

对应到我们源码里面就是如下这几块:

上面的 10 个请求可以分为以下几类:

  1. Html 代码,对应 localhost
  2. Vite 相关的代码:client ,以及 client 里面引入的 env.js
  3. 用户侧 JS 相关的代码:main.jsApp.vueHelloWorld.vue
  4. NPM 依赖相关的代码:vue.js?v=92bdfa16
  5. CSS 相关的代码:App.vue?vue&type=style&index=0&lang.cssHelloWorld.vue?vue&type=style&index=0&scoped=true&lang.css

当然你可以使用 TS、Less/Sass 诸如此类的,但是为了讲解需要,上面的内容基本是最简单也相对比较全面的了。

从上面的内容我们可以看出来,Vite 相比 Webpack/Rollup 等主要的区别如下:

  1. 起服务快
  2. 并非打包成单一的 xxx.js 文件,然后 html 文件引入使用,而是基本保持和开发时的目录结构和引用关系一致,借助浏览器对 ES Module 的支持,按需引用

接下来我们就着重从源码的角度分析这两点不同!

从 CLI 入口开始

故事的起点要从我们 app-vue2yarn dev 脚本开始说起,yarn dev 实际上就是允许了 vite 命令,而 vite 命令对应到 Vite 源码中的如下位置:

packages/vite/src/node/cli.ts

cli

  .command('[root]') // default command

  .alias('serve')

  .option('--host <host>', `[string] specify hostname`)

  // ... options

  .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)

    }

  })

可以看到主要就是一个基于 cac 的命令行命令,主要的过程就是从 ./server 中导入 createServer ,然后创建一个 server ,接着允许服务并监听端口,默认为 3000 。

一个 “简单” 的服务器

接下来再看看 server 文件中做了什么事情,主题逻辑代码如下:

packages/vite/src/node/server/index.ts

github.com/vitejs/vite…

export async function createServer(

  inlineConfig: InlineConfig = {}

): Promise<ViteDevServer> {

  const config = await resolveConfig(inlineConfig, 'serve', 'development')



  // ...



  const middlewares = connect() as Connect.Server

  const httpServer = middlewareMode

    ? null

    : await resolveHttpServer(serverConfig, middlewares)



  // ...



  const plugins = config.plugins

  const container = await createPluginContainer(config, watcher)

  const moduleGraph = new ModuleGraph(container)



  // ...



  const server: ViteDevServer = {

    config: config,

    middlewares,

    get app() {

      config.logger.warn(

        `ViteDevServer.app is deprecated. Use ViteDevServer.middlewares instead.`

      )

      return middlewares

    },

    httpServer,

    pluginContainer: container,

    moduleGraph,

    transformWithEsbuild,

    transformRequest(url, options) {

      return transformRequest(url, server, options)

    },

    transformIndexHtml: null as any,

    listen(port?: number, isRestart?: boolean) {

      return startServer(server, port, isRestart)

    },

    _optimizeDepsMetadata: null,

    _ssrExternals: null,

    _globImporters: {},

    _isRunningOptimizer: false,

    _registerMissingImport: null,

    _pendingReload: null

  }



  server.transformIndexHtml = createDevHtmlTransformFn(server)



  // apply server configuration hooks from plugins

  const postHooks: ((() => void) | void)[] = []

  for (const plugin of plugins) {

    if (plugin.configureServer) {

      postHooks.push(await plugin.configureServer(server))

    }

  }



  // ...



  // main transform middleware

  middlewares.use(transformMiddleware(server))



  // ...



  // spa fallback

  if (!middlewareMode) {

    middlewares.use(

      history({

        logger: createDebugger('vite:spa-fallback'),

        // support /dir/ without explicit index.html

        rewrites: [

          {

            from: // $ /,

            to({ parsedUrl }: any) {

              createLogger('info').info(`rewrite: ${JSON.stringify(parsedUrl)}`)

              const rewritten = parsedUrl.pathname + 'index.html'

              if (fs.existsSync(path.join(root, rewritten))) {

                return rewritten

              } else {

                return `/index.html`

              }

            }

          }

        ]

      })

    )

  }



  // ...



  if (!middlewareMode) {

    // transform index.html

    middlewares.use(indexHtmlMiddleware(server))

    // handle 404s

    middlewares.use((_, 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)

    }

  }



  // ...



  // overwrite listen to run optimizer before server start

    const listen = httpServer.listen.bind(httpServer)

    httpServer.listen = (async (port: number, ...args: any[]) => {

      try {

        await container.buildStart({})

        await runOptimize()

      } catch (e) {

        httpServer.emit('error', e)

        return

      }

      return listen(port, ...args)

    }) as any



    httpServer.once('listening', () => {

      // update actual port since this may be different from initial value

      serverConfig.port = (httpServer.address() as AddressInfo).port

    })



    // ...



  return server

}

创建 server 的主体逻辑见上面的文件,主要做了如下几件事:

  1. 获取在起服务时需要的 config 配置,所有的配置内容都是在 resolveConfig 这个函数里面处理的,包括 plugins 用户插件和内建插件、cacheDirnpm 依赖预构建之后的缓存目录、在之后浏览器按需获取文件时对请求进行截获,返回相对应内容的处理函数 createResolve ,以及定义在 vite.config.js 里面的 resolve ,包含用户自定义的一些 alias 文件的处理等。
const config = await resolveConfig(inlineConfig, "serve", "development");
  1. 初始化 connect 框架生成 app 实例、传给 http.createServer 生成 httpServer,然后注册一系列中间件用于处理浏览器请求,包括对 /js/css/vue 的请求等:

    1. 主要使用 sirv 这个包,将 /public 变为静态资源目录的 servePublicMiddleware 中间件,可以通过 http://localhost:3000/public/xxx 获取 public 目录下的 xxx 文件
    2. 用于处理 js/css/vue 等请求,并返回转换后的代码的 transformMiddleware 中间件
    3. 用于处理 / ,并重定向到 /index.htmlhistory 中间件
const middlewares = connect() as Connect.Server

const httpServer = middlewareMode

    ? null

    : await resolveHttpServer(serverConfig, middlewares)



// ...

middlewares.use(servePublicMiddleware(config.publicDir))



 // main transform middleware

middlewares.use(transformMiddleware(server))



// ...

middlewares.use(

  history({

    logger: createDebugger('vite:spa-fallback'),

    // support /dir/ without explicit index.html

    rewrites: [

      {

        from: // $ /,

        to({ parsedUrl }: any) {

          createLogger('info').info(`rewrite: ${JSON.stringify(parsedUrl)}`)

          const rewritten = parsedUrl.pathname + 'index.html'

          if (fs.existsSync(path.join(root, rewritten))) {

            return rewritten

          } else {

            return `/index.html`

          }

        }

      }

    ]

  })

)
  1. 用于处理插件的 container ,它是由 createPluginContainer 来创建,以及用于构建模块依赖图的 moduleGraph ,它是由 new ModuleGraph(container) 创建,这两个函数将在之后讲解:
const container = await createPluginContainer(config, watcher);

// ...

const moduleGraph = new ModuleGraph(container);
  1. 用于对 html 进行转换,注入一些脚本的 transformIndexHtml 函数,它由 createDevHtmlTransformFn 函数创建,它将会在 indexHtmlMiddleware 中间件执行的过程中运行 createDevHtmlTransformFn 函数中添加的 devHtmlHook ,在 html 文件中注入我们在 localhost network 面板中看到的 <script type="module" src="/@vite/client"></script> 脚本,运行 vite 相关的 client 脚本内容。
server.transformIndexHtml = createDevHtmlTransformFn(server);

// ...

middlewares.use(indexHtmlMiddleware(server));
  1. 用于进行依赖预构建的优化函数 runOptimize ,用于将 npm 依赖以及用户指定的需要缓存的依赖进行打包,并缓存在 node_modules/.vite 目录下,针对这些文件的 http 请求都将添加缓存。
const runOptimize = async () => {

    if (config.cacheDir) {

      server._isRunningOptimizer = true

      try {

        server._optimizeDepsMetadata = await optimizeDeps(config)

      } finally {

        server._isRunningOptimizer = false

      }

      server._registerMissingImport = createMissingImporterRegisterFn(server)

    }

}



 // overwrite listen to run optimizer before server start

const listen = httpServer.listen.bind(httpServer)

httpServer.listen = (async (port: number, ...args: any[]) => {

  try {

    await container.buildStart({})

    await runOptimize()

  } catch (e) {

    httpServer.emit('error', e)

    return

  }

  return listen(port, ...args)

}) as any



httpServer.once('listening', () => {

  // update actual port since this may be different from initial value

  serverConfig.port = (httpServer.address() as AddressInfo).port

})

cli 中调用 server.listen() 后,会首先执行 container.buildStart({}) 调用所有注册插件的 buildStart 钩子函数,然后运行 runOptimize 依赖预构建函数,最后是监听端口,接收来自浏览器的请求。

传说中的依赖预构建

从上面的整体代码我们可以看到,在开启服务,监听端口接收来自浏览器的请求之前,会运行插件 containerbuildStart 钩子,进而运行所有插件的 buildStart 钩子,以及进行依赖预构建,运行 runOptimize 函数。

可以看到整个运行 Node 服务的生命周期中,都是一些基本不怎么耗时的收集 config 、注册各种中间件、初始化一些之后会用到的插件容器 container 以及模块依赖图 moduleGraph 等,其中最耗时的就是依赖预构建了,它主要将所有的 npm 依赖构建成单一的可缓存文件,也是 Vite 服务开启过程中的一个最大的时间瓶颈,因为 Vite 针对用户项目中的各种文件都是不做打包处理的,而是在浏览器运行时按需请求,并进行转换处理。

这可以看做是 Vite 在极致的起服务速度和极慢的浏览器 “首屏出图” 速度之间的一个权衡,而极慢的浏览器 “首屏出图” 速度则是我会在后文提到的 Vite 有什么 “不好” 的内容之一。

下面就来分析一下这个神奇的预构建过程。

const runOptimize = async () => {
  if (config.cacheDir) {
    server._isRunningOptimizer = true;

    try {
      server._optimizeDepsMetadata = await optimizeDeps(config);
    } finally {
      server._isRunningOptimizer = false;
    }

    server._registerMissingImport = createMissingImporterRegisterFn(server);
  }
};

可以看到函数体,主要是执行 optimizeDeps 函数,返回依赖预构建之后的元数据,用于索引构建之后的文件,以及映射构建前后的文件路径,然后注册 _registerMissingImport ,用于在项目运行过程中添加新的 npm 依赖时,也能预构建到缓存目录 node_modules/.vite 下。

下面分析一下 optimizeDeps 函数:

packages/vite/src/node/optimizer/index.ts

github.com/vitejs/vite…

import { esbuildDepPlugin } from './esbuildDepPlugin'

import { ImportSpecifier, init, parse } from 'es-module-lexer'

import { scanImports } from './scan'



export async function optimizeDeps(

  config: ResolvedConfig,

  force = config.server.force,

  asCommand = false,

  newDeps?: Record<string, string> // missing imports encountered after server has started

): Promise<DepOptimizationMetadata | null> {

  config = {

    ...config,

    command: 'build'

  }



  const { root, logger, cacheDir } = config

  const log = asCommand ? logger.info : debug



  if (!cacheDir) {

    log(`No cache directory. Skipping.`)

    return 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

    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 })

  }



  let deps: Record<string, string>, missing: Record<string, string>

  if (!newDeps) {

    ;({ deps, missing } = await scanImports(config))

  } else {

    deps = newDeps

    missing = {}

  }



  // update browser hash

  data.browserHash = createHash('sha256')

    .update(data.hash + JSON.stringify(deps))

    .digest('hex')

    .substr(0, 8)



  const include = config.optimizeDeps?.include

  if (include) {

    const resolve = config.createResolver({ asSrc: false })

    for (const id of include) {

      if (!deps[id]) {

        const entry = await resolve(id)

        if (entry) {

          deps[id] = entry

        } else {

          throw new Error(

            `Failed to resolve force included dependency: ${chalk.cyan(id)}`

          )

        }

      }

    }

  }



  const qualifiedIds = Object.keys(deps)



  if (!qualifiedIds.length) {

    writeFile(dataPath, JSON.stringify(data, null, 2))

    log(`No dependencies to bundle. Skipping.\n\n\n`)

    return data

  }



  const total = qualifiedIds.length

  const maxListed = 5

  const listed = Math.min(total, maxListed)

  const extra = Math.max(0, total - maxListed)

  const depsString = chalk.yellow(

    qualifiedIds.slice(0, listed).join(`\n  `) +

      (extra > 0 ? `\n  (...and ${extra} more)` : ``)

  )



  const flatIdDeps: Record<string, string> = {}

  const idToExports: Record<string, ExportsData> = {}

  const flatIdToExports: Record<string, ExportsData> = {}



  await init

  for (const id in deps) {

    const flatId = flattenId(id)

    flatIdDeps[flatId] = deps[id]

    const entryContent = fs.readFileSync(deps[id], 'utf-8')

    const exportsData = parse(entryContent) as ExportsData

    for (const { ss, se } of exportsData[0]) {

      const exp = entryContent.slice(ss, se)

      if (/export\s+*\s+from/.test(exp)) {

        exportsData.hasReExports = true

      }

    }

    idToExports[id] = exportsData

    flatIdToExports[flatId] = exportsData

  }



  const define: Record<string, string> = {

    'process.env.NODE_ENV': JSON.stringify(config.mode)

  }

  for (const key in config.define) {

    const value = config.define[key]

    define[key] = typeof value === 'string' ? value : JSON.stringify(value)

  }



  const start = Date.now()



  const result = await build({

    entryPoints: Object.keys(flatIdDeps),

    bundle: true,

    keepNames: config.optimizeDeps?.keepNames,

    format: 'esm',

    external: config.optimizeDeps?.exclude,

    logLevel: 'error',

    splitting: true,

    sourcemap: true,

    outdir: cacheDir,

    treeShaking: 'ignore-annotations',

    metafile: true,

    define,

    plugins: [esbuildDepPlugin(flatIdDeps, flatIdToExports, config)]

  })



  const meta = result.metafile!



  createLogger('info').info(`${JSON.stringify(meta)}`)



  // 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

      )

    }

  }



  writeFile(dataPath, JSON.stringify(data, null, 2))



  return data

}

可以看到,上面的函数主要做了这么几件事情:

  1. 接收 config,然后 data,形如 { hash, browserHash, optimized } ,其中 browserHash 主要用于浏览器获取预构建的 npm 依赖时,添加的查询字符串,用于在依赖变化时,浏览器能更新缓存,也就是我们之前看到的 vue.js?v=92bdfa16 ,这个 92bdfa16 ,主要在处理浏览器请求时,调用 resolvePlugin 时,运行 tryNodeResolve 函数对 npm 依赖的请求添加这个 browserHashoptimized 则是形如 npmDep: { file, src, needsInterop } 的键值对,比如 vue 依赖,则是如下内容:
"vue": {

  "file": "/Users/bytedance/Projectes/my-projects/learning/vite/app-vue2/node_modules/.vite/vue.js",

  "src": "/Users/bytedance/Projectes/my-projects/learning/vite/app-vue2/node_modules/vue/dist/vue.runtime.esm-bundler.js",

  "needsInterop": false

}
  1. 如果 node_modules/.vite/_metadata.json 文件存在,且 hash 相同,则表示已经构建过了,并且没有更新,则直接返回 prevData
const dataPath = path.join(cacheDir, "_metadata.json");

const mainHash = getDepHash(root, config);

const data: DepOptimizationMetadata = {
  hash: mainHash,

  browserHash: mainHash,

  optimized: {},
};

if (!force) {
  let prevData;

  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;
  }
}
  1. 通过 scanImports 找出需要依赖预构建的依赖,结合用户定义的需要处理的依赖 config.optimizeDeps?.include ,deps 是一个对象,是依赖名到其在文件系统中的路径的映射如:{ vue: '/Users/bytedance/Projectes/my-projects/learning/vite/app-vue2/node_modules/vue/dist/vue.runtime.esm-bundler.js' }
let deps: Record<string, string>, missing: Record<string, string>;

if (!newDeps) {
  ({ deps, missing } = await scanImports(config));
} else {
  deps = newDeps;

  missing = {};
}

// ...

const include = config.optimizeDeps?.include;

if (include) {
  const resolve = config.createResolver({ asSrc: false });

  for (const id of include) {
    if (!deps[id]) {
      const entry = await resolve(id);

      if (entry) {
        deps[id] = entry;
      } else {
        throw new Error(
          `Failed to resolve force included dependency: ${chalk.cyan(id)}`
        );
      }
    }
  }
}
  1. 使用 es-module-lexerparse 处理依赖的代码,读取其中的 exportsData ,并完成依赖 id(文件路径)到 exportsData 的映射,用于之后 esbuild 构建时进行依赖图分析并打包到一个文件里面,其中 exportsData 为这个文件里引入的模块 imports 和导出的模块 exports
const flatIdDeps: Record<string, string> = {}

  const idToExports: Record<string, ExportsData> = {}

  const flatIdToExports: Record<string, ExportsData> = {}



  await init

  for (const id in deps) {

    const flatId = flattenId(id)

    flatIdDeps[flatId] = deps[id]

    const entryContent = fs.readFileSync(deps[id], 'utf-8')

    const exportsData = parse(entryContent) as ExportsData

    for (const { ss, se } of exportsData[0]) {

      const exp = entryContent.slice(ss, se)

      if (/export\s+*\s+from/.test(exp)) {

        exportsData.hasReExports = true

      }

    }

    idToExports[id] = exportsData

    flatIdToExports[flatId] = exportsData

  }

举个 es-module-lexer 例子。

  1. 使用 esbuild 进行依赖的预构建,并将构建之后的文件写入缓存目录:node_modules/.vite ,得益于 esbuild 比传统构建工具快 10-100 倍的速度,所以依赖预构建也是非常快的,且一次构建之后,后续可以缓存;

build 构建函数传入用户 vite.config.js define 定义的环境变量,需要进行依赖预构建的文件入口 Object.keys(flatIdDeps) 等, 以及处理依赖的 esbuild 插件 esbuildDepPlugin ,这个插件主要做了以下三件事:

  1. 主要用于处理某个依赖文件及其依赖图,转换 mjs|ts|jsx|tsx|svelte|vue 等文件成为 js 代码,less|sass|scss|styl 等文件成为 css ,前提是使用了相关的插件,其中 mjs|ts|jsx|tsx 等是默认支持的
  2. 将某个依赖的依赖图中的文件统一打包到一个 esm 文件中,如 vue 依赖,打包成一个 vue.js ,或者 lodash 依赖,打包成一个 lodash-es 文件,减少 http 请求数量

一个比较直观的例子就是,当我们直接使用 import { debounce } from "lodash-es" 时,浏览器会导入 600+ 文件,大概需要 1 s 多:

而经过依赖预构建之后,浏览器只需要导入一个文件,且只需 20 ms :

  1. 处理一些不兼容模块 commonjs 模块等,将它们打包成 esm 文件,比如 react 的包,使得浏览器可以使用
const define: Record<string, string> = {
  "process.env.NODE_ENV": JSON.stringify(config.mode),
};

for (const key in config.define) {
  const value = config.define[key];

  define[key] = typeof value === "string" ? value : JSON.stringify(value);
}

const start = Date.now();

const result = await build({
  entryPoints: Object.keys(flatIdDeps),

  bundle: true,

  keepNames: config.optimizeDeps?.keepNames,

  format: "esm",

  external: config.optimizeDeps?.exclude,

  logLevel: "error",

  splitting: true,

  sourcemap: true,

  outdir: cacheDir,

  treeShaking: "ignore-annotations",

  metafile: true,

  define,

  plugins: [esbuildDepPlugin(flatIdDeps, flatIdToExports, config)],
});
  1. 进行依赖预构建并写入到缓存目录之后,最后就是补充 data.optimized 内容,并将内容写入到缓存目录下的 _metadata.json 用于之后进行依赖获取和走构建缓存等:
const meta = result.metafile!



  createLogger('info').info(`${JSON.stringify(meta)}`)



  // 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

      )

    }

  }



  writeFile(dataPath, JSON.stringify(data, null, 2))

其中 needsInterop 为记录那些在依赖预构建时,使用了 commonjs 语法的依赖,如果使用了 commonjs ,那么 needsInteroptrue ,这个属性主要用于在浏览器请求对应的依赖时(构建前是 commonjs 形式),Vite 的 importAnalysisPlugin 插件会进行依赖性导入分析,使用 transformCjsImport 函数,它会对需要预编译且为 CommonJS 的依赖导入代码进行重写。举个例子,当我们在 Vite 项目中使用 react 时:

import React, { useState, createContext } from "react";

此时 React 的导入就是 needsInterop 为 true,所以 importAnalysisPlugin 插件的会对导入 React 的代码进行重写:

import $viteCjsImport1_react from "/@modules/react.js";

const React = $viteCjsImport1_react;

const useState = $viteCjsImport1_react["useState"];

const createContext = $viteCjsImport1_react["createContext"];

之所以要进行重写的缘由是因为 CommonJS 的模块并不支持命名方式的导出,即没有 exports xxx 这样的语法,只有 exports.xxx。所以,如果不经过插件的转化,则会看到这样的异常:

Uncaught SyntaxError: The requested module '/@modules/react.js' does not provide an export named 'useState'

最后将 data 写入的路径为 node_modules/.vite/_metadata.json ,内容如下:

{

  "hash": "cd74d918",

  "browserHash": "92bdfa16",

  "optimized": {

    "vue": {

      "file": "/Users/bytedance/Projectes/my-projects/learning/vite/app-vue2/node_modules/.vite/vue.js",

      "src": "/Users/bytedance/Projectes/my-projects/learning/vite/app-vue2/node_modules/vue/dist/vue.runtime.esm-bundler.js",

      "needsInterop": false

    }

  }

}

依赖预构建总结

经过上面的分析,我们可以总结依赖预构建的几点特点:

  1. 在快速起服务和浏览器首屏出图直接的一个取舍,而得益与 esbuild 的快速构建,使得起服务快的同时,浏览器首屏出图也快,而且可以进行缓存
  2. 使得可以使用一些 (j|t)sx? /vue /svelte 的包成为可能
  3. 针对 commonjs 等也可以进行转换使用

所以 Vite 并不是一个纯的 bundless 工具,或者说构建/编译几乎是不可或缺的内容。

一个请求的 Vite 之旅

GET localhost

实际 GET / => /index.html

讲完依赖预构建,接下来我们可以放心的讲解一个基于 Vite 的 Vue 项目的运行过程,也就是我们在 network 面板里面看到的那些请求,以及它们与项目目录里面的对应关系。

首先我们知道,在 createServer 中注册了 history 中间件,针对 / 请求,会重定向到 /index.html,重定向之后的请求则会激活 indexHtmlMiddleware 中间件的处理:

packages/vite/src/node/server/middlewares/indexHtml.ts 下的 indexHtmlMiddleware 函数内容:

export function indexHtmlMiddleware(
  server: ViteDevServer
): Connect.NextHandleFunction {
  return async (req, res, 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") {
      createLogger("info").info(`html middleware`);

      const filename = getHtmlFilename(url, server);

      if (fs.existsSync(filename)) {
        try {
          let html = fs.readFileSync(filename, "utf-8");

          // 这里调用 transformIndexHtml

          html = await server.transformIndexHtml(url, html);

          return send(req, res, html, "html");
        } catch (e) {
          return next(e);
        }
      }
    }

    next();
  };
}

上面函数会调用 transformIndexHtml ,然后执行 packages/vite/src/node/plugins/html.ts 下的 applyHtmlTransforms 函数,执行用于给 html 注入内容的 hooks 如 [...preHooks, devHtmlHook, ...postHooks],并在 html 文件的 headbody 标签前后插入脚本。

其中 devHtmlHook 主要做的事情就是在 html 文件头部注入 <script type="module" src="/@vite/client"></script> 脚本,也就是我们看到的第一个请求 localhost 返回的内容:

devHtmlHook 则是在 server 中调用 createDevHtmlTransformFn 函数时注入的 Hooks,在 packages/vite/src/node/server/middlewares/indexHtml.ts 下的 createDevHtmlTransformFn 函数内容:

export function createDevHtmlTransformFn(

  server: ViteDevServer

): (url: string, html: string) => Promise<string> {

  const [preHooks, postHooks] = resolveHtmlTransforms(server.config.plugins)



  return (url: string, html: string): Promise<string> => {

    return applyHtmlTransforms(

      html,

      url,

      getHtmlFilename(url, server),

      [...preHooks, devHtmlHook, ...postHooks],

      server

    )

  }

}



// devHtmlHook 函数

const devHtmlHook: IndexHtmlTransformHook = async (

  html,

  { path: htmlPath, server }

) => {

  // TODO: solve this design issue

  // Optional chain expressions can return undefined by design

  // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain

  const config = server?.config!

  const base = config.base || '/'



  const s = new MagicString(html)

  let scriptModuleIndex = -1



  await traverseHtml(html, htmlPath, (node) => {

    if (node.type !== NodeTypes.ELEMENT) {

      return

    }



        // ...



      html = s.toString()



      return {

        html,

        tags: [

          {

            tag: 'script',

            attrs: {

              type: 'module',

              // 这里注入 /@vite/client 脚本

              src: path.posix.join(base, CLIENT_PUBLIC_PATH)

            },

            injectTo: 'head-prepend'

          }

        ]

      }

    }

GET client

实际 GET /@vite/client

首先会走 transformMiddleware

export function transformMiddleware(

  server: ViteDevServer

): Connect.NextHandleFunction {

  const {

    config: { root, logger, cacheDir },

    moduleGraph

  } = server



  // determine the url prefix of files inside cache directory

  let cacheDirPrefix: string | undefined

  if (cacheDir) {

    const cacheDirRelative = normalizePath(path.relative(root, cacheDir))

    if (cacheDirRelative.startsWith('../')) {

      // if the cache directory is outside root, the url prefix would be something

      // like '/@fs/absolute/path/to/node_modules/.vite'

      cacheDirPrefix = `/@fs/${normalizePath(cacheDir).replace(/ ^ //, '')}`

    } else {

      // if the cache directory is inside root, the url prefix would be something

      // like '/node_modules/.vite'

      cacheDirPrefix = `/${cacheDirRelative}`

    }

  }



  return async (req, res, next) => {

    if (req.method !== 'GET' || knownIgnoreList.has(req.url!)) {

      return next()

    }



    const withoutQuery = cleanUrl(url)



      if (

        isJSRequest(url) ||

        isImportRequest(url) ||

        isCSSRequest(url) ||

        isHTMLProxy(url)

      ) {

        // strip ?import

        url = removeImportQuery(url)

        // Strip valid id prefix. This is prepended to resolved Ids that are

        // not valid browser import specifiers by the importAnalysis plugin.

        url = unwrapId(url)



        // for CSS, we need to differentiate between normal CSS requests and

        // imports

        if (isCSSRequest(url) && req.headers.accept?.includes('text/css')) {

          url = injectQuery(url, 'direct')

        }



        // check if we can return 304 early

        const ifNoneMatch = req.headers['if-none-match']

        if (

          ifNoneMatch &&

          (await moduleGraph.getModuleByUrl(url))?.transformResult?.etag ===

            ifNoneMatch

        ) {

          isDebug && debugCache(`[304] ${prettifyUrl(url, root)}`)

          res.statusCode = 304

          return res.end()

        }



        // resolve, load and transform using the plugin container

        const result = await transformRequest(url, server, {

          html: req.headers.accept?.includes('text/html')

        })

        if (result) {

          const type = isDirectCSSRequest(url) ? 'css' : 'js'

          const isDep =

            DEP_VERSION_RE.test(url) ||

            (cacheDirPrefix && url.startsWith(cacheDirPrefix))

          return send(

            req,

            res,

            result.code,

            type,

            result.etag,

            // allow browser to cache npm deps!

            isDep ? 'max-age=31536000,immutable' : 'no-cache',

            result.map

          )

        }

      }

    } catch (e) {

      return next(e)

    }



    next()

  }

}

会命中 isJSRequest(url) 逻辑,进入中间件的处理过程:

  1. 对 url 进行 transformRequest,主要的逻辑为通过 pluginContainer.resolveId 获取到实际的文件位置 id ,然后根据这个位置,使用 pluginContainer.load 来获取对应的文件内容,如果文件内容并非浏览器可以直接使用的 esm 内容,那么就需要 pluginContainer.transform 进行文件内容的转换,最后返回转换后的 codemap 以及 etag,用于缓存。
export async function transformRequest(

  url: string,

  { config, pluginContainer, moduleGraph, watcher }: ViteDevServer,

  options: TransformOptions = {}

): Promise<TransformResult | null> {

  url = removeTimestampQuery(url)

  const { root, logger } = config

  const prettyUrl = isDebug ? prettifyUrl(url, root) : ''

  const ssr = !!options.ssr



  // resolve

  const id = (await pluginContainer.resolveId(url))?.id || url

  const file = cleanUrl(id)



  let code: string | null = null

  let map: SourceDescription['map'] = null



  // load

  const loadStart = isDebug ? Date.now() : 0

  const loadResult = await pluginContainer.load(id, ssr)



  // ...

  if (typeof loadResult === 'object') {

      code = loadResult.code

      map = loadResult.map

    } else {

      code = loadResult

    }



    // ...



  // ensure module in graph after successful load

  const mod = await moduleGraph.ensureEntryFromUrl(url)

  ensureWatchedFile(watcher, mod.file, root)



  // transform

  const transformStart = isDebug ? Date.now() : 0

  const transformResult = await pluginContainer.transform(code, id, map, ssr)

  if (

    transformResult == null ||

    (typeof transformResult === 'object' && transformResult.code == null)

  ) {

    // no transform applied, keep code as-is

    isDebug &&

      debugTransform(

        timeFrom(transformStart) + chalk.dim(` [skipped] ${prettyUrl}`)

      )

  } else {

    isDebug && debugTransform(`${timeFrom(transformStart)} ${prettyUrl}`)

    code = transformResult.code!

    map = transformResult.map

  }



  if (map && mod.file) {

    map = (typeof map === 'string' ? JSON.parse(map) : map) as SourceMap

    if (map.mappings && !map.sourcesContent) {

      await injectSourcesContent(map, mod.file)

    }

  }



  return (mod.transformResult = {

      code,

      map,

      etag: getEtag(code, { weak: true })

    } as TransformResult)

}

pluginContainer.resolveId 在调用时,会逐个调用每个插件上的 resolveId 方法,一旦遇到 aliasPlugin ,在 config 中,曾注册过对应的 /`` ^ ``/@vite// 的 alias,此插件将用于将 /``@vite/client 替换成 CLIENT_DIR + / + client ,也就是 vite/dist/client/client

实际处于 packages/vite/src/node/config.ts 路径下的 resolvedAlias

 // resolve alias with internal client alias

  const resolvedAlias = mergeAlias(

    // #1732 the CLIENT_DIR may contain $$ which cannot be used as direct

    // replacement string.

    // @ts-ignore because @rollup/plugin-alias' type doesn't allow function

    // replacement, but its implementation does work with function values.

    [{ find: / ^ /@vite//, replacement: () => CLIENT_DIR + '/' }],

    config.resolve?.alias || config.alias || []

  )



  const resolveOptions: ResolvedConfig['resolve'] = {

    dedupe: config.dedupe,

    ...config.resolve,

    alias: resolvedAlias

  }



  const resolved = {

  // ...

    resolve: resolveOptions

    // ...

}

packages/vite/src/node/plugins/index.tsaliasPlugin 插件中作为参数传入

export async function resolvePlugins(

  config: ResolvedConfig,

  prePlugins: Plugin[],

  normalPlugins: Plugin[],

  postPlugins: Plugin[]

): Promise<Plugin[]> {



// ...



  return [

    isBuild ? null : preAliasPlugin(),

    aliasPlugin({ entries: config.resolve.alias }),

    ...prePlugins,

    // ...

  ].filter(Boolean) as Plugin[]

}

aliasPlugin 里面改写路径之后,会继续将改写过的路径传给下一个插件,最终进入 resolvePlugin 插件的 tryNodeResolve 函数,获取到 @fs/Users/bytedance/Projectes/my-projects/learning/vite/vite/packages/vite/dist/client/client.js 文件的路径并返回,最终通过 pluginContainer.load 获取 loadResult,然后 通过 pluginContainer.transform 获取其转换过的代码,通过 send 方法发送给浏览器,而 client.js 里面的代码主要用于与服务器进行 ws 通信来进行 hmr 热更新、以及重载页面等操作。

受限于篇幅,本文接下来的内容不再细化。

下面的所有请求,都会走一个类似上面的流程,最终发送给浏览器的代码是浏览器可以运行的代码,其中针对 Vue 文件是需要走类似 @vitejs/plugin-vue 的 plugin 的转换的,感兴趣的同学可以自行了解一下

GET /src/main.js

实际 GET /src/main.js

GET env.js

实际 GET /@fs/Users/bytedance/Projectes/my-projects/learning/vite/vite/packages/vite/dist/client/env.js

GET vue.js?v=92bdfa16

实际 GET /node_modules/.vite/vue.js?v=92bdfa16

GET App.vue

实际 GET /src/App.vue

GET App.vue?vue&type=style&index=0&lang.css

实际 GET /src/App.vue?vue&type=style&index=0&lang.css

有什么 “不好” 的?

正如上面提到的,Vite 只对 npm 依赖进行预构建,对于用户编写的文件不进行预处理,而是通过浏览器支持的 ES Module 来进行按需读取,所以如果用户文件过多,且没有进行一定的 Code Spliting 等操作,那么可想而知,首屏是非常慢的,可以通过这个视频直观的看出来:

5.2021-06-30 17_34_40.gif

所以使用 Vite 的开发,对我们的首屏性能优化就提出了更高的要求,这也直接给生产下带来了一定帮助,也正是因为 Vite 是主要面向开发侧的,所以可以尽可能的用最先进的技术,如 Http2?Http3?来进行网络请求,以及更好的懒加载、缓存技术。

还有一点就是,Vue 生产内建了 Rollup 打包工具,这对原先使用 Webpack 的项目也不太友好,但得益于 Vite 社区的活跃和尤大的号召力,社区中已经有成型的基于 Webpack 来生产打包,开发使用 Vite 的解决方案:github.com/IndexXuan/v…

生态如何?

Vite 拥有比较完善的生态,主要的项目如 github.com/vitejs/awes… 在不断的更新,且 Vite 社区比较活跃,社区成员也很乐于解答问题:

  1. discord:discord.com/channels/80…
  2. Github discussion:github.com/vitejs/vite…

同时 Vite 支持多框架:React/Vue/Svelte 等。

我能用在生产项目中吗?

如果你是想从头开始一个新项目,亦或对首屏性能优化有很大的兴趣,那么建议你一定要试一试,有可能一不小心,就回不去了!

Hail OpenSource!

世界已经被开源吞噬,庆幸在这样一个商业氛围及其浓厚的今天,我们还有幸能阅读到优秀的项目源码,站在巨人的肩膀上!