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

前言

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

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

源码获取

传送门

更新进度

公众号:更新至第12

博客:更新至第5

源码分析

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

import { defineConfig } from 'vite';

export default defineConfig({
    ...
});

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

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

// 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函数

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

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

进入packages\vite\src\node\config.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}\n//${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}\n//${configTimestamp}`).toString("base64")
    )
  ).default;
}

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

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

总结

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

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容