资讯专栏INFORMATION COLUMN

Javascript模块全揽

lily_wang / 2464人阅读

摘要:要求模块编写必须在真正的代码之外套上一层规定的代码包装,样子看起来是这样的模块代码通过传递一个签名为的回调函数给函数,就可以把需要注入的变量和函数注入到模块代码内。

之前写的文章急速Js全栈教程得到了不错的阅读量,霸屏掘金头条3天,点赞过千,阅读近万,甚至还有人在评论区打广告,可见也是一个小小的生态了;)。看来和JS全栈有关的内容,还是有人颇有兴趣的。

这次文章的内容,是JavaScript模块。JavaScript Module 真是很讨厌,但是不得不了解的话题。奇葩在于:

它一个非常老的语言,并且使用非常广泛

可是它很多年来也不支持模块。这得厂家当前是多大的心呢

再一个可是,它可以直接用现有的语言机制,实现自己的模块,这个就厉害了,因为它释放了社区的力量。事实证明,社区果然不可小看,这个年代,蚂蚁雄兵胜过大象的

再再一个但是,它的模块还可以有很多型的,这说的是分裂

这么多型的模块,还搞了各自独立的标准出来,这说的是整合

最近的ES2017,终于在前端也有了媲美后端的模块,但是大家并不准备把它用起来,很多人表示需要继续Webpack玩转ES6模块。

把ES6模块真用的起来,可以不在乎Webpack等打包工具带来的加载优化,各种小文件不必打包这点来说,我看还得加上HTTP/2的配合就好很多了。这也是文章将要介绍的一个主旨吧。ES6模块的引入,确实有可能对当前主流的打包模式有些影响,参考文章6内有所论述

文章自然也不少,但是写作此文的理由还是存在:

我还没有看到一个完整的全览,并且结合HTTP/2的更加没有看到。

而且,在我看来,即使有了ES6模块,也得了解和学习之前拼出来的各种模块,因为社区内的代码还大量的使用这样的模块,其中的一些设计模式,比如IIFE,也是值得一看的。

看到JS社区的热情和推动力,相信JS发展的未来是美好的

目录

最古老的模块加载

文件dep1.js

var v1 = "dep1"
function dep1(){
    return v1+"-"+dep2()
}

文件dep2.js

var v2 = "dep2"
function dep2(){
    return v2
}

当使用浏览器加载index.html文件时,如我所愿,它会随后加载dep2,dep1,并调用函数dep1,后者调用dep2,然后在控制台输出:

dep1+dep2

功能是有效的,依赖关系是是对的,输出也是如期望的。但是它也带来了额外的问题:

全局变量污染。在本案例中,可以在console内验证,发现变量v1,v2,函数dep1,dep2都是全局变量。但是由于script的加载机制,以及当前采用的Javascript函数和变量的定义不是局部化的,导致了这样的问题。

依赖关系并不严密。事实上,dep2内的引入变量和函数,只有dep1看得到即可,无需导入到全局变量内。

加载和执行效率难以细颗粒度的调优。本例内,dep1依赖dep2,它们被并行转入,但是执行必须是串行的,首先执行dep2,然后执行dep1,在此案例中,这样做是合适的,但是有不少代码模块之间并不存在依赖关系,它们完全可能并发装入并发执行,但是使用script装入是不能如此的,它会按照标签的次序一个个的执行。如果有比较好的指定依赖关系的方法就好了。

讨论到此,我感觉我在重复先辈们的话,实际上1960年代,第一届软件工程会议,就提出了模块化编程的概念,并且在之后多年一直努力的批评全局变量和Goto语句了。有时候,你会发现,这样看起来非常不济的语言,却可以在现实的项目中如鱼得水,发展的非常的好。而软件工程思想指导下的一些名流语言却早早夭折。这是另外一个有趣的话题了,或许以后有机会谈到。

后端的借鉴

后端Nodejs干净利索的解决了此问题。做法就是对每一个装入的模块都注入一个require函数和一个exports对象。其中require函数可以被模块用来引入其他模块,而exports对象则被用来引出当前模块的功能接口。还是以前文提到的作为案例,做法就是:

文件index.js

