探索Vite开发服务核心工具之:预优化(Pre-Bundling)

2,645 阅读7分钟

本文为原创文章,引用请注明出处,欢迎大家收藏和分享💐💐

背景

前段时间用Vite2.x造了个Vue3的个人项目,在Vite的加持下,无论是项目冷启动、热更新和构建,比起webpack速度都提升n00%(n≥10)以上,怀着强烈的好奇心,就把Vite的源码搞了下来学习下,顺便水篇文章方便以后重温😂😂😂。

认识构建工具的开发服务「Dev server」

开发服务是指开发者在本地开发前端项目时,构建工具会额外启动的一个本地服务。如执行npm run dev命令后,框架执行服务启动操作,启动完成后,你就能通过浏览器输入http://localhost:xxxx("xxxx"为端口号)看到自己开发的页面了。

OK,咋们聊下Vite的本地服务。。。它思路设计还是很独特的,Vite也是通过这种机制取得更高效的处理速度和更好的开发体验!

为了做对比,先看下传统bundle打包方式服务启动方式,以webpack为例。

Webpack的开发服务

image.png 在项目冷启动时,webpack会通过entry入口文件逐层检查文件的依赖,例如有3个ts文件:

// a.ts
export const a = 1;

// b.ts
export const b = 2;

// sum.ts
import { a } from './a.ts';
import { b } from './b.ts';

export default sum() => a + b;


// bundle打包后大致是这样的
// bundle.js
const a = 1;
const b = 2;

const sum = function() {
    return a + b;
}

export default sum;

为了方便理解,上面是简略代码,但可以看出来,webpack在成功启动开发服务前,要收集所有依赖后打包成一个bundle.js文件。这种打包方法能有效整合模块之间的依赖关系,统一输出,减少资源加载数量。
但也是是有短板的:一是服务的启动需要前置依赖组件打包完成,当组件越来越多且复杂后,项目的启动时间会越来越长(几十秒甚至几分钟);二是在热更新项目时,哪怕使用HRM方式去diff文件差异,修改后的效果也需要几秒钟才能在浏览器中反映出来。如此循环往复,迟钝的反馈会极大地影响开发者的开发效率和幸福感。。

Vite的开发服务

image.png

下面是引用官方对Vite开发服务的解析。

Vite 通过在一开始将应用中的模块区分为 依赖 和 源码 两类,改进了开发服务器启动时间。

  • 依赖 大多为在开发时不会变动的纯 JavaScript。一些较大的依赖(例如有上百个模块的组件库)处理的代价也很高。依赖也通常会存在多种模块化格式(例如 ESM 或者 CommonJS)。
    Vite 将会使用 esbuild 预构建依赖。Esbuild 使用 Go 编写,并且比以 JavaScript 编写的打包器预构建依赖快 10-100 倍。
  • 源码 通常包含一些并非直接是 JavaScript 的文件,需要转换(例如 JSX,CSS 或者 Vue/Svelte 组件),时常会被编辑。同时,并不是所有的源码都需要同时被加载(例如基于路由拆分的代码模块)。
    Vite 以 原生 ESM 方式提供源码。这实际上是让浏览器接管了打包程序的部分工作:Vite 只需要在浏览器请求源码时进行转换并按需提供源码。根据情景动态导入代码,即只在当前屏幕上实际使用时才会被处理。

通俗来讲,执行npm run dev命令后,Vite先把本地server启动,之后通过项目的入口收集依赖,把没有提供esm格式的依赖和内部大量的依赖提前打包,这个过程成为:预优化(Pre-Bundling)。预优化后在页面需要加载依赖时,通过http方式把资源请求回来,做到了真正的按需加载。

关于如何实现预优化,正是下面要详述部分。

Vite1.0和2.0预优化工具差异

Vite至此发布了2个大版本。其实,Vite1.0和2.0预优化还是有很大差异的。
按开发者的描述: Vite2.0 在底层使用了 http + connect模块来代替 1.0 中的 koa 框架的一些能力。并且预优化的工具也由 rollup 的 commonjs 插件 替换为 esbuild ,这两个关键点的优化,使得执行效率大幅增加。

大家可以感受一下esbuild带来的速度加成 image.png

