四时宝库

程序员的知识宝库

father-build 使用总结(build的用法以及知识点)

一、介绍

umijs/father 是个由 lerna 管理的,基于 rollup 和 babel 的工具库。组件打包功能主要是 packages 下的 father-build 实现的。如果只做组件打包,不需要文档功能,可直接安装 father-build,使用和配置同 father。

1.1 father 特性

?? 基于 docz 的文档功能(不再维护,建议 迁移到 dumi 或 单独安装 docz 使用)

?? 基于 rollup 和 babel 的组件打包功能

?? 支持 TypeScript

?? 支持 cjs、esm 和 umd 三种格式的打包

?? esm 支持生成 mjs,直接为浏览器使用

?? 支持用 babel 或 rollup 打包 cjs 和 esm

?? 支持多 entry

?? 支持 lerna

?? 支持 css 和 less,支持开启 css modules

?? 支持 test

?? 支持用 prettier 和 eslint 做 pre-commit 检查

1.2 CJS、ESM 和 UMD

它们是什么?

它们是在 JS 里用来实现“模块”的不同规则。

CJS

CJS 就是 CommonJS 规范的缩写。

语法:

// doSomething.js
// 导出
module.exports = function doSomething(n) {
  // 做点啥
}
// 引入 
const doSomething = require('./doSomething.js'); 

特点:

  • 每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。
  • 每个模块内部,module 变量代表当前模块。这个变量是一个对象,它的 exports 属性(即module.exports)是对外的接口。 加载某个模块,其实是加载该模块的module.exports属性。
  • 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
  • 模块加载的顺序,按照其在代码中出现的顺序。
  • 值得一提的是,CommonJS 规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作。

UMD

UMD 是 Universal Module Definition(通用模块定义) 的缩写。

语法:

(function (root, factory) {
    if (typeof define === "function" && define.amd) {
        // AMD
        define(["jquery", "underscore"], factory);
    } else if (typeof exports === "object") {
        // CommonJS 等
        module.exports = factory(require("jquery"), require("underscore"));
    } else {
        // 浏览器全局变量(root 即 window)
        root.Requester = factory(root.$, root._);
    }
}(this, function ($, _) {
    // 方法
    function a() {}; // 私有方法,因为它没有被返回(见下面)
    function b() {}; // 公共方法,因为被返回了
    function c() {}; // 公共方法,因为被反会了
    // 暴露公共方法
    return {
        b: b,
        c: c
    }
}));

特点:

  • UMD 是为了让模块同时兼容 AMD 和 CommonJS 规范而出现的,多被一些需要同时支持浏览器端和服务端引用的第三方库所使用,UMD是一个时代的产物。
  • 可以使用<script>标签直接引用。
  • 通常在 ESM 不起作用的情况下用作备用 。

ESM

ESM 就是 ECMAScript Module 的缩写。

语法:

// 导出
export default function() {
  // 做点啥
};
export const foo() {...};
export const bar() {...};

// 引入
import {foo, bar} from './myLib';

特点:

  • ESM规范是ES标准的模块化规范
  • 它兼具两方面的优点:具有 CJS 的简单语法和 AMD 的异步
  • CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
    • CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。
    • ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
    • 运行时加载: CommonJS 模块就是对象;即在输入时是先加载整个模块,生成一个对象,然后再从这个对象上面读取方法,这种加载称为“运行时加载”。
    • 编译时加载: ES6 模块不是对象,而是通过 export 命令显式指定输出的代码,import 时采用静态命令的形式。即在 import 时可以指定加载某个输出值,而不是加载整个模块,这种加载称为“编译时加载”。

模块规范

CJS

(CommonJS )

UMD

(Universal Module Definition)

ESM

(ECMAScript Module)

特点

  • 同步加载
  • 不支持 tree-shaking
  • 可以使用<script>标签引用
  • 异步加载
  • 支持 tree-shaking

运行环境

仅 NodeJS

NodeJS、浏览器

NodeJS、浏览器

father-build 打包目录

lib 目录

dist 目录

es 目录

相关配置

package.json 发行打包相关参数

  • main:定义一个入口文件
  • module:定义一个针对 es6 模块及语法的入口文件
  • unpkg:unpkg 是一个前端常用的公共CDN 服务。配置了这个参数,可以让上传到 npm的所有文件都开启 unpkg 的 cdn 服务。

webpack 的 target 属性

含义:由于 JavaScript 既可以编写服务端代码也可以编写浏览器代码,所以 webpack 提供了 target 属性,用来制定构建目标。

默认值:当配置了 browserslist 的时候,默认值是 "browserslist" ;否则就是 "web" 。

webpack.config.js

module.exports = {
  target: 'node',
};


webpack 的 resolve.mainFields 属性

