查看原文
其他

【第1894期】NutUI CLI源码解析

朱志达 前端早读课 2020-09-30

前言

通过前几期大家应该有了解NutUI吧。今日早读文章由京东用户体验设计部@朱志达投稿分享。

京东用户体验设计部-前端开发部现有前端开发人员 50 左右,主要为京东零售集团、京东健康提供 WEB 前端开发、APP RN开发,小程序开发、小游戏开发、H5开发等能力支持。

正文从这开始~~

前言

NodeJs的出现,让前端工程化的理念不断深入。先是带来了Gulp、Webpack等强大的构建工具,随后又出现了 vue-clicreate-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 脚手架的命令并不陌生

  1. $ npm run serve

  1. {

  2. "scripts": {

  3. "serve": "vue-cli-service serve",

  4. "build": "vue-cli-service build"

  5. }

  6. }

NutUI 中使用方式也是如此

  1. $ npm run dev

  1. "scripts": {

  2. "dev": "nutui-cli dev",

  3. "build": "nutui-cli build",

  4. "build:site": "nutui-cli build-site",

  5. "add": "nutui-cli add"

  6. },

我们可以看到 vue/cli执行的实际命令是

  1. $ vue-cli-service serve

NutUI执行的实际命令是

  1. $ nutui-cli dev

此时我们思考一下, vue-cli-servicenutui-cli 这个命令是如何被我们的项目感知的呢

那么此时,我要在这里啰嗦两句

Node.js 之 process.argv

process.argv 属性返回一个数组,其中包含当启动 Node.js 进程时传入的命令行参数。第一个元素是 process.execPath。如果需要访问 argv[0] 的原始值,参阅 process.argv0。第二个元素将是正在执行的 JavaScript 文件的路径。

例如,假设 process-args.js 的脚本如下:

  1. // 打印 process.argv。

  2. process.argv.forEach((val, index) => {

  3. console.log(`${index}: ${val}`);

  4. });

启动 Node.js 进程:

  1. $ node process-args.js one two=three four

输出如下:

  1. 0: /usr/local/bin/node

  2. 1: /Users/mjr/work/node/process-args.js

  3. 2: one

  4. 3: two=three

  5. 4: four

查看CLI 目录 中 package.json

cli/package.json

  1. ...

  2. "bin": {

  3. "nutui-cli": "./dist_cli/bin/index.js"

  4. },

  5. "scripts": {

  6. "dev": "tsc --watch --incremental"

  7. }

  8. ...

package.json 中的 bin属性 用来指定各个内部命令对应的可执行文件的位置,上述配置我们可以看到 nutui-cli命令执行的对应脚本为 ./dist_cli/bin/index.js

那么我们此时去查看一下github 中的对应位置

发现并没有这个目录,这是怎么一回事呢,思考一下 🤔, 我们发现CLI内部 src 目录下 并没有js文件,全部都是 TypeScript 文件。

😯~~ 原来是这样, 我们可以发现 package.json scripts 中的 dev:tsc--watch--incremental 有这个命令。既然源码中有这个 dev 命令,那我就先运行一下

  1. // 进入cli 源码目录

  2. $ cd lib/plugin/cli/

  3. // 安装依赖...

  4. $ yarn

  5. // 安装完成后执行dev命令

  6. $ npm run dev

此时再看一下项目中文件夹,发现dist_cli有了

那么 🤔这个 tsc 又是什么呢, 经过搜索后, tsc 其实是TypeScript的编译命令,将会把ts文件转换为js 这又得说起 TypeScripttsconfig.json ,先打开 tsconfig.json 看看

  1. {

  2. "compilerOptions": {

  3. "target": "es5",

  4. "module": "commonjs",

  5. "declaration": true,

  6. "sourceMap": true,

  7. "outDir": "./dist_cli",

  8. "strict": true,

  9. "moduleResolution": "node",

  10. "esModuleInterop": true,

  11. "forceConsistentCasingInFileNames": true,

  12. "resolveJsonModule": true

  13. },

  14. "include": ["src/**/*"],

  15. "exclude": [

  16. "node_modules"

  17. ]

  18. }

