整体项目优点:
这个项目目前是我和一个同事在维护,也欢迎一起共建,群微信二维码放文章末尾,欢迎加入。github地址如下和优点如下(欢迎star, 这篇文章相当于源码分析)。
简单来讲我们要实现的功能就是你的业务项目或组件库不用关心繁琐的webpack开发环境,打包配置和优化配置的问题, 不用关心打包组件库的glup配置问题(打包组件库和打包业务代码需求是不一样的,尤其是ui组件库,由于定制化要求过高,只有glup能满足,rollup也不行,所以配置更为繁琐)。
目前流行的开源库cli工具现状
现在流行的b端react组件库,比如
- 阿里 - ant design
- 字节抖音 - arco design
- 有赞zent,众安的移动端组件库zarm等等
都有自己单独的cli打包工具,比如ant用的叫@ant-design/tools
,字节有arco-design/cli, 这个打包工具的本质就是,假设你们用webpack开发项目,你们的项目都会配置webpack的dev或者buil命令和config文件
那么意味着,你们每开一个项目,配置webpack就来回粘贴这些配置文件,假如有一天webpack升级了6或者7,或者你们想用vite代替webpack,这些项目是不是配置文件都挨个替换,而且项目迭代期间每个项目都会被对应项目下的同学改来改去,对于后续统一维护是一个噩梦
更致命的是,打包组件库,webpack不支持esm模块,这个cli工具还要区分打包的是业务项目还是组件库,然后执行不同的打包命令。所以将工程化的文件单独集成到一个cli工具包,是非常必要的。(别以为rollup可以,打包成antdesign要求的按需加载模块,rollup很难做,要定制化用gulp做,我们的工具都帮你屏蔽这些烦人的配置)
接下来我们实现一套可以用于生产环境的cli打包工具。(第一部分是用法,如果想看技术细节可以略过这部分)
目标
我们暂定组件库名字为@mx-design/cli,作为一个npm包来发布,命令行里的命令是mx,使用方式如下(使用方式大概两分钟就能看完): 在package.json的devDependencies中加入
"devDependencies": {
+ "@mx-design/cli": "1.0.2"
}
开发环境配置
"scripts": {
"start": "mx dev",
},
为了实现dev环境自定义配置,我们还会读取你在根目录的mx.config.js文件,案例如下:
const path = require('path');
module.exports = {
// 自定义入口文件,必填
entries: {
index: {
entry: ['./src/index.js'],
template: './public/index.html',
favicon: './favicon.ico',
},
// 别名配置,可省略
resolve: {
alias: {
'@': path.join(process.cwd(), '/'),
},
},
// 加入自定义Babel插件
setBabelOptions: (options) => {
options.plugins.push([
'prismjs',
{
languages: ['javascript', 'typescript', 'jsx', 'tsx', 'css', 'scss', 'markup', 'bash'],
theme: 'default',
css: true,
},
]);
},
// 加入自定义loader
setRules: (rules) => {
rules.push({
test: /\.md$/,
use: ['raw-loader'],
});
},
};
好了,这就配置好开发环境了,是不是很简单,目前我们用的webpack5启动开发环境,解放你的webpack配置问题。
build业务代码更简单
"scripts": {
"start": "mx buildSite",
}
我们也会读取你根目录下mx.config.js文件配置,当然还有一些遍历的命令行选项,比如
"scripts": {
"start": "mx buildSite --analyzer", // 启用包分析工具
}
"scripts": {
"start": "mx buildSite --out-dir lib", // 打包后的地址目录默认是dist,这里改成了lib
}
打包组件库命令行如下(以下是建议的配置,命令行输入npm/yarn run build即可):
"scripts": {
"build:types": "rimraf types && tsc --outDir types -d --emitDeclarationOnly",
"build:es": "rimraf esm && mx buildLib --mode esm --entry-dir ./components --less-2-css --copy-less",
"build:cjs": "rimraf lib && mx buildLib --mode cjs --entry-dir ./components --less-2-css --copy-less",
"build:umd": "rimraf dist && mx buildLib --mode umd --entry ./components/index",
"build": "yarn build:types && yarn build:cjs && yarn build:es && yarn build:umd",
}
上面命令解释如下:
--mode cjs
- 表示打包cjs模式
--mode esm
- 表示打包esm模式
--mode umd
- 表示打包umd模式
--mode cjs
- 表示打包cjs模式
--less-2-css
- 表示将less转为css
--entry-dir
- mode是esm和cjs生效
- 传入打包时入口目录 默认是src
--entry
- umd模式生效
- umd入口文件 默认是src/index
--copy-less
- 复制less文件到
-out-dir-umd
- 在mode是umd模式生效
- 输出umd格式的目录,默认是
./dist
--out-dir-esm
- 输出esm格式的目录, 默认是
./esm
- 输出esm格式的目录, 默认是
--out-dir-cjs
- 输出cjs格式的目录,默认
./lib"
- 输出cjs格式的目录,默认
--analyzerUmd
- 是否webpack打包启用分析器
test测试更简单(jest测试),自动测试__tests__文件夹下的js,jsx,ts,tsx结尾的文件。
"scripts": {
"test": "mx test --watch", // 启用增量测试模式
}
以上的命令所有详细参数可以这样查看:
"scripts": {
"buildLibHelp": "mx help buildLib", // 查看所有打包组件库的命令行参数
"buildSiteHelp": "mx help buildSite", // 查看所有webpack打包业务代码的命令行参数
"testHelp": "mx help test", // 查看单元测试所有命令行参数
"devHelp": "mx help dev", // 查看所有dev环境配置参数
}
从0开始写代码
开始,使用commander来读取命令行参数
如何创建自己的mx命令呢?
需要在package.json的bin字段,加上
"bin": {
"mx": "./bin/index.js"
},
这样,当别人下载你的npm包的时候,使用mx命令就对应的是调用你npm包里bin目录下的index.js,也就是说别人在package.josn的script输入mx命令,就相当于调用了mx-design包里,bin目录下的index.js了
我们看看index.js是长什么样子
#!/usr/bin/env node
require('../lib/index');
很简单就是调用的lib下的index.ts文件
lib目录使我们最终生成的组件库(比如需要ts转译成js,babel转译语法什么的),里面的index.js就是入口文件。我们看项目里实际开发的index.ts入口文件吧。
讲解index.ts文件之前,我需要介绍一下commander这个库的简单用法
// index.js
const program = require('commander');
program.version('1.0.0').parse(process.argv);
上面的代码执行node index.js -V
或者 node index.js --version
会得到版本号1.0.0
program.version('1.0.0')是注册命令的意思,parse是解析命令行的参数,这里传入process.argv,意思是解析process.argv里的参数,所以我们输入node index.js --version
,其实就是把参数version传给了commander
commander注册过version命令,所以会得注册的版本号。
src目录下的index.ts(代码解释会写在注释里)
// 一个解析命令行参数的库,非常流行
import commander from "commander";
// 打包组件库的入口文件
import { buildLib } from "./buildLib/index";
// 打包项目代码的入口文件
import { buildSite } from "./buildSite/index";
// 打包dev环境的入口文件
import { runDev } from "./dev/index";
// 获取package.json的版本字段
import { version } from "../package.json";
// 执行单元测试的入口文件
import { runTest } from "./test";
// 注册version命令
commander.version(version, "-v, --version");
// 注册打包组件库命令,后续会讲解这个函数
buildLib(commander);
// 注册打包业务项目命令,后续会讲解这个函数
buildSite(commander);
// 注册打启动开发环境命令,后续会讲解这个函数
runDev(commander);
runTest(commander);
// commander解析命令行参数
commander.parse(process.argv);
// 如果命令行没有参数如执行mx,则会显示帮助文档
if (!commander.args[0]) {
commander.help();
}
开发环境配置
我们先来看看执行mx dev时,执行了函数runDev(commander)
,这个函数的运行流程是什么,runDev函数如下
// 当你mx dev时,真正执行的文件是development
import development from "./development";
// DEV就是字符串'dev'
import { DEV } from "../constants";
export const runDev = (commander) => {
// commander注册'dev'这个参数的命令
commander
.command(DEV)
.description("运行开发环境")
.option("-h, --host <host>", "站点主机地址", "localhost")
// 默认端口号3000
.option("-p, --port <port>", "站点端口号", "3000")
// 命令最终运行的文件
.action(development);
};
dev环境的重点来了,development文件长啥样呢?
这个development有3个重点问题:
-
如何写一个compose函数,提高你的代码质量,不知道compose函数的同学请看这篇文章终极compose函数封装方案,或者你直接看我下面的代码就明白了
-
如何启动WebpackDevServer
-
启动的时候我们会启动默认端口3000,那如果3000端口已经被占用了,我们提前直到3000端口占用,并找到一个没有被占用的端口让webpackDevServer启动呢?
第一个问题: 如何写一个优雅的函数迭代器,将配置合并
我们这里的compose代码如下:
// 同步函数链
export const syncChainFns = (...fns) => {
const [firstFn, ...otherFns] = fns;
return (...args) => {
if (!otherFns) return firstFn(...args);
return otherFns.reduce((ret, task) => task(ret), firstFn(...args));
};
};
我们写个简单的案例调用一下:
function add(a, b){
return a+b;
}
function addVersion(sum){
return `version: ${sum}.0.0`;
}
syncChainFns(add, addVersion)(1,2) // 'version: 3'
也就是我们函数链条就像一个工厂加工货物一样,1号人员加工后,给后面一个人继续加工,最后得到结果,可以类比redux的compose函数实现。这样的写法就是函数编程的初步思想,组合思想。
我们后续会用这个函数来处理webpack配置,因为webpack配置可以分为4个函数处理
- 首先有初始化的webpack dev 配置
- 然后有用户自定义的配置,比如自己建立一个mx.config.js文件,作为配置文件
- 是否是ts环境,name就要把ForkTsCheckerWebpackPlugin加入到webpack的plugin里,加快ts的编译速度
- 最后交给webpack函数编译,这样就生成了最终交给webpackDevServer启动的值了
第二个问题:如何启动WebpackDevServer
我刚才说到生成的最终要启动的文件,webpackDevServer这样启动,注意,这是webpack5的启动方法,跟之前4的参数位置不一样
const serverConfig = {
publicPath: "/",
compress: true,
noInfo: true,
hot: true,
};
const devServer = new WebpackDevServer(compiler, serverConfig)
第三个问题:启动dev的端口号被占用了咋办
我们使用一个库,用来检测端口是否被占用的库叫detect,这个库如果发现端口是被占用了,会返回一个没有被占用的端口号
const resPort = await detect(port, host)
好了,解决了这三个问题,我们简单看下development文件,不懂的函数不要紧,大致思路上面已经介绍了,我们后面将里面比较重要的函数。
import webpack from "webpack";
import WebpackDevServer from "webpack-dev-server";
import getWebpackConfig from "../config/webpackConfig";
import { isAddForkTsPlugin, syncChainFns, getProjectConfig } from "../utils";
import { DEV } from "../constants";
import { IDevelopmentConfig } from "../interface";
import detect from "detect-port-alt";
const isInteractive = process.stdout.isTTY;
async function choosePort(port, host) {
const resPort = await detect(port, host);
if (resPort === Number(port)) {
return resPort;
}
const message = `Something is already running on port ${port}.`;
if (isInteractive) {
console.log(message);
return resPort;
}
console.log(message);
return null;
}
export default ({ host, port }: IDevelopmentConfig) => {
const compiler = syncChainFns(
getWebpackConfig,
getProjectConfig,
isAddForkTsPlugin,
webpack
)(DEV);
const serverConfig = {
publicPath: "/",
compress: true,
noInfo: true,
hot: true,
};
const runDevServer = async (port) => {
const devServer = new WebpackDevServer(compiler, serverConfig);
const resPort = await choosePort(port, host);
if (resPort !== null) {
devServer.listen(resPort, host, (err) => {
if (err) {
return console.error(err.message);
}
console.warn(`http://${host}:${resPort}\n`);
});
}
};
runDevServer(port);
};
打包业务代码脚本解析
首先打包业务代码和打包组件库,你知道有什么区别吗?
业务组件库,目前来说,还是用webpack是最合适的选择之一,因为我们业务上线的代码需要的是稳定性,webpack生态和生态的稳定性是很多打包工具所不具备的,不需要开发环境的效率问题(webpack5比4快很多了),比如有人选择开发环境用vite。
业务代码一般使用umd格式打包就行了。
而组件库代码,比如antdesign,element ui,这些库不仅仅需要umd格式,最需要的是esm module,导出的是import语法,这个webpack是做不了的。为啥做不了,是因为webpack有自己的一套require规则,你用的import最终还是要被webpack这套加载模块语法转译了。
所以esm module你可以用roll up,但是但是,我仔细调研了一番,多入口打包rollup是不支持的,而且我们需要在css打包上苦费心思一番,后面讲,打包css是非常非常讲究的,rollup不好满足,所以我们后续直接使用gulp来分别打包css和js了。
就是因为定制化要求很高,不得不用glup去定制化打包流程。
我们先看看更简单的打包业务代码脚本的入口
import build from "./buildSite";
import { BUILD_SITE } from "../constants";
export const buildSite = (commander) => {
// 打包业务组件
// 这个命令实际上执行的是buildSite这个文件
commander
.command(BUILD_SITE)
.description("打包业务代码")
.option("-d, --out-dir <path>", "输出目录", "dist")
.option("-a, --analyzer", "是否启用分析器")
.action(build);
};
接着,我们看看build文件,以下主要解释的是getWebpackConfig文件,和getProjectConfig文件的代码
import webpack from "webpack";
// webpack代码打包分析插件
import { BundleAnalyzerPlugin } from "webpack-bundle-analyzer";
// 获取webpack基础配置
import getWebpackConfig from "../config/webpackConfig";
// 获取webpack定制化的配置
import { getProjectPath, getProjectConfig, syncChainFns } from "../utils";
// 接口配置
import { IDeployConfig } from "../interface";
// 这个常量是字符串“buildSite”
import { BUILD_SITE } from "../constants";
export default ({ outDir, analyzer }: IDeployConfig) => {
// 这个syncChainFns函数上面已经介绍过了,就是一个函数组合的组合器
const config = syncChainFns(
// 这个函数后面会讲到,就是获取不同环境下webpack的配置文件
getWebpackConfig,
// 这个函数后面会讲到,用来获取用户自定义的webpck配置文件
getProjectConfig,
// 判断是否需要加入 加快ts的解析的插件
isAddForkTsPlugin
)(BUILD_SITE);
config.output.path = getProjectPath(outDir);
// 是否启用代码包体积分析插件
if (analyzer) {
config.plugins.push(
new BundleAnalyzerPlugin({
analyzerMode: "static",
generateStatsFile: true,
})
);
}
webpack(config).run((err) => {
if (err) {
logger.error("webpackError: ", JSON.stringify(err));
}
});
};
以下是getWebpackConfig代码,比较简单,工厂模式的运用,很简单,就是根据命令行不同的参数调用不同的函数,比如mx dev,就调用的getDevConfig函数,获取webpack在dev环境的配置
const getWebpackConfig = (type?: IWebpackConfigType): Configuration => {
switch (type) {
case DEV:
return getDevConfig();
case BUILD_SITE:
return getBuildConfig();
case BUILD_LIB:
return getBuildConfig();
default:
return getDevConfig();
}
};
getProjectConfig主要是提供给用户自定配置的函数,我们主要分析一下如何拿到用户的自定义配置.
export const getCustomConfig = (
configFileName = "mx.config.js"
): Partial<CustomConfig> => {
const configPath = path.join(process.cwd(), configFileName);
if (fs.existsSync(configPath)) {
// eslint-disable-next-line import/no-dynamic-require
return require(configPath);
}
return {};
};
可以看到,就是读取项目下的mx.config.js,我们看看mx.config.js的写法,很简单就是假如自己想要插件和plugin,以及入口配置。
const path = require('path');
module.exports = {
entries: {
index: {
entry: ['./web/index.js'],
template: './web/index.html',
favicon: './favicon.ico',
},
},
resolve: {
alias: {
'@': path.join(process.cwd(), '/'),
},
},
setBabelOptions: (options) => {
options.plugins.push(['import', { libraryName: 'antd', style: 'css' }]);
},
setRules: (rules) => {
rules.push({
test: /\.md$/,
use: ['raw-loader'],
});
},
};
打包组件库的核心配置文件
打包组件库的代码要比之前的复杂很多! 老规矩,看下入口文件
import build from "./build";
import { BUILD_LIB } from "../constants";
export const buildLib = (commander) => {
// 当你输入mx buildLib的时候,就是执行这个命令
// 这个命令实际上执行的是build文件
// 我们会打包es和commonjs规范的两个包
commander
.command(BUILD_LIB)
.description("打包编译仓库")
.option("-a, --analyzerUmd", "是否启用webpack打包分析器")
.option("-e, --entry <path>", "umd打包路径入口文件", "./src/index")
.option("--output-name <name>", "打包Umd格式后对外暴露的名称")
.option("--entry-dir <path>", "cjs和esm打包路径入口目录", "./src")
.option("--out-dir-umd <path>", "输出umd格式的目录", "./dist")
.option("--out-dir-esm <path>", "输出esm格式的目录", "./esm")
.option("--out-dir-cjs <path>", "输出cjs格式的目录", "./lib")
.option("--copy-less", "拷贝不参与编译的文件")
.option("--less-2-css", "是否编译组件样式")
.option("-m, --mode <esm|umd|cjs>", "打包模式 目前支持umd和esm两种")
.action(build);
};
我们看下build文件,也就是你输入mx buildLib后,执行的文件,我们先看看umd的打包,这个简单,稍微复杂一些的是glup配置。
import webpack from "webpack";
import webpackMerge from "webpack-merge";
// gulp任务,后面会讲
import { copyLess, less2css, buildCjs, buildEsm } from "../config/gulpConfig";
import getWebpackConfig from "../config/webpackConfig";
// 工具函数,后面用到就讲
import { getProjectPath, logger, run, compose } from "../utils";
// 代码包体积分析插件
import { BundleAnalyzerPlugin } from "webpack-bundle-analyzer";
// 环境常量
import {
BUILD_LIB,
CJS,
ESM,
UMD,
COPY_LESS,
LESS_2_LESS,
CLEAN_DIR,
} from "../constants";
// package.json的name属性作为打包出来的包名,当然也可以自定义
const { name } = require(getProjectPath("package.json"));
// 校验name是否有斜杠,这会影响打包出来的结果
const checkName = (outputName, name) => {
if (!outputName && name?.includes("/")) {
logger.warn(
"package.json的包名包含斜杠,webpack打包时会以斜杠来建立文件夹,所以请注意打包后文件名是否符合你的要求"
);
}
};
/**
* build for umd
* @param analyzer 是否启用分析包插件
* @param outDirUmd 输出目录
* @param entry 打包的入口文件
* @param outputName 打包出来的名字
*/
const buildUmd = async ({ analyzerUmd, outDirUmd, entry, outputName }) => {
const customizePlugins = [];
const realName = outputName || name;
checkName(outputName, name);
const umdTask = (type) => {
return new Promise((resolve, reject) => {
const config = webpackMerge(getWebpackConfig(type), {
entry: {
[realName]: getProjectPath(entry),
},
// 这里主要是设置libraryTarget是设置打包格式是umd
// library是配置打包出来的包名的
output: {
path: getProjectPath(outDirUmd),
library: realName,
libraryTarget: "umd",
libraryExport: "default",
},
plugins: customizePlugins,
});
if (analyzerUmd) {
config.plugins.push(
new BundleAnalyzerPlugin({
analyzerMode: "static",
generateStatsFile: true,
})
);
}
return webpack(config).run((err, stats) => {
if (stats.compilation.errors?.length) {
console.log("webpackError: ", stats.compilation.errors);
}
if (err) {
logger.error("webpackError: ", JSON.stringify(err));
reject(err);
} else {
resolve(stats);
}
});
});
};
logger.info("building umd");
await umdTask(BUILD_LIB);
logger.success("umd computed");
};
接下来讲最复杂的gulp配置,先看入口文件:
- 之前我们先解决写一个类似koa的框架的compose函数,这个函数是一个函数执行器,把各个异步函数按顺序调用,比如说有异步函数1,异步函数2,异步函数3,我需要按照顺序调用1,2,3,并且这1,2,3是解耦的,类似中间件的形式加入,并共享一些数据
我们先看看函数:
export function compose(middleware, initOptions) {
const otherOptions = initOptions || {};
function dispatch(index) {
if (index == middleware.length) return;
const currMiddleware = middleware[index];
return currMiddleware(() => dispatch(++index), otherOptions);
}
dispatch(0);
}
这个函数的意思是:
- 按数组顺序拿到middleware函数
- 然后函数调用时,第一个参数传入下一个调用的函数,主动调用才会执行middleware下一个函数,并且把一个去去全局共享数据otherOptions传入下去。
下面是利用compose函数执行各个函数的文件,也就是mx buildLib真正执行的文件,文件内容太多,我就拿一个build esm来解释
import webpack from "webpack";
import webpackMerge from "webpack-merge";
// gulp任务,后面会讲
import { copyLess, less2css, buildCjs, buildEsm } from "../config/gulpConfig";
import getWebpackConfig from "../config/webpackConfig";
// 工具函数,后面用到就讲
import { getProjectPath, logger, run, compose } from "../utils";
// 代码包体积分析插件
import { BundleAnalyzerPlugin } from "webpack-bundle-analyzer";
// 环境常量
import {
BUILD_LIB,
CJS,
ESM,
UMD,
COPY_LESS,
LESS_2_LESS,
CLEAN_DIR,
} from "../constants";
const buildLib = async ({
analyzerUmd,
mode,
entry,
outDirEsm,
outDirCjs,
outDirUmd,
copyLess,
entryDir,
less2Css,
cleanDir,
outputName,
}) => {
// 注册中间件,然后用compose函数去组合
const buildProcess = [bulidLibFns[CLEAN_DIR]];
// 是否打包umd格式,是的话加入我们之前讲的umd打包函数
if (mode === UMD) {
buildProcess.push(bulidLibFns[UMD]);
}
// 是否打包esm格式,是的话加入相应打包函数,
if (mode === ESM) {
buildProcess.push(bulidLibFns[ESM]);
}
// 省略一些代码,就是来加入各种处理函数,比如有编译less到css的中间件是否加入
compose(buildProcess, {
analyzerUmd,
mode,
entry,
outDirEsm,
outDirCjs,
outDirUmd,
copyLess,
entryDir,
less2Css,
cleanDir,
outputName,
});
};
// 着重看一下esm函数中buildEsm方法
const bulidLibFns = {
[CLEAN_DIR]: async (next, otherOptions) => {
await run(
`rimraf ${otherOptions.outDirEsm} ${otherOptions.outDirCjs} ${otherOptions.outDirUmd}`,
`打包前删除 ${otherOptions.outDirEsm} ${otherOptions.outDirCjs} ${otherOptions.outDirUmd} 文件夹`
);
next();
},
[ESM]: async (next, otherOptions) => {
logger.info("buildESM ing...");
await buildEsm({
mode: otherOptions.mode,
outDirEsm: otherOptions.outDirEsm,
entryDir: otherOptions.entryDir,
});
logger.success("buildESM computed");
next();
},
};
export default buildLib;
我们看看gulp配置文件buildesm,主要执行的是compileScripts函数,这个函数我们接着看
const buildEsm = ({ mode, outDirEsm, entryDir }) => {
const newEntryDir = getNewEntryDir(entryDir);
/**
* 编译esm
*/
gulp.task("compileESM", () => {
return compileScripts(mode, outDirEsm, newEntryDir);
});
return new Promise((res) => {
return gulp.series("compileESM", () => {
res(true);
})();
});
};
/**
* 编译脚本文件
* @param {string} babelEnv babel环境变量
* @param {string} destDir 目标目录
* @param {string} newEntryDir 入口目录
*/
function compileScripts(mode, destDir, newEntryDir) {
const { scripts } = paths;
return gulp
.src(scripts(newEntryDir)) // 找到入口文件
.pipe(babel(mode === ESM ? babelEsConfig : babelCjsConfig)) // 使用gulp-babel处理
.pipe(
// 使用gulp处理css
through2.obj(function z(file, encoding, next) {
this.push(file.clone());
// 找到目标
if (file.path.match(/(\/|\\)style(\/|\\)index\.js/)) {
const content = file.contents.toString(encoding);
file.contents = Buffer.from(cssInjection(content)); // 处理文件内容
file.path = file.path.replace(/index\.js/, "css.js"); // 文件重命名
this.push(file); // 新增该文件
next();
} else {
next();
}
})
)
.pipe(gulp.dest(destDir));
}
大概内容,已完毕。后面会出一个自动发布脚本,各个流程是自由组合配置的,所以没用bash,用的node去写,让配置更灵活,这个做完又要去建立组件库查看的官网,最后才一个一个写组件。慢慢来吧!
欢迎一起交流:微信:a2298613245