刘利新 2019-09-07
作为一门语言的引入代码方式,相较于其他如PHP的include和require,Ruby的require,Python的import机制,Javascript是直接使用 <script> 标签。
因为Javascript是一门单线程语言,GUI渲染线程和Javascript引擎线程是互斥的,代码执行 <script> 标签GUI渲染线程会挂起,然后下载资源,执行脚本,完成之后再继续往下执行。在那段时间内界面是不会响应用户操作的。用户体验相当不友好。同时还带来一系列的隐患:
<script>
标签也提供了 defer 和 async 属性可以实现异步加载。渲染引擎遇到这一行命令,就会开始下载外部脚本,但不会等它下载和执行,而是直接执行后面的命令,区别在于:
<script src="xx.js" defer></script> <script src="xx.js" async></script>
出于需要社区制定了一些模块加载方案,最主要的有 CommonJS 和 AMD 两种。前者用于服务器,后者用于浏览器。
CommonJS规范为Javascript制定的美好愿景是希望Javascript能够在任何地方运行,具备跨宿主环境执行的能力,例如:
这些规范基本覆盖了模块,二进制,Buffer,字符集编码,I/O流,进程环境,文件系统,套接字,单元测试,Web服务器网关接口,包管理等。
1, 引用
模块上下文提供 require() 方法引入外部模块,一般如下
var fs = require('fs');
2, 定义
模块中存在一个上下文 module对象 ,它代表模块自身, module对象 提供了 exports对象 用于导出当前模块的变量、函数、类,并且是唯一的导出出口。
//a.js模块 exports.a = 1; //引用a.js模块 var a = require('a');
3, 标识
require() 方法接受小驼峰命名的字符串,或者相对/绝对路径,并且可以省略文件后缀,它有自己一套匹配规则,后面再讲。
//a.js模块 var a = require('./a'); var a = require('/a'); var a = require('a'); var a = require('a/a');
至此看来使用相当简单,模块的意义在于将类聚的变量、函数、类等限定在私有作用域中,同时支持引入导出功能连接上下游依赖,避免了变量污染等问题。
exports 是引用 module.exports 的值,而真正导出的是 module.exports ,接着就是基本类型和引用类型的区别。
如果直接替换 module.exports 或者exports相当于切断了和原有对象之间的关联,后续两者互不影响了。
第一次 require() 一个脚本的时候会执行代码然后在内存中会生成一个模块对象缓存起来,类似
{ id: '...',//模块的识别符,通常是带有绝对路径的模块文件名 filename: '',//模块的文件名,带有绝对路径 exports: {...},//导出变量、函数、类 loaded: true,//模块是否已经完成加载 parent: {},//调用该模块的模块 children: [],//该模块要用到的其他模块 ... }
例如你创建一个文件脚本代码执行就可以查看到这些信息。
exports.a = 1; console.log(module); // Module { // id: '.', // exports: { a: 1 }, // parent: null, // // filename: 'C:\\project\\test\\module_demo\\test1.js', // loaded: false, // children: [], // paths: // [ 'C:\\project\\test\\module_demo\\node_modules', // 'C:\\project\\test\\node_modules', // 'C:\\project\\node_modules', // 'C:\\node_modules' ] }
以后需要引用模块的变量、函数、类就在这个模块对象的 exports 取出,即使再次 require() 进来模块也不会重新执行,只会从缓存获取。
Nodejs 借鋻了 CommonJS 但不完全按照规范实现了自己的模块系统。
在Nodejs 引入模块会经历三个步骤:
在 Nodejs 中有两种模块
Nodejs 会对引用过的模块进行缓存以减少二次引入的开销。而且缓存的是模块编译和执行之后的对象。所以 require() 对相同模块的再次加载都是优先缓存方式,核心模块的缓存检查依然优先于文件模块。
前面提过的模块标识,例如:
Nodejs在定位文件模块有自己的一套查找策略,你可以随便一个文件夹执行一个脚本如下看看打印信息,我是 Windows 系统结果如下
console.log(module.paths); // [ 'C:\\work\\project\\test\\node_modules', // 'C:\\work\\project\\node_modules', // 'C:\\work\\node_modules', // 'C:\\node_modules' ]
从中可以看出他会从当前执行文件所在目录下的 node_modules,沿路径向上逐层递归查找 node_modules 直到根目录为止。
模块加载过程会逐个尝试直到符合条件或者没有符合为止,你可以看出里面有着很明显的问题。
这就是自定义模块最慢的原因。
这是引入模块的最后阶段,定位到目标文件之后会新建一个模块对象,然后根据路径载入进行编译,不同后缀文件载入方式不同:
每个编译成功之后的模块都会以其文件路径作为索引缓存在 Module_cache。根据不同的扩展后缀 Nodejs 有不同的读取方式。
1, Javascript模块编译
在编译过程中,Nodejs 会对获取的模块进行包装,如下:
(function(exports, require, module, __filename, __dirname) { //模块源码 })
2, C/C++模块编译
Nodejs 调用 process.dlopen() 方法进行加载执行,通过 libuv封装库 支持 Windows 和 *nix 平台下实现,因为.node本身就是C/C++写的,所以它不需要编译,衹要加载执行就可以了,执行效率较高。
3, JSON文件编译
上面说过通过fs模块同步读取文件之后用 JSON.parse() 解析返回结果,赋值给模块对象的 exports。
除了配置文件,如果你开发中有需要用到json文件的时候可以不用 fs模块 去读取,而是直接 require() 引入更好,因为能享受到缓存加载的便利。
上面说过 Nodejs 模块分为核心模块和文件模块,刚才讲的都是文件模块的编译过程,而 Nodejs 的核心模块在编译成可执行文件过程中会被编译进二进制文件。核心模块也分Javascript和C/C++编写,前者在Node的lib目录,后者在Node的src目录。
Nodejs 采用V8附带的 js2c.py工具 将内置的Javascript代码(src/node.js和lib/*.js)转成C++的数组,生成 node_natives.h 头文件,Javascript代码以字符串形式存储在nodejs命名空间里,此时还不能直接执行。等 Nodejs 启动进程时候才被直接加载进内存中,所以不需要引入就能直接使用。
和文件模块一样也会被包装成模块对象,区别在于获取源代码的方式以及缓存执行结果的位置。
核心模块源文件通过 process.binding('natives') 取出,编译完成后缓存到 NativeModule._cache 对象上,而文件模块会被缓存到 Module._cache。
每个内建模块在定义之后会通过 NODE_MODULE宏 将模块定义到nodejs命名空间,模块的具体初始化方法被挂载在结构的 register_func 成员。
node_extensions.h 文件将散列的内建模块统一放进 node_module_list数组 中,Nodejs 提供了 get_builtin_module() 方法从中取出。
内建模块优势在于本身C/C++编写性能优异,编译成二进制文件时候被直接加载进内存,无需再做标识符定位,文件定位,编译等过程。
Nodejs 启动会生成全局变量 process,提供 Binding() 方法协助加载内建模块。
加载过程中我们会先生成 exports空对象 ,然后调用 get_builtin_module() 方法去取内建模块,通过执行 register_func 填充空对象,最后按模块名缓存起来并返回给调用方使用。
至此我们已经有个大概概念了,梳理一下各种模块之间的关系:
直到ES6标准化模块功能,统一替代了之前多种模块实现库,成为浏览器和服务器通用的模块解决方案。ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量、函数、类。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。
ES6 的模块有几个需要注意的地方:
// CommonJS模块 let {readFile} = require('fs'); // ES6模块 import {readFile} from 'fs';
以上为例。
CommonJS加载整个 fs模块 生成一个模块对象,然后从对象中导出 readFile方法 。
ES6 模块通过 import命令 从 fs模块 加载输入的变量、函数、类。
结果就是ES6模块效率高,但是拿不到模块对象本身。
加载方案 | 加载 | 输出 |
---|---|---|
CommonJS | 运行时加载 | 拷贝 |
ES6 模块 | 编译时输出接口 | 引用 |
由于 ES6 模块是编译时加载,使得静态分析成为可能。比如引入宏(macro)和类型检验(type system)这些只能靠静态分析实现的功能。
ES6 模块还有以下好处:
支持输出变量、函数、类。
//变量 export var a = 1; //函数 export function log(n) { console.log(n); } //类 export class Num {}
我习惯写法是使用对象方式输出,整个模块导出什么一目了然。
//变量 var a = 1; //函数 function log(n) { console.log(n); } //类 class Num {} export {a, log, Num};
这种写法也支持as关键字对外重命名
export {a as b, log as cng, Num as Digit};
这里有一个隐藏比较深的概念性知识,export命令规定的是对外的接口必须与模块内部的变量、函数、类建立一一对应关系。这种写法是OK的。
export var a = 1; //或者 var a = 1; export { a, //或者 a as b, }
但是你不能这么写,尽管看起来没什么问题,不过没有提供对外的接口,只是直接或者间接输出1。
export 1; //或者 var a = 1; export a
特别容易让人混淆的是这一句,所以要特别注意
//正确 export var a = 1; //错误 var a = 1; export a
这不仅仅是针对变量,包括函数和类也遵循这种写法,之所以会有这种要求是因为 export语句 输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值。
export var a = 1 setTimeout(() => a = 2, 3000); //后续引用a会得到2
和expor相对应的按需引入写法如下
//直接引入写法 import {a, log, Num} from 'xx';
import也支持使用as关键字
import {a as b, log as cng, Num as Digit} from 'xx';
和 export 动态绑定值不同,import 是只读静态执行,即你不能修改引用的模块变量、函数、类等,也不能使用表达式和变量这种运行时才能引入静态分析阶段没法得到值的写法。
//修改属性 import{a} from 'xx' a = 2//error
//表达式引入 import{'l' + 'og'} from 'xx' //变量引入 var module = 'xx'; import {} from module//error
//判断引入 //error if (true) { import {} from 'xx1'; } else { import {} from 'xx2'; }
因为多次引用也只会执行一次,尽管不推荐,但是这种写法也是可以的
import {a} from 'xx'; import {log} from 'xx'; //等价于 import {a, log} from 'xx';
import也支持这种写法,仅仅执行模块,但是不输入任何变量、函数、类。
import 'xx';
export 支持 关键字default 设置默认导出的变量、函数、类:
1, 每个模块只支持一个关键字default默认导出;
2, 可以使用函数名或匿名函数导出,即使指定了函数名也不能在模块外部引用,等同视为匿名函数加载;
//函数 function log(n) { console.log(n); } export default log; //或者 export default function(n) { console.log(n); } //或者 export default function log(n) { console.log(n); }
其他模块加载该模块时,import命令可以为该默认导出函数指定任意名字。
export default function log(n) { console.log(n); } //加载 import anyName from 'xx';
如果想在一条 import语句 中,同时输入默认函数和其他接口,可以写成下面这样。
import log, {a, Num as Digit} from 'xx';
本质上这也只是一种语法糖,与下面写法等价
export default log; import log from 'xx'; //==等价== export { log as default} import { default as log } from 'xx';
因为default也是变量,所以不能后面再加变量
export default var a = 1;
但是可以直接输出
export default 1; export default a;
用星号(*)指定一个对象,所有输出值都加载在这个对象上面。
import * as all from 'xx'; const {a, log, Num} = all;
这里提供了两种写法,他们之间会有些不同。
//引入后导出 import {log} from 'xx'; export {log}; //直接导出 export {log} from 'xx'; //或者 export {log as default} from 'xx';
区别在于第二三种是没有导入动作,所以不能在该模块引用对应的变量、函数、类。
需要注意的是下面三种写法ES6目前还不支持。
export * as all from "xx"; export all from "xx"; export log, {a, Digit as Num} from 'xx';
import命令 会被 JavaScript引擎静态分析,先于模块内的其他语句执行,而 Nodejs 的 require() 是运行时加载模块,import命令无法取代require的动态加载功能,所以如果在Nodejs 中使用ES6模块语法要注意这一点。
//成功 var fs = require('f'+'s'); //报错 import fs from ('f'+'s');
有一个提案,建议引入 import() 函数,完成动态加载,已经有实现方案了,我没用过就不说了。