我们可以看到关键点 outDir 代表输出路径 、 include 代表编译路径。文档>tsconfig.json

大家如果还不懂TS的话,可要抓紧补一下了,在Vue3.0中的源码基本都是TS ,可见TS的重要性

tips: 这里啰嗦两句,我们在读源码的过程中就是要这样,刨根问底,打破砂锅搜(百度、谷歌)到底 ,不懂就去搜索搞明白。

ok,上面我们已经了解了TS的基本语法,接下来真正的读一下

入口命令脚本接受/分发器 bin/index.ts
  1. #!/usr/bin/env node

  2. import { setNodeEnv } from '../util';

  3. process.argv[2] === 'dev' ? setNodeEnv('development') : setNodeEnv('production');

  4. import program from 'commander';

  5. import { dev } from '../commands/dev';

  6. import { build } from '../commands/build';

  7. import { buildSite } from '../commands/build-site';

  8. ...

  9. const config = require(ROOT_CLI_PATH('package.json'));

  10. program.version(`@nutui/cli ${config.version}`, '-v', '--version')

  11. program.command('dev')

  12. .description('本地调试运行官网和Demo示例')

  13. .action(dev)

  14. program.command('build')

  15. .description('构建完整版nutui和各个组件可发布到npm的静态资源包')

  16. .action(build)

  17. program.command('build-site')

  18. .description('构建官网和Demo示例,进行官网发布')

  19. .action(buildSite)

  20. ...

  21. program.parse(process.argv);

我们进行逐一解析

  1. #!/usr/bin/env node

该命令必须放在第一行, 否者不会生效

在写npm包的时候需要在脚本的第一行写(必须),用于指明该脚本文件要使用node来执行。

/usr/bin/env 用来告诉用户到path目录下去寻找node,#!/usr/bin/env node 可以让系统动态的去查找node,已解决不同机器不同用户设置不一致问题。

node 命令的工具 commander

我们可以看到nutui中第4行引入了第三方库:commander

  1. import program from 'commander';

我们从整体代码上来看不难理解, commander 去监听用户输入的指令 dev build ...等命令,然后去触发 action指向的对应方法 ,到这里,我相信大家对 命令分发器这个模块 已经了如指掌了,那么代码中的

  1. import { dev } from '../commands/dev';

  2. import { build } from '../commands/build';

对应的 dev build 等方法,我们可以看到都是从 commands命令接收器 模块中引入,那么接下来,我们接着介绍 命令接收器

2. 命令接收器

tips:我们可通过 ctrl| command键 + 鼠标左键点击 快速跳转到对应方法

这里只解析三个模块命令,其它的命令大致相同,感兴趣大家也可以去读一下

本地调试 dev.ts
  1. import { compileSite } from '../compiler/site';

  2. export async function dev() {

  3. await compileSite();

  4. }

网站构建 build-site.ts
  1. import { emptyDir } from 'fs-extra';

  2. import { compileSite } from '../compiler/site';

  3. import { DIST_DIR } from "../util/dic";

  4. export async function buildSite() {

  5. await emptyDir(DIST_DIR);

  6. await compileSite(true);

  7. process.exit()

  8. }

  1. import { emptyDir } from 'fs-extra';

fs-extra 文件操作包,不过多介绍,大家可以去看文档

我们主要看一下 dev.tsbuildSite.ts 中同时引用了 compileSite 关键方法,只是传入的参数不同,通过功能介绍我们得知 dev是为了本地调试官网+demo示例,而 build-site是为了构建官网+demo示例,那么这里可以大致猜到 compileSite 的参数 分别是为了区分 dev和build构建。稍后我们去看一下 compileSite逻辑。

