小试牛刀 -- 打造专属于你的npm scripts插件工具集

139 阅读6分钟

前言:谈到前端开发,各种工具繁琐的配置实在让人头大,而对于一个企业来说,维护一个统一的企业级别的工具配置或设计,对工程效率和人效的提升是非常重要的。在这个背景下,负责抽象提取工程基建中的种种工具和方案的插件工具集就应时而生了。

目标:制作一个开箱即用的npm scripts插件工具集,提供基础工具的方案设计和具体配置,并发布成一个npm插件,以实现高度抽象复用。

开始制作 "basic-scirpts", 以babel工具为例进行介绍
设计思想:在设计上,支持开发者在项目中添加.babelrc等配置文件或者在项目的package.json文件中添加对应的babel配置项,这样,在使用的时候可以在默认的配置方案的基础之上,自定义自己的个性化工具配置和方案。basic-scirpts在运行时读取这些信息,并采用开发者自定义的配置。

我们在项目中通过package.json进行配置:

{ "babel": { "presets": ["basic-scripts/babel"], "plugins": ["glamorous-displayname"] } }

这样我们就可以使用basic-scripts定义的babel预设/配置方案,以及对应的插件。设计方案如下:

// 使用 browserslist 包进行降级目标设置,获取当前环境下需要兼容的浏览器列表
const browserslist = require("browserslist");

// 用于解析node版本
const semver = require("semver");

// 几个工具包,这里不再一一展开
const {
  ifDep,
  ifAnyDep,
  ifTypescript,
  parseEnv,
  appDirectory,
  pkg,
} = require("../utils");

// 获取环境变量
const { BABEL_ENV, NODE_ENV, BUILD_FORMAT } = process.env;

// 几个关键变量的判断
const isTest = (BABEL_ENV || NODE_ENV) === "test";
const isPreact = parseEnv("BUILD_PREACT", false);
const isRollup = parseEnv("BUILD_ROLLUP", false);
const isUMD = BUILD_FORMAT === "umd";
const isCJS = BUILD_FORMAT === "cjs";
const isWebpack = parseEnv("BUILD_WEBPACK", false);
const isMinify = parseEnv("BUILD_MINIFY", false);
const treeshake = parseEnv("BUILD_TREESHAKE", isRollup || isWebpack);
const alias = parseEnv("BUILD_ALIAS", isPreact ? { react: "preact" } : null);

// 是否使用 @babel/runtime
const hasBabelRuntimeDep = Boolean(
  pkg.dependencies && pkg.dependencies["@babel/runtime"]
);
const RUNTIME_HELPERS_WARN =
  'You should add @babel/runtime as dependency to your package. It will allow reusing "babel helpers" from node_modules rather than bundling their copies into your files.';
  
// 强制使用 @babel/runtime,以减少编译后代码体积等, 否则抛出错误
if (!treeshake && !hasBabelRuntimeDep && !isTest) {
  throw new Error(RUNTIME_HELPERS_WARN);
} else if (treeshake && !isUMD && !hasBabelRuntimeDep) {
  console.warn(RUNTIME_HELPERS_WARN);
}

// 获取用户的 browserslist 配置,默认给一个 ie 10 和 ios 7 配置
const browsersConfig = browserslist.loadConfig({ path: appDirectory }) || [
  "ie 10",
  "ios 7",
];

// 获取 envTargets,如果是测试环境则使用运行时的node版本作为编译目标,否则如果使用webpack或者rollup打包则针对当前环境下需要兼容的目标浏览器去做编译,默认使用依赖包中最低的node版本作为编译目标
const envTargets = isTest
  ? { node: "current" }
  : isWebpack || isRollup
  ? { browsers: browsersConfig }
  : { node: getNodeVersion(pkg) };
  
// 约束@babel/preset-env 的配置默认使用以下配置项  
const envOptions = {modulesfalse, loosetrue, targets: envTargets}