// index.js
var d = require("./dep1")
console.log(d.dep1())

文件dep1.js

var d = require("./dep2")
var v1 = "dep1"
function dep1(){
    return v1+"-"+d.dep2()
}
exports.dep1 = dep1

文件dep2.js

var v2 = "dep2"
function dep2(){
    return v2
}
exports.dep2 = dep2

执行命令:

$ node index.js 
dep1-dep2

这里有一点变化,就是在nodejs内使用index.js代替了index.html。可以看到:

Nodejs提供了很好的局部化定义变量和函数的能力,如果使用exports声明引出,其他模块看不到本模块的定义。比如v2变量没有声明引出,当然实际上在本案例内本来也不必引出,那么在dep1内并不会看到v2变量。类似的v1也不会出现在index.js内。

Nodejs提供了更加细粒度的依赖关系。index.js依赖dep1,但是并不依赖于dep2,那么index.js就只要引入dep1,而不必同时引入dep2。这样的依赖关系,更加符合实际工程代码的需求,而不是一股脑的、不分层次的引入全部需要用到的代码。

在传统的服务器开发的诸多语言中,模块都是最基础也是最必备的,像是JavaScript连个内置模块支持都没有的是不常见的(或者说根本没有?)。使用诸如的require和exports,就在后端干净利索的解决了困恼前端的模块问题,这不免让前端觉得应该效仿之。当然,Nodejs加载模块是同步的,这个是不能在前端效仿的。因为后端从磁盘加载代码,速度根本不是问题,而前端加载的都是从网络上进行的, 如果同步的话,加上Javas本身的单线程限定,整个UI就会因为加载代码而被卡死的。对比下两者的速度差异,你就明白了:

硬盘 I/O        
-----------------
HDD:    100 MB/s    
SSD:    600 MB/s    
SATA-III:    6000 Mb/s    
-----------------
网速 I/O
ADSL:    4 Mb/s
4G:    100 Mb/s
Fiber:    100 Mb/s
借鉴后的样子,先看看Modules/Async规范

思路倒也简单,只要自己编写一个库,有它来异步加载其他模块,并在加载时注入需要的require和exports即可。这方面的库有几个,比如requirejs,sea.js等。因为我们只是为了讲清楚概念和思路,因此会那概念上最清晰,和Nodejs最为一致的库来说明问题,并不会因为那个更加主流而去选择它。从这个标准看,sea.js是说明概念问题的最佳模块装入库。

sea.js 是一个模块加载器,模块加载器需要实现两个基本功能:

实现模块定义规范

加载运行符合规范的模块

核心落脚点,就在规范二字上。sea.js要求模块编写必须在真正的代码之外套上一层规定的代码包装,样子看起来是这样的:

define(function(require, exports, module) {
    // 模块代码
});

通过传递一个签名为function(require, exports, module)的回调函数给define函数,就可以把需要注入的变量和函数注入到模块代码内。之前的实例代码,在这里写成:

文件index.js

// index.js
define(function(require, exports, module) {
    var d = require("./dep1")
    console.log(d.dep1())
});

文件dep1.js

define(function(require, exports, module) {
    var d = require("./dep2")
    var v1 = "dep1"
    function dep1(){
        return v1+"-"+d.dep2()
    }
    exports.dep1 = dep1
});

文件dep2.js

define(function(require, exports, module) {
    var v2 = "dep2"
    function dep2(){
        return v2
    }
    exports.dep2 = dep2
});

除了加上一层有点看起来莫名其妙的外套代码,其他的模块代码,你该怎么写就怎么写。倘若不是那么洁癖,这样的代码确实解决了之前使用script标签加载代码带来的全局变量污染等问题,并且还是可以异步加载的,那些看起来不错的依赖关系,也如Nodejs一样。以上代码,可以直接把nodejs对应的代码拷贝过来,加上外套即可运行。

我们不妨加入seajs文件,来看看实际的使用效果:

//index.html


这里为了偷懒,我使用了seajs的CDN文件。如果有遇到什么问题,你不妨自己下载一个seajs文件,然后改成你的URL即可。

加载此HTML文件,可以在控制台看到输出:

dep1+dep2

说明seajs执行效果不错!

