【Vite】1380- 详解 Vite 依赖预构建流程

我们知道,首次执行 vite 时,服务启动后会对 node_modules 模块和配置 optimizeDeps 的目标进行预构建。本节我们就去探索预构建的流程。

按照惯例,先准备好一个例子。本文我们用 vue 的模板去初始化 DEMO:

pnpm create vite my-vue-app -- --template vue

项目创建好之后,我们再安装 lodash-es 这个包,去研究 vite 是如何将几百个文件打包成一个文件的:

pnpm add lodash-es -P

DEMO 代码量比较多,这里就不贴代码了,嫌麻烦的童鞋可以 fork Github repository[1]

流程概览

当我们服务启动之后,除了会调用插件容器的 buildStart 钩子,还会执行预构建 runOptimize:

// ...
const listen = httpServer.listen.bind(httpServer)
httpServer.listen = (async (port: number, ...args: any[]) => {
  if (!isOptimized) {
    try {
      // 插件容器初始化
      await container.buildStart({})
      // 预构建
      await runOptimize()
      isOptimized = true
    } catch (e) {
      httpServer.emit('error', e)
      return
    }
  }
  return listen(port, ...args)
}) as any
// ...

const runOptimize = async () => {
  server._isRunningOptimizer = true
  try {
    // 依赖预构建
    server._optimizeDepsMetadata = await optimizeDeps(
      config,
      config.server.force || server._forceOptimizeOnRestart
    )
  } finally {
    server._isRunningOptimizer = false
  }
  server._registerMissingImport = createMissingImporterRegisterFn(server)
}

入口处将配置 config 和是否强制缓存的标记(通过 --force 传入或者调用 restart API)传到 optimizeDeps。optimizeDeps 逻辑比较长,我们先通过流程图对整个流程有底之后,再按照功能模块去阅读源码。

【Vite】1380- 详解 Vite 依赖预构建流程_第1张图片

简述一下整个预构建流程:

  1. 首先会去查找缓存目录(默认是 node_modules/.vite)下的 _metadata.json 文件;然后找到当前项目依赖信息(xxx-lock 文件)拼接上部分配置后做哈希编码,最后对比缓存目录下的 hash 值是否与编码后的 hash 值一致,一致并且没有开启 force 就直接返回预构建信息,结束整个流程;

  2. 如果开启了 force 或者项目依赖有变化的情况,先保证缓存目录干净(node_modules/.vite 下没有多余文件),在 node_modules/.vite/package.json 文件写入 type: module 配置。这就是为什么 vite 会将预构建产物视为 ESM 的原因。

  3. 分析入口,依次查看是否存在 optimizeDeps.entries、build.rollupOptions.input、*.html,匹配到就通过 dev-scan 的插件寻找需要预构建的依赖,输出 deps 和 missing,并重新做 hash 编码;

  4. 最后使用 es-module-lexer[2] 对 deps 模块进行模块化分析,拿到分析结果做预构建。构建结果将合并内部模块、转换 CommonJS 依赖。最后更新 data.optimizeDeps 并将结果写入到缓存文件。

剥丝抽茧

全流程上我们已经清楚了,接下来我们就深入上述流程图中绿色方块(逻辑复杂)的代码。因为步骤之间的代码关联比较少,在分析下面逻辑时会截取片段代码

计算依赖 hash

export async function optimizeDeps(
  config: ResolvedConfig,
  force = config.server.force,
  asCommand = false,
  newDeps?: Record, // missing imports encountered after server has started
  ssr?: boolean
): Promise {
  // ...
 // 缓存文件信息
  const dataPath = path.join(cacheDir, '_metadata.json')
  // 获取依赖的hash,这里的依赖是 lock 文件、以及 config 的部分信息
  const mainHash = getDepHash(root, config)
  // 定义预编译优化的元数据
  const data: DepOptimizationMetadata = {
    hash: mainHash,
    browserHash: mainHash,
    optimized: {}
  }

  // 不强制刷新
  if (!force) {
    let prevData: DepOptimizationMetadata | undefined
    try {
      // 读取 metadata 信息
      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 })
  }
  // 缓存目录的模块被识别成 ESM
  writeFile(
    path.resolve(cacheDir, 'package.json'),
    JSON.stringify({ type: 'module' })
  )
  
  // ...
}