含义:当从 npm 包中导入模块时(例如,import * as D3 from 'd3'),此选项将决定在 npm 包的 package.json 中使用哪个字段导入模块。

默认值:根据 webpack 配置中指定的 target 不同,默认值也会有所不同。

当 target 属性设置为 webworker, web 或者没有指定的话:

webpack.config.js

module.exports = {
  //...
  resolve: {
    mainFields: ['browser', 'module', 'main'],
  },
};

对于其他任意的 target(包括 node),默认值为:

webpack.config.js

module.exports = {
  //...
  resolve: {
    mainFields: ['module', 'main'],
  },
};

项目在解析依赖包的文件时,是按照 mainFields 中属性的顺序决定优先级。

比如对于以上 antd 的 package.json 中的配置,如果我们项目中 target 没有指定,那默认会先找 browser 配置的入口文件,没有的话,再找 module 属性配置的文件。

1.3 常用配置

以下是 公司项目的 father 配置:

import {readdirSync} from 'fs';
import {join} from 'path';

const headPkgs: string[] = [
    'emotion',
     ...
];
const tailPkgs = [];
const type = process.env.BUILD_TYPE;
let config = {};

if (type === 'es') {
  config = {
    // 是否输出 cjs 格式,以及指定 cjs 格式的打包方式等。
    cjs: false,
    // 是否输出 esm 格式,以及指定 esm 格式的打包方式等。
    esm: {
      // 指定 esm 的打包类型,可选 rollup 或 babel。
      type: 'rollup',
      // 是否在 esm 模式下把 import 项里的 /lib/ 转换为 /es/。
      // 比如 import 'foo/lib/button';,在 cjs 模式下会保持原样,在 esm 模式下会编译成 import 'foo/es/button';。
      importLibToEs: true,
    },
    // 是否把 helper 方法提取到 @babel/runtime 里。
    // 推荐开启,能节约不少尺寸
    // runtimeHelpers 只对 esm 有效,cjs 下无效,因为 cjs 已经不给浏览器用了,只在 ssr 时会用到,无需关心小的尺寸差异
    runtimeHelpers: true,
    // 自定义 packages 目录下的构建顺序
    pkgs: [...headPkgs, ...tailPkgs],
    // 配置是否开启 css modules。
    // 如果组件中用了 css modules,但不开启这个配置,会导致样式引入失败
    // 虽然 father 提供这个能力,但不建议为组件库启用 CSS Modules,
    // 这将使得组件库用户很难覆写样式,下一版的 father 也将移除该特性。
    cssModules: true,
    // 在 rollup 模式下做 less 编译,支持配置 less 在编译过程中的 Options
    lessInRollupMode: {
      javascriptEnabled: true,
    },
    // 配置额外的 babel plugin
    extraBabelPlugins: [
      ['babel-plugin-import', { libraryName: 'antd', libraryDirectory: 'es', style: true }, 'antd'],
    ],
    // 是否禁用类型检测。
    disableTypeCheck: true
  };
}

export default config;

二、调试

那 father-build 是如何工作的呢?

  1. 先来建一个项目(umi 搭建一个) 。
  2. 使用 vscode 来调试,配置 launch.json
{
    "version": "0.2.0",
    "configurations": [   
        {
            "type": "node",
            "request": "launch",
            "name": "Debug father",
            "skipFiles": [
                "<node_internals>/**"
            ],
            "runtimeExecutable": "npm",
            "runtimeArgs": [
                "run-script",
                "build"
            ]
        }
    ]
}
  1. 点击 debug,开始调试

三、源码分析

在前面的调试中,我们可以看到,入口文件是 bin/father-build.js,在 father-build 中,支持通过命令行传递参数。相应的源码如下:

 const args = yParser(process.argv.slice(2));
 const buildArgs = stripEmptyKeys({
    esm: args.esm && { type: args.esm === true ? 'rollup' : args.esm },
    cjs: args.cjs && { type: args.cjs === true ? 'rollup' : args.cjs },
    umd: args.umd && { name: args.umd === true ? undefined : args.umd },
    file: args.file,
    target: args.target,
    entry: args._,
    config: args.config,
  });

比如 node ./bin/father-build.js --esm --cjs --umd --file bar ./src/index.js ,然后通过 yargs-parser 进行解析,得到结果为

然后进入到 build.ts文件,判断是否使用了 lerna,然后根据是否是 lerna来调整打包的逻辑。代码如下:

 const useLerna = existsSync(join(opts.cwd, 'lerna.json'));
 const isLerna = useLerna && process.env.LERNA !== 'none';
 const dispose = isLerna ? await buildForLerna(opts) : await build(opts);
 

