webpack 介绍

模块化的前身今世

近年随着硬件升级以及网络普及, 互联网应用的重心慢慢向富客户端偏移. 前端开发与构建也开始遇到了和其他语言相似的挑战: 代码复用, 关注分离以及灵活的继承.

社区的主流趋势是在 JavaScript 中引入模块化的概念以及一个模块加载系统. 目前主流的模块管理有 AMD, CommonJs 以及 ES6 规范的 import

由于浏览器加载 script 异步的天性, 最先(也是最自然)诞生的模块是 异步模块定义(Asynchronous Module Definition), 简称 AMD.

1
define(['myDep', 'jquery'], function(myDep, $) {...})

AMD 通过一个数组声明自己所依赖的文件,当所有依赖加载完毕后, 作为参数传递给回调函数. RequireJs 是一个 AMD 比较常见的实现. 类似的还有国人玉伯写的 Sea.js(虽然 Sea.js 提出了 CMD 的概念, 但本质还是 AMD 的变形).

AMD 允许以 runtime 的形式运行, 动态的加载依赖文件, 也可以一次性将依赖构建在一个或多个文件中运行, 后者在生产环境下使用较为普遍, 可以有效的优化页面加载速度, 例如 Require.js 提供的 r.js, 在 CLI 中执行可以将源码构建并优化成生产环境运行的目标代码, 写入指定目录, 这一点已经和 webpack 比较相似了.

另外值得一提的是 Require.js 并不仅仅可以处理 js 模块, 还可以用于引入 css, 文本资源等等. 通过一些 Require.js 的插件来实现, 在代码中也需要显示的声明文件类型 (eg: require('css!style.css')). 相比日后 webpack 要繁琐些.

CommonJs 是 nodejs 默认的加载方式:

1
2
const $ = require(‘jQuery’);
console.log($.version);

这种同步的写法在浏览器中显然无法直接运行. 但我们可以在构建阶段将 js 文本解析成抽象语法树(Abstract Syntax Tree), 然后将所有 require 的内容合并在一个文件中. 这样的解析工具有 Browserify 以及后来的 Webpack.

WebpackBrowserify 基础上做了一些优化, 比如可以将构建的文件拆分成多个 chunk, 不用刷新页面就可以’热替换’模块等.

ES6 的 import 实际上和 CommonJs 比较相似, 不单独赘述.

AMD 需要把真正的代码放入一个回调函数中, 很多类库在文件头部会出现判断是否存在 define 和 define.amd 的判断逻辑然后分别调以不同的模块加载方式. 相较之下, CommonJsES6 import 的用法对于源码破坏性要小的多, 也更符合其他语言的模块加载逻辑.

Webpack 与 Grunt, Gulp

其实 Webpack 与 Grunt/Gulp 并不是一类工具.

Grunt/Gulp 是一种基于项目工程的任务执行工具(task runner). 一般用于对项目中指定目录下的一类文件进行处理或监听. 有点类似于 C 项目的 make. 例如利用 Grunt 可以将 A 目录下的所有 js 文件通过 babel 转译成 es5的格式, 并写入 B 目录中.

Webpack 则是一种模块打包工具. Webpack 会从定义的入口文件(entry)出发, 将所有被依赖的 js/css/png 等类型文件调用指定的 loader, 并将结果输出到指定位置.

实际上 Grunt 有对应的插件可以运行 webpack. 但目前的 webpack 功能已经十分强大, 足以代替 Grunt/Gulp 单独使用. 多引用一个工具就会多一分出错的可能, 并增加学习的成本, 现在的项目我不建议去使用 Grunt/Gulp 这一类型的工具.

那么相对于 Grunt / Gulp, Webpack 又有什么优势呢?

不得不提到, npm 战胜 bower 后统一了JavaScript 的包管理市场. 面对第三方代码的引用, Grunt / Gulp 这种通过描述规则的构建方式很难有效的自动化筛选出需要打包的依赖. 而 Webpack 通过解析程序进行模块化分析, 很轻易的可以挑选出依赖中需要打包的部分. 甚至通过 code splitting 技术可以实现依赖的按需加载. 这一优势很自然的成为了当下最热门的构建工具.

所以, Webpack 到底可以做什么

首先 Webpack 像 Browserify一样, 可以对 CommonJs, ES6的模块进行处理, 使之可以正常的运行在浏览器上. 这里说的模块不仅仅是 js, 还有 css, 图片以及其他各种各样的模块

就像官方 banner 画的这样.

entry 开始, 依赖的模块会去调用 webpack 的 module 进行处理. webpack 的 module 有以下几个属性

  1. test: 可以处理的文件格式
  2. exclude: 忽略处理的目录
  3. loaders/use: 通过什么样的 loader 去处理资源

