Libon

Vite 源码分析(三)

#vite
分析 Vite@3 源码实现之 vite/src/node/config.ts

ToC

resolveConfig(inlineConfig, ‘serve’, ‘development’)

resolveConfig 方法内部先是保存了一些通过函数调用传递过来的参数,同时还根据 mode 参数的值设置 process\.env\.NODE_ENV 的值为 development 或是 production

let config = inlineConfig
let configFileDependencies: string[] = []
let mode = inlineConfig.mode || defaultMode

if (mode === 'production') {
  process\.env\.NODE_ENV = 'production'
}

// production env would not work in serve, fallback to development
// 译:开发服务器不能以生产环境运行,内部重新修改为开发模式
if (command === 'serve' && process\.env\.NODE_ENV === 'production') {
  process\.env\.NODE_ENV = 'development'
}

const configEnv = {
  mode, // development | production
  command, // serve | build
  ssrBuild: !!config.build?.ssr
}

let { configFile } = config
// 如果没有指定 --config 属性则会是 undefined
// 通常只有在 babel 做 transform 转换的时候才会是 false
// 否则正常的加载都会进行加载配置的操作
if (configFile !== false) {
  const loadResult = await loadConfigFromFile(
    configEnv,
    configFile,
    config.root,
    config.logLevel
  )
  // 如果找到了配置文件则进行和现有的配置文件进行合并
  if (loadResult) {
    config = mergeConfig(loadResult.config, config)
    // 并保存配置文件的路径地址和配置文件中所使用到的依赖项
    // 方便在 pre-bundling 的阶段一起做优化
    configFile = loadResult.path
    configFileDependencies = loadResult.dependencies
  }
}

查找对应的配置文件用到的方法是:loadConfigFromFile,它主要做的事情是根据预设的配置文件路径(也可能没有进行设置),在本机上查找对应的文件,读取并使用 esbuild 进行编译转换。

loadConfigFromFile(configEnv,configFile,config.root,config.logLevel)

在 loadConfigFromFile 文件中会找到配置文件的真实路径,如果启动的时候传入了目标文件位置,则使用目标位置查找,否则会使用启动项目时的根目录来分别尝试读取 'vite.config.js', 'vite.config.mjs', 'vite.config.ts', 'vite.config.cjs', 'vite.config.mts', 'vite.config.cts' 文件,因为是使用 for...of 方法来依次遍历的这个文件名列表,所以当文件名为 vite.config.js 读取最快,为 vite.config.cts 的时候最慢。读取文件是否存在时使用 fs.existsSync(filePath) 来进行判断的,如果不存在就会跳过本次循环,直到找到对应的配置文件。

如果最终没有找到文件则会在控制台打印:vite:config no config file found. 。如果找到了对应的配置文件则会获取这个文件的模块系统是 CommonJS 还是 ESModule,读取的方式为:

  1. 默认 isESM = false
  2. 如果文件以 mjs/mts 结尾,则文件是 ESM 格式
  3. 如果文件以 cjs/cts 结尾,则文件时 CJS 格式
  4. 如果以上方法都不适用,说明文件是以 js/ts 结尾,则通过通过 lookupFIle 查找到该项目的根目录并读取 package.json 文件内容,判断 .type === 'module',如果为 true 则表示是 ESM,反之亦然

为什么需要判断配置文件的格式呢?因为它决定了最终转换配置文件时输出的文件格式名和部分模块独有的全局变量(__dirname, __fileName, import.meta.url)是否能正产使用。接着会调用 bundleConbfigFile 方法传入 resolvePath(配置文件路径)和 isESM(是否是 ESModule),来将配置文件的代码用 esbuild 打包编译并替换模块独有的全局变量为静态变量值。

bundleConfigFile(resolvedPath, isESM)

在 esbuild 进行配置文件打包时,有两个自定义的 plugin,分别为:externalize-depsinject-file-scope-variables ,它们的作用是读取到 monorepo 中的一些共享依赖(或者称之为外部依赖),和注入静态变量(__dirname, __fileName, import.meta.url 在进行打包的时候会被 esbuild 打包时设置的 define 配置项中定义的变量名替换,而注入静态变量则是将这些静态变量名进行声明赋值,让它变成一个真实可用的变量)。