先来看一下非 lerna 模式的打包逻辑:

  1. 首先使用了 babel-register。使用 babel-register 之后,后续被 node 使用 require 语法引用的文件,都会被 babel 进行代码转换。
  2. 获取打包配置 getBundleOpts(opts) 。
  3. 根据配置的 cjs、umd、esm 选项,来开始使用 babel 或者 rollup 进行打包。

代码逻辑如下:

export async function build(opts: IOpts, extraOpts: IExtraBuildOpts = {}) {
  ...
  // register babel for config files
  registerBabel({
    cwd,
    only: customConfigPath ? CONFIG_FILES.concat(customConfigPath) : CONFIG_FILES,
  });
  // Get user config
  const bundleOptsArray = getBundleOpts(opts);


  for (const bundleOpts of bundleOptsArray) {
    ...
    // Build umd
    if (bundleOpts.umd) {
      log(`Build umd`);
      await rollup({
        cwd,
        rootPath,
        log,
        type: 'umd',
        entry: bundleOpts.entry,
        watch,
        dispose,
        bundleOpts,
      });
    }


    // Build cjs
    if (bundleOpts.cjs) {
      const cjs = bundleOpts.cjs as IBundleTypeOutput;
      log(`Build cjs with ${cjs.type}`);
      if (cjs.type === 'babel') {
        await babel({ cwd, rootPath, watch, dispose, type: 'cjs', log, bundleOpts });
      } else {
        await rollup({
          cwd,
          rootPath,
          log,
          type: 'cjs',
          entry: bundleOpts.entry,
          watch,
          dispose,
          bundleOpts,
        });
      }
    }


    // Build esm
    if (bundleOpts.esm) {
      const esm = bundleOpts.esm as IEsm;
      log(`Build esm with ${esm.type}`);
      const importLibToEs = esm && esm.importLibToEs;
      if (esm && esm.type === 'babel') {
        await babel({ cwd, rootPath, watch, dispose, type: 'esm', importLibToEs, log, bundleOpts });
      } else {
        await rollup({
          cwd,
          rootPath,
          log,
          type: 'esm',
          entry: bundleOpts.entry,
          importLibToEs,
          watch,
          dispose,
          bundleOpts,
        });
      }
    }
  }
  return dispose;
}

接下来看一下 father-build 内部是怎么获取用户配置的

  1. 首先通过getExistFile() 来获取入口文件,内部是 fs 模块的 existsSync 方法判断入口文件是否存在,通常就是我们项目下 src/index.js。
  2. 接着通过 getUserConfig() 来获取用户的配置信息,内部也是通过 fs 模块判断 .fatherrc.js, .fatherrc.jsx, fatherrc.ts', .fatherrc.tsx, .umirc.library.js, .umirc.library.jsx, umirc.library.ts, umirc.library.tsx 是否存在,存在的话,就读取里面的配置信息 ,通常就是我们项目下的配置的.fatherrc.js文件配置的打包参数。同时通过 ajv这个包,来对 schema.ts 中定义配置文件应该遵循的格式进行校验。
  3. 根据获取的 userConfig 开始启用 babel 模式或者 rollup 模式打包。

接下来,看看获取到配置信息之后,father-build 是如何使用 babel 或者 rollup 打包的。

先来看一下 babel 的实现。

代码中硬编码了读取 src 目录,因此此时的 entry 配置是无效的。然后通过 pattern 找出需要编译的文件,进入到 createStream 方法

核心代码如下:

  function createStream(src) {
    const tsConfig = getTSConfig();
    const babelTransformRegexp = disableTypeCheck ? /\.(t|j)sx?$/ : /\.jsx?$/;


    function isTsFile(path) {
      return /\.tsx?$/.test(path) && !path.endsWith(".d.ts");
    }


    function isTransform(path) {
      return babelTransformRegexp.test(path) && !path.endsWith(".d.ts");
    }


    return vfs
      // 读取源文件
      .src(src, {
        allowEmpty: true,
        base: srcPath,
      })
      // gulp-plumber这是一款防止因 gulp 插件的错误而导致管道中断,plumber 可以阻止 gulp 插件发生错误导致进程退出并输出错误日志。
      .pipe(watch ? gulpPlumber() : through.obj())
      .pipe(
      // 先处理 ts
        gulpIf((f) => !disableTypeCheck && isTsFile(f.path), gulpTs(tsConfig))
      )
      .pipe(
        gulpIf(
          // 处理 less 文件
          (f) => lessInBabelMode && /\.less$/.test(f.path),
          gulpLess(lessInBabelMode || {})
        )
      )
      .pipe(
        gulpIf(
          (f) => isTransform(f.path),
          through.obj((file, env, cb) => {
            try {
              file.contents = Buffer.from(
               // 遇到 tsx, jsx 就用 babel 去处理
               // transform 方法也就是根据 babel 配置来编译文件
                transform({
                  file,
                  type,
                })
              );
              // .jsx -> .js
              file.path = file.path.replace(extname(file.path), ".js");
              cb(null, file);
            } catch (e) {
              signale.error(`Compiled faild: ${file.path}`);
              console.log(e);
              cb(null);
            }
          })
        )
      )
      // const srcPath = join(cwd, "src");
      // const targetDir = type === "esm" ? "es" : "lib";
      // const targetPath = join(cwd, targetDir);
      .pipe(vfs.dest(targetPath));
  }

