ToC
- resolveConfig(inlineConfig, ‘serve’, ‘development’)
- loadConfigFromFile(configEnv,configFile,config.root,config.logLevel)
- bundleConfigFile(resolvedPath, isESM)
- loadConfigFromBundledFile(resolvedPath,bundled.code,isESM)
resolveConfig(inlineConfig, ‘serve’, ‘development’)
resolveConfig 方法内部先是保存了一些通过函数调用传递过来的参数,同时还根据 mode
参数的值设置 process\.env\.NODE_ENV
的值为 development
或是 production
:
1let config = inlineConfig2let configFileDependencies: string[] = []3let mode = inlineConfig.mode || defaultMode4
5if (mode === 'production') {6 process\.env\.NODE_ENV = 'production'7}8
9// production env would not work in serve, fallback to development10// 译:开发服务器不能以生产环境运行,内部重新修改为开发模式30 collapsed lines
11if (command === 'serve' && process\.env\.NODE_ENV === 'production') {12 process\.env\.NODE_ENV = 'development'13}14
15const configEnv = {16 mode, // development | production17 command, // serve | build18 ssrBuild: !!config.build?.ssr19}20
21let { configFile } = config22// 如果没有指定 --config 属性则会是 undefined23// 通常只有在 babel 做 transform 转换的时候才会是 false24// 否则正常的加载都会进行加载配置的操作25if (configFile !== false) {26 const loadResult = await loadConfigFromFile(27 configEnv,28 configFile,29 config.root,30 config.logLevel31 )32 // 如果找到了配置文件则进行和现有的配置文件进行合并33 if (loadResult) {34 config = mergeConfig(loadResult.config, config)35 // 并保存配置文件的路径地址和配置文件中所使用到的依赖项36 // 方便在 pre-bundling 的阶段一起做优化37 configFile = loadResult.path38 configFileDependencies = loadResult.dependencies39 }40}
查找对应的配置文件用到的方法是: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,读取的方式为:
- 默认 isESM = false
- 如果文件以 mjs/mts 结尾,则文件是 ESM 格式
- 如果文件以 cjs/cts 结尾,则文件时 CJS 格式
- 如果以上方法都不适用,说明文件是以 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-deps
、inject-file-scope-variables
,它们的作用是读取到 monorepo 中的一些共享依赖(或者称之为外部依赖),和注入静态变量(__dirname, __fileName, import.meta.url 在进行打包的时候会被 esbuild 打包时设置的 define 配置项中定义的变量名替换,而注入静态变量则是将这些静态变量名进行声明赋值,让它变成一个真实可用的变量)。
1const dirnameVarName = '__vite_injected_original_dirname'2const filenameVarName = '__vite_injected_original_filename'3const importMetaUrlVarName = '__vite_injected_original_import_meta_url'4const result = await build({5 absWorkingDir: process.cwd(), // 工作的绝对路径6 entryPoints: [fileName], // 需要打包的文件入口7 outfile: 'out.js', // 输出的文件名8 write: false, // 是否写入到外部文件9 target: ['node14.18', 'node16'], // 生成的代码需要对哪些环境进行 hack10 platform: 'node', // 打包平台12 collapsed lines
11 bundle: true, // 把相关的依赖项单独打包,而不是捆绑在一起 https://esbuild.github.io/api/#bundle12 format: isESM ? 'esm' : 'cjs', // 打包的文件模块系统13 sourcemap: 'inline', // 讲 sourcemap 和代码打包在一起14 metafile: true, // 记录了模块文件的元信息 https://esbuild.github.io/api/#metafile15 define: {16 // 替换全局变量为其他的变量名17 __dirname: dirnameVarName,18 __filename: filenameVarName,19 'import.meta.url': importMetaUrlVarName20 },21 plugins: [...]22})
当然这里面涉及到了 esbuild plugin 的概念,在本文中不会对其详细展开,有机会再写一遍关于 esbuild plugin 的文章,这里只会对 vite 已经编写好的插件进行一个简单的解读,了解一下主要做了哪些事情。
1{2 name: 'externalize-deps',3 setup(build) {4 build.onResolve({ filter: /.*/ }, ({ path: id, importer }) => {5 // externalize bare imports6 // 如果文件路径不是以 . 开头并且 路径不是一个绝对路径则表示是外部依赖7 if (id[0] !== '.' && !path.isAbsolute(id)) {8 return {9 external: true10 }55 collapsed lines
11 }12 // bundle the rest and make sure that the we can also access13 // it's third-party dependencies. externalize if not.14 // monorepo/15 // ├─ package.json16 // ├─ utils.js -----------> bundle (share same node_modules)17 // ├─ vite-project/18 // │ ├─ vite.config.js --> entry19 // │ ├─ package.json20 // ├─ foo-project/21 // │ ├─ utils.js --------> external (has own node_modules)22 // │ ├─ package.json23 const idFsPath = path.resolve(path.dirname(importer), id)24 const idPkgPath = lookupFile(idFsPath, [`package.json`], {25 pathOnly: true26 })27 // 如果找到了 monorepo 的根模块的 package.json28 if (idPkgPath) {29 const idPkgDir = path.dirname(idPkgPath)30 // 如果该文件需要向上一个或多个目录才能访问到 vite 配置31 // 这意味着它有自己的node_modules(例如foo-project)32 if (path.relative(idPkgDir, fileName).startsWith('..')) {33 return {34 // 在捆绑为单个 vite 配置后,将实际导入的地址规范化35 path: isESM ? pathToFileURL(idFsPath).href : idFsPath,36 external: true37 }38 }39 }40 })41 }42},43{44 name: 'inject-file-scope-variables',45 setup(build) {46 build.onLoad({ filter: /\.[cm]?[jt]s$/ }, async (args) => {47 const contents = await fs.promises.readFile(args.path, 'utf8')48 const injectValues =49 `const ${dirnameVarName} = ${JSON.stringify(50 path.dirname(args.path)51 )};` +52 `const ${filenameVarName} = ${JSON.stringify(args.path)};` +53 `const ${importMetaUrlVarName} = ${JSON.stringify(54 pathToFileURL(args.path).href55 )};`56
57 return {58 // 如果文件路径是以 ts 结尾,则使用 typescript 作为文件加载器,否则使用默认的 loader59 loader: args.path.endsWith('ts') ? 'ts' : 'js',60 // 把声明的三个静态变量文件插入到文件内容最顶部61 contents: injectValues + contents62 }63 })64 }65}
最终将代码及文件的依赖关系导出:
1const { text } = result.outputFiles[0]2return {3 code: text,4 dependencies: result.metafile ? Object.keys(result.metafile.inputs) : []5}
result.metafile
属性大致是这样的:
1{2 inputs: { 'vite.config.js': { bytes: 5027, imports: [] } },3 outputs: {4 'out.js': {5 imports: [],6 exports: [],7 entryPoint: 'vite.config.js',8 inputs: [Object],9 bytes: 1615010 }2 collapsed lines
11 }12}
在 bundleConfigFile 完成配置文件的转换后,会通过 loadConfigFromBundledFile 方法将配置文件动态加载进来,让其变成一个真正的 JavaScript 对象或函数(vite.config.js 也可以接收一个默认导出的函数作为配置,当导出一个函数的时候,参数是一个包含 mode、command、ssrBuild 的对象)。
loadConfigFromBundledFile(resolvedPath,bundled.code,isESM)
如果文件模块系统是 esm,写将代码写入一个临时文件,并使用 dynamicImport
方法,而 dynamicImport 方法则是一个普通函数,在函数内部返回这个文件:
1// @ts-expect-error2export const usingDynamicImport = typeof jest === 'undefined'3
4/**5 * Dynamically import files. It will make sure it's not being compiled away by TS/Rollup.6 *7 * As a temporary workaround for Jest's lack of stable ESM support, we fallback to require8 * if we're in a Jest environment.9 * See https://github.com/vitejs/vite/pull/5197#issuecomment-93805407710 *5 collapsed lines
11 * @param file File path to import.12 */13export const dynamicImport = usingDynamicImport14 ? new Function('file', 'return import(file)') // === function (file) { return import(file) }15 : _require
至于为什么要这么做,在方法的注释上已经描述清楚了,为了不被 ts 和 rollup 编译掉。fileName
指的是配置文件在磁盘中的绝对路径(比如:/Volumes/code/vite/playground/html/vite.config.js),在创建临时文件后会尝试使用 await importZ()
来导入它,导入完成后将尝试删除它:
1// for esm, before we can register loaders without requiring users to run node2// with --experimental-loader themselves, we have to do a hack here:3// write it to disk, load it with native Node ESM, then delete the file.4if (isESM) {5 const fileBase = `${fileName}.timestamp-${Date.now()}` // 包含配置文件的绝对路径的临时文件名6 const fileNameTmp = `${fileBase}.mjs` // 完整文件路径7 const fileUrl = `${pathToFileURL(fileBase)}.mjs` // 文件以 file:// 协议存在于磁盘中的地址8 fs.writeFileSync(fileNameTmp, bundledCode)9 try {10 // import 只支持 file 协议的文件地址10 collapsed lines
11 return (await dynamicImport(fileUrl)).default12 } finally {13 try {14 // 在加载完成后,移除这个临时文件15 fs.unlinkSync(fileNameTmp)16 } catch {17 // already removed if this function is called twice simultaneously18 }19 }20}
而 CJS 的模块导入则要更麻烦一点,
1// for cjs, we can register a custom loader via `_require.extensions`2else {3 const extension = path.extname(fileName) // 文件的后缀名4 const realFileName = fs.realpathSync(fileName) // 配置文件在磁盘中的真实绝对地址5 // 如果对应的后缀已存在内置的 loader,则保留其后缀,否则更换为 js loader,不然就会出现没有合适的解析器能解析对应文件内容的情况6 // Node 中内置的解析器有 .js、.json、.node7 const loaderExt = extension in _require.extensions ? extension : '.js'8 const defaultLoader = _require.extensions[loaderExt]! // 找到文件对应的解析器进行缓存9 // 重写对应格式的加载器10 _require.extensions[loaderExt] = (module: NodeModule, filename: string) => {15 collapsed lines
11 if (filename === realFileName) {12 ;(module as NodeModuleWithCompile)._compile(bundledCode, filename)13 } else {14 defaultLoader(module, filename)15 }16 }17 // clear cache in case of server restart18 // 译:在服务重启的时候删掉缓存19 delete _require.cache[_require.resolve(fileName)]20 const raw = _require(fileName)21 // 恢复原本的文件加载器22 _require.extensions[loaderExt] = defaultLoader23 // 如果这个文件是已经被 esbuild 编译过的文件则返回默认导出,否则返回原始内容24 return raw.__esModule ? raw.default : raw25}
最后会返回带有 path
、config
、dependencies
字段的的对象。normalizePath
属性的主要作用是判断当前运行平台是否是 window,如果是则会将路径中的 \\
转换为 /
。
1import path from 'node:path'2
3export function slash(p: string): string {4 return p.replace(/\\/g, '/')5}6
7export const isWindows = os.platform() === 'win32'8
9export function normalizePath(id: string): string {10 return path.posix.normalize(isWindows ? slash(id) : id)1 collapsed line
11}
现在调用栈会回到 resolveConfig 函数中,但其实剩下的工作都是对各种边界的处理,以及对 plugins 的排序和过滤,在处理完所有情况后,会将所有的选项合并后返回出来,至此,createServer
内部的 resolveConfig
就完成了,接下来会处理 resolveHttpsConfig
的配置项,但其实这个并不关键,因为默认情况下,httpsOptions
都会是一个 undefined,除非指定了 https
参数。
1const httpsOptions = await resolveHttpsConfig(2 config.server.https,3 config.cacheDir4)
所以我们下一篇来看一下 resolveChokidarOptions
、 resolveHttpServer
、createWebSocketServer
和创建 connect
中间件系统的处理。