通常项目的 entry 会从 .js 文件开始, 通过 babel-loader 进行解析, 并且将所依赖(require / import)的模块调用相对应的 webpack modules 去处理. 直到遍历完全部的内容.

除了基本的模块打包外, webpack 和它周围的生态还提供了振奋人心的工具.

source map

通过模块化构建生成的代码很难直接阅读, 这给调试带来了不便. 目前主流浏览器都支持 source map 的功能让命令行的信息映射到对应原始脚本上.

顺带一提, 除了普通的 source map 外, webpack 的开发工具(devtool)还支持一种eval-source-map的格式, 这种模式会让原脚本在 eval 中执行, 有一定的安全隐患, 但在开发模式下并不会产生额外的生成 source map的时间.

dev server

webpack-dev-server 可以在不生成文件的基础上创建一个 web 服务器, 并提供开发所需要的大部分辅助功能. (如路由代理, 热加载等等)

loaders

webpack 已经拥有大量的 loaders. 过去使用 Grunt/Gulp 需要对 js 与 css 进行单独的任务处理. 在 Webpack 中, 设置好 entry, 以及资源文件对应的 loader 的规则, webpack 就会自动完成 babel / sass / less 等构件工具的工作.

loaders 中 raw-loaderimports-loader 比较特殊, 经常用来做 hack 之类的奇怪的事情.

raw-loader 顾名思义就是以原始内容的方式导入模块. 例如某些(奇怪的)场合我们需要将 js 以字符串变量的形式被读取, 就可以写成 import rawJs from 'raw-loader!some.js'

imports-loader 用于向依赖中注入全局变量. 在一些需要兼容第三方代码的场合十分有用. 例如某些类库会判断 window.jQuery 是否存在, 就可以用到 imports-loader 注入 jQuery 对象: import 'imports-loader?jQuery=jquery!./example.js'

imports-loader 也可以用来阻止 AMD: imports-loader?define=>false

还有一个比较神奇的 loader 是bundle-loader. 这个组件用于异步加载代码模块, 参考 基于Webpack 2的React组件懒加载

插件

插件和 loader 不同, loader 在构建过程中作用于一个个资源文件. 插件则作用于完整的构建.

例如将构建后的文件分成多段的 CommonsChunkPlugin, 自动生成 html 文件, 并注入构建后目标文件的 HtmlWebpackPlugin, 以及开发阶段不用刷新浏览器页面就可以热更新修改后模块的 HotModuleReplacementPlugin.

社区支持

webpack 广受欢迎很大程度上归功于社区提供了大量 loaders / plugins 以及优化实践的资料. 参考 webpack 的文档, 可以很方便的创建满足自己需求且符合通用标准的 loader 或 plugin, 并提供给更多的人使用. 目前几个流行框架, 如 React, Vue 与 Angular 也提供了 webpack 的构建方案与 boilerplate.

新特性

在最近间隔很短的几个月里, webpack 先后发布了 2.0 和 3.0两个版本

Tree Shaking

tree-shaking 是指借助es6 import export 语法静态性的特点来删掉 export 但是没有 import 过的东西. 最早出现在 rollup.js 中, 后来在 webpack2.0 被引入.

Scope Hoisting

webpack 3则是进一步借鉴了 rollup.js 的功能, 新引入了一种提升模块作用域的功能. 优化了 webpack 内部打包 js 模块的数据结构, 轻微缩小了构建体积, 以及降低构建后带来的额外性能损失, 详细可以参考这篇文章: webpack3之Scope Hoisting

loader 的工作原理

Webpack 的 loader 实现非常简单, 我们通过官方较为简单的 file-loader 看看具体的实现.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import path from 'path';
import loaderUtils from 'loader-utils';

export default function fileLoader(content) {
if (!this.emitFile) throw new Error('emitFile is required from module system');

const query = loaderUtils.getOptions(this) || {};
const configKey = query.config || 'fileLoader';
const options = this.options[configKey] || {};

const config = {
publicPath: undefined,
useRelativePath: false,
name: '[hash].[ext]',
};

Object.keys(options).forEach((attr) => {
config[attr] = options[attr];
});

...

return `export default ${publicPath};`;
}
export const raw = true;

loader-utils 是 webpack 为 loader 开发准备的辅助工具, 最常见的用法是通过 loaderUtils.getOptions 获取 webpack.config.js 为 loader 配置的 query 参数.

本质上 loader 就是一个函数, 输入的参数是 webpack 处理资源的内容, 返回值则是处理的结果. file-loader 而言处理的对象一般为图片等格式的附件, 将文件复制到目标目录后, 返回结果则是对应的资源位置.

顺带注意一下结尾处的 export const raw = true. 默认情况下, loader 传入的参数会被 webpack 转码成 utf8 格式. 如果希望直接处理二进制或其他编码的内容, 需要允许 loader.raw = true