一、介绍

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

father-build 第三方包(father-build使用总结)(1)

它们是什么?

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

CJS

CJS 就是 CommonJS 规范的缩写。

语法:

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

特点:

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 } }));

特点:

ESM

ESM 就是 ECMAScript Module 的缩写。

语法:

// 导出 export default function() { // 做点啥 }; export const foo() {...}; export const bar() {...}; // 引入 import {foo, bar} from './myLib';

特点:

模块规范

CJS

(CommonJS )

UMD

(Universal Module Definition)

ESM

(ECMAScript Module)

特点

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

运行环境

仅 NodeJS

NodeJS、浏览器

NodeJS、浏览器

father-build 打包目录

lib 目录

dist 目录

es 目录

相关配置package.json 发行打包相关参数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'], }, };

father-build 第三方包(father-build使用总结)(2)

项目在解析依赖包的文件时,是按照 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,开始调试

father-build 第三方包(father-build使用总结)(3)

father-build 第三方包(father-build使用总结)(4)

三、源码分析

father-build 第三方包(father-build使用总结)(5)

在前面的调试中,我们可以看到,入口文件是 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 进行解析,得到结果为

father-build 第三方包(father-build使用总结)(6)

然后进入到 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.

,