前言
目前为止,我们已经完成了本地http
服务器的创建,它尚是一个封闭的环境,用户无法从外部传递参数来做个性化配置
本节我们需要将一部分能力的控制权交由用户管理
源码获取
更新进度
公众号:更新至第12
节
博客:更新至第5
节
源码分析
当配置过多时,向用户提供配置文件是一个明智的选择,在vite
中指定vite.config.xx
为配置文件
ts
import { defineConfig } from 'vite';
export default defineConfig({
...
});
之所以扩展名是.xx
,是因为vite
要兼容大多数常见的文件后缀,比如.js
、.ts
等,如下是 vite 支持的配置文件后缀
ts
// packages\vite\src\node\constants.ts
export const DEFAULT_CONFIG_FILES = [
"vite.config.js",
"vite.config.mjs",
"vite.config.ts",
"vite.config.cjs",
"vite.config.mts",
"vite.config.cts",
];
既然有动态可选的,就一定要有托底的配置项来保证vite
能够正常提供服务,因此,在http
服务器的最开始创建阶段,就需要去对配置项进行处理
ts
// packages\vite\src\node\server\index.ts
export async function _createServer(
inlineConfig: InlineConfig = {},
options: { ws: boolean },
): Promise<ViteDevServer> {
const config = await resolveConfig(inlineConfig, 'serve')
...
}
沿着resolveConfig
函数,向下找,并将代码定位到loadConfigFromFile
函数
ts
// packages\vite\src\node\config.ts
export async function loadConfigFromFile(
configEnv: ConfigEnv,
configFile?: string,
configRoot: string = process.cwd(),
logLevel?: LogLevel
): Promise<{
path: string;
config: UserConfig;
dependencies: string[];
} | null> {}
在该函数中,vite
会按照DEFAULT_CONFIG_FILES
依次查找用户侧是否存在配置文件
ts
for (const filename of DEFAULT_CONFIG_FILES) {
const filePath = path.resolve(configRoot, filename);
if (!fs.existsSync(filePath)) continue;
resolvedPath = filePath;
break;
}
找到配置文件后,尝试去获取文件类型,从如下逻辑可知,vite
优先把文件扩展名作为判断依据,其次会降级为取package.json
中的module
字段,这是因为后续对是否是esm
格式的处理方式的差异导致的
ts
let isESM = false;
// 校验vite.config.xx配置文件的扩展名来识别使用的是哪一种模块规范
if (/\.m[jt]s$/.test(resolvedPath)) {
isESM = true;
} else if (/\.c[jt]s$/.test(resolvedPath)) {
isESM = false;
} else {
// 如果无法从扩展名获取有用的信息,则找package.json,该文件的type字段也可以用以区分cjs和esm
try {
const pkg = lookupFile(configRoot, ["package.json"]);
isESM =
!!pkg && JSON.parse(fs.readFileSync(pkg, "utf-8")).type === "module";
} catch (e) {}
}
下一步去读取配置文件,并且此时的配置文件是在用户侧未经过打包处理的,是不能直接拿来使用的,因此需要vite
对其进行下打包处理,即bundleConfigFile
要完成的工作
ts
const bundled = await bundleConfigFile(resolvedPath, isESM);
进入bundleConfigFile
,它本质上就是借助了第三方打包工具做了一次build
处理,vite
使用的是 esbuild
,但是实际上可以是任意其他的如rollup
亦或者是webpack
ts
async function bundleConfigFile(
fileName: string,
isESM: boolean,
): Promise<{ code: string; dependencies: string[] }> {
...
const result = await build({
...
})
const { text } = result.outputFiles[0]
return {
code: text,
dependencies: result.metafile ? Object.keys(result.metafile.inputs) : [],
}
}
回到loadConfigFromFile
函数,去导入打包好的文件
ts
const userConfig = await loadConfigFromBundledFile(
resolvedPath,
bundled.code,
isESM
);
正常来说,esm
文件使用import
导入,cjs
文件使用require
就好了,事实上在svite
中这样做也完全ok
,不过vite
要考虑和兼容的情况更多,比如vite
中对cjs
的处理,它对默认的require
行为进行了重写,原因是require
内部会执行一次文件的读取行为获取code
,这对于当前来说是没有必要的,因为此时已经事实上拿到了源码,即bundled.code
ts
const extension = path.extname(fileName);
const realFileName = await promisifiedRealpath(fileName);
const loaderExt = extension in _require.extensions ? extension : ".js";
// 保存默认的require行为
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);
}
};
// 清除缓存
delete _require.cache[_require.resolve(fileName)];
const raw = _require(fileName);
_require.extensions[loaderExt] = defaultLoader;
return raw.__esModule ? raw.default : raw;
回到loadConfigFromFile
函数,获取到文件内导出的内容,该部分可能是一个函数,也可能是一个对象
ts
const config = await(
typeof userConfig === "function" ? userConfig(configEnv) : userConfig
);
最后需要对用户配置文件中的配置的TypeScript
类型做支持,为此,vite
提供了单独的defineConfig
函数
ts
export function defineConfig(config: UserConfigExport): UserConfigExport {
return config;
}
代码实现
首先,svite
的目的不是做成vite
,而是帮助读者更好的理解vite
,因此,我们只需要支持一种配置文件后缀即可:svite.config.ts
进入packages\vite\src\node\config.ts
文件,新增并导出DEFAULT_CONFIG_FILES
ts
export const DEFAULT_CONFIG_FILES = ["svite.config.ts"];
找到该文件下的resolveConfig
函数,它在本地 server 的创建流程
一节中已经被正确放置到调用处,如下,新增parseConfigFile
函数来处理配置文件相关的读取与设置
ts
export async function resolveConfig(userConf: UserConfig) {
const internalConf = {};
const conf = {
...userConf,
...internalConf,
};
const userConfig = await parseConfigFile(conf);
return {
...conf,
...userConfig,
};
}
进入parseConfigFile
函数,它的第一步仍然是从用户侧匹配对应的配置文件
ts
let resolvedPath: string | undefined;
for (const filename of DEFAULT_CONFIG_FILES) {
const filePath = resolve(process.cwd(), filename);
if (!existsSync(filePath)) continue;
resolvedPath = filePath;
break;
}
如果我们的配置文件只有一个默认的export
ts
export default {
name: "spp",
};
那我直接使用import
导入理论上是没有问题的
ts
await import(resolvedPath);
但是现实是这会报错
ts
Error [ERR_UNSUPPORTED_ESM_URL_SCHEME]: Only file and data URLs are supported by the default ESM loader. On Windows, absolute paths must be valid file:// URLs. Received protocol 'c:'
这是由于node
默认的esm
加载器不支持导致的,为此我们需要读取到源码并将其转化为base64
后再交给node
进行加载
ts
const code = readFileSync(resolvedPath, "utf-8");
const dynamicImport = new Function("file", "return import(file)");
const configTimestamp = `${resolvedPath}.timestamp:${Date.now()}-${Math.random()
.toString(16)
.slice(2)}`;
const res = (
await dynamicImport(
"data:text/javascript;base64," +
Buffer.from(`${code}\n//${configTimestamp}`).toString("base64")
)
).default;
现在,新建一个.ts
文件并在svite.config.ts
中引入作为配置项的值,此时再次运行会再次报错!!!
ts
// svite.config.ts
import { name } from "./other";
export default {
name,
};
针对这种情况,我们还需要对用户侧的ts
文件进行打包,并将其构建成一个boundle
,至于打包工具,同样选择esbuild
,因为它快
如下,我们将用户文件作为esbuild
的打包入口,指定bundle
为true
将引入的外部依赖合并成一个,并且指定write
为false
,这样就不会实际生成文件了
ts
async function buildBoundle(fileName: string) {
const result = await build({
absWorkingDir: process.cwd(),
entryPoints: [fileName],
outfile: "out.js",
write: false,
target: ["node14.18", "node16"],
platform: "node",
bundle: true,
format: "esm",
mainFields: ["main"],
sourcemap: "inline",
metafile: false,
});
const { text } = result.outputFiles[0];
return text;
}
此时,只需要使用buildBoundle
的结果替换前文readFileSync
读取的内容就可以了,我这里顺便将其提取成了一个函数
ts
async function loadConfigFromBoundled(code: string, resolvedPath: string) {
const dynamicImport = new Function("file", "return import(file)");
const configTimestamp = `${resolvedPath}.timestamp:${Date.now()}-${Math.random()
.toString(16)
.slice(2)}`;
return (
await dynamicImport(
"data:text/javascript;base64," +
Buffer.from(`${code}\n//${configTimestamp}`).toString("base64")
)
).default;
}
接下来,只需要对userConfigFile
做下校验,如果是函数,我们就将内部的配置项向用户传递一份
ts
return typeof userConfigFile === "function"
? userConfigFile(conf)
: userConfigFile;
总结
本节,为svite
增加了配置文件,它让svite
具有了开放性,用户可以通过该文件传递受支持的配置从而影响内部的工作行为
在实现的过程中,稍微有点复杂的是配置文件打包和转base64
的这两个操作,前者是为了消除ts
,后者则是为了加载配置文件