// babel 默认方案
module.exports = () => ({
  // presets最佳实践
  presets: [
    [require.resolve("@babel/preset-env"), envOptions],
    
    // 如果存在 react 或 preact 依赖,则补充 @babel/preset-react
    ifAnyDep(
      ["react", "preact"],
      [
        require.resolve("@babel/preset-react"),
        { pragma: isPreact ? ifDep("react", "React.h", "h") : undefined },
      ]
    ),
    
    // 如果使用 Typescript,则补充 @babel/preset-typescript
    ifTypescript([require.resolve("@babel/preset-typescript")]),
  ].filter(Boolean),
  
  // 强制使用一系列插件/最佳实践
  plugins: [
    [
      // 强制使用 @babel/plugin-transform-runtime
      require.resolve("@babel/plugin-transform-runtime"),
      { useESModules: treeshake && !isCJS },
    ],
    
    // 使用 babel-plugin-macros
    require.resolve("babel-plugin-macros"),
    
    // 是否使用别名配置
    alias
      ? [
          require.resolve("babel-plugin-module-resolver"),
          { root: ["./src"], alias },
        ]
      : null,
      
    // 是否编译为 UMD 规范
    isUMD
      ? require.resolve("babel-plugin-transform-inline-environment-variables")
      : null,
      
    // 强制使用 @babel/plugin-proposal-class-properties编译class
    [
      require.resolve("@babel/plugin-proposal-class-properties"),
      { loose: true },
    ],
    
    // 是否进行压缩
    isMinify
      ? require.resolve("babel-plugin-minify-dead-code-elimination")
      : null,
      
    treeshake
      ? null
      : require.resolve("@babel/plugin-transform-modules-commonjs"),
  ].filter(Boolean),
});

// 获取 node 版本
function getNodeVersion({ engines: { node: nodeVersion = "10.13" } = {} }) {
  const oldestVersion = semver
    .validRange(nodeVersion)
    .replace(/[>=<|]/g, " ")
    .split(" ")
    .filter(Boolean)
    .sort(semver.compare)[0];
  if (!oldestVersion) {
    throw new Error(
      `Unable to determine the oldest version in the range in your <span class="hljs-keyword">package</span>.json at engines.node: <span class="hljs-string">"${nodeVersion}"</span>. Please attempt to make it less ambiguous.`
    );
  }
  return oldestVersion;
}


以上我们的babel默认设计方案中强制使用了一系列最佳实践,下面继续介绍在basic-scripts中是如何调用并执行上面的默认babel设计方案进行编译的。
const path = require("path");
// 支持使用 DEFAULT_EXTENSIONS,具体见:https://www.babeljs.cn/docs/babel-core#default_extensions
// DEFAULT_EXTENSIONS为@babel/core支持的默认文件类型如.ts等
const { DEFAULT_EXTENSIONS } = require("@babel/core");
const spawn = require("cross-spawn"); // 编译工具
const yargsParser = require("yargs-parser"); // 解析后的npm命令行参数
const rimraf = require("rimraf"); // 用来删除多余的babel复制的文件
const glob = require("glob"); // 用来获取全部的需要删除的文件如 **/xx/** 对应的所有文件

// 工具方法
const {
  hasPkgProp,
  fromRoot,
  resolveBin,
  hasFile,
  hasTypescript,
  generateTypeDefs,
} = require("../../utils");

let args = process.argv.slice(2);
const here = (p) => path.join(__dirname, p);
// 解析命令行参数
const parsedArgs = yargsParser(args);

// 是否使用 basic-scripts 提供的默认 babel 方案
const useBuiltinConfig =
  !args.includes("--presets") &&
  !hasFile(".babelrc") &&
  !hasFile(".babelrc.js") &&
  !hasFile("babel.config.js") &&
  !hasPkgProp("babel");
   
// 使用 basic-scripts 提供的默认 babel 方案, 读取相关配置, babelrc.js即为上面的babel配置文件, config为 [] 则不做处理, 如果项目中没有对应的babel配置文件则会使用咱们的这个默认babel方案进行编译
const config = useBuiltinConfig
  ? ["–presets", here(".../.../config/babelrc.js")]
  : [];
  