seajs通过use方法来加载入口模块,可选接收一个回调函数,当模块加载完成会调用此回调函数,并传入对应的模块作为参数

来获取到模块后,等待模块(包括模块依赖的模块)加载完成会调用回调函数。

分析模块的依赖,按依赖关系递归执行document.createElement(‘script’),这些标签的创建会导致浏览器加载对应的脚本

对模块的价值,都是异步加载,浏览器不会失去响应,它指定的回调函数,只有前面的模块都加载成功后,才会运行,解决了依赖性的问题。

可以在控制台输入:

seajs.data.fetchedList

查看文件加载清单。

因为不是语言自带,而是社区通过现有的语言特性,硬造出来的一个模块系统,因为看起来代码不免累赘。但是在没有原生模块的情况下,这样做确实是管用的。要知道真正的原生模块,在ES6标准之后才出现,这都是2015年的事儿了。在一些有名的应用如Gmail、Google Map的推动下,Web从简单的展示到App的变化,迫切需要这样类似的模块技术,大家等不了那么久,先弄一个能用的是很重要的。

为什么要套这层外壳呢?就是为了解决全局变量污染问题。在JavaScript语言内,唯一提供本地作用域的就是函数和闭包,通过闭包function(require, exports, module),模块加载器给模块注入了必要的函数和变量。看起来在模块之内的任何地方都可以使用require和exports,但是他没都不是全局变量,而是闭包内变量。这些变量都是局部化的,绝对不会污染全局空间。

使用require函数,可以就近指定对其他模块的依赖,函数本身是由sea.js这样的模块加载器提供,它会内部构造依赖关系图谱,并根据依赖关系,设置加入script标签的次序。

更加详细的理解这层外壳,可以阅读seajs源代码,代码量并不大,值得一读。或者看看此问答

当然Seajs也引入了自己的规范,叫做CMD规范。它的前身是Modules/Wrappings规范。SeaJS更多地来自 Modules/2.0 的观点,同时借鉴了 RequireJS 的不少东西,比如将Modules/Wrappings规范里的 module.declare改为define等。
说是规范,却不像是一般的规范那么冗长,可能打印出来也就一两页的纸张而已,这也是JavaScript社区的一个特点吧。Modules/Wrappings

seajs的作者在一篇文章中提到了业界在开发前端模块加载器时的场景:

大概 09 年 - 10 年期间,CommonJS 社区大牛云集。CommonJS 原来叫 ServerJS,推出 Modules/1.0 规范后,在 Node.js 等环境下取得了很不错的实践。09年下半年这帮充满干劲的小伙子们想把 ServerJS 的成功经验进一步推广到浏览器端,于是将社区改名叫 CommonJS,同时激烈争论 Modules 的下一版规范。分歧和冲突由此诞生,逐步形成了三大流派:

Modules/1.x 流派。这个观点觉得 1.x 规范已经够用,只要移植到浏览器端就好。要做的是新增 Modules/Transport 规范,即在浏览器上运行前,先通过转换工具将模块转换为符合 Transport 规范的代码。主流代表是服务端的开发人员。现在值得关注的有两个实现:越来越火的 component 和走在前沿的 es6 module transpiler。

Modules/Async 流派。这个观点觉得浏览器有自身的特征,不应该直接用 Modules/1.x 规范。这个观点下的典型代表是 AMD 规范及其实现 RequireJS。

Modules/2.0 流派。这个观点觉得浏览器有自身的特征,不应该直接用 Modules/1.x 规范,但应该尽可能与 Modules/1.x 规范保持一致。这个观点下的典型代表是 BravoJS 和 FlyScript 的作者。BravoJS 作者对 CommonJS 的社区的贡献很大,这份 Modules/2.0-draft 规范花了很多心思。FlyScript 的作者提出了 Modules/Wrappings 规范,这规范是 CMD 规范的前身。可惜的是 BravoJS 太学院派,FlyScript 后来做了自我阉割,将整个网站(flyscript.org)下线了。这个故事有点悲壮,下文细说。

也谈谈require.js

