4

原始时代

作为一门语言的引入代码方式,相较于其他如PHP的include和require,Ruby的require,Python的import机制,Javascript是直接使用 <script> 标签。
因为Javascript是一门单线程语言,GUI渲染线程和Javascript引擎线程是互斥的,代码执行 <script> 标签GUI渲染线程会挂起,然后下载资源,执行脚本,完成之后再继续往下执行。在那段时间内界面是不会响应用户操作的。用户体验相当不友好。同时还带来一系列的隐患:

  • 引入顺序可能会引起代码无效甚至报错;
  • 互不了解的代码也许会造成重复命名覆盖;
  • 难以串联代码之间逻辑关系;
  • 执行顺序受影响的因素更多;
  • 不易管理维护;

<script> 标签也提供了 deferasync 属性可以实现异步加载。渲染引擎遇到这一行命令,就会开始下载外部脚本,但不会等它下载和执行,而是直接执行后面的命令,区别在于:

  • defer: 等到整个页面在内存中正常渲染结束(DOM结构完全生成,以及其他脚本执行完成),才会执行,可以保证顺序加载;
  • async: 一旦下载完,渲染引擎就会中断渲染,执行这个脚本以后,再继续渲染,不保证顺序加载;
<script src="xx.js" defer></script>
<script src="xx.js" async></script>

出于需要社区制定了一些模块加载方案,最主要的有 CommonJSAMD 两种。前者用于服务器,后者用于浏览器。

CommonJS规范

CommonJS规范为Javascript制定的美好愿景是希望Javascript能够在任何地方运行,具备跨宿主环境执行的能力,例如:

  • 富客户端应用
  • 服务器端Javascript应用程序(如Nodejs )
  • 命令行工具
  • 桌面图形界面应用程序
  • 混合应用(Titanium和Adobe AIR等形式应用)

这些规范基本覆盖了模块,二进制,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() 方法接受小驼峰命名的字符串,或者相对/绝对路径,并且可以省略文件后缀,它有自己一套匹配规则,后面再讲。

  • "./" 开头表示相对路径引用模块;
  • "/" 开头表示绝对路径引用模块;
  • 不带上面符号开头的小驼峰字符串表示默认提供的核心模块或者 node_modules 下安装模块;
  • 不带上面符号开头的路径字符串表示 node_modules 下安装模块对应后续路径;
//a.js模块
var a = require('./a');
var a = require('/a');
var a = require('a');
var a = require('a/a');

至此看来使用相当简单,模块的意义在于将类聚的变量、函数、类等限定在私有作用域中,同时支持引入导出功能连接上下游依赖,避免了变量污染等问题。

module.exports 和exports的关系?

exports 是引用 module.exports 的值,而真正导出的是 module.exports ,接着就是基本类型引用类型的区别。
如果直接替换 module.exports 或者 exports 相当于切断了和原有对象之间的关联,后续两者互不影响了。

CommonJS加载原理

第一次 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() 进来模块也不会重新执行,只会从缓存获取。

CommonJS优点

  • 模块引用顺序决定加载顺序;
  • 每个模块只会加载一次,然后将运行结果缓存起来二次利用,以后再次加载就直接读取缓存。要想让模块再次运行,必须清除缓存;
  • 每个模块都有其单独的作用域,不会污染全局;

Nodejs 模块实现

Nodejs 借鋻了 CommonJS 但不完全按照规范实现了自己的模块系统。

在Nodejs 引入模块会经历三个步骤:

  • 路径分析;
  • 文件定位;
  • 编译执行;

在 Nodejs 中有两种模块

  • Nodejs 提供的核心模块;
    这部分模块在 Nodejs 源代码编译过程中编译进了二进制执行文件。在 Nodejs 进程启动时部分核心模块被直接加载进了内存中,所以在引用的时候可以省去文件定位和编译执行的步骤,并且在路径分析优先判断,所以加载速度是最快的。
  • 由用户编写的文件模块;
    这部分模块在运行时动态加载,需要经历完整步骤。

Nodejs 会对引用过的模块进行缓存以减少二次引入的开销。而且缓存的是模块编译和执行之后的对象。所以 require() 对相同模块的再次加载都是优先缓存方式,核心模块的缓存检查依然优先于文件模块。

Nodejs 模块标识

前面提过的模块标识,例如:

  • 核心模块fs等
    优先级仅次于缓存加载,如果直接引用自己编写的和核心模块具有相同标识的模块会引用失败,必须选择不同标识符或者使用路径方式加载。
  • 路径形式文件模块
    分析过程中 rerquire() 会将路径转换成真实路径,并以此为索引将编译后结果缓存起来,因为指明了模块位置所以查找过程会省点时间,速度慢于核心模块。
  • 自定义模块
    可能是以包或者文件形式的特殊模块,查找费时速度最慢的一种,因为他会用到模块路径的查找方法。