// 是否使用 babel-core 所提供的 DEFAULT_EXTENSIONS 能力
const extensions =
  args.includes("–extensions") || args.includes("–x")
    ? []
    : ["–extensions", [...DEFAULT_EXTENSIONS, ".ts", ".tsx"]];
    
// 忽略某些文件夹,不进行编译
const builtInIgnore = "**/tests/**,**/mocks/**";

// 参数中有–ignore代表根据用户的自定义配置进行处理,其它同理
const ignore = args.includes("–ignore") ? [] : ["–ignore", builtInIgnore];

// 是否复制文件,除非npm命令行参数指明不需要babel复制文件,则不作处理,否则需要babel复制文件
const copyFiles = args.includes("–no-copy-files") ? [] : ["–copy-files"];

// 是否使用特定的 output 文件夹
const useSpecifiedOutDir = args.includes("–out-dir");

// 默认的 output 文件夹名为 dist
const builtInOutDir = "dist";
const outDir = useSpecifiedOutDir ? [] : ["–out-dir", builtInOutDir];

// 通过命令行参数传递项目中是否有ts声明文件信息
const noTypeDefinitions = args.includes("–no-ts-defs");

// 编译开始前,是否先清理 output 文件夹
if (!useSpecifiedOutDir && !args.includes("–no-clean")) {
  rimraf.sync(fromRoot("dist"));
} else {
  args = args.filter((a) => a !== "–no-clean"); // 清除标记/参数
}
if (noTypeDefinitions) {
  args = args.filter((a) => a !== "–no-ts-defs"); // 清除标记/参数, 后续会根据noTypeDefinitions生成ts声明文件
}

// 入口编译流程
function go() {
  // 使用 spawn.sync 方式,调用 @babel/cli进行babel编译
  let result = spawn.sync(
    resolveBin("@babel/cli", { executable: "babel" }),
    [
      ...outDir,
      ...copyFiles,
      ...ignore,
      ...extensions,
      ...config,
      "src",
    ].concat(args),
    { stdio: "inherit" }
  );
  
  // 特别说明,以下status为0则代表编译正常
  // 如果 status 不为 0,返回编译状态
  if (result.status !== 0) return result.status;
  
  // 编译生成ts声明文件(如有需要)
  const pathToOutDir = fromRoot(parsedArgs.outDir || builtInOutDir);
  // 使用 Typescript,并产出 type 类型
  if (hasTypescript && !noTypeDefinitions) {
    console.log("Generating TypeScript definitions");
    result = generateTypeDefs(pathToOutDir);
    console.log("TypeScript definitions generated");
    if (result.status !== 0) return result.status;
  }
  
  // 因为 babel 目前仍然会拷贝一份需要忽略不进行编译的文件,所以我们将这些文件手动进行清理
  const ignoredPatterns = (parsedArgs.ignore || builtInIgnore)
    .split(",")
    .map((pattern) => path.join(pathToOutDir, pattern));
  const ignoredFiles = ignoredPatterns.reduce(
    (all, pattern) => [...all, ...glob.sync(pattern)],
    []
  );
  ignoredFiles.forEach((ignoredFile) => {
    rimraf.sync(ignoredFile);
  });
  return result.status;
}
process.exit(go());

上述的核心过程可以概括为:使用useBuiltinConfig来判定是否使用默认的babel编译方案,生成其它辅助配置如extensions等。在编译的时候主要做了三件事情:

  1. 使用 spawn.sync 方式,调用 @babel/cli进行babel编译
  2. 编译生成ts声明文件(如有需要)
  3. 因为 babel 目前仍然会拷贝一份需要忽略不进行编译的文件,所以我们将这些文件手动进行清理

总结:通过上面的设计,就实现了一个basic-scripts插件工具集的复用方案,如果项目中不配置babel配置文件,则默认使用basic-scripts中的babel方案进行编译,否则使用项目中的babel方案进行编译

最后,希望对你有所帮助或启发!