这个模块加载器是更加主流的。之所以不是首先提到它,是因为概念上来说seajs更加简明。和seajs相比,requirejs是更加主流的框架。它的差异主要是一些零零散散的不同,比如模块代码的外套是不太一样的:

require(["moduleA", "moduleB", "moduleC"], function (moduleA, moduleB, moduleC){

    // some code here

});

导出模块变量和函数的方式,也是不同的。requirejs的引出方式是直接返回:

return {foo:foo}

一样的案例,使用requirejs的话,代码是这样的:

index.html文件


index.js文件:

require(["./dep1"], function (d){
    console.log(d.dep1())        
});

dep1.js文件:

define(["./dep2"], function (d){
    var v1 = "dep1"
    function dep1(){
        return v1+"-"+d.dep2()
    }
    return {dep1:dep1}
});

dep2.js文件:

define(function() {
    var v2 = "dep2"
    function dep2(){
        return v2
    }
    return {dep2:dep2}
});

浏览器打开文件index.html,可以看到控制台输出一样的结果。

稍加对比require.js和sea.js。使用Require.js,默认推荐的模块格式是:

define(["a","b"], function(a, b) {
  // do sth
})

使用seajs的时候,类似的功能,代码这样写:

define(function(require, exports, module) {
  var a = require("a")
  var b = require("b")
  // do sth
  ...
})

Seajs的做法是更加现代的。我需要用的时候,我才去引用它,而不是实现什么都引用好,然后用的时候直接用就好。

Modules/1.x规范

以require.js为代表的Modules/Async流派,尊重了浏览器的特殊性,代价是不管写什么模块,都得自己给自己穿上一层外套,对于有代码洁癖的人来说,这样的情况是看不下去的。最好是开发人员编写干干净净的模块代码,框架开发者做一个工具,这个工具自动的把这些代码转义成客户端认可的异步代码。即在浏览器上运行前,先通过转换工具将模块转换为符合规范的代码。这就是Modules/1.x 流派的做法。需要注意的是1.x和2.0还有Async流派不能简单的认为版本号大的就更好。倒是理解成各自不同的解决方案为好。

以我们自己的案例来说,就是可以直接把nodejs代码那里,使用一个工具做一个转换,即可得到符合前端需要的代码,这些代码是异步加载的、是可以保证模块变量局部化的、是可以由良好的依赖关系定义的。工具browerfy就是做这个的。我们来试试具体是怎么玩的。

首先安装此工具:

npm install --global browserify

到你的nodejs代码内,然后转换此代码,生成一个新的js文件,一般命名为bundle.js:

browserify index.js -o bundle.js

然后创建index.html并引入bundle.js:


使用浏览器打开此HTML文件,可以在控制台看到熟悉的输出,这说明转换是有效的:

dep1+dep2

本身nodejs的代码,是不能在浏览器执行的,浏览器内也没有什么require函数,但是转换后就可以执行了。那么,转换的过程,到底玩了什么魔术?

像是browserify这样的工具,就是找到全面被引入的代码,解析它的依赖关系,并且自动的加入我们在requirejs里面需要的外套代码。尽管bundle.js文件并不是为了阅读优化的,但是可以取出其中的代码片段来证实我们的观点:

{"./dep2":2}],2:[function(require,module,exports){
        var v2 = "dep2"
        function dep2(){
            return v2
        }
        exports.dep2 = dep2
},{}],3:[function(require,module,exports){
        var d = require("./dep1")
        console.log(d.dep1())
},{"./dep1":1}]},{},[3]);

我们可以看到本来的nodejs代码,以及它们对应的外套。还是比较简单,就不进一步解释了。browserify不但完成了加外套代码的工作,还同时把若干小文件打成一个大的文件,对于当前使用的HTTP主流版本1.1来说,这样做会让加载效率更高。但是对于HTTP/2.0来说,它已经支持了多个文件在一个连接内交错传递,因此再做打包的意义就不大了。只是...HTTP/2.0的普及还需要时日。

browerify完成的工作简明而单一。另外一个主流的同类工具叫做webpack,不但可以转换js代码,还可以打包css文件、图片文件,并且可以做一些工程化的管理,代价就是webpack学起来也困难的多。实际上像是Vuejs这样的UI开发框架,内部就是使用了webpack做工程化管理和代码转译的。但是在模块化方面,两者是差不多的。就不另外介绍了。

