美文网首页
unbuild技术揭秘

unbuild技术揭秘

作者: 习惯水文的前端苏 | 来源:发表于2023-04-09 13:34 被阅读0次

好文推荐

localStorage的别样用法
借助npm包统一包管理器

前言

前端的打包工具层出不穷,老牌的webpack、rollup、gulp,新生代的esbuild,vite、rspack、tsup,他们都很好的解决了某一领域的痛点,但由于其配置的繁杂导致其学习成本较大,直到看到unbuild,突然有种这双手不要也不要紧的感觉......

作者对该库的介绍是

A unified javascript build system

我的理解是:一个零配置或低配置的javascript构建工具,该结论主要源自对源码的阅读与理解,若有偏差还望评论指正

源码阅读方式

1.理清整体的实现思路
2.对我认为比较亮眼的代码做进一步分析

核心流程梳理

通过其scripts配置,我们可以知道其入口在src/cli.ts(jiti是一个typescript运行时,放到下一遍文章详谈)

"scripts": {
    "build": "pnpm unbuild",
    "unbuild": "jiti ./src/cli"
}

进入cli.ts文件,其核心做了两件事

  • 获取脚本传入的配置和项目根目录
const args = mri(process.argv.splice(2));
const rootDir = resolve(process.cwd(), args._[0] || ".");
  • 调用build函数
await build(rootDir, args.stub).catch((error) => {
    console.error(`Error building ${rootDir}: ${error}`);
    throw error;
});

进入build.ts文件

  • 配置合并

配置的来源有:package.json、用户自定义配置文件、内建的静态配置、智能分析

// 来源一:用户自定义配置文件
const buildConfig: BuildConfig = tryRequire("./build.config", rootDir) || {};
// 来源二:package.json
const pkg: PackageJson & Record<"unbuild" | "build", BuildConfig> =
    tryRequire("./package.json", rootDir);
// 来源三:智能分析
await ctx.hooks.callHook("build:prepare", ctx);
// 来源四:内建的静态配置 
.....略
  • 创建上下文

上下文是npm包开发中常用到的方式,能有效避免不同文件之间依赖的相互引用

const ctx: BuildContext = {
    options,
    warnings: new Set(),
    pkg,
    buildEntries: [],
    usedImports: new Set(),
    hooks: createHooks(),
};
  • 清空dist文件夹
if (options.clean) {
    for (const dir of new Set(options.entries.map((e) => e.outDir).sort())) {
      await rmdir(dir!);
      await fsp.mkdir(dir!, { recursive: true });
    }
}
  • 执行打包

此时,预准备工作基本完成,开始进入构建流程,这其实就是使用了rollup的编程式api来进行打包

// 获取rollup配置
const rollupOptions = getRollupOptions(ctx);
// 之间rollup打包
const buildResult = await rollup(rollupOptions);
// 输出打包文件
await buildResult.write(outputOptions);
  • 总结

目前为止,从流程上看,一切都还是中规中矩的,它就是套了壳的rollup

亮点一:零配置

在流程梳理的配置合并阶段,我们提到了合并来源包含了智能分析分支,这其实也是unbuild支持零配置的关键点之一

  • 递归读取用户文件
export function listRecursively(path: string) {
  const filenames = new Set<string>();
  const walk = (path: string) => {
    const files = readdirSync(path);
    for (const file of files) {
      const fullPath = resolve(path, file);
      if (statSync(fullPath).isDirectory()) {
        filenames.add(fullPath + "/");
        walk(fullPath);
      } else {
        filenames.add(fullPath);
      }
    }
  };
  walk(path);
  return [...filenames];
}
  • 推断打包输出格式

1.根据exports的语法进行格式化

export function inferExportType(
  condition: string,
  previousConditions: string[] = [],
  filename = ""
): "esm" | "cjs" {
  if (filename) {
    if (filename.endsWith(".d.ts")) {
      return "esm";
    }
    if (filename.endsWith(".mjs")) {
      return "esm";
    }
    if (filename.endsWith(".cjs")) {
      return "cjs";
    }
  }
  switch (condition) {
    case "import":
      return "esm";
    case "require":
      return "cjs";
    default: {
      if (previousConditions.length === 0) {
        // TODO: Check against type:module for default
        return "esm";
      }
      const [newCondition, ...rest] = previousConditions;
      return inferExportType(newCondition, rest, filename);
    }
  }
}

