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 里用来实现“模块”的不同规则。
CJSCJS 就是 CommonJS 规范的缩写。
语法:
// doSomething.js
// 导出
module.exports = function doSomething(n) {
// 做点啥
}
// 引入
const doSomething = require('./doSomething.js');
特点:
- 每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。
- 每个模块内部,module 变量代表当前模块。这个变量是一个对象,它的 exports 属性(即module.exports)是对外的接口。 加载某个模块,其实是加载该模块的module.exports属性。
- 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
- 模块加载的顺序,按照其在代码中出现的顺序。
- 值得一提的是,CommonJS 规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作。
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 就是 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) |
特点 |
|
|
|
运行环境 |
仅 NodeJS |
NodeJS、浏览器 |
NodeJS、浏览器 |
father-build 打包目录 |
lib 目录 |
dist 目录 |
es 目录 |
- main:定义一个入口文件
- module:定义一个针对 es6 模块及语法的入口文件
- unpkg:unpkg 是一个前端常用的公共CDN 服务。配置了这个参数,可以让上传到 npm的所有文件都开启 unpkg 的 cdn 服务。
含义:由于 JavaScript 既可以编写服务端代码也可以编写浏览器代码,所以 webpack 提供了 target 属性,用来制定构建目标。
默认值:当配置了 browserslist 的时候,默认值是 "browserslist" ;否则就是 "web" 。
webpack.config.js
module.exports = {
target: 'node',
};
含义:当从 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 是如何工作的呢?
- 先来建一个项目(umi 搭建一个) 。
- 使用 vscode 来调试,配置 launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug father",
"skipFiles": [
"<node_internals>/**"
],
"runtimeExecutable": "npm",
"runtimeArgs": [
"run-script",
"build"
]
}
]
}
- 点击 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);
- 首先使用了 babel-register。使用 babel-register 之后,后续被 node 使用 require 语法引用的文件,都会被 babel 进行代码转换。
- 获取打包配置 getBundleOpts(opts) 。
- 根据配置的 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;
}
- 首先通过getExistFile() 来获取入口文件,内部是 fs 模块的 existsSync 方法判断入口文件是否存在,通常就是我们项目下 src/index.js。
- 接着通过 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 中定义配置文件应该遵循的格式进行校验。
- 根据获取的 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.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}`);
}
- babel 模式默认不支持 cssModules。
- babel 模式打包资源放到 dist、es、lib 文件夹下,rollup 模式全部放到 dist 文件夹下(新的 father-build 的 esm 已经支持定义 dir,将打包资源放到自定义文件夹下,但是esm.mjs格式的资源还不能自定义,还是打包到 dist 文件夹下面 )。
- boss 中 webpack 配置添加 module,因为 father-build 不同模式、打包的资源放到的文件夹不一样,要注意发包的时候 package.json 的资源引用配置,可能因为 webpack 配置resolve 添加的 module 导致资源找不到。
GitHub - umijs/father: Library toolkit based on rollup and babel.
,