组件库构建 build.ts
  1. import { emptyDir } from 'fs-extra';

  2. import { compilePackage } from '../compiler/package';

  3. import { DIST_DIR } from "../util/dic";

  4. import logger from '../util/logger';

  5. import { compilePackageDisperse } from '../compiler/package.disperse';

  6. export async function build() {

  7. try {

  8. await emptyDir(DIST_DIR);

  9. await compilePackage(false);

  10. logger.success(`build compilePackage false package success!`);

  11. await compilePackage(true);

  12. logger.success(`build compilePackage true package success!`);

  13. await compilePackageDisperse();

  14. logger.success(`build compilePackageDisperse package success!`);

  15. process.exit();

  16. } catch (error) {

  17. logger.error(error)

  18. }

  19. }

build 这边就有意思了,分别是 compilePackagecompilePackageDisperse 这两个重要方法 而 compilePackagecompileSite使用方法类似,分别传入 true falsecompilePackageDisperse 则是直接调用。

compileSitecompilePackagecompilePackageDisperse这三个重要方法都是从 compiler模块拿到的,那么此时进入下一小节逐一解读。

3. 编译逻辑处理

网站编译 site.ts > compileSite
  1. import { devConfig } from '../webpack/dev.config';

  2. import { prodConfig } from '../webpack/prod.config';

  3. import { compileWebPack } from './webpack';

  4. import logger from '../util/logger';

  5. export async function compileSite(prod: boolean = false) {

  6. try {

  7. prod ? await compileWebPack(prodConfig) : compileWebPack(devConfig);

  8. prod && logger.success('build site success!');

  9. } catch (error) {

  10. logger.error(error);

  11. }

  12. }

组件库编译 package.ts > compilePackage
  1. import { packageConfig } from '../webpack/package.config';

  2. import { compileWebPack } from './webpack';

  3. export function compilePackage(isMinimize: boolean) {

  4. return compileWebPack(packageConfig(isMinimize))

  5. }

按需加载组件库编译 package.disperse.ts > compilePackageDisperse
  1. import { compileWebPack } from './webpack';

  2. import { packageDisperseConfig } from '../webpack/package.disperse.config';

  3. export function compilePackageDisperse() {

  4. return compileWebPack(packageDisperseConfig())

  5. }

我们可以看到 上面 devConfigprodConfigpackageConfig 这三个config 都是从webpack文件夹中依次按需提取,其实关键点在于 webpack.ts > compileWebPack 这个方法 它才是重中之重,负责核心编译

核心构建 webpack.ts
  1. import Webpack from 'webpack';

  2. import WebpackDevServer from 'webpack-dev-server';

  3. import logger from "../util/logger";


  4. function devServer(config: Webpack.Configuration) {

  5. const compiler = Webpack(config);

  6. const devServerOptions = {

  7. ...

  8. };

  9. const server = new WebpackDevServer(compiler, devServerOptions);

  10. server.listen(8001, '0.0.0.0', (err: Error) => {

  11. if (err) logger.error(err);

  12. });

  13. }

  14. function build(config: Webpack.Configuration) {

  15. return new Promise((resolve, reject) => {

  16. Webpack(config, (err: any, stats) => {

  17. if (err || stats.hasErrors()) {

  18. // 在这里处理错误

  19. ...

  20. reject(err || stats.toJson())

  21. } else {

  22. // 处理完成

  23. resolve();

  24. }

  25. });

  26. });

  27. }

  28. export function compileWebPack(config: Webpack.Configuration) {

  29. switch (config.mode) {

  30. case "development":

  31. devServer(config);

  32. break;

  33. case "production":

  34. return build(config);

  35. }

  36. }

我们可以看到webpack.ts 对外暴露 exportfunctioncompileWebPack 入参方法强制约束为 Webpack.Configuration 类型

这个地方充分体现了ts强约束给我带来的好处,其方法内部通过 config.mode 属性来区分 进行 devServer 还是 build 构建。

看到这里,不知道大家是否注意到有在文件的顶部2行代码

  1. import Webpack from 'webpack';

  2. import WebpackDevServer from 'webpack-dev-server';

