一 为什么要模块化打包
前端历史发展
Ajax -> Commonjs -> ESM
Ajax让前端这个工种从一个人写 JSP/ASP.net 页面里分离出来.单独成为一个工种, 开始大放异彩.独自美丽.
在这个时代 jQuery 是当红的炸子鸡
而 Commonjs 让前端进入模块化时代, 前端可以引用很多别人开发的 npm 包.开始第一步走入大型化协作深水区.
就是在这个时代, 前端构建工具开始流行.
ES6里的出现ESM标准化模块方案 的出现让前端可以更方便的开发大型项目,但是也让前端项目的复杂性超过以前很多倍.
在这种情况下, 构建工具改革进入深水区.你拼我赶, 你增加一个新功能,我也增加一个新功能, 大家 拼完功能拼速度. 很多构建化工具都会晒出速度, 自己的速度是 xx 的几分之一.
没有模块化打包的世界
那么为什么要有模块化,
又为什么要模块打包呢?
以前的页面是直接在一个页面引入多个脚本,比如 jQuery, jQuery 的插件.项目的业务 js. 这样写的时候一个 js 会非常长.
Brendan Eich 用 10 天写的 js,我以前一直觉得它优点多多, 不过最大的问题就是全局 window 的问题.定义的变量,函数,默认都在一个 window 作用域下.
比如
// a.js
var userName = '牛夫人';
console.log(userName);
// b.js
var userName = '小甜甜';
console.log(userName);
// index.html写法1
<script src= 'a.js' />
<script src= 'b.js' />
console.log(userName); // 小甜甜
// index.html写法2
<script src= 'b.js' />
<script src= 'a.js' />
console.log(userName); // 牛夫人
仅仅只是引入 a.js和 b.js两个文件的顺序不同就会导致当前页面输出不一样的值. 而且没有模块化的话很容易把业务代码写在一个 很长的 js 里.
从性能来说,这个 js 会加载很长时间, 网站性能不好. 从代码角度来说. 很容易作用域相互混淆不说, 既不利于协作,也开发效率低 所以很需要模块化打包工具.
模块化打包
所以当 Rollup 这种工具横空出世,模块化打包的概念开始流行.
你写代码的时候可以按照小模块分开写, 然后用上一个带有模块化打包功能的工具.
指定一个入口文件, 模块化打包工具会自动把你的文件按照入口和入口依赖的模块,按照顺序组织好.
这个过程,写代码就像搭积木. 你的项目是由一个个模块组成的.
二 模块化标准
Commonjs
2009 年发布.
Commonjs 最先开始使用是在 Nodejs 里提出的.
后来Webpack 能基于 Commonjs 打包.但是它的写法和 No'de'j's 里的不是完全一样.
Commonjs的写法是这样的
// user.js
let user = {
getName: function () {
return '吴晓兰'
},
getAge: function () {
return 888
}
};
exports = user;
// index.js
require('./user.js').
从写法上来说,它和 ECMA module 挺像的.但是他俩在引入方式上是不同的. 这里后面会讲.
ES6 module(推荐)
这是 TC39 在 ECMA262 在2015 年发布的模块化标准方案.
优点多多.
优点
静态引入, 所以比较方便做静态化代码分析.
能和构建工具结合的更好.
缺点
无法根据表达式引入. 让他不能做一些更有意思的功能.
AMD
AMD 的意思是 Aysnc module AMD 和以上几种模块化的的最大区别从名字就可以看出来. 它主要是异步引入模块的.
缺点
容易陷入地狱回调. 所以现在用的人也不是很多. 这种代码维护起来比较麻烦. 容易造 bug.
Commonjs 和 ES6 module 的区别
Commonjs 必须引入整体,module 可以引入局部
比如同样的想要获得用户名 Commonjs 是这么写的
let user = require('./user');
const userName = user.getName();
而 ES6 可以这样引入
import {getName} from './user.js'
const userName = getName();
引入方式分动态和静态
Commonjs 的引入是支持写在表达式里的. 而 ES6 module 只能在最顶级引入,且不支持表达式 Commonjs 可以这么写
if(causeA) {
require('./a.js');
} else {
require('./b.j');
}
// 写在 forEach 里.
['./a.js','.b.js']).forEach(item=> {
require(item);
});
而 import 的引入只能在顶级引入字符串形式的文件地址, 不能引入表达式
值映射和值复制.
Commonjs 里 require 进来的文件,不管你引入几次, 其实就是第一次执行. 然后会把这个执行结果存在内存里. 等你再次 require 和调用. 其实不会再执行了,而是获取这个文件第一次的执行结果. 来做个测试
// user.js
console.log('我的天');
let user = {
getName: function () {
return '吴晓兰'
},
getAge: function () {
return 888
}
};
module.exports = user;
}
// index.js
let user = require('./user.js');
console.log(user.getName());
let user2 = require('./user.js');
console.log(use2r.getName());
// 执行 index.js,只会输出 1 个我的天.
Commonjs 的引入可以只是挂载
require 可以全局挂载不赋值变量, ES6 module 必须赋值.
有时候我们只是想执行一个 js 文件,引入进来并执行就好. 并不需要再为它赋值. Commonjs 可以单独的引用执行. ES6 module 则不可以.
require('./user'); // Commonjs写法
import user from './user' // ES6 module写法
三 模块化打包工具说明
模块化打包工具演变
开发工具演变
- Grunt, 比较早. 工作流打包,没有 Gulp 那么方便
- Gulp, 也是工作流. 比起 Grun't,他的 task 更方便复用
- Rollup,比较早期的模块化打包工具,就是他提出了 tree shaking
- Webpack.打包集大成者. 也是 Vue1-2 默认推荐的打包工具(目前使用最多)
- Turbopack: Webpack团队开发出的下一代打包工具,速度比 Webpack 快很多
- Vite: Vue 官方开发的打包工具.现在还支持 react 打包
工具本身编译语言的演变
很多新编译工具为什么一出来就能号召比其他的编译工具快几倍,甚至几十倍呢.
1 个原因是本身打包思路的优化. 另外一个核心的原因是开发工具的编译速度其实和它自身是用的什么语言也有很大关系.
开发代码分为低级语言和高级语言.
- 低级语言机器便于理解,性能高,比如 Go,Rust.但是开发人员很难理解.
- 高级语言比如 JS,机器很难理解,需要经过多层编译
以浏览器 V8 引擎来说,你写的 js 代码会被他进行三次编译.
js 代码 -> 被解析器解析成 AST 代码 -> 被解释器解释成字节码 Bytecode -> 被编译器编译成机器码.
光一行普通的js 申明代码就会申城对应的 8,9 屏的字节码.
过去很多编译工具都是前端自己开发的,是基于 js 的.
而很多新工具充分利用了编译型语言开发的功能组件.从而让自己的编译速度也能比纯 js 模块的编译工具速度快很多倍.
模块化打包工具的封装程度
我一直觉得模块化打包封装的耦合度挺重要的. 按照他们和打包的耦合程度,可以分为低耦合,中度耦合,高耦合.
低耦合
低耦合就是类似Grunt,Gulp,Rollup 这种.就像它只是给了你个毛坯房, 剩下的硬装,软装都要你自己弄. 用起来会比较心累.
以 Grunt 为例, 它的定位就是一个 js 任务工具(用 Nodejs去执行一些任务).但是并不是一个打包工具.
虽然也有一些配套的插件,比如 concat,uglify ,但是需要你自己去获取源文件,去自己组织调用各个插件, 这就需要开发自己撸起袖子干很多事, 用起来效率偏低
使用场景: js类库
中耦合
Rollup 这种. 它自己也有类似 tree shaking, bundle 这种功能. 无法做到真正的省心. 使用场景: js类库
高耦合(推荐)
Webpack, Parcel 这种. 它自己的定位就是一个打包工具. 所以能Webpack,Parcel 都能做到开箱即用.
而且他们兼具零配置默认打包, 但是你也可以配置. 这就像直接提供了个精装修房, 水电, 硬装都给你配好了, 你只要弄弄软装, 会方便许多.
使用场景: web服务和 js类库都可以
打包 里非常重要的几个点
Babel
js 打包的话离不开 Babel 这个核心模块 比如 Parcel 和 Vite 都内置了 Babel.
CSS预处理器
CSS 打包的话是因为直接写 CSS(尤其在 CSS 还没有 CSS 变量的时候)效率是比较低的. 而 CSS 预处理器能带来很多让项目可以更丰富功能的地方.
所以才需要 Less,Sass, Scss这种预处理器.
但是浏览器默认又是不支持这几种文件格式的, 就需要打包工具把他们转化成 CSS.
AST
像 Parcel,Vite,Webpack 他们在打包 js 的时候就像 V8引擎 一样.要做解析的.
这一步就是把我们写的代码先编译成 AST.把代码变成像 json 一样的抽象代码树. 然后进行语法分析.
语法分析有问题的话就报错, 没有的话那就继续进行下一步.
Grunt 和 Gulp
定义
基于
stream, 强调代码优于配置的打包工具
Grunt 和 Gulp 是原生 js,jQuery 时代的打包工具. 现在用的人很稀少了
这俩放在一块讲是因为他们俩都是基于 stream, 强调代码优于配置的打包工具
这俩就像兄弟俩. 比较相像.
Gulp是 Grunt 的升级版. 比起 Grunt 来,Gulp 要更灵活点. 它的 task 是可以复用的. task里的 以前 Grunt 的配置是类似酱紫的.
Grunt.registerTask(taskName, [description, ] taskList)
Gulp比起 Grunt 升级的地方在于,Gulp 的 task 又是由一个个更细的子 task,每个子 task 是可以被复用在多个 task 上的
Gulp.task('CSS', [taskA,taskB,taskC], function (stream) {
})
不过 现在 Grunt 经过多年的迭代后, 我发现最新的 Grunt 也有了一些变化
- 支持类似 Webpack 的配置语法.
- 支持像 Gulp 一样的可复用子 task
Grunt.initConfig({
concat: {
foo: {
files: [
{ src: ["src/aa.js", "src/aaa.js"], dest: "dest/a.js" },
{ src: ["src/aa1.js", "src/aaa1.js"], dest: "dest/a1.js" },
],
},
bar: {
files: [
{ src: ["src/bb.js", "src/bbb.js"], dest: "dest/b/", nonull: true },
{
src: ["src/bb1.js", "src/bbb1.js"],
dest: "dest/b1/",
filter: "isFile",
},
],
},
},
});
文档:只有英文版, 没有中文版
Gulp 的优点
正因为本身功能很少, 所以代码轻量, 打包速度快.而且自定义程度可以非常高.
Gulp的缺点
自身实现的功能太少,更适合打包类库,不适合做 web 项目打包.
其实,就算是打包类库,我也会觉得 Rollup 比它更适合.
Fis
最新版本是 Fis3.
这是百度团队自己开发的一个构建工具.
Fis 是 Grunt 之后,Webpack 之前的构建工具.
所以它身上既有 Grunt 的构建风格, 但是又开始有点 Webpack 的雏形了. 比如在那个时代, 有了代码简单的构建工具(Grunt)这种. 但是还需要自己手动写 web server. 而 Fis开始自带 web server 了.
不过它的便捷度还是不如它的后辈 Webpack. 甚至单独在 js 打包上也比不过 Rollup.没有tree shaking 这种功能. 现在它早就停止维护了. 所以不需要关心了.
中文文档: 有
Rollup
定义 :
是一个 JavaScript 模块打包工具,可以将多个小的代码片段编译为完整的库和应用。
Rollup 现在虽然直接用的人不多, 但是是它在打包上提出的一些性能优化的做法.比如提出了 tree shaking 这个概念可以说在 js 打包上非常重要
比如Webpack 2 就是集成了它的这个功能
到现在,主流的打包工具Webpack,Vite都是把他集成到了自己里面.在js打包这块其实主要用的都是 Rollup.
Rollup的优点
Rollup最主要的优点是对 js 代码做了不错的优化. 另外一个优点是打包后的 bundle 输出格式特别多. umd 这种都支持.
Rollup的缺点
Rollup 是专门聚焦于 js 打包的, 那项目里其他资源,比如 CSS 这种它是没法打包的.
所以它只适合用在专门的js 工具库打包, 或者被嵌入别的打包工具. 不适合直接用它来打包web 项目
它 有个不算缺点的缺点, 就是它的模块化打包是只支持 ES module 的.
不过如果你的代码里还有 Com'mo'njs 这样的模块. 那你可以安一个 Rollup 的 Commonjs 插件,这样也能做到兼容.
文档: 有中文版
browserify
定义
让你可以在浏览器里使用 npm 包
它的原理就是你通过 require 的方式引用 npm 包,然后它再把他们编译成浏览器也能理解的代码.
这个其实比较小众.
文档 只有英文版.
Parcel(推荐)
据说 bundle这个概念 是 Parcel 提出的.
最新版本是 Parcel2.
微软团队就有在使用这个构建工具.
优点
零配置 Parcel 也算是集大成的打包工具. 可以做到零配置. 我自己使用过几次, 觉得 Parcel 真的使用体验不要太友好. 比如你 只要在一个项目里执行 Parcel 命令.后面跟着入口文件.比如
npm install Parcel-bundler -g
npm init -y
// 新建index.html, index.js,而且在 index 里引入index.js
Parcel index.html
Parcel会直接
- 安装依赖的包
- 安装本地开发 web 服务器
- 进行热更新.你的每次更改它都会 watch 到然后自动刷新浏览器
- 自动把 index.html里引入的 js 替换成带内容指纹的文件引用
我们看下一个只是Parcel index.html这么一个简单的命令.Parcel 生成的 index.html
这简直是你刚感觉只要感觉渴了, 就有人给你端了水递过来,温度还是调好的温水 那么,Parcel 如此和打包高度耦合, 它会不会写的太死, 实际用起来感觉不好用呢. 仔细一看不会,
Parcel 的构建还支持Vue,react 版本.
而且针对各种情况都做了考虑. 比如如果 watch 不生效怎么办.如果你有自己的 web 服务器怎么办. 如果你是多页应用. 以及开发环境和生产环境不同的打包需求, 它都有考虑到.
支持的文件类型丰富
而且它和其他打包工具不同的是, 默认就支持 CSS,图片等资源的打包
编译速度块
Parcel 的打包速度也很快. 他的官网有过一个编译速度的比较. 显示在一台2016 年的mac上编译一个有100 多个模块的编译速度
| Webpack | Parcel | Parcel cache |
|---|---|---|
| 20s | 9s | 2s |
文档 Parcel 的文档有中文版, 对我们来说,学习成本比较低.
Webpack
Webpack 一开始诞生的时代
- 还没有出台前台模块化标准.
- 后面就算出台了浏览器兼容性也很不容乐观.
- 那个时候很多包都是 Nodejs 的
Webpack 的打包就是根据一个 entry 入口, 生成多个 chunk, 最后生成 bundle.
如图所示.
Webpack 的优点
非常丰富的社区
在 npm 搜 Webpack,有 2.6 万 个包可以供你使用
支持多种模块化标准
相比于其他Vite,Rol'lu'p 等模块化打包工具,Webpack 有一个很大的优点, 他是支持模块标准最多的打包工具,他支持 Commonjs, ES6 module, Amd 等.
而其他的模块打包工具一般就支持一到两种.
那这样做有什么好处呢, 意味着除了 js 代码, 你还可以引用海量的 Nodejs 模块.
而且我非常喜欢的 Webpack4 的零配置.
Webpack的缺点
我本来觉得 Webpack 没有缺点,直到我用过 Parcel和 Vite 所谓的零配置是受限的
Webpack的零配置其实只是说初步能打包. 但是要想真的跑起来一个完整的项目, 是很不够的.
比如 Webpack 打包文件默认支持 js, json. 如果你要在项目里引入 Le's's 这种预编译 CSS, 就会直接报错.
再比如真实的生产环境,一般都不是直接引用打包后的 js 文件名main.js.
为了让浏览器在缓存和能及时更新服务器最新 web 代码之间取得平衡,引用资源其实不是直接引用的.而是会引用诸如index.3d5e4f.js这样的文件内容指纹.
Webpack 需要你引入plugins.然后自己再写一点代码进行配置. 甚至这个要弄好也不简单.
大型项目编译速度有点慢
我之前有一个项目,是多页应用,当时我们还是 windows 电脑. 每次 在开发环境的编译就要30min. 我想让一个后来进阿里的同事去优化下它的打包速度,看看是不是有什么配置没有配置好. 它弄了两星期最后告诉我这个没法搞定.
后来项目改成 SPA 和大家集体换成 Mac 电脑才让编译速度很快.
我有一个朋友,他们是个大型web项目,据说每次编译都需要好几分钟.
那么 Webpack 为什么编译速度这么慢呢.
开发环境编译后的代码不是很友好
因为 Webpack 是兼容多种模块化的. 这样它要编译就还得先判断你是什么模块,然后做相应的处理. 而且不管你是什么模块, 最后都是编译成类似 Commonjs 的语法.
Turbopack
Turbopack 的开发团队就是 Webpack 开发团队的.
优点
编译速度快
几个月前,Turbopack.
大概意思就是编译同样的代码.Turbopack 的编译速度是 Vite 的十倍
引起了很多人的赞叹. 但是这个论点引来了 Vue 的作者尤雨溪的发文驳斥.
尤雨溪认为 Turbopack 团队证据不足. 而且按照 Turbopack 团队自己说的编译速度.Turbopack 是 0.36s.Vite 是 3s 左右.
Turbopack 团队是直接舍弃后面的小数点变成 0.3s 和 3s 的 速度pk. 这样也没有十倍差距那么大.
同时编译速度不能光看一个项目的, 比如因为编译原理甚至其他编译条件的不同. 编译 1 个文件和编译 100 个文件,1000 个文件,谁的速度更快可能都是不同的。
举一个例子有些编译工具充分利用了电脑的多核.那他在这种机器上就能表现更好.
这个事情虽然有点被打脸,但是至少也能看的出来 Turbopack 团队对这个构建工具的编译速度是挺自豪的.
Vite
Vite 的字面意思就是快速的.
它为什么能这么快呢.一大原因是因为它充分利用了 ES module. 所以,它号称自己是不需要打包的.
本来 Vite 只是实现一些 Vue 团队对未来的构建工具的一些构想, 结果弄着弄着, 就发现诶,效果不错, 以后就用这个打包得了.
Vite 作为 Vue3 默认推荐的打包工具, 可谓是打包界的明日之星.
Vite 并不是从 0 开始的一个构建工具, 它的很多功能是基于我们前面介绍过的各种工具,然后取长补短.
Vite 从自身组成来说, 主要由两部分组成
- 一个开发服务器,因为它基于 ESM,所以更方便的做一些优化处理.比如提供基于模块级别的热更新
- 代码预编译,基于 Rollup.
Vite 从开发环境来说又可以分成两部分
开发环境
基于 esbuild 进行打包编译. 因为 esbuild 是用 Go 语言编写的, 这样可以最大化的利用系统资源,速度会比用 js 编译速度快 10-100 倍.
生产环境
进行 Rollup 的资源输入输出.为什么生产环境就不用 esbuild 了呢. 那是因为 esbuild 不支持code spliting等特性.
Vite 的优点
Vite 的优点很多.
编译速度快. 本地化和生产环境都用了性能非常高且最合适的工具.
而且它充分利用了 ES module 的特性.本地化开发如果你不是写的 Vue 文件甚至它是不需要再有资源的打包这个部分的,直接让浏览器去加载模块就好了.
比如 项目是由这几个文件组成
- index.html
- index.js(被 index.html引用)
- a.js(被index.js引用) 你只要在index.html里设置好 type. 它直接可以这样加载他们.不需要经过额外的合并.
- index.js(被 index.html引用)
<script type="module" src="./index.js">
到时候a.js就是这样被引入的.
这种原生态编译从速度上来说也会快很多。
我朋友力哥他们公司在生产环境把 Babel 换成 esbuild,编译速度直接从 6 分钟降到了 10 秒钟。
可读性起比 Webpack 的打包后的代码, 在开发环境来说, 真的很直观很丝滑~
支持的框架挺多
Vue, react, preact, svelte 等.
Vite 有多受欢迎呢. 有很多人希望 react 官方把构建工具从create react换成 Vite. 因为觉得 Vite编译性能很高,更新非常活跃.而且create react也有四五个月没有更新过了.
现在 react 官方推荐的两个备选构建工具就是 Vite 和 Parcel.
默认支持 TS 和 CSS
Vite本身就是 TS 编写的.
Vite 中的 url 也会被默认替换.
Vite的缺点
在开发环境会有多次重复的请求
会阻塞首屏的加载 生态 Vite 的生态还没有 webpack 那么成熟. 要用 Vite 的人以 Vue3 为主.而 Vite 和 Vue3 用的人还都不是占主流.
我看到有个网友说他们把构建工具从 Webpack 换成 Vite 后, 一直报错,最后发现是因为Vite 的依赖机制和 Webpack 不一致, 所以之前没报错的现在一直报错. 而且很难解决
最后他们又切换回 Webpack 了.
还有个网友说,他们把项目的 Vue2 升级到 Vue3.
结果重构了很多功能后发现要用的某个图表插件没有 Vue3 版本. 又只能切换回 Vue2 去了.