开发中最流行的 commonjs、AMD、ES6、CMD 规范。
参考资料:
https://exploringjs.com/es6/ch_modules.html#static-module-structure
https://mp.weixin.qq.com/s/MPEhWlS9KiIc9I6Of5GpOQ
http://www.ruanyifeng.com/blog/2015/05/commonjs-in-browser.html
http://foio.github.io/requireJS/
https://github.com/ZhiCYue/requireJs-analysis/blob/master/require.js
学习笔记 + 原理分析
理解原理后,网上再搜集相关资料时,会对一些概念有更好的认识,比如:延时执行、立即执行、运行时、编译时等等。
再比如:“AMD是提前执行,CMD是延迟执行。” ,“CMD 推崇依赖就近,AMD 推崇依赖前置” 等等。
Node 应用由模块组成,采用 CommonJS 模块规范。每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。在服务器端,模块的加载是运行时同步加载的;在浏览器端,模块需要提前编译打包处理。
- 所有代码都运行在模块作用域,不会污染全局作用域。
- 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
- 模块加载的顺序,按照其在代码中出现的顺序。
- 暴露模块:module.exports = value 或 exports.xxx = value;
- 引入模块:require(xxx), 如果是第三方模块,xxx 为模块名;如果是自定义模块,xxx 为模块文件路径。
- 服务端:node app.js
- 浏览器:借助 Browserify。参考: browserify js/src/app.js -o js/dist/bundle.js
记录点 1 CommonJS 模块的加载机制是,输入的是被输出的值的拷贝(这里要区分基本类型、非基本类型)。也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。
// lib.js
var counter = 3;
function incCounter() {
counter++;
}
module.exports = {
counter: counter,
incCounter: incCounter,
};
// main.js
var counter = require('./lib').counter;
var incCounter = require('./lib').incCounter;
console.log(counter); // 3
incCounter();
console.log(counter); // 3
// 示例二:复杂对象,是浅拷贝
// b.js
let count = {
a: 1
}
let plusCount = () => {
count.a ++
}
module.exports = {
count,
plusCount
}
// a.js
let mod = require('./b.js')
console.log(mod.count.a) // 1
mod.plusCount()
mod = require('./b.js')
console.log(mod.count.a) // 2
记录点 2 浏览器用不支持 CommonJS 格式。要想让浏览器用上这些模块,必须转换格式。
浏览器不兼容CommonJS的根本原因,在于缺少四个Node.js环境的变量。
module
exports
require
global
只要能够提供这四个变量,浏览器就能加载 CommonJS 模块。
下面是一个简单的示例。
var module = {
exports: {}
};
(function(module, exports) {
exports.multiply = function (n) { return n * 1000 };
}(module, module.exports))
var f = module.exports.multiply;
f(5) // 5000
上面代码向一个立即执行函数提供 module 和 exports 两个外部变量,模块就放在这个立即执行函数里面。模块的输出值放在 module.exports 之中,这样就实现了模块的加载。
记录点 3 CommonJS 运行时加载。
先看代码:
// CommonJS模块
let { stat, exists, readFile } = require('fs');
// 等同于
let _fs = require('fs');
let stat = _fs.stat;
let exists = _fs.exists;
let readfile = _fs.readfile;
上面代码的实质是整体加载fs模块(即加载fs的所有方法),生成一个对象(_fs),然后再从这个对象上面读取 3 个方法。这种加载称为“运行时加载
”,因为只有运行时才能得到这个对象,导致完全没办法在编译时做“静态优化”。(后文会比对es6 的实现
)
请看一个例子,main.js 模块加载 foo.js 模块。
// foo.js
module.exports = function(x) {
console.log(x);
};
// main.js
var foo = require("./foo");
foo("Hi");
使用下面的命令,就能将main.js转为浏览器可用的格式。
$ browserify main.js > compiled.js
browser 转换后的compiled.js 源码:
(function () {
function r(e, n, t) {
function o(i, f) {
if (!n[i]) {
if (!e[i]) {
var c = "function" == typeof require && require;
if (!f && c) return c(i, !0);
if (u) return u(i, !0);
var a = new Error("Cannot find module '" + i + "'");
throw a.code = "MODULE_NOT_FOUND", a
}
var p = n[i] = { exports: {} };
e[i][0].call(p.exports, function (r) {
var n = e[i][1][r];
return o(n || r)
}, p, p.exports, r, e, n, t)
}
return n[i].exports
}
for (var u = "function" == typeof require && require, i = 0; i < t.length; i++) o(t[i]);
return o
}
return r
})()({
1: [function (require, module, exports) {
// foo.js
module.exports = function (x) {
console.log(x);
};
}, {}], 2: [function (require, module, exports) {
// main.js
var foo = require("./foo.js");
foo("Hi");
}, { "./foo.js": 1 }]
}, {}, [2]);
compiled.js 在html文件中引入,浏览器打开,即可看到控制台输出 Hi。
重构后的compiled.js 代码如下:(方便理解)
/** 模块一 */
var module1 = function (require, module, exports) {
// foo.js
module.exports = function (x) {
console.log(x);
};
}
/** 模块二 */
var module2 = function (require, module, exports) {
// main.js
var foo = require("./foo.js");
foo("Hi");
}
/** 将模块放置数组中,并保存对应依赖 */
var rObject = {
1: [ module1, {} ],
2: [ module2, { "./foo.js": 1 } ]
}
/** 相同依赖的进行缓存 */
var cObject = {};
/** 定义入口js 模块索引 */
var mArr = [ 2 ];
/**
* run 函数
* 注:commonjs 的核心
* @param {Object} relation
* @param {Object} cache
* @param {*} t
*/
function run(relation, cache, t) {
function schedule(i, flag) {
if (!cache[i]) {
if (!relation[i]) {
// 如果require 存在,并且类型为function
var _require = "function" == typeof require && require;
// 执行require 方法,运行依赖的js 脚本
if (!flag && _require) return _require(i, !0);
if (_out_require) return _out_require(i, !0);
// 抛出异常
var err = new Error("Cannot find module '" + i + "'");
err.code = "MODULE_NOT_FOUND";
throw err;
}
// 为每个模块定义一个缓存对象
var _module = cache[i] = { exports: {} };
relation[i][0].call(_module.exports, function (path) {
// 获取path 脚本在relation 中的序号
var num = relation[i][1][path];
return schedule(num || path)
}, _module, _module.exports, run, relation, cache, t)
}
return cache[i].exports;
}
for (var _out_require = "function" == typeof require && require, i = 0; i < t.length; i++) {
schedule(t[i]);
}
return schedule;
}
// 执行
run(rObject, cObject, mArr);
至此browserify 的原理一目了然。
但是,browserify 不能在浏览器中使用,但按照上述解析后的代码的方式编写模块,则可以在浏览器端使用commonjs。其他方式的浏览器端commonjs,可以学习阮老师的 tiny-browser-require .
node中的require() 方法源码解读:http://www.ruanyifeng.com/blog/2015/05/require.html
CommonJS 规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作。AMD 规范则是非同步加载模块,允许指定回调函数。
由于 Node.js 主要用于服务器编程,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不用考虑非同步加载的方式,所以 CommonJS 规范比较适用。但是,如果是浏览器环境,要从服务器端加载模块,这时就必须采用非同步模式,因此浏览器端一般采用 AMD 规范。此外 AMD 规范比 CommonJS 规范在浏览器端实现要来着早。
定义暴露模块:
// 定义没有依赖的模块
define(function(){
return 模块
})
// 定义有依赖的模块
define(['module1', 'module2'], function(m1, m2){
return 模块
})
引入使用模块:
require(['module1', 'module2'], function(m1, m2){
使用 m1/m2
})
主要的js 库:require.js
官网: http://www.requirejs.cn/
github : https://github.com/requirejs/requirejs
在 index.html 引入
< script data-main="js/main" src="js/libs/require.js">< /script>
如果不使用amd 的方式,使用通常的闭包方式引入js,如果js脚本多并且相互依赖复杂时,代码可能会是这样子:
// index.html 文件
<div><h1>Modular Demo 1: 未使用 AMD(require.js)</h1></div>
<script type="text/javascript" src="js/modules/dataService.js"></script>
<script type="text/javascript" src="js/modules/alerter.js"></script>
// ... 更多的脚本引入
<script type="text/javascript" src="js/main.js"></script>
弊端:引入顺序要要求严格,js请求次数多。
源码:https://cdn.bootcss.com/require.js/2.3.6/require.js
相关资料:http://foio.github.io/requireJS/
分析require源码前,先了解一下脚本的异步加载相关:
(1) XHR eval
在浏览器原理中知道,浏览器解析script标签的js脚本是同步的,即会阻塞dom的渲染过程。如以下代码:
// a.js
alert(document.getElementById('div'))
<html lang="en">
<head>
<title>Documenttitle>
head>
<body>
<script>
var XMLHttpReq = new XMLHttpRequest();
XMLHttpReq.onreadystatechange = function(){
if (XMLHttpReq.readyState == 4) {
if (XMLHttpReq.status == 200) {
var text = XMLHttpReq.responseText;
eval(text)
}
}
};
XMLHttpReq.open("get", 'a.js', true);
XMLHttpReq.send();
script>
<div id="div">Hello.div>
body>
html>
通过静态服务访问index.html, 可知方式1 是异步方式,在请求a.js 脚本时不阻塞dom的渲染,模拟请求延时长一些,则可以拿到div 元素;方式 2 是同步方式,a.js脚本在执行时,页面是还没有id为"div"元素的,因此alert的内容始终为null。
xhr eval方式缺点: 不支持跨域请求。
(2) script dom element
我们也可以直接在浏览器中插入script dom节点。如下代码:
<html lang="en">
<head>
<title>Documenttitle>
head>
<body>
<script>
var scriptElem = document.createElement('script');
scriptElem.src = 'http://127.0.0.1:9006/a.js';
document.getElementsByTagName('head')[0].appendChild(scriptElem);
script>
<div id="div">Hello.div>
body>
html>
启动本机静态服务访问index.html, 地址:http://localhost:9006/index.html 可以看到结果,脚本的加载执行不阻塞dom的渲染(由于页面的div比较简单浏览器渲染很快,a.js脚本中的alert 测试时都能够获取到渲染后的div,而a.js是在加载完后就开始执行的)
优点:支持跨域请求。
缺点:需要工程师自己在代码层面实现执行顺序的控制。
记录点 3 requir.js 就是通过script dom element 的方式实现的。
(3) document write script tag
这种方式是使用document.write 方法:
document.write("");
优点:
scritp Tag可以保证多个脚本并行加载。
可以保证脚本按文档中出现的顺序执行。
缺点:
会阻塞其他资源并行下载。
(4) defer和async属性
async="async"不会阻塞其他资源,但是无法保证脚本的执行顺序。defer="defer"阻塞其他资源的加载,并且可以保证脚本的执行顺序,但是要到页面解析完成后才开始执行脚本。
当外部脚本按常规方式加载时,它会阻塞行内脚本的执行,可以保证顺序。但是脚本通过上述的几种方式异步加载时,就无法保证行内脚本和异步脚本之间的顺序。
保证行内脚本和外部脚本的执行顺序:
(1) onlode事件
添加script dom节点时,监听加载事件:
//行内函数
function callback(){
Console.log(‘calllback’);
}
//异步加载函数
function loadScript(url, callback){
var script = document.createElement ("script")
script.type = "text/javascript";
if (script.readyState){ //IE
script.onreadystatechange = function(){
if (script.readyState == "loaded" || script.readyState == "complete"){
script.onreadystatechange = null;
callback();
}
};
} else { //Others
script.onload = function(){
callback();
};
}
script.src = url;
document.getElementsByTagName("head")[0].appendChild(script);
}
//控制行内脚本和外部脚本的执行顺序
loadScript('a.js',callback);
(2) 定时器
通过定时检查外部脚本是否加载完成(即检查对应变量是否存在):
<script src="MyJs.js">script>
<script>
function callback(){
}
function checkMyJs(){
if(undefined===typeof(MyJs)){
setTimeout(checkMyJs, 300)
}else{
callback();
}
}
script>
特点:
onload 和定时器的方式缺点:多个脚本的执行顺序不好控制。
(1) 同域脚本
对于同域中的多个外部脚本,可以使用XHR的方式加载脚本,并通过一个队列来控制脚本的执行顺序。
// a.js
console.log('a');
// b.js
console.log('b');
// c.js
console.log('c');
<html lang="en">
<head>
<title>Documenttitle>
head>
<body>
<script>
var ScriptLoader = ScriptLoader || {}
ScriptLoader.Script = {
//脚本队列
queueScripts: [],
loadScriptXhrInjection: function (url, onload, bOrder) {
var iQ = ScriptLoader.Script.queueScripts.length;
if (bOrder) {
var qScript = { response: null, onload: onload, done: false };
ScriptLoader.Script.queueScripts[iQ] = qScript;
}
var xhrObj = ScriptLoader.Script.getXHROject();
xhrObj.onreadystatechange = function () {
if (xhrObj.readyState == 4) {
//有顺序要求的脚本需要添加的队列,按添加顺序执行
if (bOrder) {
//有顺序要求的脚本需要设置加载和执行状态
ScriptLoader.Script.queueScripts[iQ].response = xhrObj.responseText;
//执行脚本队列
ScriptLoader.Script.injectScripts();
} else {//没有顺序要求的脚本可直接执行
eval(xhrObj.responseText);
if (onload) {
onload();
}
}
}
}
xhrObj.open("get", url, true);
xhrObj.send();
},
injectScripts: function () {
var len = ScriptLoader.Script.queueScripts.length;
//按顺序执行队列中的脚本
for (var i = 0; i < len; i++) {
var qScript = ScriptLoader.Script.queueScripts[i];
//没有执行
if (!qScript.done) {
//没有加载完成
if (!qScript.response) {
//停止,等待加载完成, 由于脚本是按顺序添加到队列的,因此这里保证了脚本的执行顺序
break;
} else {//已经加载完成了
eval(qScript.response);
if (qScript.onload) {
qScript.onload();
}
qScript.done = true;
}
}
};
},
getXHROject: function () {
var xhrObj = new XMLHttpRequest();
return xhrObj;
}
}
function initB(){
console.log('initB.');
}
function initC(){
console.log('initC.');
}
console.log('==start==')
// 说明:这里的路径不支持跨域
ScriptLoader.Script.loadScriptXhrInjection('a.js', null, false);
ScriptLoader.Script.loadScriptXhrInjection('b.js', initB, true);
ScriptLoader.Script.loadScriptXhrInjection('c.js', initC, true);
console.log('==end==')
script>
<div>Hello.div>
body>
html>
(2) 不同域脚本
script dom element 可以异步执行脚本,不阻塞其他资源;而document write script 可以异步加载脚本,会阻塞其他资源,在所有浏览器都可以保证执行顺序。因此我们可以根据浏览器选择以上两种方案来控制 不同域的脚本的执行顺序。
<html lang="en">
<head>
<title>Documenttitle>
head>
<body>
<script>
var ScriptLoader = ScriptLoader || {}
function addEvent(element, type, handler){
if(element.addEventListener){
element.addEventListener(type, handler, false);
}else if(element.attchEvent){
element.attachEvent('on' + type, handler);
}
}
ScriptLoader.script = {
loadScriptDomElement: function (url, onload) {
var script = document.createElement("script");
script.type = "text/javascript";
if (script.readyState) {
//IE
script.onreadystatechange = function () {
if (script.readyState == "loaded" || script.readyState == "complete") {
script.onreadystatechange = null;
onload();
}
};
} else {
//Others
script.onload = function () {
onload();
};
}
script.src = url;
document.getElementsByTagName("head")[0].appendChild(script);
},
loadScriptDomWrite: function (url, onload) {
document.write('