这个地方重点说一下,在传统的 vue/cli2脚手架调用时,是通过下面 WebPack CLI 命令 在项目 package.json 进行调用

  1. "scripts": {

  2. "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js",

  3. "start": "npm run dev",

  4. "build": "node build/build.js"

  5. }

而在 NutUI-CLI 则是通过 WebPack Node API WebpackDevServerWebpack 来进行调用,那么这样调用,有什么好处呢。

引用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.tspackage.disperse.config.ts这两个配置文件,主要用来构建组件库。

package.config.ts
  1. import Webpack from 'webpack';

  2. import merge from 'webpack-merge';

  3. import { baseConfig } from './base.config';

  4. ...

  5. export function packageConfig(isMinimize: boolean) {

  6. const _packageConfig: Webpack.Configuration = {

  7. mode: 'production',

  8. devtool: 'source-map',

  9. entry: {

  10. nutui: ROOT_PACKAGE_PATH('src/nutui.js'),

  11. },

  12. output: {

  13. path: ROOT_PACKAGE_PATH('dist/'),

  14. filename: isMinimize ? '[name].min.js' : '[name].js',

  15. library: '[name]',

  16. libraryTarget: 'umd',

  17. umdNamedDefine: true,

  18. // https://stackoverflow.com/questions/49111086/webpack-4-universal-library-target

  19. globalObject: `(typeof self !== 'undefined' ? self : this)`


  20. },

  21. externals: {

  22. vue: {

  23. root: 'Vue',

  24. commonjs: 'vue',

  25. commonjs2: 'vue',

  26. amd: 'vue'

  27. }

  28. },

  29. ...

  30. };

  31. isMinimize && _packageConfig.plugins?.push(new OptimizeCSSAssetsPlugin())

  32. return merge(baseConfig, _packageConfig);

  33. }

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'}}

  1. - root:可以通过一个全局变量访问 library(例如,通过 script 标签)。

  2. - commonjs:可以将 library 作为一个 CommonJS模块访问。

  3. - commonjs2:和上面的类似,但导出的是 module.exports.default.

  4. - amd:类似于 commonjs,但使用 AMD 模块系统。

output.globalObject 当以库为目标时,特别是当libraryTarget为“umd”时,此选项指示将使用哪个全局对象来装载库。要使UMD build在浏览器和Node.js上都可用,请将output.globalObject选项设置为“this”。

使用此配置文件打包 可以生成我们全局引入的js文件

ok ,全局引入组件的原理我们搞清楚了,接下来分析下 按需加载每个默认的配置文件 package.disperse.config

package.disperse.config.ts
  1. import Webpack from 'webpack';

  2. import merge from 'webpack-merge';

  3. import { ROOT_PACKAGE_PATH } from '../util/dic';

  4. import { baseConfig } from './base.config';

  5. import MiniCssExtractPlugin from 'mini-css-extract-plugin';

  6. import CopyWebpackPlugin from 'copy-webpack-plugin';

  7. import OptimizeCSSAssetsPlugin from 'optimize-css-assets-webpack-plugin';

  8. const confs = require(ROOT_PACKAGE_PATH('src/config.json'));

  9. export function packageDisperseConfig() {

  10. const entry: any = {};

  11. confs.packages.map((item: any) => {

  12. const cptName: string = item.name.toLowerCase();

  13. entry[cptName] = ROOT_PACKAGE_PATH(`src/packages/${cptName}/index.js`);

  14. });


  15. const _packageDisperseConfig: Webpack.Configuration = {

  16. mode: 'production',

  17. devtool: 'source-map',

  18. entry,

  19. ...

  20. plugins: [

  21. ...

  22. new CopyWebpackPlugin([

  23. {

  24. from: ROOT_PACKAGE_PATH('src/'),

  25. to: ROOT_PACKAGE_PATH('dist/'),

  26. ignore: ['demo.vue', 'doc.md', 'config.json', 'nutui.js', '*.spec.js']

  27. }

  28. ]),

  29. new CopyWebpackPlugin([

  30. {

  31. from: ROOT_PACKAGE_PATH('types/'),

  32. to: ROOT_PACKAGE_PATH('dist/types/')

  33. }

  34. ]),

  35. ]

  36. };

  37. return merge(baseConfig, _packageDisperseConfig);

  38. }