const dirnameVarName = '__vite_injected_original_dirname'
const filenameVarName = '__vite_injected_original_filename'
const importMetaUrlVarName = '__vite_injected_original_import_meta_url'
const result = await build({
  absWorkingDir: process.cwd(), // 工作的绝对路径
  entryPoints: [fileName], // 需要打包的文件入口
  outfile: 'out.js', // 输出的文件名
  write: false, // 是否写入到外部文件
  target: ['node14.18', 'node16'], // 生成的代码需要对哪些环境进行 hack
  platform: 'node', // 打包平台
  bundle: true, // 把相关的依赖项单独打包,而不是捆绑在一起 https://esbuild.github.io/api/#bundle
  format: isESM ? 'esm' : 'cjs', // 打包的文件模块系统
  sourcemap: 'inline', // 讲 sourcemap 和代码打包在一起
  metafile: true, // 记录了模块文件的元信息 https://esbuild.github.io/api/#metafile
  define: {
    // 替换全局变量为其他的变量名
    __dirname: dirnameVarName,
    __filename: filenameVarName,
    'import.meta.url': importMetaUrlVarName
  },
  plugins: [...]
})

当然这里面涉及到了 esbuild plugin 的概念,在本文中不会对其详细展开,有机会再写一遍关于 esbuild plugin 的文章,这里只会对 vite 已经编写好的插件进行一个简单的解读,了解一下主要做了哪些事情。

{
  name: 'externalize-deps',
  setup(build) {
    build.onResolve({ filter: /.*/ }, ({ path: id, importer }) => {
      // externalize bare imports
      // 如果文件路径不是以 . 开头并且 路径不是一个绝对路径则表示是外部依赖
      if (id[0] !== '.' && !path.isAbsolute(id)) {
        return {
          external: true
        }
      }
      // bundle the rest and make sure that the we can also access
      // it's third-party dependencies. externalize if not.
      // monorepo/
      // ├─ package.json
      // ├─ utils.js -----------> bundle (share same node_modules)
      // ├─ vite-project/
      // │  ├─ vite.config.js --> entry
      // │  ├─ package.json
      // ├─ foo-project/
      // │  ├─ utils.js --------> external (has own node_modules)
      // │  ├─ package.json
      const idFsPath = path.resolve(path.dirname(importer), id)
      const idPkgPath = lookupFile(idFsPath, [`package.json`], {
        pathOnly: true
      })
			// 如果找到了 monorepo 的根模块的 package.json
      if (idPkgPath) {
        const idPkgDir = path.dirname(idPkgPath)
        // 如果该文件需要向上一个或多个目录才能访问到 vite 配置
        // 这意味着它有自己的node_modules(例如foo-project)
        if (path.relative(idPkgDir, fileName).startsWith('..')) {
          return {
            // 在捆绑为单个 vite 配置后,将实际导入的地址规范化
            path: isESM ? pathToFileURL(idFsPath).href : idFsPath,
            external: true
          }
        }
      }
    })
  }
},
{
  name: 'inject-file-scope-variables',
  setup(build) {
    build.onLoad({ filter: /\.[cm]?[jt]s$/ }, async (args) => {
      const contents = await fs.promises.readFile(args.path, 'utf8')
      const injectValues =
        `const ${dirnameVarName} = ${JSON.stringify(
          path.dirname(args.path)
        )};` +
        `const ${filenameVarName} = ${JSON.stringify(args.path)};` +
        `const ${importMetaUrlVarName} = ${JSON.stringify(
          pathToFileURL(args.path).href
        )};`

      return {
        // 如果文件路径是以 ts 结尾,则使用 typescript 作为文件加载器,否则使用默认的 loader
        loader: args.path.endsWith('ts') ? 'ts' : 'js',
        // 把声明的三个静态变量文件插入到文件内容最顶部
        contents: injectValues + contents
      }
    })
  }
}

最终将代码及文件的依赖关系导出:

const { text } = result.outputFiles[0]
return {
  code: text,
  dependencies: result.metafile ? Object.keys(result.metafile.inputs) : []
}

result.metafile 属性大致是这样的:

{
  inputs: { 'vite.config.js': { bytes: 5027, imports: [] } },
  outputs: {
    'out.js': {
      imports: [],
      exports: [],
      entryPoint: 'vite.config.js',
      inputs: [Object],
      bytes: 16150
    }
  }
}

bundleConfigFile 完成配置文件的转换后,会通过 loadConfigFromBundledFile 方法将配置文件动态加载进来,让其变成一个真正的 JavaScript 对象或函数(vite.config.js 也可以接收一个默认导出的函数作为配置,当导出一个函数的时候,参数是一个包含 mode、command、ssrBuild 的对象)。

loadConfigFromBundledFile(resolvedPath,bundled.code,isESM)

