先说说前端打包方案的黑暗历史

在很长的一段前端历史里, 是不存在打包这个说法的. 那个时候页面基本是纯静态的或者服务端输出的, 没有AJAX, 也没有jQuery. 那个时候的JavaScript就像个玩具, 用处大概就是在侧栏弄个时钟, 用media player放个mp3之类的脚本, 代码量不是很多, 直接放在<script>标签里或者弄个js文件引一下就行, 日子过得很轻松愉快.

随后的几年, 人们开始尝试在一个页面里做更多的事情. 容器的显示, 隐藏, 切换. 用css写的弹层, 图片轮播等等. 但如果一个页面内不能向服务器请求数据, 能做的事情毕竟有限的, 代码的量也能维持在页面交互逻辑范围内. 这时候很多人开始突破一个页面能做的事情的范围, 使用隐藏的iframe和flash等作为和服务器通信的桥梁, 新世界的大门慢慢地被打开, 在一个页面内和服务器进行数据交互, 意味着以前需要跳转多个页面的事情现在可以用一个页面搞定. 但由于iframe和flash技术过于tricky和复杂, 并没能得到广泛的推广.

直到Google推出Gmail的时候(2004年), 人们意识到了一个被忽略的接口, XMLHttpRequest, 也就是我们俗称的AJAX, 这是一个使用方便的, 兼容性良好的服务器通信接口. 从此开始, 我们的页面开始玩出各种花来了, 前端一下子出现了各种各样的库, Prototype, Dojo, MooTools, Ext JS, jQuery… 我们开始往页面里插入各种库和插件, 我们的js文件也就爆炸了…

随着js能做的事情越来越多, 引用越来越多, 文件越来越大, 加上当时大约只有2Mbps左右的网速, 下载速度还不如3G网络, 对js文件的压缩和合并的需求越来越强烈, 当然这里面也有把代码混淆了不容易被盗用等其他因素在里面. JSMin, YUI Compressor, Closure Compiler, UglifyJS 等js文件压缩合并工具陆陆续续诞生了. 压缩工具是有了, 但我们得要执行它, 最简单的办法呢, 就是windows上搞个bat脚本, mac/linux上搞个bash脚本, 哪几个文件要合并在一块的, 哪几个要压缩的, 发布的时候运行一下脚本, 生成压缩后的文件.

基于合并压缩技术, 项目越做越大, 问题也越来越多, 大概就是以下这些问题:

  • 库和插件为了要给他人调用, 肯定要找个地方注册, 一般就是在window下申明一个全局的函数或对象. 难保哪天用的两个库在全局用同样的名字, 那就冲突了.
  • 库和插件如果还依赖其他的库和插件, 就要告知使用人, 需要先引哪些依赖库, 那些依赖库也有自己的依赖库的话, 就要先引依赖库的依赖库, 以此类推…

恰好就在这个时候(2009年), 随着后端JavaScript技术的发展, 人们提出了CommonJS的模块化规范, 大概的语法是: 如果a.js依赖b.jsc.js, 那么就在a.js的头部, 引入这些依赖文件:

  1. var b = require('./b')
  2. var c = require('./c')

那么变量bc会是什么呢? 那就是b.jsc.js导出的东西, 比如b.js可以这样导出:

  1. exports.square = function(num) {
  2. return num * num
  3. }

然后就可以在a.js使用这个square方法:

  1. var n = b.square(2)

如果c.js依赖d.js, 导出的是一个Number, 那么可以这样写:

  1. var d = require('./d')
  2. module.exports = d.PI // 假设d.PI的值是3.14159

那么a.js中的变量c就是数字3.14159, 具体的语法规范可以查看Node.js的文档.

但是CommonJS在浏览器内并不适用. 因为require()的返回是同步的, 意味着有多个依赖的话需要一个一个依次下载, 堵塞了js脚本的执行. 所以人们就在CommonJS的基础上定义了Asynchronous Module Definition (AMD)规范(2011年), 使用了异步回调的语法来并行下载多个依赖项, 比如作为入口的a.js可以这样写:

  1. require(['./b', './c'], function(b, c) {
  2. var n = b.square(2)
  3. console.log(c) // 3.14159
  4. })

相应的导出语法也是异步回调方式, 比如c.js依赖d.js, 就写成这样:

  1. define(['./d'], function(d) {
  2. return d.PI
  3. })

可以看到, 定义一个模块是使用define()函数, define()require()的区别是, define()必须要在回调函数中返回一个值作为导出的东西, require()不需要导出东西, 因此回调函数中不需要返回值, 也无法作为被依赖项被其他文件导入, 因此一般用于入口文件, 比如页面中这样加载a.js:

  1. <script src="js/require.js" data-main="js/a"></script>

以上是AMD规范的基本用法, 更详细的就不多说了(反正也淘汰了~), 有兴趣的可以看这里.

js模块化问题基本解决了, css和html也没闲着. 什么less, sass, stylus的css预处理器横空出世, 说能帮我们简化css的写法, 自动给你加vendor prefix. html在这期间也出现了一堆模板语言, 什么handlebars, ejs, jade, 可以把ajax拿到的数据插入到模板中, 然后用innerHTML显示到页面上.