再来看一下 rollup的实现。

如果选择使用 rollup 进行打包,那么代码就会先经过 rollup.ts 进入到 getRollupConfig.ts 中来,且在进入到 getRollupConfig 之前,会经过 normalizeBundleOpts 处理一些入参,比如处理 overridesByEntry 参数。到了 getRollupConfig.ts 中,就根据 type 来拼装 rollup 的参数, 包括组合 plugins,externals 来进行编译。

核心代码如下:

  switch (type) {
    case 'esm':
      const output: Record<string, any> = {
        dir: join(cwd, `${esm && (esm as any).dir || 'dist'}`),
        entryFileNames: `${(esm && (esm as any).file) || `${name}.esm`}.js`,
      }
    
      return [
        {
          input,
          output: {
            format,
            ...output,
          },
          plugins: [...getPlugins(), ...(esm && (esm as any).minify ? [terser(terserOpts)] : [])],
          external: testExternal.bind(null, external, externalsExclude),
        },
        ...(esm && (esm as any).mjs
          ? [
              {
                input,
                output: {
                  format,
                  file: join(cwd, `dist/${(esm && (esm as any).file) || `${name}`}.mjs`),
                },
                plugins: [
                  ...getPlugins(),
                  replace({
                    'process.env.NODE_ENV': JSON.stringify('production'),
                  }),
                  terser(terserOpts),
                ],
                external: testExternal.bind(null, externalPeerDeps, externalsExclude),
              },
            ]
          : []),
      ];


    case 'cjs':
      return [
        {
          input,
          output: {
            format,
            file: join(cwd, `dist/${(cjs && (cjs as any).file) || name}.js`),
          },
          plugins: [...getPlugins(), ...(cjs && (cjs as any).minify ? [terser(terserOpts)] : [])],
          external: testExternal.bind(null, external, externalsExclude),
        },
      ];


    case 'umd':
      // Add umd related plugins
      const extraUmdPlugins = [
        commonjs({
          include,
          // namedExports options has been remove from https://github.com/rollup/plugins/pull/149
        }),
      ];


      return [
        {
          input,
          output: {
            format,
            sourcemap: umd && umd.sourcemap,
            file: join(cwd, `dist/${(umd && umd.file) || `${name}.umd`}.js`),
            globals: umd && umd.globals,
            name: (umd && umd.name) || (pkg.name && camelCase(basename(pkg.name))),
          },
          plugins: [
            ...extraUmdPlugins,
            ...getPlugins(),
            replace({
              'process.env.NODE_ENV': JSON.stringify('development'),
            }),
          ],
          external: testExternal.bind(null, externalPeerDeps, externalsExclude),
        },
        ...(umd && umd.minFile === false
          ? []
          : [
              {
                input,
                output: {
                  format,
                  sourcemap: umd && umd.sourcemap,
                  file: join(cwd, `dist/${(umd && umd.file) || `${name}.umd`}.min.js`),
                  globals: umd && umd.globals,
                  name: (umd && umd.name) || (pkg.name && camelCase(basename(pkg.name))),
                },
                plugins: [
                  ...extraUmdPlugins,
                  ...getPlugins({ minCSS: true }),
                  replace({
                    'process.env.NODE_ENV': JSON.stringify('production'),
                  }),
                  terser(terserOpts),
                ],
                external: testExternal.bind(null, externalPeerDeps, externalsExclude),
              },
            ]),
      ];


    default:
      throw new Error(`Unsupported type ${type}`);
  }

项目踩坑

  1. babel 模式默认不支持 cssModules。
  2. babel 模式打包资源放到 dist、es、lib 文件夹下,rollup 模式全部放到 dist 文件夹下(新的 father-build 的 esm 已经支持定义 dir,将打包资源放到自定义文件夹下,但是esm.mjs格式的资源还不能自定义,还是打包到 dist 文件夹下面 )。
  3. boss 中 webpack 配置添加 module,因为 father-build 不同模式、打包的资源放到的文件夹不一样,要注意发包的时候 package.json 的资源引用配置,可能因为 webpack 配置resolve 添加的 module 导致资源找不到。

五、参考链接

GitHub - umijs/father: Library toolkit based on rollup and babel.

发表评论:

控制面板
您好,欢迎到访网站!
  查看权限
网站分类
最新留言
    友情链接