果你是 JavaScript 的新手,一些像 “module bundlers vs module loaders”、“Webpack vs Browserify” 和 “AMD vs.CommonJS” 这样的术语,很快让你不堪重负。
JavaScript 模块系统可能令人生畏,但理解它对 Web 开发人员至关重要。
在这篇文章中,我将以简单的言语(以及一些代码示例)为你解释这些术语。 希望这对你有会有帮助!
好作者能将他们的书分成章节,优秀的程序员将他们的程序划分为模块。
就像书中的章节一样,模块只是文字片段(或代码,视情况而定)的集群。然而,好的模块是高内聚低松耦的,具有不同的功能,允许在必要时对它们进行替换、删除或添加,而不会扰乱整体功能。
使用模块有利于扩展、相互依赖的代码库,这有很多好处。在我看来,最重要的是:
1)可维护性: 根据定义,模块是高内聚的。一个设计良好的模块旨在尽可能减少对代码库部分的依赖,这样它就可以独立地增强和改进,当模块与其他代码片段解耦时,更新单个模块要容易得多。
回到我们的书的例子,如果你想要更新你书中的一个章节,如果对一个章节的小改动需要你调整每一个章节,那将是一场噩梦。相反,你希望以这样一种方式编写每一章,即可以在不影响其他章节的情况下进行改进。
2)命名空间: 在 JavaScript 中,顶级函数范围之外的变量是全局的(这意味着每个人都可以访问它们)。因此,“名称空间污染”很常见,完全不相关的代码共享全局变量。
在不相关的代码之间共享全局变量在开发中是一个大禁忌。正如我们将在本文后面看到的,通过为变量创建私有空间,模块允许我们避免名称空间污染。
3)可重用性:坦白地说:我们将前写过的代码复制到新项目中。 例如,假设你从之前项目编写的一些实用程序方法复制到当前项目中。
这一切都很好,但如果你找到一个更好的方法来编写代码的某些部分,那么你必须记得回去在曾经使用过的其他项目更新它。
这显然是在浪费时间。如果有一个我们可以一遍又一遍地重复使用的模块,不是更容易吗?
有多种方法来创建模块,来看几个:
模块模式用于模拟类的概念(因为 JavaScript 本身不支持类),因此我们可以在单个对象中存储公共和私有方法和变量——类似于在 Java 或 Python 等其他编程语言中使用类的方式。这允许我们为想要公开的方法创建一个面向公共的 API,同时仍然将私有变量和方法封装在闭包范围中。
有几种方法可以实现模块模式。在第一个示例中,将使用匿名闭包,将所有代码放在匿名函数中来帮助我们实现目标。(记住:在 JavaScript 中,函数是创建新作用域的唯一方法。)
例一:匿名闭包
(function () {
// 将这些变量放在闭包范围内实现私有化
var myGrades = [93, 95, 88, 0, 55, 91];
var average = function() {
var total = myGrades.reduce(function(accumulator, item) {
return accumulator + item}, 0);
return '平均分 ' + total / myGrades.length + '.';
}
var failing = function(){
var failingGrades = myGrades.filter(function(item) {
return item < 70;});
return '挂机科了 ' + failingGrades.length + ' 次。';
}
console.log(failing()); // 挂机科了次
}());
使用这个结构,匿名函数就有了自己的执行环境或“闭包”,然后我们立即执行。这让我们可以从父(全局)命名空间隐藏变量。
这种方法的优点是,你可以在这个函数中使用局部变量,而不会意外地覆盖现有的全局变量,但仍然可以访问全局变量,就像这样:
var global = '你好,我是一个全局变量。)';
(function () {
// 将这些变量放在闭包范围内实现私有化
var myGrades = [93, 95, 88, 0, 55, 91];
var average = function() {
var total = myGrades.reduce(function(accumulator, item) {
return accumulator + item}, 0);
return '平均分 ' + total / myGrades.length + '.';
}
var failing = function(){
var failingGrades = myGrades.filter(function(item) {
return item < 70;});
return '挂机科了 ' + failingGrades.length + ' 次。';
}
console.log(failing()); // 挂机科了次
onsole.log(global); // 你好,我是一个全局变量。
}());
注意,匿名函数的圆括号是必需的,因为以关键字 function 开头的语句通常被认为是函数声明(请记住,JavaScript 中不能使用未命名的函数声明)。因此,周围的括号将创建一个函数表达式,并立即执行这个函数,这还有另一种叫法 立即执行函数(IIFE)。如果你对这感兴趣,可以在这里了解到更多。
例二:全局导入
jQuery 等库使用的另一种流行方法是全局导入。它类似于我们刚才看到的匿名闭包,只是现在我们作为参数传入全局变量:
(function (globalVariable) {
// 在这个闭包范围内保持变量的私有化
var privateFunction = function() {
console.log('Shhhh, this is private!');
}
// 通过 globalVariable 接口公开下面的方法
// 同时将方法的实现隐藏在 function() 块中
globalVariable.each = function(collection, iterator) {
if (Array.isArray(collection)) {
for (var i = 0; i < collection.length; i++) {
iterator(collection[i], i, collection);
}
} else {
for (var key in collection) {
iterator(collection[key], key, collection);
}
}
};
globalVariable.filter = function(collection, test) {
var filtered = [];
globalVariable.each(collection, function(item) {
if (test(item)) {
filtered.push(item);
}
});
return filtered;
};
globalVariable.map = function(collection, iterator) {
var mapped = [];
globalUtils.each(collection, function(value, key, collection) {
mapped.push(iterator(value));
});
return mapped;
};
globalVariable.reduce = function(collection, iterator, accumulator) {
var startingValueMissing = accumulator === undefined;
globalVariable.each(collection, function(item) {
if(startingValueMissing) {
accumulator = item;
startingValueMissing = false;
} else {
accumulator = iterator(accumulator, item);
}
});
return accumulator;
};
}(globalVariable));
在这个例子中,globalVariable 是唯一的全局变量。与匿名闭包相比,这种方法的好处是可以预先声明全局变量,使得别人更容易阅读代码。
例三:对象接口
另一种方法是使用立即执行函数接口对象创建模块,如下所示:
var myGradesCalculate = (function () {
// 将这些变量放在闭包范围内实现私有化
var myGrades = [93, 95, 88, 0, 55, 91];
// 通过接口公开这些函数,同时将模块的实现隐藏在function()块中
return {
average: function() {
var total = myGrades.reduce(function(accumulator, item) {
return accumulator + item;
}, 0);
return'平均分 ' + total / myGrades.length + '.';
},
failing: function() {
var failingGrades = myGrades.filter(function(item) {
return item < 70;
});
return '挂科了' + failingGrades.length + ' 次.';
}
}
})();
myGradesCalculate.failing(); // '挂科了 2 次.'
myGradesCalculate.average(); // '平均分 70.33333333333333.'
正如您所看到的,这种方法允许我们通过将它们放在 return 语句中(例如算平均分和挂科数方法)来决定我们想要保留的变量/方法(例如 myGrades)以及我们想要公开的变量/方法。
例四:显式模块模式
这与上面的方法非常相似,只是它确保所有方法和变量在显式公开之前都是私有的:
var myGradesCalculate = (function () {
// 将这些变量放在闭包范围内实现私有化
var myGrades = [93, 95, 88, 0, 55, 91];
var average = function() {
var total = myGrades.reduce(function(accumulator, item) {
return accumulator + item;
}, 0);
return'平均分 ' + total / myGrades.length + '.';
};
var failing = function() {
var failingGrades = myGrades.filter(function(item) {
return item < 70;
});
return '挂科了' + failingGrades.length + ' 次.';
};
// Explicitly reveal public pointers to the private functions
// that we want to reveal publicly
return {
average: average,
failing: failing
}
})();
myGradesCalculate.failing(); // '挂科了 2 次.'
myGradesCalculate.average(); // '平均分 70.33333333333333.'
这可能看起来很多,但它只是模块模式的冰山一角。 以下是我在自己的探索中发现有用的一些资源:
所有这些方法都有一个共同点:使用单个全局变量将其代码包装在函数中,从而使用闭包作用域为自己创建一个私有名称空间。
虽然每种方法都有效且都有各自特点,但却都有缺点。
首先,作为开发人员,你需要知道加载文件的正确依赖顺序。例如,假设你在项目中使用 Backbone,因此你可以将 Backbone 的源代码 以<script> 脚本标签的形式引入到文件中。
但是,由于 Backbone 对 Underscore.js 有很强的依赖性,因此 Backbone 文件的脚本标记不能放在Underscore.js 文件之前。
作为一名开发人员,管理依赖关系并正确处理这些事情有时会令人头痛。
另一个缺点是它们仍然会导致名称空间冲突。例如,如果两个模块具有相同的名称怎么办?或者,如果有一个模块的两个版本,并且两者都需要,该怎么办?
幸运的是,答案是肯定的。
有两种流行且实用的方法:CommonJS 和 AMD。
CommonJS 是一个志愿者工作组,负责设计和实现用于声明模块的 JavaScript API。
CommonJS 模块本质上是一个可重用的 JavaScript,它导出特定的对象,使其可供其程序中需要的其他模块使用。 如果你已经使用 Node.js 编程,那么你应该非常熟悉这种格式。
使用 CommonJS,每个 JavaScript 文件都将模块存储在自己独立的模块上下文中(就像将其封装在闭包中一样)。 在此范围内,我们使用 module.exports 导出模块,或使用 require 来导入模块。
在定义 CommonJS 模块时,它可能是这样的:
function myModule() {
this.hello = function() {
return 'hello!';
}
this.goodbye = function() {
return 'goodbye!';
}
}
module.exports = myModule;
我们使用特殊的对象模块,并将函数的引用放入 module.exports 中。这让 CommonJS 模块系统知道我们想要公开什么,以便其他文件可以使用它。
如果想使用 myModule,只需要使用 require 方法就可以,如下:
var myModule = require('myModule');
var myModuleInstance = new myModule();
myModuleInstance.hello(); // 'hello!'
myModuleInstance.goodbye(); // 'goodbye!'
与前面讨论的模块模式相比,这种方法有两个明显的好处:
另外需要注意的是,CommonJS 采用服务器优先方法并同步加载模块。 这很重要,因为如果我们需要三个其他模块,它将逐个加载它们。
现在,它在服务器上运行良好,但遗憾的是,在为浏览器编写 JavaScript 时使用起来更加困难。 可以这么说,从网上读取模块比从磁盘读取需要更长的时间。 只要加载模块的脚本正在运行,它就会阻止浏览器运行其他任何内容,直到完成加载,这是因为 JavaScript 是单线程且 CommonJS 是同步加载的。
CommonJS一切都很好,但是如果我们想要异步加载模块呢? 答案是 异步模块定义,简称 AMD。
使用 AMD 的加载模块如下:
define(['myModule', 'myOtherModule'], function(myModule, myOtherModule) {
console.log(myModule.hello());
});
define 函数的第一个参数是一个数组,数组中是依赖的各种模块。这些依赖模块在后台(以非阻塞的方式)加载进来,一旦加载完毕,define 函数就会调用第二个参数,即回调函数执行操作。
接下来,回调函数接收参数,即依赖模块 - 示例中就是 myModule 和 myOtherModule - 允许函数使用这些依赖项, 最后,所依赖的模块本身也必须使用 define 关键字来定义。例如,myModule如下所示:
define([], function() {
return {
hello: function() {
console.log('hello');
},
goodbye: function() {
console.log('goodbye');
}
};
});
因此,与 CommonJS 不同,AMD 采用浏览器优先的方法和异步行为来完成工作。 (注意,有很多人坚信在开始运行代码时动态加载文件是不利的,我们将在下一节关于模块构建的内容中探讨更多内容)。
除了异步性,AMD 的另一个好处是模块可以是对象,函数,构造函数,字符串,JSON 和许多其他类型,而CommonJS 只支持对象作为模块。
也就是说,和CommonJS相比,AMD不兼容io、文件系统或者其他服务器端的功能特性,而且函数包装语法与简单的require 语句相比有点冗长。
对于同时支持 AMD 和 CommonJS 特性的项目,还有另一种格式:通用模块定义(Universal Module Definition, UMD)。
UMD 本质上创造了一种使用两者之一的方法,同时也支持全局变量定义。因此,UMD 模块能够同时在客户端和服务端同时工作。
简单看一下 UMD 是怎样工作的:
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD
define(['myModule', 'myOtherModule'], factory);
} else if (typeof exports === 'object') {
// CommonJS
module.exports = factory(require('myModule'), require('myOtherModule'));
} else {
// Browser globals (Note: root is window)
root.returnExports = factory(root.myModule, root.myOtherModule);
}
}(this, function (myModule, myOtherModule) {
// Methods
function notHelloOrGoodbye(){}; // A private method
function hello(){}; // A public method because it's returned (see below)
function goodbye(){}; // A public method because it's returned (see below)
// Exposed public methods
return {
hello: hello,
goodbye: goodbye
}
}));
Github 上 enlightening repo 里有更多关于 UMD 的例子。
你可能已经注意到,上面的模块都不是 JavaScript 原生的。相反,我们已经创建了通过使用模块模式、CommonJS 或 AMD 来模拟模块系统的方法。
幸运的是,TC39(定义 ECMAScript 的语法和语义的标准组织)一帮聪明的人已经引入了ECMAScript 6(ES6)的内置模块。
ES6 为导入导出模块提供了很多不同的可能性,已经有许多其他人花时间解释这些,下面是一些有用的资源:
与 CommonJS 或 AMD 相比,ES6 模块最大的优点在于它能够同时提供两方面的优势:简明的声明式语法和异步加载,以及对循环依赖项的更好支持。
也许我个人最喜欢的 ES6 模块功能是它的导入模块是导出时模块的实时只读视图。(相比起 CommonJS,导入的是导出模块的拷贝副本,因此也不是实时的)。
下面是一个例子:
// lib/counter.js
var counter = 1;
function increment() {
counter++;
}
function decrement() {
counter--;
}
module.exports = {
counter: counter,
increment: increment,
decrement: decrement
};
// src/main.js
var counter = require('../../lib/counter');
counter.increment();
console.log(counter.counter); // 1
在这个例子中,我们基本上创建了两个模块的对象:一个用于导出它,一个在我们需要的时候引入。
此外,在 main.js 中的对象目前是与原始模块是相互独立的,这就是为什么即使我们执行 increment 方法,它仍然返回 1,因为引入的变量和最初导入的变量是毫无关联的。需要改变你引入的对象唯一的方式是手动执行增加:
counter.counter++;
console.log(counter.counter); // 2
另一方面,ES6创建了我们导入的模块的实时只读视图:
// lib/counter.js
export let counter = 1;
export function increment() {
counter++;
}
export function decrement() {
counter--;
}
// src/main.js
import * as counter from '../../counter';
console.log(counter.counter); // 1
counter.increment();
console.log(counter.counter); // 2
超酷?我发现这一点是因为ES6允许你可以把你定义的模块拆分成更小的模块而不用删减功能,然后你还能反过来把它们合成到一起, 完全没问题。
总体上看,模块打包只是将一组模块(及其依赖项)以正确的顺序拼接到一个文件(或一组文件)中的过程。正如 Web开发的其它方方面面,棘手的问题总是潜藏在具体的细节里。
将程序划分为模块时,通常会将这些模块组织到不同的文件和文件夹中。 有可能,你还有一组用于正在使用的库的模块,如 Underscore 或 React。
因此,每个文件都必须以一个 <script> 标签引入到主 HTML 文件中,然后当用户访问你的主页时由浏览器加载进来。 每个文件使用 <script> 标签引入,意味着浏览器不得不分别逐个的加载它们。
这对于页面加载时间来说简直是噩梦。
为了解决这个问题,我们将所有文件打包或“拼接”到一个大文件(或视情况而定的几个文件),以减少请求的数量。 当你听到开发人员谈论“构建步骤”或“构建过程”时,这就是他们所谈论的内容。
另一种加速构建操作的常用方法是“缩减”打包代码。 缩减是从源代码中移除不必要的字符(例如,空格,注释,换行符等)的过程,以便在不改变代码功能的情况下减少内容的整体大小。
较少的数据意味着浏览器处理时间会更快,从而减少了下载文件所需的时间。 如果你见过具有 “min” 扩展名的文件,如 “underscore-min.js” ,可能会注意到与完整版相比,缩小版本非常小(不过很难阅读)。
除了捆绑和/或加载模块之外,模块捆绑器还提供了许多其他功能,例如在进行更改时生成自动重新编译代码或生成用于调试的源映射。
构建工具(如 Gulp 和 Grunt)能为开发者直接进行拼接和缩减,确保为开发人员提供可读代码,同时有利于浏览器执行的代码。
当你使用一种标准模块模式(上部分讨论过)来定义模块时,拼接和缩减文件非常有用。 你真正在做的就是将一堆普通的 JavaScript 代码捆绑在一起。
但是,如果你坚持使用浏览器无法解析的非原生模块系统(如 CommonJS 或 AMD(甚至是原生 ES6模块格式)),则需要使用专门工具将模块转换为排列正确、浏览器可解析的代码。 这就是 Browserify,RequireJS,Webpack 和其他“模块打包工具”或“模块加载工具”的用武之地。
除了打包和/或加载模块之外,模块打包器还提供了许多其他功能,例如在进行更改时生成自动重新编译代码或生成用于调试的源映射。
下面是一些常见的模块打包方法:
正如前面所知道的,CommonJS以同步方式加载模块,这没有什么问题,只是它对浏览器不实用。我提到过有一个解决方案——其中一个是一个名为 Browserify 的模块打包工具。Browserify 是一个为浏览器编译 CommonJS模块的工具。
例如,有个 main.js 文件,它导入一个模块来计算一组数字的平均值:
var myDependency = require(‘myDependency’);
var myGrades = [93, 95, 88, 0, 91];
var myAverageGrade = myDependency.average(myGrades);
在这种情况下,我们有一个依赖项(myDependency),使用下面的命令,Browserify 以 main.js 为入口把所有依赖的模块递归打包成一个文件:
browserify main.js -o bundle.js
Browserify 通过跳入文件分析每一个依赖的 抽象语法树(AST),以便遍历项目的整个依赖关系图。一旦确定了依赖项的结构,就把它们按正确的顺序打包到一个文件中。然后,在 html 里插入一个用于引入 “bundle.js” 的 <script> 标签,从而确保你的源代码在一个 HTTP 请求中完成下载。
类似地,如果有多个文件且有多个依赖时,只需告诉 Browserify 的入口文件路径即可。最后打包后的文件可以通过 Minify-JS 之类的工具压缩打包后的代码。
如果你正在使用 AMD,你需要使用像 RequireJS 或者 Curl 这样的 AMD 加载器。模块加载器(与模块打包工具不同)会动态加载程序需要运行的模块。
提醒一下,AMD 与 CommonJS 的主要区别之一是它以异步方式加载模块。 从这个意义上说,对于 AMD,从技术上讲,实际上并不需要构建步骤,因为异步加载模块意味着在运行过程中逐步下载那些程序所需要的文件,而不是用户刚进入页面就一下把所有文件都下载下来。
但实际上,对于每个用户操作而言,随着时间的推移,大容量请求的开销在生产中没有多大意义。 大多数 Web 开发人员仍然使用构建工具打包和压缩 AMD 模块以获得最佳性能,例如使用 RequireJS 优化器,r.js 等工具。
总的来说,AMD 和 CommonJS 在打包方面的区别在于:在开发期间,AMD 可以省去任何构建过程。当然,在代码上线前,要使用优化工具(如 r.js)进行优化。
就打包工具而言,Webpack 是一个新事物。它被设计成与你使用的模块系统无关,允许开发人员在适当的情况下使用 CommonJS、AMD 或 ES6。
你可能想知道,为什么我们需要 Webpack,而我们已经有了其他打包工具了,比如 Browserify 和 RequireJS,它们可以完成工作,并且做得非常好。首先,Webpack 提供了一些有用的特性,比如 “代码分割”(code splitting) —— 一种将代码库分割为“块(chunks)”的方式,从而能实现按需加载。
例如,如果你的 Web 应用程序,其中只需要某些代码,那么将整个代码库都打包进一个大文件就不是很高效。 在这种情况下,可以使用代码分割,将需要的部分代码抽离在"打包块",在执行按需加载,从而避免在最开始就遇到大量负载的麻烦。
代码分割只是 Webpack 提供的众多引人注目的特性之一,网上有很多关于 “Webpack 与 Browserify 谁更好” 的激烈讨论。以下是一些客观冷静的讨论,帮助我稍微理清了头绪:
当前 JS 模块规范(CommonJS, AMD) 与 ES6 模块之间最重要的区别是 ES6 模块的设计考虑到了静态分析。这意味着当你导入模块时,导入的模块在编译阶段也就是代码开始运行之前就被解析了。这允许我们在运行程序之前移,移除那些在导出模块中不被其它模块使用的部分。移除不被使用的模块能节省空间,且有效地减少浏览器的压力。
一个常见的问题,使用一些工具,如 Uglify.js ,缩减代码时,有一个死码删除的处理,它和 ES6 移除没用的模块又有什么不同呢?只能说 “视情况而定”。
死码消除(Dead codeelimination)是一种编译器原理中编译最优化技术,它的用途是移除对程序运行结果没有任何影响的代码。移除这类的代码有两种优点,不但可以减少程序的大小,还可以避免程序在运行中进行不相关的运算行为,减少它运行的时间。不会被运行到的代码(unreachable code)以及只会影响到无关程序运行结果的变量(Dead Variables),都是死码(Dead code)的范畴。
有时,在 UglifyJS 和 ES6 模块之间死码消除的工作方式完全相同,有时则不然。如果你想验证一下, Rollup’s wiki 里有个很好的示例。
ES6 模块的不同之处在于死码消除的不同方法,称为“tree shaking”。“tree shaking” 本质上是死码消除反过程。它只包含包需要运行的代码,而非排除不需要的代码。来看个例子:
假设有一个带有多个函数的 utils.js 文件,每个函数都用 ES6 的语法导出:
export function each(collection, iterator) {
if (Array.isArray(collection)) {
for (var i = 0; i < collection.length; i++) {
iterator(collection[i], i, collection);
}
} else {
for (var key in collection) {
iterator(collection[key], key, collection);
}
}
}
export function filter(collection, test) {
var filtered = [];
each(collection, function(item) {
if (test(item)) {
filtered.push(item);
}
});
return filtered;
}
export function map(collection, iterator) {
var mapped = [];
each(collection, function(value, key, collection) {
mapped.push(iterator(value));
});
return mapped;
}
export function reduce(collection, iterator, accumulator) {
var startingValueMissing = accumulator === undefined;
each(collection, function(item) {
if(startingValueMissing) {
accumulator = item;
startingValueMissing = false;
} else {
accumulator = iterator(accumulator, item);
}
});
return accumulator;
}
接着,假设我们不知道要在程序中使用什么 utils.js 中的哪个函数,所以我们将上述的所有模块导入main.js中,如下所示:
import * as Utils from ‘./utils.js’;
最终,我们只用到的 each 方法:
import * as Utils from ‘./utils.js’;
Utils.each([1, 2, 3], function(x) { console.log(x) });
“tree shaken” 版本的 main.js 看起来如下(一旦模块被加载后):
function each(collection, iterator) {
if (Array.isArray(collection)) {
for (var i = 0; i < collection.length; i++) {
iterator(collection[i], i, collection);
}
} else {
for (var key in collection) {
iterator(collection[key], key, collection);
}
}
};
each([1, 2, 3], function(x) { console.log(x) });
注意:只导出我们使用的 each 函数。
同时,如果决定使用 filte r函数而不是每个函数,最终会看到如下的结果:
import * as Utils from ‘./utils.js’;
Utils.filter([1, 2, 3], function(x) { return x === 2 });
tree shaken 版本如下:
function each(collection, iterator) {
if (Array.isArray(collection)) {
for (var i = 0; i < collection.length; i++) {
iterator(collection[i], i, collection);
}
} else {
for (var key in collection) {
iterator(collection[key], key, collection);
}
}
};
function filter(collection, test) {
var filtered = [];
each(collection, function(item) {
if (test(item)) {
filtered.push(item);
}
});
return filtered;
};
filter([1, 2, 3], function(x) { return x === 2 });
此时,each 和 filter 函数都被包含进来。这是因为 filter 在定义时使用了 each。因此也需要导出该函数模块以保证程序正常运行。
我们知道 ES6 模块的加载方式与其他模块格式不同,但我们仍然没有讨论使用 ES6 模块时的构建步骤。
遗憾的是,因为浏览器对 ES6模 块的原生支持还不够完善,所以现阶段还需要我们做一些补充工作。
下面是几个在浏览器中 构建/转换 ES6 模块的方法,其中第一个是目前最常用的方法:
作为 web 开发人员,我们必须经历很多困难。转换语法优雅的ES6代码以便在浏览器里运行并不总是容易的。
问题是,什么时候 ES6 模块可以在浏览器中运行而不需要这些开销?
答案是:“尽快”。
ECMAScript 目前有一个解决方案的规范,称为 ECMAScript 6 module loader API。简而言之,这是一个纲领性的、基于 Promise 的 API,它支持动态加载模块并缓存它们,以便后续导入不会重新加载模块的新版本。
它看起来如下:
// myModule.js
export class myModule {
constructor() {
console.log('Hello, I am a module');
}
hello() {
console.log('hello!');
}
goodbye() {
console.log('goodbye!');
}
}
// main.js
System.import(‘myModule’).then(function(myModule) {
new myModule.hello();
});
// ‘hello!’
你亦可直接对 script 标签指定 “type=module” 来定义模块,如:
<script type="module">
// loads the 'myModule' export from 'mymodule.js'
import { hello } from 'mymodule';
new Hello(); // 'Hello, I am a module!'
</script>
更加详细的介绍也可以在 Github 上查看:es-module-loader
此外,如果您想测试这种方法,请查看 SystemJS,它建立在 ES6 Module Loader polyfill 之上。 SystemJS 在浏览器和 Node 中动态加载任何模块格式(ES6模块,AMD,CommonJS 或 全局脚本)。
它跟踪“模块注册表”中所有已加载的模块,以避免重新加载先前已加载过的模块。 更不用说它还会自动转换ES6模块(如果只是设置一个选项)并且能够从任何其他类型加载任何模块类型!
对于日益普及的 ES6 模块,下面有一些有趣的观点:
对于 HTTP/1,每个TCP连接只允许一个请求。这就是为什么加载多个资源需要多个请求。有了 HTTP/2,一切都变了。HTTP/2 是完全多路复用的,这意味着多个请求和响应可以并行发生。因此,我们可以在一个连接上同时处理多个请求。
由于每个 HTTP 请求的成本明显低于HTTP/1,因此从长远来看,加载一组模块不会造成很大的性能问题。一些人认为这意味着模块打包不再是必要的,这当然有可能,但这要具体情况具体分析了。
例如,模块打包还有 HTTP/2 没有好处,比如移除冗余的导出模块以节省空间。 如果你正在构建一个性能至关重要的网站,那么从长远来看,打包可能会为你带来增量优势。 也就是说,如果你的性能需求不是那么极端,那么通过完全跳过构建步骤,可以以最小的成本节省时间。
总的来说,绝大多数网站都用上 HTTP/2 的那个时候离我们现在还很远。我预测构建过程将会保留,至少在近期内。
一旦 ES6 成为模块标准,我们还需要其他非原生模块规范吗?
我觉得还有。
Web 开发遵守一个标准方法进行导入和导出模块,而不需要中间构建步骤——网页开发长期受益于此。但 ES6 成为模块规范需要多长时间呢?
机会是有,但得等一段时间 。
再者,众口难调,所以“一个标准的方法”可能永远不会成为现实。
希望这篇文章能帮你理清一些开发者口中的模块和模块打包的相关概念,共进步。
ure是一个轻量级框架。这类框架的作用就是通过给相应元素添加预设好的class,来快速的实现预设效果。
pure只有短短数千行代码,但是可控拓展,非常实用,对于新手来说,pure是css框架入门的一个比较好的选择。在后期做项目时也可能用到类似于boostrap这样的大型框架。所以前期对于轻量级框架源码的理解是很有帮助的。
Pure 小得不要不要的,压缩成gzip文件仅为3.7KB*。力求每一行代码都精简到极致,以便最大程度压缩CSS大小,更利于移动Web制作。如果您仅使用其中的一部分模块,那CSS真是小到没有朋友了!
将 Pure 引入你的页面,你可以借助 free unpkg CDN 添加 Pure ,而无需下载到本地。将下面的<link>内容直接复制添加到页面的<head>部分即可。
<link rel="stylesheet" href="https://unpkg.com/purecss@1.0.0/build/pure-min.css" integrity="sha384-CCTZv2q9I9m3UOxRLaJneXrrqKwUNOzZ6NGEUMwHtShDJ+nCoiXJCAgi05KfkLGY" crossorigin="anonymous">
添加 Viewport Meta 元素
Viewportmeta元素控制移动端浏览器的宽度和缩放。为了自适应设备的宽度,请将下面一行加入<head>中。
<meta name="viewport" content="width=device-width, initial-scale=1">
Pure的栅格系统非常简单。你可以使用.pure-g创建行, 使用pure-u-*创建列。
下面是1行3列的栅格:
<div class="pure-g">
<div class="pure-u-1-3"><p>Thirds</p></div>
<div class="pure-u-1-3"><p>Thirds</p></div>
<div class="pure-u-1-3"><p>Thirds</p></div>
</div>
Pure的栅格系统是移动设备优先和响应式的, 你也可以自定义CSS媒体查询和栅格的class名,我们先来看个普通使用的例子。
首先引入grids-responsive.css:
<!--[if lte IE 8]>
<link rel="stylesheet" href="https://unpkg.com/purecss@1.0.0/build/grids-responsive-old-ie-min.css">
<![endif]-->
<!--[if gt IE 8]><!-->
<link rel="stylesheet" href="https://unpkg.com/purecss@1.0.0/build/grids-responsive-min.css">
<!--<![endif]-->
下面是grids-responsive.css文件中默认的响应判断:
Pure不同于其他框架,她更加开放化、简单化、扁平化。我们始终认为:编写新的CSS规则比覆盖已有的CSS规则更容易。通过增加几行代码就能做出属于你自己的的UI,想想都让人激动!
另外还有丰富的表单、按钮、表格、菜单等等扩展组件
此外,还有:
想要详细学的同学可以上官网学习。https://www.purecss.cn/
随着现代 JavaScript 开发 Web 应用变得复杂,命名冲突和依赖关系也变得难以处理,因此需要模块化。而引入模块化,可以避免命名冲突、方便依赖关系管理、提高了代码的复用性和和维护性,因此,在 JavaScript 没有模块功能的前提下,只能通过第三方规范实现模块化:
它们都是基于 JavaScript 的语法和词法特性 “伪造” 出类似模块的行为。而 TC-39 在 ECMAScript 2015 中加入了模块规范,简化了上面介绍的模块加载器,原生意味着可以取代上述的规范,成为浏览器和服务器通用的模块解决方案,比使用库更有效率。而 ES6 的模块化的设计目标:
ECMAScript 在 2015 年开始支持模块标准,此后逐渐发展,现已经得到了所有主流浏览器的支持。ECMAScript 2015 版本也被称为 ECMAScript 6。
ES6 模块借用了 CommonJS 和 AMD 的很多优秀特性,如下所示:
ES6 模块系统也增加了一些新行为。
浏览器运行时在知道应该把某个文件当成模块时,会有条件地按照上述 ES6 模块行为来施加限制。与 <script type="module"> 关联或者通过 import 语句加载的 JavaScript 文件会被认定为模块。
ES6 模块内部的所有变量,外部无法获取,因此提供了 export 关键字从模块中导出实时绑定的函数、对象或原始值,这样其他程序可以通过 import 关键字使用它们。export 支持两种导出方式:命名导出和默认导出。不同的导出方式对应不同的导入方式。
在 ES6 模块中,无论是否声明 "use strict;" 语句,默认情况下模块都是在严格模式下运行。export 语句不能用在嵌入式脚本中。
通过在声明的前面加上 export 关键字,一个模块可以导出多个内容。这些导出的内容通过名字区分,被称为命名导出。
// 导出单个特性(可以导出 var,let,const)
export let name = "小明";
export function sayHi(name) {
console.log(`Hello, ${name}!`);
}
export class Sample {
...
}
或者导出事先定义的特性
let name = "小明";
const age = 18;
function sayHi(name) {
console.log(`Hello, ${name}!`);
}
export {name, age, sayHi}
导出时也可以指定别名,别名必须在 export 子句的大括号语法中指定。因此,声明值、导出值和未导出值提供别名不能在一行完成。
export {name as username, age, sayHi}
但导出语句必须在模块顶级,不能嵌套在某个块中:
// 允许
export ...
// 不允许
if (condition) {
export ...
}
默认导出就好像模块与被导出的值是一回事。默认导出使用 default 关键字将一个值声明为默认导出,每个模块只能有一个默认导出。重复的默认导出会导致 SyntaxError。如下所示:
// 导出事先定义的特性作为默认值
export default {
name: "Xiao Ming",
age: 18,
sex: "boy"
};
export {sayHi as default} // ES 6 模块会识别作为别名提供的 default 关键字。此时,虽然对应的值是使用命名语法导出的,实际上则会称为默认导出 等同于 export default function sayHi() {}
// 导出单个特性作为默认值
export default function () {...}
export default class {...}
ES6 规范对不同形式的 export 语句中可以使用什么不可以使用什么规定了限制。某些形式允许声明和赋值,某些形式只允许表达式,而某些形式则只允许简单标识符。注意,有的形式使用了分号,有的则没有。
下面列出几种会导致错误的 export 形式:
// 会导致错误的不同形式:
// 行内默认导出中不能出现变量声明
export default const name = '小刘';
// 只有标识符可以出现在export 子句中
export { 123 as name }
// 别名只能在export 子句中出现
export const name = '小红' as uname;
注意:声明、赋值和导出标识符最好分开。这样不容易搞错了,同时也可以让 export 语句集中在一块。而且,没有被 export 关键字导出的变量、函数或类会在模块内保持私有。
模块导入的值还可以再次导出,这样的话,可以在父模块集中多个模块的多个导出。可以使用 export from 语法实现:
export {default as m1, name} from './module1.js'
// 等效于
import {default as m1, name} from "./module1.js"
export {m1, name}
外部模块的默认导出也可以重用为当前模块的默认导出:
export { default } from './module1.js';
也可以在重新导出时,将导入模块修改为默认导出,如下所示:
export { name as default } from './module1.js';
而想要将所有命名导出可以使用如下语法:
export * from './module1.js';
该语法会忽略默认导出。但这种语法也要注意导出名称是否冲突。如下所示:
// module1.js
export const name = "module1:name";
// module2.js
export * from './mudule1.js'
export const name = "module2:name";
// index.js
import { name } from './module2.js';
console.log(name); // module2:name
最终输出的是 module2.js 中的值,这个 “重写” 是静默发生的。
使用 export 关键字定义了模块的对外接口以后,其它模块就能通过 import 关键字加载这个模块了。但与 export 类似,import 也必须出现在模块的顶级:
// 允许
import ...
// 不允许
if (condition) {
import ...
}
模块标识符可以是相对于当前模块的相对路径,也可以是指向模块文件的绝对路径。它必须是纯字符串,不能是动态计算的结果。例如,不能是拼接的字符串。
当使用 export 命名导出时,可以使用 * 批量获取并赋值给保存导出集合的别名,而无须列出每个标识符:
const name = "Xiao Ming", age = 18, sex = "boy";
export {name, age, sex}
// 上面的命名导出可以使用如下形式导入(上面的代码是在 module1.js 模块中)
import * as Sample from "./module1.js"
console.log(`My name is ${Sample.name}, A ${Sample.sex},${Sample.age} years old.`);
也可以指名导入,只需要把名字放在 {} 中即可:
import {name, sex as s, age} from "./module1.js";
console.log(`My name is ${name}, A ${s},${age} years old.`);
import 引入是采用的 Singleton 模式,多次使用 import 引入同一个模块时,只会引入一次该模块的实例:
import {name, age} from "./module1.js";
import {sex as s} from "./module1.js";
// 等同于,并且只会引入一个 module1.js 实例
import {name, sex as s, age} from "./module1.js";
而使用默认导出的话,可以使用 default 关键字并提供别名来导入,也可以直接使用标识符就是默认导出的别名导入:
import {default as Sample} from "./module1.js"
// 与下面的方式等效
import Sample from "./module1.js"
而模块中同时有命名导出和默认导出,可以在 import 语句中同时导入。下面三种方式都等效。
import Sample, {sayHi} from "./module1.js"
import {default as Sample, sayHi} from "./module1.js"
import Sample, * as M1 from "./module1.js"
当然,也可以将整个模块作为副作用而导入,而不导入模块中的特定内容。这将运行模块中的全局代码,但实际上不导入任何值。
import './module1.js'
import 导入的值与 export 导出的值是绑定关系,绑定是不可变的。因此,import 对所导入的模块是只读的。但是可以通过调用被导入模块的函数来达到目的。
import Sample, * as M1 from "./module1.js"
Sample = "Modify Sample"; // 错误
M1.module1 = "Module 1"; // 错误
Sample.name = "小亮"; // 允许
这样做的好处是能够支持循环依赖,并且一个大的模块可以拆成若干个小模块时也可以运行,只要不尝试修改导入的值。
注意:如果要在浏览器中原生加载模块,则文件必须带有 .js 扩展名,不然可能无法解析。而使用构建工具或第三方模块加载器打包或解析 ES6 模块,可能不需要包含扩展名。
标准的 import 关键字导入模块是静态的,会使所有被导入的模块,在加载时就被编译。而最新的 ES11 标准中引入了动态导入函数 import(),不必预先加载所有模块。该函数会将模块的路径作为参数,并返回一个 Promise,在它的 then 回调里使用加载后的模块:
import ('./module1.mjs')
.then((module) => {
// Do something with the module.
});
这种使用方式也支持 await 关键字。
let module = await import('./module1.js');
import() 的使用场景如下:
ES6 模块既可以通过浏览器原生加载,也可以与第三方加载器和构建工具一起加载。
完全支持 ES6 模块的浏览器可以从顶级模块异步加载整个依赖图。浏览器会解析入口模块,确定依赖,并发送对依赖模块的请求。这些文件通过网络返回后,浏览器会解析它们的内容,确认依赖,如果二级依赖还没有加载,则会发送更多请求。这个异步递归加载过程会持续到整个依赖图都解析完成。解析完依赖,应用就可以正式加载模块了。
模块文件按需加载,且后续模块的请求会因为每个依赖模块的网络延迟而同步延迟。即,module1 依赖 module2,module2 依赖 module3。浏览器在对 module2 的请求完成之前并不知道要请求 module3。这种架子啊方式效率高,也不需要外部工具,但加载大型应用的深度依赖图可能要花费很长时间。
想要在 HTML 页面中使用 ES6 模块,需要将 type="module" 属性放在 <script> 标签中,来声明该 <script> 所包含的代码在浏览器中作为模块执行。它可以嵌入在网页中,也可以作为外部文件引入:
<script type="module">
// 模块代码
</script>
<script type="module" src="./module1.js"></script>
<script type="module">模块加载的顺序与 <script defer> 加载的脚本一样按顺序执行。但执行会延迟到文档解析完成,但执行顺序就是<script type="module">在页面中出现的顺序。
也可以给模块标签添加 async 属性。这样影响是双重的,不仅模块执行顺序不再与 <script> 标签在页面中的顺序绑定,模块也不会等待文档完成解析才执行。不过,入口模块必须等待其依赖加载完成。
Worker 为了支持 ES6 模块,在 Worker 构造函数中可以接收第二个参数,其 type 属性的默认值是 classic,可以将 type 设置为 module 来加载模块文件。如下所示:
// 第二个参数默认为{ type: 'classic' }
const scriptWorker = new Worker('scriptWorker.js');
const moduleWorker = new Worker('moduleWorker.js', { type: 'module' });
在基于模块的工作者内部,self.importScripts() 方法通常用于在基于脚本的工作者中加载外部脚本,调用它会抛出错误。这是因为模块的 import 行为包含了 importScripts()。
如果浏览器原生支持 ES6 模块,可以直接使用,而不支持的浏览器可以使用第三方模块系统(System.js)或在构建时将 ES6 模块进行转译。
脚本模块可以使用 type="module" 属性设定,而对于不支持模块的浏览器,可以使用 nomodule 属性。此属性会通知支持 ES6 模块的浏览器不执行脚本。不支持模块的浏览器无法识别该属性,从而忽略该属性。如下所示:
// 支持模块的浏览器会执行这段脚本
// 不支持模块的浏览器不会执行这段脚本
<script type="module" src="module.js"></script>
// 支持模块的浏览器不会执行这段脚本
// 不支持模块的浏览器会执行这段脚本
<script nomodule src="script.js"></script>
ES6 在语言层面上支持了模块,结束了 CommonJS 和 AMD 这两个模块加载器的长期分裂状况,重新定义了模块功能,集两个规范于一身,并通过简单的语法声明来暴露。
模块的使用不同方式加载 .js 文件,它与脚本有很大的不同:
浏览器对原生模块的支持越来越好,但也提供了稳健的工具以实现从不支持到支持 ES6 模块的过渡。
*请认真填写需求信息,我们会在24小时内与您取得联系。