返回 登录
6

前端模块化发展简史

前端发展日新月异,短短不过 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。

本文来自「Qunar 技术沙龙」,作者王宝同,2014 年加入 Qunar,在旅游度假事业部担任前端工程师。擅长代码结构设计与优化,喜欢研究构建工具,折腾 Node。

评论