前言
NodeJs的出现,让前端工程化的理念不断深入。先是带来了Gulp、Webpack等强大的构建工具,随后又出现了vue-cli
和create-react-app
等完善的脚手架,提供了完整的项目架构,让我们可以更多的关注业务,而不必在项目基础设施上花费大量时间。
但是,这些现成的脚手架未必就能满足我们的业务需求,也未必是最佳实践,相信每个大公司都有定制开发的的脚手架,今天我们来读一下京东 NutUI 组件库中的内置脚手架 NutUI-CLI
NutUI CLI 简介
NutUI-CLI 是一个 Vue 组件库构建工具,通过它可以搭建一套 Vue 组件库
功能
- dev 本地调试运行官网和Demo示例
- add 快速创建符合
NutUI
的标准组件 - build 构建组件库,生成可用于生产环境的组件代码
- build-site 构建组件库官网+Demo示例网站
- ...
为了让大家快速的了解内部逻辑,我梳理了一个脑图供大家参考
NutUI-CLI 源码地址 https://github.com/jdf2e/nutui/tree/v3-alpha/lib/plugin/cli
具体程序流程顺序可分为 入口命令脚本接受/分发器
> 命令接收器
> 编译逻辑处理
> webpack配置
1. 入口命令脚本接受/分发器
CLI 在 NutUI 中是如何被调用起来的
相信大家对下面 @vue/cli 脚手架的命令并不陌生
$ npm run serve
{
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build"
}
}
NutUI 中使用方式也是如此
$ npm run dev
"scripts": {
"dev": "nutui-cli dev",
"build": "nutui-cli build",
"build:site": "nutui-cli build-site",
"add": "nutui-cli add"
},
我们可以看到 vue/cli执行的实际命令是
$ vue-cli-service serve
NutUI执行的实际命令是
$ nutui-cli dev
此时我们思考一下,vue-cli-service
和nutui-cli
这个命令是如何被我们的项目感知的呢
那么此时,我要在这里啰嗦两句
Node.js 之 process.argv
process.argv 属性返回一个数组,其中包含当启动 Node.js 进程时传入的命令行参数。 第一个元素是 process.execPath。 如果需要访问 argv[0] 的原始值,参阅 process.argv0。 第二个元素将是正在执行的 JavaScript 文件的路径。
例如,假设 process-args.js 的脚本如下:
// 打印 process.argv。
process.argv.forEach((val, index) => {
console.log(`${index}: ${val}`);
});
启动 Node.js 进程:
$ node process-args.js one two=three four
输出如下:
0: /usr/local/bin/node
1: /Users/mjr/work/node/process-args.js
2: one
3: two=three
4: four
查看CLI 目录 中 package.json
<img src="https://img11.360buyimg.com/imagetools/jfs/t1/100749/39/16010/80966/5e7850abE723c8b30/49a417383cfc1dc5.png" width="500" />
cli/package.json
...
"bin": {
"nutui-cli": "./dist_cli/bin/index.js"
},
"scripts": {
"dev": "tsc --watch --incremental"
}
...
package.json
中的 bin属性 用来指定各个内部命令对应的可执行文件的位置,上述配置我们可以看到 nutui-cli
命令执行的对应脚本为 ./dist_cli/bin/index.js
那么我们此时去查看一下github 中的对应位置
发现并没有这个目录,这是怎么一回事呢,思考一下 🤔, 我们发现CLI内部 src 目录下 并没有js文件,全部都是 TypeScript 文件。
😯~~ 原来是这样, 我们可以发现 package.json
scripts 中的 dev : tsc --watch --incremental
有这个命令。 既然源码中有这个 dev 命令,那我就先运行一下
// 进入cli 源码目录
$ cd lib/plugin/cli/
// 安装依赖...
$ yarn
// 安装完成后执行dev命令
$ npm run dev
<img src="https://img14.360buyimg.com/imagetools/jfs/t1/110636/6/9950/70185/5e7850abEf4a5bfa4/fc528f37d90cdec5.png" width="500" />
此时再看一下项目中文件夹,发现dist_cli有了
<img src="https://img12.360buyimg.com/imagetools/jfs/t1/87189/24/16209/70313/5e7850aaE63cccba9/c54356c3b6598436.png" width="300" />
那么 🤔这个 tsc
又是什么呢, 经过搜索后, tsc 其实是TypeScript的编译命令,将会把ts文件转换为js 这又得说起 TypeScript
之 tsconfig.json
,先打开tsconfig.json
看看
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"declaration": true,
"sourceMap": true,
"outDir": "./dist_cli",
"strict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": [
"node_modules"
]
}
我们可以看到关键点 outDir
代表输出路径 、 include
代表编译路径。
文档>tsconfig.json
大家如果还不懂TS的话,可要抓紧补一下了,在Vue3.0中的源码基本都是TS ,可见TS的重要性
tips: 这里啰嗦两句,我们在读源码的过程中就是要这样,刨根问底,打破砂锅搜(百度、谷歌)到底 ,不懂就去搜索搞明白。
ok,上面我们已经了解了TS的基本语法,接下来真正的读一下
入口命令脚本接受/分发器 bin/index.ts
#!/usr/bin/env node
import { setNodeEnv } from '../util';
process.argv[2] === 'dev' ? setNodeEnv('development') : setNodeEnv('production');
import program from 'commander';
import { dev } from '../commands/dev';
import { build } from '../commands/build';
import { buildSite } from '../commands/build-site';
...
const config = require(ROOT_CLI_PATH('package.json'));
program.version(`@nutui/cli ${config.version}`, '-v', '--version')
program.command('dev')
.description('本地调试运行官网和Demo示例')
.action(dev)
program.command('build')
.description('构建完整版nutui和各个组件可发布到npm的静态资源包')
.action(build)
program.command('build-site')
.description('构建官网和Demo示例,进行官网发布')
.action(buildSite)
...
program.parse(process.argv);
我们进行逐一解析
#!/usr/bin/env node
该命令必须放在第一行, 否者不会生效
在写npm包的时候需要在脚本的第一行写(必须),用于指明该脚本文件要使用node来执行。
/usr/bin/env 用来告诉用户到path目录下去寻找node,#!/usr/bin/env node 可以让系统动态的去查找node,已解决不同机器不同用户设置不一致问题。
node 命令的工具 commander
我们可以看到nutui中第4行引入了第三方库: commander
import program from 'commander';
我们从整体代码上来看不难理解,commander
去监听用户输入的指令 dev
build
...等命令,然后去触发action
指向的对应方法 ,到这里,我相信大家对 命令分发器这个模块 已经了如指掌了,那么代码中的
import { dev } from '../commands/dev';
import { build } from '../commands/build';
对应的 dev build
等方法,我们可以看到都是从 commands 命令接收器
模块中引入,那么接下来,我们接着介绍 命令接收器
2. 命令接收器
tips:我们可通过ctrl
|command
键 + 鼠标左键点击 快速跳转到对应方法
这里只解析三个模块命令,其它的命令大致相同,感兴趣大家也可以去读一下
本地调试 dev.ts
import { compileSite } from '../compiler/site';
export async function dev() {
await compileSite();
}
网站构建 build-site.ts
import { emptyDir } from 'fs-extra';
import { compileSite } from '../compiler/site';
import { DIST_DIR } from "../util/dic";
export async function buildSite() {
await emptyDir(DIST_DIR);
await compileSite(true);
process.exit()
}
import { emptyDir } from 'fs-extra';
fs-extra
文件操作包,不过多介绍,大家可以去看文档
我们主要看一下 dev.ts
和 buildSite.ts
中同时引用了 compileSite
关键方法,只是传入的参数不同,通过功能介绍我们得知 dev
是为了本地调试官网+demo示例,而build-site
是为了构建官网+demo示例,那么这里可以大致猜到compileSite
的参数 分别是为了区分 dev和build构建。稍后我们去看一下compileSite
逻辑。
组件库构建 build.ts
import { emptyDir } from 'fs-extra';
import { compilePackage } from '../compiler/package';
import { DIST_DIR } from "../util/dic";
import logger from '../util/logger';
import { compilePackageDisperse } from '../compiler/package.disperse';
export async function build() {
try {
await emptyDir(DIST_DIR);
await compilePackage(false);
logger.success(`build compilePackage false package success!`);
await compilePackage(true);
logger.success(`build compilePackage true package success!`);
await compilePackageDisperse();
logger.success(`build compilePackageDisperse package success!`);
process.exit();
} catch (error) {
logger.error(error)
}
}
build
这边就有意思了,分别是 compilePackage
和 compilePackageDisperse
这两个重要方法
而compilePackage
和 compileSite
使用方法类似,分别传入 true
false
,compilePackageDisperse
则是直接调用。
compileSite
、compilePackage
、compilePackageDisperse
这三个重要方法都是从compiler
模块拿到的,那么此时进入下一小节逐一解读。
3. 编译逻辑处理
网站编译 site.ts > compileSite
import { devConfig } from '../webpack/dev.config';
import { prodConfig } from '../webpack/prod.config';
import { compileWebPack } from './webpack';
import logger from '../util/logger';
export async function compileSite(prod: boolean = false) {
try {
prod ? await compileWebPack(prodConfig) : compileWebPack(devConfig);
prod && logger.success('build site success!');
} catch (error) {
logger.error(error);
}
}
组件库编译 package.ts > compilePackage
import { packageConfig } from '../webpack/package.config';
import { compileWebPack } from './webpack';
export function compilePackage(isMinimize: boolean) {
return compileWebPack(packageConfig(isMinimize))
}
按需加载组件库编译 package.disperse.ts > compilePackageDisperse
import { compileWebPack } from './webpack';
import { packageDisperseConfig } from '../webpack/package.disperse.config';
export function compilePackageDisperse() {
return compileWebPack(packageDisperseConfig())
}
我们可以看到 上面 devConfig
、prodConfig
、packageConfig
这三个config 都是从webpack文件夹中依次按需提取,其实关键点在于 <span style="color:#0e88eb;font-weight:bold;">webpack.ts > compileWebPack</span> 这个方法
它才是重中之重,负责核心编译
核心构建 webpack.ts
import Webpack from 'webpack';
import WebpackDevServer from 'webpack-dev-server';
import logger from "../util/logger";
function devServer(config: Webpack.Configuration) {
const compiler = Webpack(config);
const devServerOptions = {
...
};
const server = new WebpackDevServer(compiler, devServerOptions);
server.listen(8001, '0.0.0.0', (err: Error) => {
if (err) logger.error(err);
});
}
function build(config: Webpack.Configuration) {
return new Promise((resolve, reject) => {
Webpack(config, (err: any, stats) => {
if (err || stats.hasErrors()) {
// 在这里处理错误
...
reject(err || stats.toJson())
} else {
// 处理完成
resolve();
}
});
});
}
export function compileWebPack(config: Webpack.Configuration) {
switch (config.mode) {
case "development":
devServer(config);
break;
case "production":
return build(config);
}
}
我们可以看到webpack.ts 对外暴露 export function compileWebPack
入参方法强制约束为 Webpack.Configuration
类型
这个地方充分体现了ts强约束给我带来的好处,其方法内部通过 config.mode
属性来区分 进行 devServer
还是 build
构建。
看到这里,不知道大家是否注意到有在文件的顶部2行代码
import Webpack from 'webpack';
import WebpackDevServer from 'webpack-dev-server';
这个地方重点说一下,在传统的 vue/cli2
脚手架调用时,是通过下面 WebPack CLI 命令 在项目 package.json 进行调用
"scripts": {
"dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js",
"start": "npm run dev",
"build": "node build/build.js"
}
而在 NutUI-CLI 则是通过 WebPack Node API WebpackDevServer
、Webpack
来进行调用,那么这样调用,有什么好处呢。
引用webpack官方文档:webpack 提供了 Node.js API,可以在 Node.js 运行时下直接使用。
当你需要自定义构建或开发流程时,Node.js API 非常有用,因为此时所有的报告和错误处理都必须自行实现,webpack 仅仅负责编译的部分。所以 stats 配置选项不会在 webpack() 调用中生效。
那么我们现在知道了,compileWebPack 只负责编译 ,文件全部从 webpack文件夹中提取,我们接下来进入下章 WebPack配置
4. WebPack配置
base.config.ts、dev.config.ts、prod.config.ts这三个配置文件主要用于 dev调试 和打包网站,平时大家项目中都有用到, 我就不过多介绍了,大家可以通过webpack官方文档去了解,我们主要讲一下package.config.ts
和package.disperse.config.ts
这两个配置文件,主要用来构建组件库。
package.config.ts
import Webpack from 'webpack';
import merge from 'webpack-merge';
import { baseConfig } from './base.config';
...
export function packageConfig(isMinimize: boolean) {
const _packageConfig: Webpack.Configuration = {
mode: 'production',
devtool: 'source-map',
entry: {
nutui: ROOT_PACKAGE_PATH('src/nutui.js'),
},
output: {
path: ROOT_PACKAGE_PATH('dist/'),
filename: isMinimize ? '[name].min.js' : '[name].js',
library: '[name]',
libraryTarget: 'umd',
umdNamedDefine: true,
// https://stackoverflow.com/questions/49111086/webpack-4-universal-library-target
globalObject: `(typeof self !== 'undefined' ? self : this)`
},
externals: {
vue: {
root: 'Vue',
commonjs: 'vue',
commonjs2: 'vue',
amd: 'vue'
}
},
...
};
isMinimize && _packageConfig.plugins?.push(new OptimizeCSSAssetsPlugin())
return merge(baseConfig, _packageConfig);
}
-
entry: { nutui: ROOT_PACKAGE_PATH('src/nutui.js'), }
src/nutui.js 将所有组件封装统一打包 -
output.libraryTarget: 'umd'
- 将你的 library 暴露为所有的模块定义下都可运行的方式。它将在 CommonJS, AMD 环境下运行,或将模块导出到 global 下的变量。了解更多请查看 UMD 仓库。 - 当使用了 libraryTarget: "umd",设置:
umdNamedDefine: true
会对 UMD 的构建过程中的 AMD 模块进行命名。否则就使用匿名的 define。 -
output.externals: { vue: { root: 'Vue', commonjs: 'vue', commonjs2: 'vue', amd: 'vue' } }
- root:可以通过一个全局变量访问 library(例如,通过 script 标签)。
- commonjs:可以将 library 作为一个 CommonJS 模块访问。
- commonjs2:和上面的类似,但导出的是 module.exports.default.
- amd:类似于 commonjs,但使用 AMD 模块系统。
-
output.globalObject
当以库为目标时,特别是当libraryTarget为“umd”时,此选项指示将使用哪个全局对象来装载库。要使UMD build在浏览器和Node.js上都可用,请将output.globalObject选项设置为“this”。
使用此配置文件打包 可以生成我们全局引入的js文件
<img src="https://img12.360buyimg.com/imagetools/jfs/t1/89257/5/16125/26500/5e7850aaE0389f07e/3a1d6f4da84209de.png" width="300" />
ok ,全局引入组件的原理我们搞清楚了,接下来分析下 按需加载每个默认的配置文件package.disperse.config
。
package.disperse.config.ts
import Webpack from 'webpack';
import merge from 'webpack-merge';
import { ROOT_PACKAGE_PATH } from '../util/dic';
import { baseConfig } from './base.config';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import CopyWebpackPlugin from 'copy-webpack-plugin';
import OptimizeCSSAssetsPlugin from 'optimize-css-assets-webpack-plugin';
const confs = require(ROOT_PACKAGE_PATH('src/config.json'));
export function packageDisperseConfig() {
const entry: any = {};
confs.packages.map((item: any) => {
const cptName: string = item.name.toLowerCase();
entry[cptName] = ROOT_PACKAGE_PATH(`src/packages/${cptName}/index.js`);
});
const _packageDisperseConfig: Webpack.Configuration = {
mode: 'production',
devtool: 'source-map',
entry,
...
plugins: [
...
new CopyWebpackPlugin([
{
from: ROOT_PACKAGE_PATH('src/'),
to: ROOT_PACKAGE_PATH('dist/'),
ignore: ['demo.vue', 'doc.md', 'config.json', 'nutui.js', '*.spec.js']
}
]),
new CopyWebpackPlugin([
{
from: ROOT_PACKAGE_PATH('types/'),
to: ROOT_PACKAGE_PATH('dist/types/')
}
]),
]
};
return merge(baseConfig, _packageDisperseConfig);
}
核心代码,将每一个组件作为一个入口,构成多入口
const confs = require(ROOT_PACKAGE_PATH('src/config.json'));
const entry: any = {};
confs.packages.map((item: any) => {
const cptName: string = item.name.toLowerCase();
entry[cptName] = ROOT_PACKAGE_PATH(`src/packages/${cptName}/index.js`);
});
我们可以看到 confs.packages中
"packages": [
{
"name": "Cell",
"version": "1.0.0",
"sort": "4",
"chnName": "列表项",
"type": "component",
"showDemo": true,
"desc": "列表项,可组合成列表",
"author": "Frans"
},
{
"name": "Dialog",
"version": "1.0.0",
"sort": "2",
"chnName": "对话框",
"type": "method",
"showDemo": true,
"desc": "模态弹窗,支持按钮交互,支持图片弹窗。",
"star": 5,
"author": "Frans"
},
...
]
通过过多入口可以构建出 每个组件的js和css
<img src="https://img13.360buyimg.com/imagetools/jfs/t1/94118/6/15958/61263/5e7850aaE034f2c60/9fd4c64fec70fa94.png" width="300" />
那么我们实际在dev开发模式时还需引入vue的源代码,源代码又是如何被打包呢,见plugin
中的CopyWebpackPlugin
插件
源码就比较简单了,直接拷贝过来,此时再进行打包测试
plugins: [
new MiniCssExtractPlugin({
filename: '[name]/[name].css'
}),
new OptimizeCSSAssetsPlugin(),
new CopyWebpackPlugin([
{
from: ROOT_PACKAGE_PATH('src/'),
to: ROOT_PACKAGE_PATH('dist/'),
ignore: ['demo.vue', 'doc.md', 'config.json', 'nutui.js', '*.spec.js']
}
]),
new CopyWebpackPlugin([
{
from: ROOT_PACKAGE_PATH('types/'),
to: ROOT_PACKAGE_PATH('dist/types/')
}
]),
]
打包成功,完整的 npm 静态资源包
<img src="https://img14.360buyimg.com/imagetools/jfs/t1/96893/3/15845/73312/5e7850aaE5cb19b62/1c4fcf55208511f1.png" width="300" />
<img src="https://img13.360buyimg.com/imagetools/jfs/t1/90351/14/16126/53487/5e7850aaE2d2193fc/113c52f509414184.png" width="300" />
总结
文章的主要目的是鼓励大家更加主动阅读和学习源码,帮助大家学更多 vue 和webpack的 相关知识,使我们成为更优秀的开发者。定期投资几个小时来阅读源码,短期无效,长远看来,是受益无穷的事情。通过阅读源码,你将从更深层次了解你平时所用框架的工作原理,反之又促进你更好的使用、扩展它。从而帮助你缩短从需求到编码的完成时间。
最后,附上 NUTUI 组件库官方网址 https://nutui.jd.com
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。