// 所有可能的依赖 lock 文件,分别对应 npm、yarn、pnpm 的包管理
const lockfileFormats = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml']

/**
 * 获取依赖的 hash 值
 *
 * @param {string} root 根目录
 * @param {ResolvedConfig} config 服务配置信息
 * @return {*}  {string}
 */
function getDepHash(root: string, config: ResolvedConfig): string {
  // 获取 lock 文件的内容
  let content = lookupFile(root, lockfileFormats) || ''
  // 同时也将跟部分会影响依赖的 config 的配置一起加入到计算 hash 值
  content += JSON.stringify(
    {
      mode: config.mode,
      root: config.root,
      resolve: config.resolve,
      assetsInclude: config.assetsInclude,
      plugins: config.plugins.map((p) => p.name),
      optimizeDeps: {
        include: config.optimizeDeps?.include,
        exclude: config.optimizeDeps?.exclude
      }
    },
    (_, value) => {
      // 常见的坑:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify
      if (typeof value === 'function' || value instanceof RegExp) {
        return value.toString()
      }
      return value
    }
  )
  // 通过 crypto 的 createHash 进行 hash 加密
  return createHash('sha256').update(content).digest('hex').substring(0, 8)
}

上述代码先去 cacheDir 目录下获取 _metadata.json 的信息,然后计算当前依赖的 hash 值,计算过程主要是通过 xxx-lock 文件,结合 config 中跟依赖相关的部分配置去计算 hash 值。最后判断如果服务没有开启 force (即刷新缓存的参数)时,去读取缓存元信息文件中的 hash 值,结果相同就直接返回缓存元信息文件即 _metadata.json 的内容;

否则就判断是否存在 cacheDir(默认情况下是 node_modules/.vite),存在就清空目录文件,不存在就创建缓存目录;最后在缓存目录下创建 package.json 文件并写入 type: module 信息,这就是为什么预构建后的依赖会被识别成 ESM 的原因。

在开启了 force 参数或者依赖前后的 hash 值不相同时,就会去扫描并分析依赖,这就进入下一个阶段。

依赖搜寻,智能分析