核心代码,将每一个组件作为一个入口,构成多入口

  1. const confs = require(ROOT_PACKAGE_PATH('src/config.json'));

  2. const entry: any = {};

  3. confs.packages.map((item: any) => {

  4. const cptName: string = item.name.toLowerCase();

  5. entry[cptName] = ROOT_PACKAGE_PATH(`src/packages/${cptName}/index.js`);

  6. });

我们可以看到 confs.packages中

  1. "packages": [

  2. {

  3. "name": "Cell",

  4. "version": "1.0.0",

  5. "sort": "4",

  6. "chnName": "列表项",

  7. "type": "component",

  8. "showDemo": true,

  9. "desc": "列表项,可组合成列表",

  10. "author": "Frans"

  11. },

  12. {

  13. "name": "Dialog",

  14. "version": "1.0.0",

  15. "sort": "2",

  16. "chnName": "对话框",

  17. "type": "method",

  18. "showDemo": true,

  19. "desc": "模态弹窗,支持按钮交互,支持图片弹窗。",

  20. "star": 5,

  21. "author": "Frans"

  22. },

  23. ...

  24. ]

通过过多入口可以构建出 每个组件的js和css

那么我们实际在dev开发模式时还需引入vue的源代码,源代码又是如何被打包呢,见 plugin中的 CopyWebpackPlugin插件

源码就比较简单了,直接拷贝过来,此时再进行打包测试

  1. plugins: [

  2. new MiniCssExtractPlugin({

  3. filename: '[name]/[name].css'

  4. }),

  5. new OptimizeCSSAssetsPlugin(),

  6. new CopyWebpackPlugin([

  7. {

  8. from: ROOT_PACKAGE_PATH('src/'),

  9. to: ROOT_PACKAGE_PATH('dist/'),

  10. ignore: ['demo.vue', 'doc.md', 'config.json', 'nutui.js', '*.spec.js']

  11. }

  12. ]),

  13. new CopyWebpackPlugin([

  14. {

  15. from: ROOT_PACKAGE_PATH('types/'),

  16. to: ROOT_PACKAGE_PATH('dist/types/')

  17. }

  18. ]),

  19. ]

打包成功,完整的 npm 静态资源包

总结

文章的主要目的是鼓励大家更加主动阅读和学习源码,帮助大家学更多 vue 和webpack的 相关知识,使我们成为更优秀的开发者。定期投资几个小时来阅读源码,短期无效,长远看来,是受益无穷的事情。通过阅读源码,你将从更深层次了解你平时所用框架的工作原理,反之又促进你更好的使用、扩展它。从而帮助你缩短从需求到编码的完成时间。

最后,附上 NUTUI 组件库官方网址 https://nutui.jd.com

扩展阅读

  • [1] NutUI组件库:https://nutui.jd.com

  • [2] NutUI-CLI:https://github.com/jdf2e/nutui/tree/v3-alpha/lib/plugin/cli

  • [3] WebPack:https://www.webpackjs.com

  • [4] TypeScript:https://www.tslang.cn

  • [5] node-fs-extra:https://github.com/jprichardson/node-fs-extra

  • [6] package.json:http://caibaojian.com/npm/files/package.json.html#bin

  • [7] commander:https://github.com/tj/commander.js

  • [8] vue-cli:https://github.com/vuejs/vue-cli


@京东用户体验设计部曾分享过


【第1884期】NutUI3.0 中单元测试的探索和实践


【第1888期】「异」曲同工 —— Generator


【第1664期】Vue组件库工程探索与实践之单元测试


【第1642期】Vue组件库工程探索与实践之按需加载


为你推荐


【第1801期】高德APP全链路源码依赖分析工程


【第1686期】通过阅读源码提高你的 JavaScript 水平


【第1611期】前端路由原理解析和实现

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存