ES6 Module

时间到了May 9, 2018,我看到了阮一峰发布了这样的微博:

今天 Firefox 60发布,默认打开了ES6 模块支持,至此所有浏览器都默认支持ES6模块。前端开发模式可能因此大变。现在的方案是所有模块发到npm,本地写好入口文件,再用webpack打包成一个脚本。但是如果浏览器原生支持,为什么还要打包呢?至少简单的应用可以直接加载入口文件,浏览器自己去抓取依赖。 ​​​​

这里所有浏览器指的是Edge、Firefox、Chrome、Safari。当然,再一次没有IE。如果想要支持IE或者比较老的版本的话,还是需要使用打包器来完成代码的转译。另外很多人表示会继续使用Webpack,原因很简单,Webpack不仅仅是完成模块打包工作,还有压缩、混淆等,并且很多框架还需要依赖它。所以迁移并非一朝一夕之功。而无需考虑老版本浏览器的兼容的代码,是完全可以大量的使用它了。了不起在把Webpack加起来转换ES Module到加外套的代码就是了。

ES6 Module不是requirejs那样加外套的样子,也不是Nodejs使用require函数的样子,而是另外一套有官方提出的模块模式。它使用的是import、export关键字。官方的就是不一样,社区是加不了关键字的。同样的案例,使用ES6 Module就是这样的了。

index.html文件:


dep1.js文件

    import {dep2} from "./dep2.js"
    var v1 = "dep1"
    export function dep1(){
        return v1+"-"+dep2()
    }
    

dep2.js文件:

    var v2 = "dep2"
    export function dep2(){
        return v2
    }

ES6 Module要求必须有后台的HTTP服务器,而不能直接在文件系统上完成Demo的测试。所幸使用Nodejs搭建一个服务器也非常简单直接:

npm i http-server -g
http-server

在浏览器内访问此HTML文件的URL,可以看到控制台输出:dep1+dep2。这个输出,已经是你的老朋友了。

Nodejs在10.9才支持实验版本的ES6 Module,是落后了点,但是对于Nodejs来说,新的模块技术本来也就并不迫切。

最佳实践建议

综合以上的内容,我认为,在不必考虑古老的浏览器兼容的情况下,最好的实践是这样的:

直接使用ES6 Module编写模块代码

使用Rollup清除没有调用的代码,降低代码的大小

使用Ugly工具压缩和混淆代码

使用HTTP/2做网络传递协议

这样的实践,会随着HTTP/2的逐步普及和ES6被更多的开发者采用,而成为更好的选择。

使用ES6 Module的坏处是无法像require那样动态的加载。但是好处是可以精确指明对于一个库,我们使用的是那些,这就给工具提供了优化的可能,就是说如果我引入了一个库,但是这个库内有些我不会用的,那么这些不会被用到的代码也不会加载到前端了。这个功能对于后端来说意义不大,但是对于前端来说,就是非常令人喜欢的功能了。实际上,这样的工具已经有了,比较知名的就是rollup,它属于了一种被称为tree-shaking的技术优化使用代码。

而以往做模块打包,很多的原因是HTTP/1.1传递大量小文件的时候开销比较大,而打包成单一的问题,就可以更好的利用HTTP/1.1的传输特性。但是HTTP/2.0的一个大的特色就是可以在单一的连接内,并发和交错的传递多个流,因为在一个连接内交错的传递多个文件,就可以不再有HTTP/1.1的连接开销了。因此,在HTTP/2.0被采纳的网络里面,打包单一文件的价值几乎没有了。直接使用小文件默认情况下就可以得到比较好的优化传输。

按照现在的技术发展的势头,要不了几年,打包器将不再那么必要,使用原生代码编写模块将会成为主流的。

参考

参考文章不少,其中模块历史和选型如下:

前端模块化开发那点历史

梳理的还是比较清晰

有点黑客精神的小伙伴,玩的很广谱

介绍Bower

npm for Beginners: A Guide for Front-end Developers

Es6module 出来了,是否应该重新考虑打包的方案?

未来

