前言
上一节我们提供的svite.config.ts
配置文件没有相应的TypeScript
类型定义,这对开发者是不友好的
本节我们通过提供一个defineConfig
函数来优化这个问题
源码获取
传送门
更新进度
公众号:更新至第16
节
博客:更新至第6
节
尝试
这似乎很简单,在packages\vite\src\node\config.ts
中导出defineConfig
函数,该函数用作向用户提供类型支持
export function defineConfig(config: UserConfig): UserConfig {
return config;
}
为了达到这个目的,还需要在packages\vite\src\node\index.ts
中导出UserConfig
export * from "./config";
接着在playground\config\svite.config.ts
中导入并使用,理论上就大功告成了
import { defineConfig } from "svite";
export default defineConfig({
server: {},
root: "",
});
此时启用该用例,会发现如下报错
Dynamic require of "fs" is not supported
原因分析
该报错说明我们正在esm
模块的执行过程中使用require
语法,这似乎有点不可思议,因为我们在packages\vite\rollup.config.ts
文件中定义的output.format
确实是esm
格式,打包结果如下所示
import { resolve } from 'node:path';
import { existsSync } from 'node:fs';
import { build } from 'esbuild';
const DEFAULT_CONFIG_FILES = ["svite.config.ts"];
async function buildBoundle(fileName) {
...
}
async function loadConfigFromBoundled(code, resolvedPath) {
...
}
function defineConfig(config) {
return config;
}
async function parseConfigFile(conf) {
...
}
async function resolveConfig(userConf) {
...
}
export { defineConfig, parseConfigFile, resolveConfig };
//# sourceMappingURL=index.js.map
如上,我们的三个import
也都是符合esm
语法的,其中fs
和path
模块是node
内置的,node
本身又支持esm
,理论上来说不可能是它们导致的。那问题貌似出现在esbuild
上
我们找到node_modules
下的esbuild
文件夹,并根据package.json
定位到入口为packages\vite\node_modules\esbuild\lib\main.js
的文件,在其源码中发现了这两句代码
...
var fs = require("fs");
var os = require("os");
...
还记得我们上一节是如何处理svite.config.ts
文件的吗?我们为了能在文件中引入外部模块,使用esbuild
进行了打包,而build
行为会分析模块中的import
并将其打包进一个boundle
,这就意味着var fs = require("fs");
这行代码将会被打包到我们最终的boundleCode
中
import * as any from "some-pkg";
const fs = require("fs");
而对于boundleCode
我们使用的是new Function
的形式,至此,真相大白
const dynamicImport = new Function("file", "return import(file)");
如何解决
既然原因是对esbuild
进行了分析打包,那是否可以跳过对esbuild
包的build
行为呢?
首先,我们找到在packages\vite\package.json
中的dependencies
,如下
"dependencies": {
"esbuild": "^0.18.8",
"magic-string": "^0.30.0",
"rollup": "^3.21.0"
}
由于esbuild
是dependencies
的一员,则意味着,我们一定能在用户项目的node_modules
中找到该依赖包,这意味着使用import { defineConfig } from 'svite
时对入口的加载过程不会抛出错误
同时由于svite
已经被打包过,因此如果我们能直接从node_modules
引入则问题能够被解决,并且为了更安全的找到对应的包,我们使用绝对路径更为妥当
import { defineConfig } from 'XXX/node_modules/svite/dist/node/index.js'
那么问题就在于,svite
如何被转换为XXX/node_modules/svite/dist/node/index.js
呢?
源码分析
我们在原因分析中已经找到了问题出在打包处,并且也提出了解决方案:
1-将裸依赖从打包中排除
2-将裸依赖导出地址替换为绝对路径
故将代码定位到bundleConfigFile
函数的名称为externalize-deps
的esbuild plugin
中,源码简化如下
// packages/vite/src/node/config.ts
async function bundleConfigFile(
fileName: string,
isESM: boolean,
): Promise<{ code: string; dependencies: string[] }> {
...
const result = await build({
...
plugins: [
{
name: 'externalize-deps',
setup(build) {
...
// 排除裸依赖
build.onResolve(
{ filter: /^[^.].*/ },
async ({ path: id, importer, kind }) => {
...
return {
path: idFsPath,
external: true,
}
},
)
},
},
...
],
})
const { text } = result.outputFiles[0]
return {
code: text,
...
}
}
如上,vite
通过onResolve
钩子来完成对模块路径的替换和模块的排除工作,这分别对应着返回对象的path
和external
属性,当为external
设置为true
后,esbuild
将会自动将模块从打包结果中排除,因此,我们的重点是分析模块路径替换是怎么实现的,也即path
的value
值是如何生成的
// 获取裸模块的本地文件路径
idFsPath = resolveByViteResolver(id, importer, !isImport)
// 将本地文件路径转换为可用于网络访问的URL
idFsPath = pathToFileURL(idFsPath).href
进入resolveByViteResolver
函数,并按顺序找到tryNodeResolve
函数,如下是笔者简化后的代码
// packages/vite/src/node/plugins/resolve.ts
export function tryNodeResolve(...): PartialResolvedId | undefined {
const { preserveSymlinks, packageCache,... } = options
...
// 获取目录,即vite.config.ts所在的目录,一般为用户项目根目录
const basedir = path.dirname(importer)
// 获取裸模块包的信息
const pkg = resolvePackageData(pkgId, basedir, preserveSymlinks, packageCache)
...
// 模块导入函数
const resolveId = deepMatch resolveDeepImport : resolvePackageEntry
...
// 从模块信息中分析入口文件地址
const resolved = resolveId(unresolvedId, pkg, targetWeb, options)
...
return resolved
}
进入resolvePackageData
函数,可以看到,vite
从npm
包的package.json
开始查找,一般来说,在第一次while
循环过程中就能找到,若实在找不到就依次到上一级
// packages/vite/src/node/packages.ts
export function resolvePackageData(...): PackageData | null {
...
const originalBasedir = basedir
while (basedir) {
...
// 找到导入npm包的package.json文件
const pkg = path.join(basedir, 'node_modules', pkgName, 'package.json')
try {
if (fs.existsSync(pkg)) {
// 读取package.json
const pkgPath = preserveSymlinks pkg : safeRealpathSync(pkg)
const pkgData = loadPackageData(pkgPath)
...
return pkgData
}
} catch {}
// 进入上一级目录查找
const nextBasedir = path.dirname(basedir)
if (nextBasedir === basedir) break
basedir = nextBasedir
}
return null
}
在pkgPath
这一行,根据preserveSymlinks
取值来决定是否使用符号链接,符号链接其实类似于一种别名,通过读取A
可直接获取源文件B
,而非符号链接则必须找到实际的源文件B
的路径才能进行内容的读取
而safeRealpathSync
的实现又根据操作系统的不同有差异
// packages/vite/src/node/utils.ts
export let safeRealpathSync = isWindows
fs.realpathSync
: fs.realpathSync.native
总而言之,vite
获取到了一个指向npm
包的绝对路径,下一步使用loadPackageData
来进行文件内容的读取,如下,其本质上就是fs
的文件读取操作
export function loadPackageData(pkgPath: string): PackageData {
const data = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
...
return data
}
现在我们返回tryNodeResolve函数
对于resolveId
的取值,则分别对应如下两种导入方式
import xxx from 'x'
import yyy from 'x/y'
笔者不打算在此处对这两个函数进行展开,因为在svite
的实现中笔者并不打算完全采用该方式。感兴趣的读者可以自己按提示找到对应的文件定位到代码处进行查看。实际上其实现思路也很简单:找到package.json
中的文件导出字段如exports
,然后进行匹配拼接即可