前端发展日新月异,短短不过10年已经从原始走向现代,甚至引领潮流。网站逐渐变成了互联网应用程序,代码量飞速增长,为了支撑这种需求和变化,同时兼顾代码质量、降低开发成本,接入模块化势在必行。伴随这一变化的是相对应的构建工具的快速成长,或是为了优化、或是为了转义,都离不开这类工具。
所谓温故而知新,本篇回顾总结下前端模块化的发展历程及辅助工具。在回顾中可以更清晰的看到当前我们用的方案所处的位置,为什么会发展到这一步,目前模块化方案带来的优势等。
1. 没有模块化的日子
最开始JavaScript承担的任务量并不多,表单验证基本上就是他的全部,最多就是简短的前端交互,这个时期JavaScript组织结构非常凌乱,大部分都是后端哥哥们顺手代劳,那时候还没有“前端”这一职位。 一般都是写到一个文件或者直接写到jsp、asp的后端模板页面上就完事了。这个阶段没啥可说的, 跳过吧。。。
2. 传统模块化
随着ajax的流行,前端能做的东西一夜之间暴涨,代码量飞速增加,单文件维护代码已经太沉重,于是拆之,进而引入模块化,将负责不同功能的代码拆分成小粒度的模块,方便维护。
这里要说的模块化是抛开现在你所熟知的require,amd,seajs等,不借助任何的模式和工具,由JavaScript直接完成的代码结构化。JavaScript天生没有模块化的概念(直到ES6), 而不像后端语言源生自带模块功能, 比如java的import,c++的include,node的require(下文说到),所以需要通过其他的方式来实现模块化。
应用模块化开发的主要目的是为了复用代码、代码结构清晰、便于维护等,比如在开发过程中,我们往往会将一些重复用到的代码提取出来,封装到一个function里,然后在需要的地方调用,那么这可以看做是一种模块化。
我们看一段代码 例 – 1:
function show(element) { // 展示一个元素 }
function close(element) { // 隐藏一个元素 }
上面的代码非常直观,就是要显示、隐藏一个dom元素,往往这种方法需要大范围多次调用,一般我们可能会放到util.js这样的文件里,这是第一步。接下来在业务代码中引用,例 – 2
<body>
<script src="lib/utils.js"></script>
<script src="lib/page-1.js"></script>
<script src="lib/page-2.js"></script>
</body>
2.1 存在的问题
以现在的经验来看,上面的写法会带来非常明显的问题,当然也是在这个模块化引入阶段逐步暴露的。
全局变量冲突风险:如果编写page-1的同学不知道utils里面有一个show/close方法,然后他自个也写了一个,同时还添加了额外逻辑,自然就覆盖了原来的方法,那么page-2同学在不知道的情况下调用了这方法,自然会发生错误.
人工维护依赖关系:因为存在依赖关系,所以必须先加载util,然后才能加载page-1/2,这里的例子非常简单,但在实际项目场景了,这样的依赖会非常多且复杂,维护非常困难,很难建立清晰的依赖关系。想想当时高大上的校内网,那种程度的页面得需要多少的模块去支撑。后续项目迭代往往会带来意料之外的问题.
2.2 尝试解决问题
针对问题1,可以做下面这些改进
2.2.1 代码提取
把这些方法放到一个object里面对外输出,例 - 3
var utils = {
_name: ‘baotong.wang’,
show: function(element) {},
close: function(element) {}
}
但这样依然不能避免我们的utils被覆盖的可能性,孱弱的英语积累让我们想不出什么更高级的词来命名utils,var fuzhufangfa = {}?。。。
不过这种写法同时还带来了暴露内部变量的问题,外部可以访问到_name。
2.2.2 命名空间
然后部分开发者引入了命名空间,这个东西牛逼了,例 – 4:
var com.company.departure.team.utils = {}
代码模块通过严格的命名规则做了规范,可以按照实际情况具体到部门、team、类库。如果一个公司在代码规范上做了这样的约束,基本上可以避免变量名冲突的问题,但同时带来的需要输入过多单词的负担,目前还没有哪个IDE能支持JavaScript像Java一样可以一路点点点下去,这些都是需要打出来的。当然我们也可以不设计成这么复杂的命名空间,var Company.ProjectName.Module = {}; 同时结合局部变量减少输入的长度。
2.2.3 闭包封装
为了解决封装内部变量的问题,就该有请立即执行的函数登场了,这也是我们接触的最多的一种模块化方式,公司内部有点年纪的项目多少都能看到这样的写法,结合命名空间如下,例 – 5:
(function() {
var Company = Company || {};
Company.Base = Company.Base || {};
var _name = ‘baotong.wang’
function show () {}
function close () {}
Company.Base.Util = {
show: show,
close: close
}
})();
上述写法通过一个立即执行的函数表达式,赋予了模块的独立作用域,同时通过全局变量配置了我们的module,从而达到模块化的目的。基本上到这一步,问题1就解决了。
2.2.4 关于依赖关系
针对问题2,代码的组织依赖关系,这块我不是很了解,向司徒求证了一下。大概情况如下。
当时业界也是有不少方案的,比如百度的Tangram与Qwrap,查了下他们的github地址,最后一次更新是在五年前。它解决依赖关系的方式是在类库中什么依赖,类似 depend=["com.qunar.dujia.lib", "", …],然后通过配套的工具去解析。
同时也有一些后端大牛为了解决前端工程化的问题发明创造了各种方案,但当时的氛围并没有现在这么重视前端,前端从业人员的水平也没现在高;同时后端哥哥们往往对前端问题、痛点了解的不深入,所以开发出来的方案很难推广。比如搞Java和搞Ruby的后端做的方案基本不太会一样。 用司徒的话来讲叫生不逢时。
2.3 这个时代的工具
2.3.1 代码合并
例 – 2中的代码引用方式相信肯定存在于一些站点上。虽然不会带来功能问题,但是却带来了很多不必要的http请求,特别是复杂页面需要引用很多独立JavaScript的时候,从而延长了页面的ready时间。所以这里需要合对文件进行合并处理,将可以合并的业务代码连接到一个文件里。
需要注意的是,合并并不是所有的文件合并为一个为好,比如公共文件jquery文件、功能公共方法可以单独引用,利用浏览器的缓存机制,减少多页面情况下总的下载量。如果站点一共就是一个SPA,合并为一个为好。
2.3.2代码混淆压缩
另外一个就是代码压缩,现在的同学对这个肯定非常熟悉了,但是即便现在找一个你熟悉的网站看一下,也不敢说一定做到了这一步。
走到这有了这两步,网站看起来就挺像那么回事了。
2.3.3 代表性工具
YUI compressor,出自雅虎,在那个时期雅虎可以说是网站优化的风向标,同样出自雅虎的前端优化34条(数量不同版本不一样)在业界也是鼎鼎大名,为前端做出了很大贡献。
这个时候适合模块化的通用工具并未出现,相信有实力的大公司都有内部的一条工具去做类似的事情,这里个人所知有限,没啥发言权,欢迎大家交流讨论。
3. Node来了
2009年,node的发布给前端同学带来了无限可能,npm生态的逐渐成熟给了我们更多选择,以往需要通过其他语言工具执行的编译过程也可以由前端一手接管。同时node也带来了commonJS,给前端的模块化提供了新的思路,我们这里首先关注node实现的commonJS规范。
3.1 commonJS概述
作为后端语言,没有模块化加载机制是运转不起来的,node选择实现了commonJS作为它的模块加载方案,整体非常简单。注:commonJS并不是node发明的,他只是按照该规范做了一套实现。
3.2 npm生态
npm生态让node有了自己的模块仓库,各种类库的不断支持让我们也有了更多选择。commonJS一开始就提供了对npm module的支持,在路径查找的时候内部配置了对node_modules文件夹的查找支持。
3.3 说说node
对前端来说幸运的是node的设计者Ryan Dahl选择了JavaScript作为他的支持语言,这也说明了JavaScript事件驱动的魅力所在。大批后端的加入丰富了作为一门后端语言的各种基本功能。
对于前端同学来说node有着天然的亲和力,让我们多了一个全新施展本领的领域;同时对于懂后端的同学来说视乎可以大干一场了。目前我们用的最多的有两部分,node布置站点、数据接口集成维护,这个和本篇没啥关系,不展开说;另外一部分就是利用node开发工作工具,提高前端的工作效率,社区里解析commonJS的、构建工程工具不断喷涌而出, 具有代表性的有grunt、gulp、browserify,webpack,前端模块化可以更进一步。
4. 模块化方案
4.1 commonJS
简单概括下commonJS的几个概念,还是非常简单的
每个文件是一个模块,有自己的作用域。这里面定义到函数、变量、类都是私有的,对其他文件不可见
每个模块内部,module变量代表当前模块,它是一个对象
module的exports属性(即module.exports)是对外的接口;加载某个模块,其实是加载该模块的module.exports属性如果文件中没有exports属性,那么外部引用不到任何东西
使用require关键字加载对应的文件,也就是模块。
require命令的基本功能是,读入并执行一个JavaScript文件,然后返回该模块的exports对象,如果没有发现该模块,报错
这里对上面的代码做了模块化的改进,文件内部设置的对外输出,下面的写法在node环境中源生支持。
// 设置文件输出
module.exports = {
func: function() {},
field: "string"
}
// 添加单个export
module.exports.show = function() {}
这里引入几个模块、文件
require("modulepath");
var Base = require("../base.js");
var page = require("./file.js");
page.show();
4.2 其它模式
除了commonJS,当前留下的模块化模式还有以requireJS为代表的AMD和以seaJS为代表CMD, 在去哪儿网内部始终是commonJS占据主流,我个人也是喜欢commonJS更多一些,requireJS可以做到在浏览器端执行动态异步模块加载,仅从首次代码下载量的角度讲,这种方案更好一些,但我们完全有其他办法在commonJS模式下解决这有个问题,所以本篇主要介绍commonJS和编译工具支持的一个思路
4.3 代码改造
基于commonJS,回过头来再看下例-5中的代码应该怎么改造。
首先,那层闭包可以不用加了,你没看到有谁在node里面加这个东西吧,这层其实还是需要有点,但是我们交给工具自动帮我们加上。
其次,我们需要在模块内部写上对外输出的内容,module.exports = ***;
然后,在业务代码中添加对模块的引用,var module = require("modulepath"), 有了这个之后就能引用module export出来的功能了
最后,通过打包工具的编译,解析commonJS,分析入口文件得到最终输出。
最终得到的代码如下:
// module.js
var _name = 'baotong.wang';
function show() { alert(_name); }
function close() {}
module.exports = {
show,
close
}
// page.js
var module = require('./module.js');
module.show();
4.4 浏览器端支持
commonJS是服务器端的模块化方案,浏览器端是不支持的,单是require就没有,所以就需要辅助工具来替我们完成commonJS代码向浏览器代码的转换。
社区成熟的解析类库有browserify,能够完美解析commonJS;因为公司内部业务的特点需要,browserify并不能满足实际需求,因此去哪儿网内部先后推出了fekit、ykit两款针对commonJS的前端工具,来执行代码的编译。前者是自己实现的一套解析commonJS的工具集,对一些规范的实现不是很规范,同时面向的是内部的module仓库,导致和主流npm环境脱节,于是有了ykit;后者是基于webpack和公司业务特点封装的一个工具集,核心打包交给了webpack,同时做了部分优化,具体前面发过一篇文章,介绍过实现机制。
在这我说下fekit的编译过程,介绍下这个工具处理commonJS的一般思路。
4.5 fekit编译过程
fekit是一个基于node的命令行工具集,在支持commonJS的过程中也做了一些修改和扩展,比如支持在css文件中通过require加载文件,做到和JS文件一样;增加对内部module仓库的支持,下图介绍了一次pack的具体执行过程。下面的流程适合模块解析相关的部分,其他业务构建部分在这里跳过。
module处理模板代码
;(function(__context) {
var module = {
id : "{{md5Key}}" ,
filename : "{{fileName}}" ,
exports : {}
};
if( !__context.____MODULES ) {
__context.____MODULES = {};
}
var r = (function( exports , module , global ) {
//----------原始文件代码----------
{source}
//----------原始文件代码----------
})( module.exports , module , __context );
__context.____MODULES[ "{{md5Key}}" ] = module.exports;
})(this);
在业务代码中的require会变成,即通过一个object拿到module.exports。 var module = context.__MODULES["md5Key"]; 以上就是对一个commonJS文件的解析过程了。
4.6 ES6的模块化方案
ES6中给出了import export这样的方案,目前为止我们都是通过babel将ES6代码转为ES5,import转为了require,export转为了module.exports,即commonJS。
他的实现原理和commonJS这种引用即引用整个类不一样,它是用啥就引用啥,export输出的也不是一个类,这里往下说就比较多了,阮一峰老师的ES6教程对这块也有比较详细的说明。限于篇幅,本篇不针对这个展开来说了。
5. 总结
本篇简单回顾了模块化的发展历程,介绍了以往存在的问题。然后到现代模块化方案的时候,讲解了commonJS,同时介绍了解析commonJS的一种方法。通过一个例子串连,讲述了模块化带来的改变。部分知识点没展开来说,大家有兴趣可以深入学习一下。同时感谢司徒指点,希望本篇能对大家有所帮助,best regards。