2.读取pkg中配置的bin、main、module、types|typings字段

if (pkg.main) {
    outputs.push({ file: pkg.main });
}

3.推测用户期望输出的文件格式

const isESMPkg = pkg.type === "module";
  for (const output of outputs.filter((o) => !o.type)) {
    const isJS = output.file.endsWith(".js");
    if ((isESMPkg && isJS) || output.file.endsWith(".mjs")) {
      output.type = "esm";
    } else if ((!isESMPkg && isJS) || output.file.endsWith(".cjs")) {
      output.type = "cjs";
    }
}
  • 推断打包入口

正向时,我们通过构建工具从入口开始执行打包,然后在pkg.json中将对应的打包好的文件设置为出口

理论上,入口也可以从出口中反向解析出来

// possiblePaths = ['dist/index', 'index']
const input = possiblePaths.reduce((source, d) => {
  if (source) {
    return source;
  }
  const SOURCE_RE = new RegExp(`${d}${isDir ? "" : "\\.\\w+"}$`);
  return sourceFiles.
    find((i) => i.match(SOURCE_RE))?.
    replace(/(\.d\.ts|\.\w+)$/, "");
}, undefined);
  • 补充或矫正文件输出类型

在推断打包出口的文件类型时,判断的依据是是否设置了type字段且值为module,这只适用于无exports和types配置的情况,因此还需要对这两种情况做处理记录

if (res.cjs) {
   ctx.options.rollup.emitCJS = true;
}
if (res.dts) {
   ctx.options.declaration = res.dts;
}

亮点二:externals与external的使用

一个重要的前提条件是,unbuild作为构建工具,本身就只在开发环境中使用,这意味着相关依赖总是可得的,而这一部分不应该占用对用户工程的打包编译

1.排除node相关

import Module from "node:module";
externals: [
   ...Module.builtinModules,
   ...Module.builtinModules.map((m) => "node:" + m),
],

2.排除依赖包

首先,需要向pkg收集dependencies、peerDependencies、pkg.devDependencies信息

options.dependencies = Object.keys(pkg.dependencies || {});
options.peerDependencies = Object.keys(pkg.peerDependencies || {});
options.devDependencies = Object.keys(pkg.devDependencies || {});
options.externals.push(...options.dependencies, ...options.peerDependencies);

接着在external钩子中判断当前import的值是否在名单中,在就跳过

const isExplicitExternal = arrayIncludes(ctx.options.externals, pkg);
if (isExplicitExternal) {
    return true;
}

亮点三:bundless

我们知道,现代主流浏览器都对esm格式提供了天然的支持,换言之,我们没有必要像webpack那样从一开始就将所有文件一股脑打包成一个bundle,而应该更加靠近bundless,两者的对比可参考如下

boundless与bound的对比

在unbuild中,通过mkdist来实现,这个放到后边单独分享

import { mkdist, MkdistOptions } from "mkdist";
const mkdistOptions: MkdistOptions = {};
await mkdist(mkdistOptions);

亮点四:js to ts

如果现在有一个需求:为一个使用js开发的npm包添加类型注释,你会怎么做?应该大部分人都会老老实实的读源码,并在理解其含义的基础上一个一个进行定义;牛逼一些的,可能会想到去将js转ast,然后再transform到ts;而unbuild已经给我们了标准答案:untyped

import { resolveSchema, generateTypes } from "untyped";
const schema = await resolveSchema(srcConfig, defaults);
generateTypes(schema)

亮点五:watch

不管是webpack又或是vite,他们的热更新套路都需要先对对变更的源文件进行编译

现在unbuild为我们启发了新大路:为源代码添加运行时环境,即jiti

output + ".cjs",
`${shebang}module.exports = require(${JSON.stringify()})
(null, { interopDefault: true, esmResolve: true })(${JSON.stringify()})`

相关文章

网友评论

      本文标题:unbuild技术揭秘

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