如果文件模块系统是 esm,写将代码写入一个临时文件,并使用 dynamicImport 方法,而 dynamicImport 方法则是一个普通函数,在函数内部返回这个文件:

// @ts-expect-error
export const usingDynamicImport = typeof jest === 'undefined'

/**
 * Dynamically import files. It will make sure it's not being compiled away by TS/Rollup.
 *
 * As a temporary workaround for Jest's lack of stable ESM support, we fallback to require
 * if we're in a Jest environment.
 * See https://github.com/vitejs/vite/pull/5197#issuecomment-938054077
 *
 * @param file File path to import.
 */
export const dynamicImport = usingDynamicImport
  ? new Function('file', 'return import(file)') // === function (file) { return import(file) }
  : _require

至于为什么要这么做,在方法的注释上已经描述清楚了,为了不被 ts 和 rollup 编译掉。fileName 指的是配置文件在磁盘中的绝对路径(比如:/Volumes/code/vite/playground/html/vite.config.js),在创建临时文件后会尝试使用 await importZ() 来导入它,导入完成后将尝试删除它:

// for esm, before we can register loaders without requiring users to run node
// with --experimental-loader themselves, we have to do a hack here:
// write it to disk, load it with native Node ESM, then delete the file.
if (isESM) {
  const fileBase = `${fileName}.timestamp-${Date.now()}` // 包含配置文件的绝对路径的临时文件名
  const fileNameTmp = `${fileBase}.mjs` // 完整文件路径
  const fileUrl = `${pathToFileURL(fileBase)}.mjs` // 文件以 file:// 协议存在于磁盘中的地址
  fs.writeFileSync(fileNameTmp, bundledCode)
  try {
    // import 只支持 file 协议的文件地址
    return (await dynamicImport(fileUrl)).default
  } finally {
    try {
      // 在加载完成后,移除这个临时文件
      fs.unlinkSync(fileNameTmp)
    } catch {
      // already removed if this function is called twice simultaneously
    }
  }
}

而 CJS 的模块导入则要更麻烦一点,

// for cjs, we can register a custom loader via `_require.extensions`
else {
  const extension = path.extname(fileName) // 文件的后缀名
  const realFileName = fs.realpathSync(fileName) // 配置文件在磁盘中的真实绝对地址
  // 如果对应的后缀已存在内置的 loader,则保留其后缀,否则更换为 js loader,不然就会出现没有合适的解析器能解析对应文件内容的情况
  // Node 中内置的解析器有 .js、.json、.node
  const loaderExt = extension in _require.extensions ? extension : '.js'
  const defaultLoader = _require.extensions[loaderExt]! // 找到文件对应的解析器进行缓存
  // 重写对应格式的加载器
  _require.extensions[loaderExt] = (module: NodeModule, filename: string) => {
    if (filename === realFileName) {
      ;(module as NodeModuleWithCompile)._compile(bundledCode, filename)
    } else {
      defaultLoader(module, filename)
    }
  }
  // clear cache in case of server restart
  // 译:在服务重启的时候删掉缓存
  delete _require.cache[_require.resolve(fileName)]
  const raw = _require(fileName)
  // 恢复原本的文件加载器
  _require.extensions[loaderExt] = defaultLoader
  // 如果这个文件是已经被 esbuild 编译过的文件则返回默认导出,否则返回原始内容
  return raw.__esModule ? raw.default : raw
}

最后会返回带有 pathconfigdependencies 字段的的对象。normalizePath 属性的主要作用是判断当前运行平台是否是 window,如果是则会将路径中的 \\ 转换为 /

import path from 'node:path'

export function slash(p: string): string {
  return p.replace(/\\/g, '/')
}

export const isWindows = os.platform() === 'win32'

export function normalizePath(id: string): string {
  return path.posix.normalize(isWindows ? slash(id) : id)
}

现在调用栈会回到 resolveConfig 函数中,但其实剩下的工作都是对各种边界的处理,以及对 plugins 的排序和过滤,在处理完所有情况后,会将所有的选项合并后返回出来,至此,createServer 内部的 resolveConfig 就完成了,接下来会处理 resolveHttpsConfig 的配置项,但其实这个并不关键,因为默认情况下,httpsOptions 都会是一个 undefined,除非指定了 https 参数。

const httpsOptions = await resolveHttpsConfig(
  config.server.https,
  config.cacheDir
)

所以我们下一篇来看一下 resolveChokidarOptionsresolveHttpServercreateWebSocketServer 和创建 connect 中间件系统的处理。


CD ..