比起rollup,esbuild能有如此表现主要得益于它的底层原理:

  1. js是单线程串行,esbuild是新开一个进程,然后多线程并行执行;
  2. esbuild用go语法编写,go是纯机器码,执行速度比JIT快;
  3. 免去 AST 抽象语法树生成,优化了构建流程。

关键词:connectesbuild 

Vite预优化

事不宜迟,直接把Vite源码clone下来「github地址」,在packages/vite/src/node/server/index.ts找到server启动函数:createServer,在这里可以找到预优化的入口optimizeDeps方法:

export async function createServer(
  inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
  // 此处省略好长代码...
  
  const runOptimize = async () => {
    if (config.cacheDir) {
      server._isRunningOptimizer = true
      try {
        server._optimizeDepsMetadata = await optimizeDeps(
          config,
          config.server.force || server._forceOptimizeOnRestart
        )
      } finally {
        server._isRunningOptimizer = false
      }
      server._registerMissingImport = createMissingImporterRegisterFn(server)
    }
  }

  // 此处又省略好长代码...
  
  return server
}

接下来我们到packages/vite/src/node/optimizer/index.ts找到optimizeDeps方法的定义:

export async function optimizeDeps(
  config: ResolvedConfig,
  force = config.server.force,
  asCommand = false,
  newDeps?: Record<string, string>, // missing imports encountered after server has started
  ssr?: boolean
): Promise<DepOptimizationMetadata | null> {
  // 省略好长代码...

  const result = await build({
    absWorkingDir: process.cwd(),
    entryPoints: Object.keys(flatIdDeps),
    bundle: true,
    format: 'esm',
    target: config.build.target || undefined,
    external: config.optimizeDeps?.exclude,
    logLevel: 'error',
    splitting: true,
    sourcemap: true,
    outdir: cacheDir,
    ignoreAnnotations: true,
    metafile: true,
    define,
    plugins: [
      ...plugins,
      esbuildDepPlugin(flatIdDeps, flatIdToExports, config, ssr)
    ],
    ...esbuildOptions
  })
  
  // 省略好长代码...
}

build()来源esbuild的构建方法,至于里面的参数大家有兴趣可以到插件官网查阅build-api

这里面要讲的,pulgins中包含了esbuildDepPlugin插件,这个插件是 Vite 在 esbuild 打包中最核心的逻辑,这插件的工作流程如下。

特定格式文件external

首先,插件对特定格式文件进行 external 处理,因为这些文件不会在esbuild阶段进行处理,所以要提前把它们找出并解析。其中externalTypesArray<fileType>类型,可以在packages/vite/src/node/optimizer/esbuildDepPlugin.ts找到它的定义。

// externalize assets and commonly known non-js file types
build.onResolve(
  {
    filter: new RegExp(`\.(` + externalTypes.join('|') + `)(\?.*)?$`)
  },
  async ({ path: id, importer, kind }) => {
    const resolved = await resolve(id, importer, kind)
    if (resolved) {
      return {
        path: resolved,
        external: true
      }
    }
  }
)

解析不同的模块

开发作者将打包模块分为两种:入口模块 和 依赖模块

入口模块:直接 import 的模块或者通过 include 制定的模块,如:import Vue from 'vue';
依赖模块:入口模块自身的依赖,也就是 dependencies

function resolveEntry(id: string) {
  const flatId = flattenId(id)
  if (flatId in qualified) {
    return {
      path: flatId,
      namespace: 'dep'
    }
  }
}

build.onResolve(
  { filter: /^[\w@][^:]/ },
  async ({ path: id, importer, kind }) => {
    if (moduleListContains(config.optimizeDeps?.exclude, id)) {
      return {
        path: id,
        external: true
      }
    }

    // ensure esbuild uses our resolved entries
    let entry: { path: string; namespace: string } | undefined
    // if this is an entry, return entry namespace resolve result
    if (!importer) {
      if ((entry = resolveEntry(id))) return entry
      // check if this is aliased to an entry - also return entry namespace
      const aliased = await _resolve(id, undefined, true)
      if (aliased && (entry = resolveEntry(aliased))) {
        return entry
      }
    }

    // use vite's own resolver
    const resolved = await resolve(id, importer, kind)
    if (resolved) {
      if (resolved.startsWith(browserExternalId)) {
        return {
          path: id,
          namespace: 'browser-external'
        }
      }
      if (isExternalUrl(resolved)) {
        return {
          path: resolved,
          external: true
        }
      }
      return {
        path: path.resolve(resolved)
      }
    }
  }
)

