造轮子之 npm i -g creatshare-app-init 源码浅析

以我的小经验来看,软件萌新写出来的代码大多“无法直视”。具体现象包括空格和换行符乱用、文件夹和变量的命名多使用拼音等。坐不住的我,便想到了通过 ESLint 配置文件来规范实验室的 JavaScript 代码规范的 Idea。

于是巧遇前实验室毕业学长曾经发布的 npm 包——creatshare-project-quick-init。安装好这个包,我们便可以在空文件夹下生成一个项目的基础骨架。

dist  //发布目录,用于生产环境
src   //开发目录,开发时所需资源
|----dist  //测试环境目录
|     |----static
|           |----css  //编译打包后的css资源
|           |----js   //打包压缩后的js资源
|           |----imgs //测试环境图片资源
|----less  //开发所需less代码
|----js    //开发所需js代码
|    |----lib //库或框架资源
|----imgs  //开发所需图片资源
index.html    //开发页面
gulpfile.js
package.json
README.md

What a good idea~!

在学长的这个包中,主要构建了 gulp 配置,less 和测试文件的骨架。虽然再无更多内容,但这份构建基础骨架的灵感还是被我愉快的收走了——学前端的人很多,但大多都太缺工程化意识了。于是,这个灵感成为了不错突破口。

creatshare-app-init 脚手架孕育而生。

0

通过这篇文章,你能了解到:

  • 如何用 NodeJS 编写命令行工具?
  • 如何发布自己的 npm 包?
  • 笔者与 creatshare-app-init 的故事?

在本文中,或多或少出现过以下关键字,我的解释是:

  • 轮子:该词在前端开发日常用语中,表示一个基于原生代码实现,但并没有对前端行业产生积极意义的模块。虽然它的出现方便了一些人的使用,但更多的加大了我们的学习成本。
  • 项目:该词在前端领域常指一个服务于用户的软件立项。
  • 模块:creatshare-app-init 就是一个模块,是开发前端项目中的一个子集。正如汽车的各个部件一样,多个模块合理组装起来才是一辆汽车。

1

尝试解析源码,第一步,从模块根目录下的 package.json 来看。

"dependencies": {
    "commander": "^2.11.0"
},
"devDependencies": {
    "babel-plugin-transform-runtime": "^6.23.0",
    "babel-preset-es2015": "^6.24.1",
    "babel-preset-stage-2": "^6.24.1",
    "babel-runtime": "^6.26.0",
    "eslint-config-standard": "^10.2.1",
    "eslint-plugin-import": "^2.8.0",
    "eslint-plugin-node": "^5.2.1",
    "eslint-plugin-promise": "^3.6.0",
    "eslint-plugin-standard": "^3.0.1"
}

如上,dependencies 声明了模块上线时的依赖,devDependencies 声明了模块开发时的依赖。该模块在上线时,即 npm 包被用户用到时,只需要 commander 库。commander 库是 NodeJS 命令行接口开发的优选解决方案,受启发于 Ruby 的 commander。在解析 bin/index.js 源码时将详细拓展。

"name": "creatshare-app-init",
"version": "2.1.0",
"description": "CreatShare 实验室前端项目初始化工具",
"bin": {
  "cs": "bin/index.js"
},
"scripts": {
  "compile": "babel src/ -d lib/",
  "prepublish": "npm run compile",
  "eslint": "eslint src bin",
  "test": "echo \"Error: no test specified\" && exit 1"
},

上面一段是 package.json 最开头的内容,字段详情如下:

  • name 字段:声明模块名称。特殊注意该字段不允许大写字母及空格的出现,且其与 version 字段形成了 npm 模块的唯一标识符。
  • version 字段:声明模块当前版本号。这里每当使用 npm publish 将模块发布到 npm 仓库中时,版本号都需要手动自增。
  • description 字段:对模块进行描述,同时有助于被检索。
  • bin 字段:npm 本身是通过 bin 属性配置一个或多个可解析到 PATH 路径下的可执行模块。模块若被全局安装,则 npm 会为 bin 中配置的文件在 bin 目录下创建一个软连接;模块若被局部安装,软连接会配置在项目内的 ./node_modules/.bin/目录下。
  • script 字段:定义模块的脚本配置。如,当我们在模块目录下使用 npm run compile 时,将自动执行 babel src/ -d lib/ 命令,进行 ECMAScript6 代码的转译。

2

刚刚提到 package.json 配置文件下的 bin 字段声明了 npm 在生成软连接时的配置。这就便是用户在安装好这个目录后,可以随时使用 cs 命令的出处。