模块路径规则

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 直到根目录为止。
模块加载过程会逐个尝试直到符合条件或者没有符合为止,你可以看出里面有着很明显的问题。

  • 层级越深查找起来越费时费力;
  • 可能你衹想查看当前目录,但是它失败后会自动尝试其他路径;

这就是自定义模块最慢的原因。

文件定位

扩展名分析

Nodejs 在标识符不包含后缀情况下会以.js, .json, .node的次序逐个尝试匹配,而且过程中需要利用fs模块以同步阻塞方式去判断是否匹配,所以在非.js文件情况指明后缀能减少性能损耗的问题。

目录分析和包

还有一种情况是经过上面步骤之后都匹配不到对应文件但是有符合的目录,此时Nodejs 会将其作为一个包的方式处理。
1)查找包下的 package.json 文件(包描述文件),通过 JSON.parse() 解析出文件读取里面的 main 属性定位对应的文件,省略后缀情况下需要执行扩展名分析步骤。
2)如果没有 package.json 或者 main 属性不对,会用默认值 index 去查找匹配文件,这一步需要扩展名分析步骤逐个尝试。
3)如果还是失败就会根据模块路径规则往上层路径寻找,直到全部路径都没有匹配文件就抛出失败。

模块编译

这是引入模块的最后阶段,定位到目标文件之后会新建一个模块对象,然后根据路径载入进行编译,不同后缀文件载入方式不同:

  • js通过fs模块同步读取文件之后编译执行;
  • node是C/C++编写的扩展文件,通过 dlopen()方法 加载最后编译生成的对象;
  • json通过fs模块同步读取文件之后用 JSON.parse() 解析返回结果;
  • 其余默认js处理方式;

每个编译成功之后的模块都会以其文件路径作为索引缓存在 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 的核心模块在编译成可执行文件过程中会被编译进二进制文件。核心模块也分JavascriptC/C++编写,前者在Node的lib目录,后者在Node的src目录。

Javascript核心模块编译

转存为C/C++代码