为了方便大家理解,这里写段伪代码,每个依赖打包前都执行以下逻辑:

if 入口模块
    将模块解析为namespace='dep'的处理流程
else
    if 为browser-external模块
        将模块解析为namespace='browser-external'的处理流程
    if 以http(s)引入的模块
        将模块解析为外部引用模块
    else
        直接解析路径

namespacedep的依赖打包

完成模块分类后,接下来要对dep模块进行解析打包。

build.onLoad({ filter: /.*/, namespace: 'dep' }, ({ path: id }) => {
  const entryFile = qualified[id]

  let relativePath = normalizePath(path.relative(root, entryFile))
  if (
    !relativePath.startsWith('./') &&
    !relativePath.startsWith('../') &&
    relativePath !== '.'
  ) {
    relativePath = `./${relativePath}`
  }

  let contents = ''
  const data = exportsData[id]
  const [imports, exports] = data
  if (!imports.length && !exports.length) {
    // cjs
    contents += `export default require("${relativePath}");`
  } else {
    if (exports.includes('default')) {
      contents += `import d from "${relativePath}";export default d;`
    }
    if (
      data.hasReExports ||
      exports.length > 1 ||
      exports[0] !== 'default'
    ) {
      contents += `\nexport * from "${relativePath}"`
    }
  }

  let ext = path.extname(entryFile).slice(1)
  if (ext === 'mjs') ext = 'js'
  return {
    loader: ext as Loader,
    contents,
    resolveDir: root
  }
})
  1. 首先将entryFile的相对路径解析出来放到relativePath变量中保存;
  2. 分析模块类型,给contents赋值。通过 esbuild 词法分析入口模块的 importexport 信息,当两个关键字都没有,判断是一个 cjs 模块,生成下面格式contents;
    contents += `export default require("${relativePath}");`
    
  3. 假如不满足第2步条件,则系统认为是一个 esm 模块,生成对应的contents:
    contents += `import d from "${relativePath}";export default d;`
    // or 
    contents += `\nexport * from "${relativePath}"`
    
  4. 解析文件扩展名,取得对应的loader
  5. 返回loader类型、模块内容、导入路径给esbuild打包,语法参考 esbuild onLoad result

23步中,contents保存都是模块的相对路径(也就是第1步的relativePath),这样做可以让程序生成正确的cache文件目录结构。

namespacebrowser-external的依赖打包

      build.onLoad(
        { filter: /.*/, namespace: 'browser-external' },
        ({ path: id }) => {
          return {
            contents:
              `export default new Proxy({}, {
  get() {
    throw new Error('Module "${id}" has been externalized for ` +
              `browser compatibility and cannot be accessed in client code.')
  }
})`
          }
        }
      )

兼容yarn pnp环境

if (isRunningWithYarnPnp) {
  build.onResolve(
    { filter: /.*/ },
    async ({ path, importer, kind, resolveDir }) => ({
      // pass along resolveDir for entries
      path: await resolve(path, importer, kind, resolveDir)
    })
  )
  build.onLoad({ filter: /.*/ }, async (args) => ({
    contents: await require('fs').promises.readFile(args.path),
    loader: 'default'
  }))
}

总结

总的看下来,在预优化这块,Vite先对依赖进行模块分类,针对不同类型resolved它们的引用路径,之后启动esbuild打包输出ES Module,最后通过http network拉取资源,使资源组装到应用上,完成按需加载。

写在最后

至此,Vite2.0的预优化部分基本讲完了,由于时间仓促,在某些细节讲解可能略显粗糙,但主流程大致如此,细节之处等以后有空再慢慢补齐🤝🤝。

另外,以后有时间再搞一篇Vite的渲染机制,当资源请求回来后,如何从原始形态渲染成最终的js|tscssvue template

欢迎大家关注本人公众号「似马非马」,一起玩耍起来!🌹🌹