我们又提到了该模块在非开发环境下只需用到 commander 模块,这个模块是 NodeJS 命令行接口开发的优选解决方案。

基于这俩点,我们就从 bin 字段所指向的 bin/index.js 聊起。

#!/usr/bin/env node

var program = require('commander')
var cs = require('../lib/cs')

program
  .allowUnknownOption()
  .version('2.1.1')
  .description('CreatShare 互联网实验室前端 Web App 项目脚手架')
  .option('-e, --enjoy')

program.
  .command('create <dir>')
  .description('创建一个新的 Web App 项目骨架')
  .action(function (rootDir) {
    cs.create(rootDir)
})

program.parse(process.argv)

就这么二十来行。因为我们要写的模块是要运行在命令行下的,就需要 #!/usr/bin/env node 语句来告诉系统使用 node 环境来运行我们的文件,必不可少。

在引入 commander 并将其赋值给 program 变量后,我们对其使用了如下方法:

  • .allowUnknownOption() 方法:
  • .version() 方法:用于设置命令程序的版本号。
  • .description() 方法:用于设置命令的描述。可以绑定在跟命令下,这里是 cs 命令;或绑定在子命令下,如 cs create <dir> 命令。
  • .option() 方法:定义命令的具体选项。
  • .command() 方法:定义命令的子命令,这里是 cs create <dir> 命令。
  • .action() 方法:用于设置命令执行的相关回调。这里绑定在 cs create <dir> 命令上,在使用该命令时触发执行回调函数。

代码最后的 process 为进程对象,是 NodeJS 运行时存在的众多全局变量之一。process 对象中的 argv 属性用来捕获命令行参数。

3

刚刚在 bin/index.js 里说明的 .action 回调函数绑定在 cs create <dir> 命令下。当我们使用该命令时,会触发 cs.create() 语句的执行,这就要提及我们引入的 lib/cs.js 文件了。

打住,第一节里展示的 package.json 中,script字段里有这么一条语句:"compile": "babel src/ -d lib/"。这是说明 lib/ 文件夹下的代码是通过 src/ 文件夹下的代码转译过来的,真正我们需要去关注的是 src/cs.js 文件。

为什么需要转译?src 里的 JavaScript 代码或多或少的使用到了 ECMAScript6 新特性,有些用户的 Node 环境并不一定能得到较好的解析。

src/cs.js 主要代码片段为:

let create = require('./create')
let path = require('path')
let distPath = path.join(__dirname, '/../dist')
let dist = process.cwd() + '/'

/**
* [运行 create 命令]
* @return {[type]} [description]
*/
exports.create = (rootDir) => {
  console.log('\n项目目录开始创建\n')
  create.init(distPath, dist, rootDir)
  helpGuide()
}

不难理解,create 变量指向 cs create <dir> 所要执行的源代码;path 是 NodeJS 自带模块,提供文件目录解析功能。

最终 src/index.js 使用 exports.create 语句向外部暴露出 create 方法。bin/index.js 便可以将该方法通过 .action() 绑定到 cs create <dir> 命令上了。

4

精彩的来了。都说 ECMAScript6 的指定振奋人心,JavaScript 的魅力越来越大,这里便是一次体验 JavaScript 在 NodeJS 上的新玩法有趣之旅。

src/create.js 文件中,主要用到了 NodeJS 自带的 fs 文件模块,来生成新项目的基础架构。文件最后暴露出的 init 方法源码如下。

exports.init = (path, dist, rootDir) => {
  createRootDir(rootDir)
  // 从新目录开始新建项目
  dist = dist + rootDir
  copyDir(path, dist)
}

init 方法获取了 path 参数、dist 参数和 rootDir 参数。在该方法中,我们先将 rootDir 参数传入 createRootDir() 函数中创建项目根目录。

在哪里创建项目根目录呢?就在执行 cs 命令时的当前目录下:

const createRootDir = (rootDir) => {
  fs.access(process.cwd(), function (err) {
    if (err) {
      // 目录不存在时创建目录
      fs.mkdirSync(rootDir)
    }
  })
}

有了项目根目录,就要将模块下 dist/ 文件夹里的所有文件递归拷贝到根目录下。一个参数用来指向 dist/ 文件夹,另一个参数用来指向根目录,便可以开始递归复制。

/**
 * [初始化静态资源]
 * @param  {[type]} src  [初始化资源路径]
 * @param  {[type]} dist [当前终端所在目录]
 * @return {[type]}      [description]
 */