托AMD和CSS预处理和模板语言的福, 我们的编译脚本也洋洋洒洒写了百来行. 命令行脚本有个不好的地方, 就是windows和mac/linux是不通用的, 如果有跨平台需求的话, windows要装个可以执行bash脚本的命令行工具, 比如msys(目前最新的是msys2), 或者使用php或python等其他语言的脚本来编写, 对于非全栈型的前端程序员来说, 写bash/php/python还是很生涩的. 因此我们需要一个简单的打包工具, 可以利用各种编译工具, 编译/压缩js, css, html, 图片等资源. 然后Grunt产生了(2012年), 配置文件格式是我们最爱的js, 写法也很简单, 社区有非常多的插件支持各种编译, lint, 测试工具. 一年多后另一个打包工具gulp诞生了, 扩展性更强, 采用流式处理效率更高.

依托AMD模块化编程, SPA(Single-page application)的实现方式更为简单清晰, 一个网页不再是传统的类似word文档的页面, 而是一个完整的应用程序. SPA应用有一个总的入口页面, 我们通常把它命名为index.html, app.html, main.html, 这个html的<body>一般是空的, 或者只有总的布局(layout), 比如下图:

layout

布局会把header, nav, footer的内容填上, 但main区域是个空的容器. 这个作为入口的html最主要的工作是加载启动SPA的js文件, 然后由js驱动, 根据当前浏览器地址进行路由分发, 加载对应的AMD模块, 然后该AMD模块执行, 渲染对应的html到页面指定的容器内(比如图中的main). 在点击链接等交互时, 页面不会跳转, 而是由js路由加载对应的AMD模块, 然后该AMD模块渲染对应的html到容器内.

虽然AMD模块让SPA更容易地实现, 但小问题还是很多的:

  • 不是所有的第三方库都是AMD规范的, 这时候要配置shim, 很麻烦.
  • 虽然RequireJS支持通过插件把html作为依赖加载, 但html里面的<img>的路径是个问题, 需要使用绝对路径并且保持打包后的图片路径和打包前的路径不变, 或者使用html模板语言把src写成变量, 在运行时生成.
  • 不支持动态加载css, 变通的方法是把所有的css文件合并压缩成一个文件, 在入口的html页面一次性加载.
  • SPA项目越做越大, 一个应用打包后的js文件到了几MB的大小. 虽然r.js支持分模块打包, 但配置很麻烦, 因为模块之间会互相依赖, 在配置的时候需要exclude那些通用的依赖项, 而依赖项要在文件里一个个检查.
  • 所有的第三方库都要自己一个个的下载, 解压, 放到某个目录下, 更别提更新有多麻烦了. 虽然可以用npm包管理工具, 但npm的包都是CommonJS规范的, 给后端Node.js用的, 只有部分支持AMD规范, 而且在npm3.0之前, 这些包有依赖项的话也是不能用的. 后来有个bower包管理工具是专门的web前端仓库, 这里的包一般都支持AMD规范.
  • AMD规范定义和引用模块的语法太麻烦, 上面介绍的AMD语法仅是最简单通用的语法, API文档里面还有很多变异的写法, 特别是当发生循环引用的时候(a依赖b, b依赖a), 需要使用其他的语法解决这个问题. 而且npm上很多前后端通用的库都是CommonJS的语法. 后来很多人又开始尝试使用ES6模块规范, 如何引用ES6模块又是一个大问题.
  • 项目的文件结构不合理, 因为grunt/gulp是按照文件格式批量处理的, 所以一般会把js, html, css, 图片分别放在不同的目录下, 所以同一个模块的文件会散落在不同的目录下, 开发的时候找文件是个麻烦的事情. code review时想知道一个文件是哪个模块的也很麻烦, 解决办法比如又要在imgs目录下建立按模块命名的文件夹, 里面再放图片.

到了这里, 我们的主角webpack登场了(2012年)(此处应有掌声).

和webpack差不多同期登场的还有Browserify. 这里简单介绍一下Browserify, Browserify的目的是让前端也能用CommonJS的语法require('module')来加载js. 它会从入口js文件开始, 把所有的require()调用的文件打包合并到一个文件, 这样就解决了异步加载的问题. 那么Browserify有什么不足之处导致我不推荐使用它呢? 主要原因有下面几点:

  • 最主要的一点, Browserify不支持把代码打包成多个文件, 在有需要的时候加载. 这就意味着访问任何一个页面都会全量加载所有文件.
  • Browserify对其他非js文件的加载不够完善, 因为它主要解决的是require()js模块的问题, 其他文件不是它关心的部分. 比如html文件里的img标签, 它只能转成Data URI的形式, 而不能替换为打包后的路径.
  • 因为上面一点Browserify对资源文件的加载支持不够完善, 导致打包时一般都要配合gulp或grunt一块使用, 无谓地增加了打包的难度.
  • Browserify只支持CommonJS模块规范, 不支持AMD和ES6模块规范, 这意味旧的AMD模块和将来的ES6模块不能使用.

基于以上几点, Browserify并不是一个理想的选择. 那么webpack是否解决了以上的几个问题呢? 废话, 不然介绍它干嘛. 那么下面章节我们用实战的方式来说明webpack是怎么解决上述的问题的.