美文网首页
《vite技术揭秘、还原与实战》第5节--支持svite.con

《vite技术揭秘、还原与实战》第5节--支持svite.con

作者: 习惯水文的前端苏 | 来源:发表于2024-01-23 15:07 被阅读0次

    前言

    目前为止,我们已经完成了本地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,后者则是为了加载配置文件

    相关文章

      网友评论

          本文标题:《vite技术揭秘、还原与实战》第5节--支持svite.con

          本文链接:https://www.haomeiwen.com/subject/cvauodtx.html