const copyDir = (src, dist) => {
  fs.access(dist, function (err) {
    if (err) {
      // 目录不存在时创建目录
      fs.mkdirSync(dist)
    }
    _copy(null, src, dist)
  })

  function _copy (err, src, dist) {
    if (err) { throw err }
    fs.readdir(src, function (err, files) {
      if (err) { throw err }
      // 过滤不生成的文件
      miscFiles.forEach(function (v) {
        if (!files.includes(v)) return
        files = files.filter(function (k) {
          return k !== v
        })
      })
      // 遍历目录中的文件
      files.forEach(function (path) {
        var _src = src + '/' + path
        var _dist = dist + '/' + path
        fs.stat(_src, function (err, st) {
          if (err) { throw err }
          // 判断是文件还是目录
          if (st.isFile()) {
            fs.writeFileSync(_dist, fs.readFileSync(_src))
          } else if (st.isDirectory()) {
            // 当是目录是,递归复制
            copyDir(_src, _dist)
          }
        })
      })
    })
  }
}

fs 文件模块的具体内容推荐阅读阮一峰的开源电子书——《JavaScript 标准参考教程》中的“NodeJS”章节,来深入浅出 fs 模块的用法。

完美,这时我们就可以发布我们的脚手架包了。

5

如何发布一个 npm 包到 npm 仓库中,供其他人使用?当我们照着第一步,将 package.json 配置好后,其实模块的准备工作已经做好了。

还没有做的就是在域名为 npmjs.com 的官网上注册一个账号。这样,当我们直接在模块根目录使用 npm publish 命令的时候,输入正确的 npmjs.com 账号、密码,就能成功发布你的开源包了!

纵然读博文是一个有趣的体验,但也可以亲自动手试一试哦。

6

也就是说,酷炫的生成新项目骨架的来源,只是简单的递归复制该模块下的 dist/ 文件夹到新项目中。但我们需要关注的重点在于,dist/ 文件夹下,到底装了什么?

“初级 Web App 项目初始化工具”一说,也就名归有主了。dist/ 模板,也就是新项目的骨架如下。

.
├── .babelrc             # ES6 代码转义规则配置
├── .eslint.js           # JavaScript 代码规范
├── .gitignore           # Git 不跟踪的特殊文件
├── LICENSE              # 开源协议
├── README.md            # 项目介绍
├── material             # README.md 引用的图片库
├── package.json         # 项目配置文件
├── src                  # 源码开发目录
│   ├── favicon.ico      # 网页标题小图标
│   ├── html             # HTML 页面模板目录
│   ├── image            # 图片资源目录
│   ├── manifest.json    # 网络应用清单
│   ├── script           # 脚本文件资源目录
│   └── style            # 样式文件资源目录
├── webpack.config.js    # Webpack 多文件打包基础配置
├── webpack.dev.js       # Webpack 开发环境配置
├── webpack.prod.js      # Webpack 发布上线配置
└── yarn.lock            # yarn 包管理器的依赖说明

新项目骨架中默认推荐了:

  • 使用 Webpack 来打包多页面;
  • 使用 ESLint 来规范自己项目的 JavaScript 代码;
  • 使用 Babel 来编译使用 ECMAScript 新特性的 JavaScript 代码。
  • 使用 MIT 开源协议;
  • 源代码都放在 src/ 目录下;
  • src/ 目录要对不同的代码进行合理的分层。

End

现在的不足,是未来的畅想。

这个模块并不完美,一个健壮的命令还应该能支持足够多的参数,运行足够有意义的子命令。比如我们常用 man 命令来看另一个命令的使用手册,那要让用户能用到 man cs 命令,还需要我们在代码中加入 man 字段等等。。

我又为什么,这么热衷于分享这个轮子?

记得有一个前端群里曾有人问过:

“怎么没有 VueJS 的源码解析?”

时,我说过:

“大牛很忙,关注的是前端前沿,不写这些源码解析博文是个好事。

“当我们想有一个源码解析教程的时候,这是一个打开新世界的契机——未尝不使我们亲自来写,通过分享走向学习效率金字塔的最高层?”

这样的能力并不是人人都能具备,也不必要让人人都具备。我曾在大一傲气的说过“做最好的自己,影响该影响的人”,现在想起来除了有立刻找地洞钻进去的冲动外,反而还是觉得有一定的道理(笑。这时候允许我自称为一次“教主”,我们的理念是:

读文档,读文档,读文档。
写博客,写博客,写博客。

推荐阅读更多精彩内容