这篇文章预计想要编写的YUI方法,YUI Combo方法,想了想还是算了,因为这样的恐龙代码,已经在日常的代码实践中逐步消失,作为一个曾经比较重要,现在则退居二线的代码库,对它最好的赞许就是让它退休,也不必给读者增加额外的阅读负担了。毕竟require.js、browerify、webpack都工作的不错,在此基础上发展的Vuejs、React.js也的得到了更多的认可。

本文讲到的模块规范和实践工具,为编写一个广为社区认可的模块起到了最基础的规范作用。但是,JavaScript社区最为令人称道的就是代码库仓库。包括NPM仓库,Bower仓库。在这些仓库内,有模块依赖管理工具,还有工程化工具。这些内容,它们当然是重要的,不在本文的范围内。

作为前端开发者,有人采用Bower管理组件依赖,也有人使用Npm做类似的工作。有很多时候,这样的实践是令人困惑的。还有这里npm and the front end,NPM官方也对npm在前端的使用,提出了自己的看法。

这些未尽的内容,或许在未来的文章中表达之。

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/97327.html

相关文章

  • # Web Components 全揽

    摘要:定制元素可以在原生元素外创建定制元素。此定制元素内部有一个加号按钮,一个减号按钮,一个显示当前值。此主题会在下一部分内介绍。定制元素的属性元素的属性被称为,对象内的属性被称为。做响应的同步处理。 Web Components 全揽 Web Components技术可以把一组相关的HTML、JS代码和CSS风格打包成为一个自包含的组件,只要使用大家熟悉的标签即可引入此组件。Web Com...

    legendmohe 评论0 收藏0
  • 80万年薪招聘AI人才,仍然供不应求!AI到底是啥?一张神图秒懂

    摘要:仅在年上半年,就有数据显示全球范围内相关产业融资规模达到亿美元,最热的地方并不在美国欧洲日本,而是在中国中国一国的融资规模就达到了亿美元,占到了全球份额的。人才极度短缺必然导致其薪资水涨船高。 showImg(http://upload-images.jianshu.io/upload_images/13825820-55da3500ae119588.jpg?imageMogr2/au...

    laoLiueizo 评论0 收藏0
  • PHPer 为什么会被 Javaer 鄙视?

    摘要:最近看了知乎上的一个话题在工作中,为什么程序员常常瞧不起程序员个人从业多年,用过的后端语言,如果你非要让我说哪种语言好,我会说凡是宏哥说的都是对的,凡是宏哥提倡的都要坚持。只有真正的理解了宏哥思想才可以洞穿一切,走出空谷。 最近看了知乎上的一个话题「在工作中,为什么 Java 程序员常常瞧不起 PHP 程序员?」 个人从业多年,用过的后端语言 ASP、ASP.NET、Java、PHP、...

    jasperyang 评论0 收藏0
  • PHPer 为什么会被 Javaer 鄙视?

    摘要:最近看了知乎上的一个话题在工作中,为什么程序员常常瞧不起程序员个人从业多年,用过的后端语言,如果你非要让我说哪种语言好,我会说凡是宏哥说的都是对的,凡是宏哥提倡的都要坚持。只有真正的理解了宏哥思想才可以洞穿一切,走出空谷。 最近看了知乎上的一个话题「在工作中,为什么 Java 程序员常常瞧不起 PHP 程序员?」 个人从业多年,用过的后端语言 ASP、ASP.NET、Java、PHP、...

    zhoutk 评论0 收藏0
  • 前端性能优化(三)——传统 JavaScript 优化的误区

    摘要:二模块化误区加快加载和执行的速度,一直是前端优化的一个热点。结果文件减少,也达到了预期的效果。避免不必要的延迟。最后再根据文件的功能类型,来决定是放在页面的头部还是尾部。 注:本文是纯技术探讨文,无图无笑点,希望您喜欢 一.前言 软件行业极其缺乏前端人才这是圈内的共识了,某种程度上讲,同等水平前端的工资都要比后端高上不少,而圈内的另一项共识则是——网页是公司的脸面! 几年前,谷歌的一项...

    UsherChen 评论0 收藏0

发表评论

0条评论

lily_wang

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<