《vite技术揭秘、还原与实战》第5节–支持svite.config.ts配置文件

前言

目前为止,我们已经完成了本地http服务器的创建,它尚是一个封闭的环境,用户无法从外部传递参数来做个性化配置

本节我们需要将一部分能力的控制权交由用户管理

源码获取

传送门

更新进度

公众号:更新至第12

博客:更新至第5

源码分析

当配置过多时,向用户提供配置文件是一个明智的选择,在vite中指定vite.config.xx为配置文件

import { defineConfig } from 'vite';

export default defineConfig({
    ...
});

之所以扩展名是.xx,是因为vite要兼容大多数常见的文件后缀,比如.js.ts等,如下是 vite 支持的配置文件后缀

// packagesvitesrc
odeconstants.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服务器的最开始创建阶段,就需要去对配置项进行处理

// packagesvitesrc
odeserverindex.ts
export async function _createServer(
  inlineConfig: InlineConfig = {},
  options: { ws: boolean },
): Promise<ViteDevServer> {
  const config = await resolveConfig(inlineConfig, 'serve')
  ...
}

沿着resolveConfig函数,向下找,并将代码定位到loadConfigFromFile函数

// packagesvitesrc
odeconfig.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依次查找用户侧是否存在配置文件

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格式的处理方式的差异导致的

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要完成的工作

const bundled = await bundleConfigFile(resolvedPath, isESM);

进入bundleConfigFile,它本质上就是借助了第三方打包工具做了一次build处理,vite使用的是 esbuild,但是实际上可以是任意其他的如rollup亦或者是webpack

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函数,去导入打包好的文件

const userConfig = await loadConfigFromBundledFile(
  resolvedPath,
  bundled.code,
  isESM
);

正常来说,esm文件使用import导入,cjs文件使用require就好了,事实上在svite中这样做也完全ok,不过vite要考虑和兼容的情况更多,比如vite中对cjs的处理,它对默认的require行为进行了重写,原因是require内部会执行一次文件的读取行为获取code,这对于当前来说是没有必要的,因为此时已经事实上拿到了源码,即bundled.code

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函数,获取到文件内导出的内容,该部分可能是一个函数,也可能是一个对象

const config = await(
  typeof userConfig === "function" ? userConfig(configEnv) : userConfig
);

最后需要对用户配置文件中的配置的TypeScript类型做支持,为此,vite提供了单独的defineConfig函数

export function defineConfig(config: UserConfigExport): UserConfigExport {
  return config;
}

代码实现

首先,svite的目的不是做成vite,而是帮助读者更好的理解vite,因此,我们只需要支持一种配置文件后缀即可:svite.config.ts

进入packagesvitesrc
odeconfig.ts
文件,新增并导出DEFAULT_CONFIG_FILES

export const DEFAULT_CONFIG_FILES = ["svite.config.ts"];

找到该文件下的resolveConfig函数,它在本地 server 的创建流程一节中已经被正确放置到调用处,如下,新增parseConfigFile函数来处理配置文件相关的读取与设置

export async function resolveConfig(userConf: UserConfig) {
  const internalConf = {};
  const conf = {
    ...userConf,
    ...internalConf,
  };
  const userConfig = await parseConfigFile(conf);
  return {
    ...conf,
    ...userConfig,
  };
}

进入parseConfigFile函数,它的第一步仍然是从用户侧匹配对应的配置文件

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

export default {
  name: "spp",
};

那我直接使用import导入理论上是没有问题的

await import(resolvedPath);

但是现实是这会报错

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进行加载

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}
//${configTimestamp}`).toString("base64")
  )
).default;

现在,新建一个.ts文件并在svite.config.ts中引入作为配置项的值,此时再次运行会再次报错!!!

// svite.config.ts
import { name } from "./other";
export default {
  name,
};

针对这种情况,我们还需要对用户侧的ts文件进行打包,并将其构建成一个boundle,至于打包工具,同样选择esbuild,因为它快

如下,我们将用户文件作为esbuild的打包入口,指定bundletrue将引入的外部依赖合并成一个,并且指定writefalse,这样就不会实际生成文件了

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读取的内容就可以了,我这里顺便将其提取成了一个函数

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}
//${configTimestamp}`).toString("base64")
    )
  ).default;
}

接下来,只需要对userConfigFile做下校验,如果是函数,我们就将内部的配置项向用户传递一份

return typeof userConfigFile === "function"
  ? userConfigFile(conf)
  : userConfigFile;

总结

本节,为svite增加了配置文件,它让svite具有了开放性,用户可以通过该文件传递受支持的配置从而影响内部的工作行为

在实现的过程中,稍微有点复杂的是配置文件打包和转base64的这两个操作,前者是为了消除ts,后者则是为了加载配置文件