// ... 接上述代码


 let deps: Record, missing: Record
  // 没有新的依赖的情况,扫描并预构建全部的 import
  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')
    .substring(0, 8)

  // 遗漏的包
  const missingIds = Object.keys(missing)
  if (missingIds.length) {
    throw new Error(
      `The following dependencies are imported but could not be resolved:\n\n  ${missingIds
        .map(
          (id) =>
            `${colors.cyan(id)} ${colors.white(
              colors.dim(`(imported by ${missing[id]})`)
            )}`
        )
        .join(`\n  `)}\n\nAre they installed?`
    )
  }

  // 获取 optimizeDeps?.include 配置
  const include = config.optimizeDeps?.include
  if (include) {
    // 创建模块解析器
    const resolve = config.createResolver({ asSrc: false })
    for (const id of include) {
      // normalize 'foo   >bar` as 'foo > bar' to prevent same id being added
      // and for pretty printing
      const normalizedId = normalizeId(id)
      if (!deps[normalizedId]) {
        const entry = await resolve(id)
        if (entry) {
          deps[normalizedId] = entry
        } else {
          throw new Error(
            `Failed to resolve force included dependency: ${colors.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
  // pre-bundling 的列表最多展示 5 项
  const maxListed = 5
  // 列表数量
  const listed = Math.min(total, maxListed)
  // 剩余的数量
  const extra = Math.max(0, total - maxListed)
  // 预编译依赖的信息
  const depsString = colors.yellow(
    qualifiedIds.slice(0, listed).join(`\n  `) +
      (extra > 0 ? `\n  (...and ${extra} more)` : ``)
  )
  // CLI 下才需要打印
  if (!asCommand) {
    if (!newDeps) {
      // This is auto run on server start - let the user know that we are
      // pre-optimizing deps
      logger.info(colors.green(`Pre-bundling dependencies:\n  ${depsString}`))
      logger.info(
        `(this will be run only when your dependencies or config have changed)`
      )
    }
  } else {
    logger.info(colors.green(`Optimizing dependencies:\n  ${depsString}`))
  }

 // ...

上述代码很长,关键都在 scanImports 函数,这个涉及到 esbuild 插件和 API,我们待会拎出来分析。其他部分的代码我们通过一张流程图来讲解:

【Vite】1380- 详解 Vite 依赖预构建流程_第2张图片
  1. 开始通过 scanImports 找到全部入口并扫描全部的依赖做预构建;返回 deps 依赖列表、missings 丢失的依赖列表;

  2. 基于 deps 做 hash 编码,编码结果赋给 data.browserHash,这个结果就是浏览器发起这些资源的 hash 参数;

    【Vite】1380- 详解 Vite 依赖预构建流程_第3张图片
  3. 对于使用了 node_modules 下没有定义的包,会发出错误信息,并终止服务;举个例子,我引入 abcd 包:

import { createApp } from 'vue'
// 引用一个不存在的包
import getABCD from 'abcd'
import App from './App.vue'
import '../lib/index'

const s = getABCD('abc')
console.log(s)

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

然后执行  dev:

【Vite】1380- 详解 Vite 依赖预构建流程_第4张图片
  1. 将 vite.config.ts 中的 optimizeDeps.include[3] 数组中的值添加到 deps 中,也举个例子:

// vite.config.js
import { defineConfig } from 'vite'
import path from 'path'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],

  optimizeDeps: {
    include: [
      path.resolve(process.cwd(), './lib/index.js')
    ]
  }
})

// ./lib/index.js 文件
import { sayHello } from './foo'

sayHello()

// ./lib/foo.js
export function sayHello () {
  console.log('hello vite prebundling')
}

上述代码我们将 ./lib/index.js 这个文件添加到预构建的 include 配置中,lib 下的两个文件内容也已经明确了。接下来执行 dev 后,我们从终端上就可以看到这个结果:

【Vite】1380- 详解 Vite 依赖预构建流程_第5张图片

我们的 lib/index.js 已经被添加到预构建列表。最后再看一下 node_modules/.vite,有一个 _Users_yjcjour_Documents_code_vite_examples_vue-demo_lib_index_js.js 文件,并且已经被构建,还有 sourcemap 文件,这就是 optimizeDeps.include[4] 的作用。具体如何构建这个文件的我们在 导出分析 去梳理。

  1. 最后根据 deps 的长度去计算命令行中显示的预构建信息,并打印。

上述整个流程逻辑比较简单,就梳理一个主流程并实际展示了部分配置的作用。还有一个关键的环节我们略过了——scanImports

/**
 * 扫描全部引入
 * @param {ResolvedConfig} config
 */
export async function scanImports(config: ResolvedConfig): Promise<{
  deps: Record
  missing: Record
}> {
  const start = performance.now()

  let entries: string[] = []

  // 预构建自定义条目
  const explicitEntryPatterns = config.optimizeDeps.entries
  // rollup 入口点
  const buildInput = config.build.rollupOptions?.input

  // 自定义条目优先级最高
  if (explicitEntryPatterns) {
    entries = await globEntries(explicitEntryPatterns, config)
  // 其次是 rollup 的 build 入口
  } else if (buildInput) {
    const resolvePath = (p: string) => path.resolve(config.root, p)
    // 字符串,转成数组
    if (typeof buildInput === 'string') {
      entries = [resolvePath(buildInput)]
    // 数组,遍历输出路径
    } else if (Array.isArray(buildInput)) {
      entries = buildInput.map(resolvePath)
    // 对象,返回对象的value数组
    } else if (isObject(buildInput)) {
      entries = Object.values(buildInput).map(resolvePath)
    } else {
      throw new Error('invalid rollupOptions.input value.')
    }
  // 默认情况下,Vite 会抓取你的 index.html 来检测需要预构建的依赖项
  } else {
    entries = await globEntries('**/*.html', config)
  }

  // 合法的入口文件只能是存在的 js、html、vue、svelte、astro 文件
  entries = entries.filter(
    (entry) =>
      (JS_TYPES_RE.test(entry) || htmlTypesRE.test(entry)) &&
      fs.existsSync(entry)
  )

  // 找不到需要预构建的入口
  if (!entries.length) {
    if (!explicitEntryPatterns && !config.optimizeDeps.include) {
      config.logger.warn(
        colors.yellow(
          '(!) Could not auto-determine entry point from rollupOptions or html files ' +
            'and there are no explicit optimizeDeps.include patterns. ' +
            'Skipping dependency pre-bundling.'
        )
      )
    }
    return { deps: {}, missing: {} }
  } else {
    debug(`Crawling dependencies using entries:\n  ${entries.join('\n  ')}`)
  }

  // 依赖
  const deps: Record = {}
  // 缺失的依赖
  const missing: Record = {}
  // 创建插件容器,为什么这里需要单独创建一个插件容器?而不是使用 createServer 时创建的那个
  const container = await createPluginContainer(config)
  // 创建 esbuild 扫描的插件
  const plugin = esbuildScanPlugin(config, container, deps, missing, entries)
  // 外部传入的 esbuild 配置
  const { plugins = [], ...esbuildOptions } =
    config.optimizeDeps?.esbuildOptions ?? {}

  // 遍历所有入口全部进行预构建
  await Promise.all(
    entries.map((entry) =>
      build({
        absWorkingDir: process.cwd(),
        write: false,
        entryPoints: [entry],
        bundle: true,
        format: 'esm',
        logLevel: 'error',
        plugins: [...plugins, plugin],
        ...esbuildOptions
      })
    )
  )

  debug(`Scan completed in ${(performance.now() - start).toFixed(2)}ms:`, deps)

  return {
    deps,
    missing
  }
}

扫描入口先从 optimizeDeps.entries 获取;如果没有就去获取 build.rollupOptions.input 配置,处理了 input 的字符串、数组、对象形式;如果都没有,就默认寻找 html 文件。然后传入 deps、missing 调用 esbuildScanPlugin 函数生成扫描插件,并从 optimizeDeps.esbuildOptions 获取外部定义的 esbuild 配置,最后调用 esbuild.build API 进行构建。整个流程汇总成一张图如下:

【Vite】1380- 详解 Vite 依赖预构建流程_第6张图片

重点来了,使用 vite:dep-scan 插件扫描依赖,并将在 node_modules 中的依赖定义在 deps 对象中,缺失的依赖定义在 missing 中。接着我们就进入该插件内部,一起学习 esbuild 插件机制:

// 匹配 html  的形式
const scriptModuleRE =
  /(]*type\s*=\s*(?:"module"|'module')[^>]*>)(.*?)<\/script>/gims
// 匹配 vue  这段代码会被解析成 import '/src/main.js',这就会进入下一个 resolve、load 过程。在进入 JS_TYPE 的解析之前,有一个全匹配 resolver 先提出来:

build.onResolve(
  {
    filter: /.*/
  },
  async ({ path: id, importer }) => {
    console.log('all resloved --------------->', id)
    // 使用 vite 解析器来支持 url 和省略的扩展
    const resolved = await resolve(id, importer)
    if (resolved) {
      // 外部依赖
      if (shouldExternalizeDep(resolved, id)) {
        return externalUnlessEntry({ path: id })
      }

      // 扩展匹配上了 htmlTypesRE,就将其归类于 html
      const namespace = htmlTypesRE.test(resolved) ? 'html' : undefined

      return {
        path: path.resolve(cleanUrl(resolved)),
        namespace
      }
    } else {
      // resolve failed... probably unsupported type
      return externalUnlessEntry({ path: id })
    }
  }
)

build.onLoad({ filter: JS_TYPES_RE }, ({ path: id }) => {
  console.log('js load --------------->', id)
  // 获取文件的后缀扩展
  let ext = path.extname(id).slice(1)
  // 如果是 mjs,将 loader 重置成 js
  if (ext === 'mjs') ext = 'js'

  let contents = fs.readFileSync(id, 'utf-8')
  
  // 通过 esbuild.jsxInject 来自动为每一个被 ESbuild 转换的文件注入 JSX helper
  if (ext.endsWith('x') && config.esbuild && config.esbuild.jsxInject) {
    contents = config.esbuild.jsxInject + `\n` + contents
  }

  // import.meta.glob 的处理
  if (contents.includes('import.meta.glob')) {
    return transformGlob(contents, id, config.root, ext as Loader).then(
      (contents) => ({
        loader: ext as Loader,
        contents
      })
    )
  }
  return {
    loader: ext as Loader,
    contents
  }
})

/.*/ 全匹配的 resolver 通过 resolve 函数获取完整地依赖路径,比如这里的 /src/main.js 就会被转成完整的绝对路径。然后再来到 JS_TYPES_RE 的 loader,最终输出文件内容 contents 和对应的解析器。/src/main.js 文件内容:

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

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

接着就会处理 vue./App.vue../lib/index 3个依赖。

对于 vue 依赖,会跟 /^[\w@][^:]/[6]  匹配。

// bare imports: record and externalize ----------------------------------
build.onResolve(
  {
    // avoid matching windows volume
    filter: /^[\w@][^:]/
  },
  async ({ path: id, importer }) => {
    console.log('bare imports --------------->', id)
    // 首先判断是否在外部入口列表中
    if (moduleListContains(exclude, id)) {
      return externalUnlessEntry({ path: id })
    }
    // 缓存,对于在node_modules或者optimizeDeps.include中已经处理过的依赖,直接返回
    if (depImports[id]) {
      return externalUnlessEntry({ path: id })
    }
    const resolved = await resolve(id, importer)
    if (resolved) {
      // 对于一些特殊的场景,也会将其视作 external 排除掉
      if (shouldExternalizeDep(resolved, id)) {
        return externalUnlessEntry({ path: id })
      }
      // 判断是否在node_modules或者optimizeDeps.include中的依赖
      if (resolved.includes('node_modules') || include?.includes(id)) {
        // 存到depImports中,这个对象就是外部传进来的deps,最后会写入到缓存文件中的对象
        // 如果是这类依赖,直接将其视作为“外部依赖”,不在进行接下来的resolve、load
        if (OPTIMIZABLE_ENTRY_RE.test(resolved)) {
          depImports[id] = resolved
        }
        return externalUnlessEntry({ path: id })
      } else {
        const namespace = htmlTypesRE.test(resolved) ? 'html' : undefined
        // linked package, keep crawling
        return {
          path: path.resolve(resolved),
          namespace
        }
      }
    } else {
      missing[id] = normalizePath(importer)
    }
  }
)

经过上述解析,vue 依赖会被视作 external dep,并将它缓存到 depImports,因为对于 node_modules 或者 optimizeDeps.include 中的依赖,只需抓取一次即可。

对于 ./App.vue,首先会进入 html type resolve,然后进入 html load load 的回调中。跟 index.html 不同的时,此时是 .vue 文件,在执行 match = regex.exec(raw) 的时候匹配到

你可能感兴趣的:(dwr,java,stream,gwt,xhtml)