Nodejs 采用V8附带的 js2c.py工具 将内置的Javascript代码(src/node.js和lib/*.js)转成C++的数组,生成 node_natives.h 头文件,Javascript代码以字符串形式存储在nodejs命名空间里,此时还不能直接执行。等 Nodejs 启动进程时候才被直接加载进内存中,所以不需要引入就能直接使用。

编译Javascript核心模块

和文件模块一样也会被包装成模块对象,区别在于获取源代码的方式以及缓存执行结果的位置。
核心模块源文件通过 process.binding('natives') 取出,编译完成后缓存到 NativeModule._cache 对象上,而文件模块会被缓存到 Module._cache。

C/C++核心模块编译(不懂C/C++,这一块简短略过)

内建模块的组织形式

每个内建模块在定义之后会通过 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 填充空对象,最后按模块名缓存起来并返回给调用方使用。

核心模块引入流程

图片描述

模块调用

至此我们已经有个大概概念了,梳理一下各种模块之间的关系:

  • C/C++内建模块是最底层核心模块,主要提供API给Javascript核心模块和第三方Javascript模块使用;
  • Javascript核心模块分两类,一类作为C/C++内建模块的封装层和桥接层,一类纯粹的功能模块;
  • 文件模块分Javascript模块和C/C++扩展模块;

ES6 Module

直到ES6标准化模块功能,统一替代了之前多种模块实现库,成为浏览器和服务器通用的模块解决方案。ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量、函数、类。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。
ES6 的模块有几个需要注意的地方:

  • 自动采用严格模式,即使你没有使用"use strict";
  • 顶层的this指向undefined;
// CommonJS模块
let {readFile} = require('fs');
// ES6模块
import {readFile} from 'fs';

以上为例。
CommonJS加载整个 fs模块 生成一个模块对象,然后从对象中导出 readFile方法 。
ES6 模块通过 import命令 从 fs模块 加载输入的变量、函数、类。
结果就是ES6模块效率高,但是拿不到模块对象本身。

|加载方案|加载|输出|
| :--- | :---: | : |
|CommonJS|运行时加载|拷贝|
|ES6 模块|编译时输出接口|引用|

由于 ES6 模块是编译时加载,使得静态分析成为可能。比如引入宏(macro)类型检验(type system)这些只能靠静态分析实现的功能。

ES6 模块还有以下好处:

  • 不再需要UMD模块格式了,将来服务器和浏览器都会支持 ES6 模块格式。目前,通过各种工具库,其实已经做到了这一点。
  • 将来浏览器的新 API 就能用模块格式提供,不再必须做成全局变量或者 navigator对象 的属性。
  • 不再需要对象作为命名空间(比如Math对象),未来这些功能可以通过模块提供。
  • ES6模块提供了 export导出命令 和 import导入命令 ,它们同样具有全局提升的效果,只要在顶层使用即可。

export导出命令

支持输出变量、函数、类。

//变量
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

import 导入命令

和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';

关键字default

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;

export 与 import 的复合写法

这里提供了两种写法,他们之间会有些不同。

//引入后导出
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';

运行原理

  1. Construction:查找.下载所有的文件并且解析为 模块记录
  2. Instantiation:把所有导出的变量放入内存指定位置(这时候还没有填入数据)。然后让导出和导入都指向内存指定位置,这叫 Linking
  3. Evaluation:执行代码,得到变量的值然后填入到内存对应位置。

0.png

模块记录

所有模块归根到底都是从至少一个入口开始,顺着入口文件一步步检索找到其所依赖的其他模块,然后从其他模块重复这个流程直到没有为止.
1.png
2.png

一开始引入的文件并不会执行,而是由JS引擎静态分析文件转换得到一个模块记录的数据结构,将所有的模块依赖信息都汇总到其中
3.png

模块构造

得到模块记录之后会下载所有的依赖,然后再次将其转换为模块记录,里面包括了

  1. 模块识别(解析依赖模块下载地址,URL或者本地系统路径)
  2. 文件下载
  3. 转换为模块记录并缓存起来

4.png

模块实例

完成上面步骤之后就已经拥有所有的模块记录了,将所有的导入导出变量一一对应到内存地址关联起来,确保他们相同的模块的相同变量都指向相同的内存地址,每个导到变量都能找到对应的导出变量,这也意味着当导出变量发生改变时会影响到所有对应的导入变量

5.png

模块执行

执行文件将变量结果赋值给内存地址,整个过程就完成了

但是其中可能会有一些副作用,例如发送请求,所以必须保证模块代码只运行一次完整流程.以URL为索引缓存模块使用模块映射,保证每个模块只有一个模块记录
6.png

CommonJS vs ES6 Modules

语法差异

import命令会被 JavaScript引擎静态分析,先于模块内的其他语句执行,而 Nodejs 的 require() 是运行时加载模块,import命令无法取代require的动态加载功能,所以如果在Nodejs 中使用ES6模块语法要注意这一点。

//成功
var fs = require('f'+'s');
//报错
import fs from ('f'+'s');

有一个提案,建议引入 import() 函数,完成动态加载,已经有实现方案了,我没用过就不说了。

加载机制

CommonJS是从文件系统加载文件,耗时远小于从网络下载,所以Nodejs加载文件的时候即使阻塞主线程也影响不大,所以当文件加载完之后它可以直接进行实例化和运行,这两个阶段并不是相互独立的的阶段,这意味着可以在返回模块实例之前顺着依赖记录去逐一加载,实例化和运行.也是因为这原因才可以实现上面用变量加载模块的操作

而ESM将算法分为多个阶段,主要原因当然是受困于网络条件限制,,在运行任何代码之前都需要先将整个模块依赖的关系图构建完成,

运行机制

上面提到过的

CommonJs引入的模块都是值传递或者引用传递,类似函数传参

ESM引入的模块都是强绑定,导出的模块发生改变引入的模块也会随之改变

可以简单理解为前者是拷贝,后者是指向引用

CommonJS

// a.js
const name = require('b')
setTimeout(() => console.log(name), 1000)

// b.js
let name = 'a'
setTimeout(() => string = 'b', 500)

module.exports = name

// a

ESM

// a.js
import name from 'b'
setTimeout(() => console.log(name), 1000)

// b.js
let name = 'a'
setTimeout(() => string = 'b', 500)

export default name

// b

循环依赖

CommonJS

7.png
最开始时,main 模块会运行 require 语句。紧接着,会去加载 counter 模块。
8.png

counter 模块会试图去访问导出对象的 message 。不过,由于 main 模块中还没运行到 message 处,所以此时得到的 message 为 undefined。JS 引擎会为本地变量分配空间并把值设为 undefined 。
9.png

运行阶段继续往下执行,直到 counter 模块顶层代码的末尾处。我们想知道,当 counter 模块运行结束后,message 是否会得到真实值,所以我们设置了一个超时定时器。之后运行阶段便返回到 main.js 中。
10.png

这时,message 将会被初始化并添加到内存中。但是这个 message 与 counter 模块中的 message 之间并没有任何关联关系,所以 counter 模块中的 message 仍然为 undefined。
11.png

如果导出值采用的是实时绑定方式,那么 counter 模块最终会得到真实的 message 值。当超时定时器开始计时时,main.js 的运行就已经完成并设置了 message 值。

ESM

使用称为实时绑定(Live Binding)的方式。导出和导入的模块都指向相同的内存地址(即值引用)。所以,当导出模块内导出的值改变后,导入模块中的值也实时改变了。

模块导出的值在任何时候都可以能发生改变,但是导入模块却不能改变它所导入的值,因为它是只读的。

参考资料

nodejs 深入浅出
ES6 标准入门(第3版)
ES modules: A cartoon deep-dive


Afterward
621 声望62 粉丝

努力去做,对的坚持,静待结果