Fork me on GitHub
代码改变世界

模块机制

2017-03-10 23:08  autrol  阅读(529)  评论(0编辑  收藏  举报

JavaScript自诞生以来,曾经没有人拿它当做一门真正的编程语言,认为它不过是一种网页小脚本而已。直到Web 2.0时代,前端工程师利用它大大提升了网页上的用户体验。在这个过程中,B/S应用展现出比C/S应用优越的地方。至此,JavaScript才被广泛重视起来。

经历了长长的后天努力过程,JavaScript不断被类聚和抽象,以更好地组织业务逻辑。从另一个角度而言,它也道出了JavaScript先天就缺乏的一项功能:模块。

在其他高级语言中,Java有类文件,Python有import机制,Ruby有require,PHP有include和require。而JavaScript通过<script>标签引入代码的方式显得杂乱无章,语言自身毫无组织和约束能力,直到出现CommonJS规范。

CommonJS规范

CommonJS规范为JavaScript制定了一个美好的愿景——希望JavaScript能够在任何地方运行。

CommonJS的出发点

CommonJS规范的提出,主要是为了弥补当前JavaScript没有标准的缺陷,以达到像Python、Ruby和Java具备开发大型应用的基础能力,而不是停留在小脚本程序的阶段。他们期望那些用CommonJS API写出的应用可以具备跨宿主环境执行的能力,这样不仅可以利用JavaScript开发富客户端应用,而且还可以编写以下应用。

  • 服务器端JavaScript应用程序。
  • 命令行工具。
  • 桌面图形界面应用程序。
  • 混合应用(Titanium和Adobe AIR等形式的应用)。

如今,CommonJS中的大部分规范虽然依旧是草案,但是已经初显成效,为JavaScript开发大型应用程序指明了一条非常棒的道路。目前,它依旧在成长中,这些规范涵盖了模块、二进制、Buffer、字符集编码、I/O流、进程环境、文件系统、套接字、单元测试、Web服务器网关接口、包管理等。

CommonJS的模块规范

CommonJS对模块的定义十分简单,主要分为模块引用、模块定义和模块标识3个部分。

  1. 模块引用(require)
var fs = require('fs');
  1. 模块定义(exports)
// math.js
exports.add = function () {
  var sum = 0,
    i = 0,
    args = arguments,
    l = args.length;
  while (i < l) {
    sum += args[i++];
  }
  return sum;
};
  1. 模块标识
    模块标识其实就是传递给require()方法的参数,它必须是符合小驼峰命名的字符串,或者以.、..开头的相对路径,或者绝对路径。它可以没有文件名后缀.js。

模块机制

CommonJS构建的这套模块导出和引入机制使得用户完全不必考虑变量污染,命名空间等方案与之相比相形见绌。

Node的模块实现

Node在实现中并非完全按照规范实现,而是对模块规范进行了一定的取舍,同时也增加了少许自身需要的特性。在Node中引入模块,需要经历如下3个步骤。

  1. 路径分析
  2. 文件定位
  3. 编译执行

在Node中,模块分为两类:一类是Node提供的模块,称为核心模块;另一类是用户编写的模块,称为文件模块。

  • 核心模块部分在Node源代码的编译过程中,编译进了二进制执行文件。在Node进程启动时,部分核心模块就被直接加载进内存中,所以这部分核心模块引入时,文件定位和编译执行这两个步骤可以省略掉,并且在路径分析中优先判断,所以它的加载速度是最 快的。
  • 文件模块则是在运行时动态加载,需要完整的路径分析、文件定位、编译执行过程,速度比核心模块慢。

Node对引入过的模块都会进行缓存,以减少二次引入时的开销。Node缓存的是编译和执行之后的对象。不论是核心模块还是文件模块,require()方法对相同模块的二次加载都一律采用缓存优先的方式,这是第一优先级的。不同之处在于核心模块的缓存检查先于文件模块的缓存检查。

CommonJS包规范

CommonJS的包规范的定义其实也十分简单,它由包结构和包描述文件两个部分组成,前者用于组织包中的各种文件,后者则用于描述包的相关信息,以供外部读取分析。

包结构

包实际上是一个存档文件,即一个目录直接打包为.zip或tar.gz格式的文件,安装后解压还原为目录。完全符合CommonJS规范的包目录应该包含如下这些文件。

  • package.json:包描述文件。
  • bin:用于存放可执行二进制文件的目录。
  • lib:用于存放JavaScript代码的目录。
  • doc:用于存放文档的目录。
  • test:用于存放单元测试用例的代码。

可以看到,CommonJS包规范从文档、测试等方面都做过考虑。当一个包完成后向外公布时,用户看到单元测试和文档的时候,会给他们一种踏实可靠的感觉。

注意: 最好不要用CommonJS规范的文件名,存储与其功能不对应的文件。

包描述文件与NPM

包描述文件用于表达非代码相关的信息,它是一个JSON格式的文件——package.json,位于包的根目录下,是包的重要组成部分。而NPM的所有行为都与包描述文件的字段息息相关。由于CommonJS包规范尚处于草案阶段,NPM在实践中做了一定的取舍,这里就只介绍实践相关主要字段了。

{
    "name": "包名。规范定义它需要由小写的字母和数字组成,可以包含.、_和-,但不允许出现空格。",
    "version": "版本号",
    "description": "包简介",
    "keywords": "关键词数组,NPM中主要用来做分类搜索。一个好的关键词数组有利于用户快速找到你编写的包。",
    "repositories": "托管源代码的位置列表,表明可以通过哪些方式和地址访问包的源代码。",
    "author": "作者",
    "bin": "一些包作者希望包可以作为命令行工具使用。配置好bin字段后,通过npm install package_name -g命令可以将脚本添加到执行路径中,之后可以在命令行中直接执行。前面的node-gyp即是这样安装的。通过-g命令安装的模块包称为全局模式。",
    "main": "入口文件",
    "scripts": "脚本说明对象。它主要被包管理器用来安装、编译、测试和卸载包。",
    "engines": "支持的JavaScript引擎列表,有效的引擎取值包括ejs、flusspferd、gpsee、jsc、spidermonkey、narwhal、node和v8。",
    "dependencies": "使用当前包所需要依赖的包列表。",
    "devDependencies": "一些模块只在开发时需要依赖。配置这个属性,可以提示包的后续开发者安装依赖包。",
    "licenses": "当前包所使用的许可证列表,表示这个包可以在哪些许可证下使用。",
    "contributors": "贡献者列表。在开源社区中,为开源项目提供代码是经常出现的事情,如果名字能出现在知名项目的contributors列表中,是一件比较有荣誉感的事。列表中的第一个贡献应当是包的作者本人。它的格式与维护者列表相同。",
    "maintainers": "包维护者列表"
}

NPM常用功能

CommonJS包规范是理论,NPM是其中的一种实践。NPM之于Node,相当于gem之于Ruby,pear之于PHP。对于Node而言,NPM帮助完成了第三方模块的发布、安装和依赖等。借助NPM,Node与第三方模块之间形成了很好的一个生态系统。

借助NPM,可以帮助用户快速安装和管理依赖包。除此之外,NPM还有一些巧妙的用法,下面我们详细介绍一下。

1.查看帮助

在安装Node之后,执行npm –v命令可以查看当前NPM的版本:

$ npm -v
3.10.9

在不熟悉NPM的命令之前,可以直接执行NPM查看到帮助引导说明:

$ npm

Usage: npm <command>

where <command> is one of:
    access, adduser, bin, bugs, c, cache, completion, config,
    ddp, dedupe, deprecate, dist-tag, docs, edit, explore, get,
    help, help-search, i, init, install, install-test, it, link,
    list, ln, login, logout, ls, outdated, owner, pack, ping,
    prefix, prune, publish, rb, rebuild, repo, restart, root,
    run, run-script, s, se, search, set, shrinkwrap, star,
    stars, start, stop, t, tag, team, test, tst, un, uninstall,
    unpublish, unstar, up, update, v, version, view, whoami

npm <cmd> -h     quick help on <cmd>
npm -l           display full usage info
npm help <term>  search for help on <term>
npm help npm     involved overview

Specify configs in the ini-formatted file:
    /Users/yangzhinian/.npmrc
or on the command line via: npm <command> --key value
Config info can be viewed via: npm help config

npm@3.10.9 /Users/yangzhinian/.nvm/versions/node/v6.9.2/lib/node_modules/npm

可以看到,帮助中列出了所有的命令,其中npm help <command &gt可以查看具体的命令说明。

2.安装依赖包

安装依赖包是NPM最常见的用法,它的执行语句是npm install express。执行该命令后,NPM会在当前目录下创建node_modules目录,然后在node_modules目录下创建express目录,接着将包解压到这个目录下。

安装好依赖包后,直接在代码中调用require('express');即可引入该包。require()方法在做路径分析的时候会通过模块路径查找到express所在的位置。模块引入和包的安装这两个步骤是相辅相承的。

全局模式安装

如果包中含有命令行工具,那么需要执行npm install express -g命令进行全局模式安装。需要注意的是,全局模式并不是将一个模块包安装为一个全局包的意思,它并不意味着可以从任何地方通过require()来引用到它。

全局模式这个称谓其实并不精确,存在诸多误导。实际上,-g是将一个包安装为全局可用的可执行命令。它根据包描述文件中的bin字段配置,将实际脚本链接到与Node可执行文件相同的路径下

"bin": {
  "express": "./bin/express"
}

事实上,通过全局模式安装的所有模块包都被安装进了一个统一的目录下,这个目录可以通过如下方式推算出来:

path.resolve(process.execPath, '..', '..', 'lib', 'node_modules');

如果Node可执行文件的位置是/usr/local/bin/node,那么模块目录就是/usr/local/lib/node_modules。最后,通过软链接的方式将bin字段配置的可执行文件链接到Node的可执行目录下。

本地安装

对于一些没有发布到NPM上的包,或是因为网络原因导致无法直接安装的包,可以通过将包下载到本地,然后以本地安装。本地安装只需为NPM指明package.json文件所在的位置即可:它可以是一个包含package.json的存档文件,也可以是一个URL地址,也可以是一个目录下有package.json文件的目录位置。具体参数如下:

npm install <tarball file>
npm install <tarball url>
npm install <folder>
从非官方源安装

如果不能通过官方源安装,可以通过镜像源安装。在执行命令时,添加 --registry=http://registry.url即可,示例如下:

npm install underscore --registry=http://registry.url

如果使用过程中几乎都采用镜像源安装,可以执行以下命令指定默认源:

npm config set registry http://registry.url

3.NPM钩子命令

"scripts": {
    "test": "make test",
    "start": "./node_modules/.bin/nodemon -L  index.js",
    "dev": "webpack-dev-server --config ./bin/build/webpack.dev.conf.js"
}

当在一个具体的包目录下执行npm run test时,将会执行"make test"命令。

4.发布包

为了将整个NPM的流程串联起来,这里将演示如何编写一个包,将其发布到NPM仓库中,并通过NPM安装回本地。

编写模块

模块的内容我们尽量保持简单,这里还是以sayHello作为例子,相关代码如下:

exports.sayHello = function () {
  return 'Hello, world.';
};

将这段代码保存为hello.js即可。

初始化包描述文件

package.json文件的内容尽管相对较多,但是实际发布一个包时并不需要一行一行编写。NPM提供的npm init命令会帮助你生成package.json文件,具体如下所示:

$ npm init
注册包仓库账号

为了维护包,NPM必须要使用仓库账号才允许将包发布到仓库中。注册账号的命令是npm adduser。这也是一个提问式的交互过程,按顺序进行即可:

$ npm adduser
Username: (jacksontian) 
Email: (shyvo1987@gmail.com)
上传包

上传包的命令是npm publish 。在刚刚创建的package.json文件所在的目录下,执行npm publish .开始上传包,相关代码如下:

$ npm publish .

在这个过程中,NPM会将目录打包为一个存档文件,然后上传到官方源仓库中。

安装包
$ npm install package_name
管理包权限

通常,一个包只有一个人拥有权限进行发布。如果需要多人进行发布,可以使用npm owner命令帮助你管理包的所有者:

$ npm owner ls eventproxy
npm http GET https://registry.npmjs.org/eventproxy
npm http 200 https://registry.npmjs.org/eventproxy
jacksontian <shyvo1987@gmail.com>

使用这个命令,也可以添加包的拥有者,删除一个包的拥有者:

npm owner ls <package name>
npm owner add <user> <package name>
npm owner rm <user> <package name>

5.分析包

在使用NPM的过程中,或许你不能确认当前目录下能否通过require()顺利引入想要的包,这时可以执行npm ls分析包。

这个命令可以为你分析出当前路径下能够通过模块路径找到的所有包,并生成依赖树,如下:

$ npm ls
/Users/jacksontian
├─┬ connect@2.0.3 
│ ├── crc@0.1.0 
│ ├── debug@0.6.0 
│ ├── formidable@1.0.9 
│ ├── mime@1.2.4 
│ └── qs@0.4.2 
├── hello_test_jackson@0.0.1 
└── urllib@0.2.3

局域NPM

NPM自身是开源的,无论是它的服务器端和客户端。通过源代码搭建自己的仓库并不是什么秘密。

对于企业内部而言,私有的可重用模块可以打包到局域NPM仓库中,这样可以保持更新的中心化,不至于让各个小项目各自维护相同功能的模块,杜绝通过复制粘贴实现代码共享的行为。

NPM潜在问题

潜在的问题在于,在NPM平台上,每个人都可以分享包到平台上,鉴于开发人员水平不一,上面的包的质量也良莠不齐。另一个问题则是,Node代码可以运行在服务器端,需要考虑安全问题。

对于包的使用者而言,包质量和安全问题需要作为是否采纳模块的一个判断条件。好的包大致具备以下几种特征:

  • 具备良好的测试。
  • 具备良好的文档(README、API)。
  • 具备良好的测试覆盖率。
  • 具备良好的编码规范。
  • 更多条件。

前后端共用模块

JavaScript在Node出现之后,比别的编程语言多了一项优势,那就是一些模块可以在前后端实现共用,这是因为很多API在各个宿主环境下都提供。但是在实际情况中,前后端的环境是略有差别的。

模块的侧重点

纵观Node的模块引入过程,几乎全都是同步的。尽管与Node强调异步的行为有些相反,但它是合理的。但是如果前端模块也采用同步的方式来引入,那将会在用户体验上造成很大的问题。UI在初始化过程中需要花费很多时间来等待脚本加载完成。

鉴于网络的原因,CommonJS为后端JavaScript制定的规范并不完全适合前端的应用场景。经过一段争执之后,AMD规范最终在前端应用场景中胜出。它的全称是Asynchronous Module Definition,即是"异步模块定义",除此之外,还有玉伯定义的CMD规范。

AMD与CMD的区别

CMD与AMD规范的主要区别在于定义模块和依赖引入的部分。AMD需要在声明模块的时候指定所有的依赖,通过形参传递依赖到模块内容中:

define(['dep1', 'dep2'], function (dep1, dep2) {
  return function () {};
});

与AMD模块规范相比,CMD模块更接近于Node对CommonJS规范的定义:

define(factory);

在依赖部分,CMD支持动态引入,示例如下:

define(function(require, exports, module) {
  // The module code goes here
});

require、exports和module通过形参传递给模块,在需要依赖模块时,随时调用require()引入即可。

兼容多种模块规范

为了让同一个模块可以运行在前后端,在写作过程中需要考虑兼容前端也实现了模块规范的环境。为了保持前后端的一致性,类库开发者需要将类库代码包装在一个闭包内。以下代码演示如何将hello()方法定义到不同的运行环境中,它能够兼容Node、AMD、CMD以及常见的浏览器环境中

;(function (name, definition) {
  // 检测上下文环境是否为AMD或CMD
  var hasDefine = typeof define === 'function',
    // 检查上下文环境是否为Node
    hasExports = typeof module !== 'undefined' && module.exports;

  if (hasDefine) {
    // AMD环境或CMD环境
    define(definition);
  } else if (hasExports) {
    // 定义为普通Node模块
    module.exports = definition();
  } else {
    // 将模块的执行结果挂在window变量中,在浏览器中this指向window对象
    this[name] = definition();
  }
})('hello', function () {
  var hello = function () {};
  return hello;
});