我们从模块化的发展历程开始
自调用函数
JavaScript诞生之初,网络设备性能还很差,网速很慢,将所有交互都放在后端的话用户体验很差,因此急需一门语言来处理简单的前端交互,如表单非空校验之类的,js就因此诞生
一开始js能做的事情并不多,代码量很少,将代码直接写到script
标签或者一个单独的js文件里即可
// index.html
// index.html
到后来ajax诞生,前端能做的事情越来越多,代码量大增,单个js文件会导致文件过大;开发者们便根据功能划分了不同的js文件,以便复用和维护
// index.html
但是早期的js是只有函数作用域的,文件里声明的变量都是在全局生效的,这就导致了全局变量的污染,多人开发维护变得困难
于是大家就想到了利用函数作用域和闭包的特性,既保留了私有变量,又暴露出了功能接口
var module1 = (function(){
var foo = 1;
function getFoo(){
return foo;
};
function changeFoo(param){
foo = param
};
return {
getFoo: getFoo,
changeFoo: changeFoo
};
})();
这就是模块化的雏形,但是用这种方式存在很多问题
- 需要手动排列模块的加载顺序来保证模块间的相互引用
- 依赖关系不清晰,应用复杂之后难以维护
- 仍然有全局变量污染问题
Commonjs / AMD / CMD
到2009年,Nodejs发布,将js带到服务端,并将服务端js的模块化规范Commonjs
发扬光大
var foo = require('./foo');
module.exports = {
bar:1
}
开发者们想把这种模块化迁移到浏览器端,但浏览器不能直接识别commonjs的语法,且commonjs是同步加载文件的,用于服务器时,依靠磁盘读取模块,影响不大,而用于浏览器时则需要靠请求获取模块,会造成阻塞的问题,因此出现了两个分支AMD
(Asynchronous Module Definition)和CMD
(Common Module Definition)规范,还有他们各自的实现方案Requirejs
和Seajs
// AMD
define(['Module1'], function (module1) {
var result1 = module1.exec();
return {
result1: result1,
}
});
// CMD
define(function (requie, exports, module) {
var module1 = require('Module1');
var result1 = module1.exec();
module.exports = {
result1: result1,
}
});
这两种实现都是通过动态创建script标签来实现异步加载js模块,最大的不同在于AMD推崇依赖前置
,模块加载完就执行依赖包,CMD推崇依赖就近
,下载后不执行,require调用时才执行依赖包
虽然AMD和CMD解决了前端模块化的问题,但这类方案都是通过“在线编译”
的方式来组织模块的,当用户访问页面后开始下载依赖包,下载好后再进行模块的依赖分析确定加载和执行顺序,这种方式存在以下问题
- 在线组织依赖包会延长页面加载的时间
- 加载过程中还会发出大量http请求,而http1.x协议的队头阻塞和浏览器并行请求限制问题会导致页面性能降低
bundle 类的构建工具
为了解决这些问题,出现了各种打包工具,最有代表性的是2011年推出的broswerify
和2012年发布的webpack
,他们可以在代码部署上线前就将模块依赖组织好,并将大量依赖包合并成少数几个,以此减少http请求的数量,提升页面性能;
当代码量很多的时候,会出单个打包产物过大的问题,webpack提供了代码拆分(Code Splitting)的功能,可以将产物包分成多个,比如将不常更新的第三方库和常更新的业务代码分开打包,利用浏览器缓存第三方库依赖包,以此提高访问速度;通过代码拆分还可以实现按需加载,提高首屏访问速度
在打包工具还在发展的过程中,开发者们逐渐不满足于仅仅打包,希望将代码压缩等重复性的工作都交给工具解决,这就是自动化构建工具的产生,代表工具依旧有webpack
,还有2012年发布的grunt
,及2013年发布的gulp
gulp
是编程式的,链式调用,写配置像是写业务代码一样
// gulpfile.js
const { src, dest } = require('gulp');
const less = require('gulp-less');
const minifyCSS = require('gulp-csso');
function css() {
return src('client/templates/*.less')
.pipe(less())
.pipe(minifyCSS())
.pipe(dest('build/css'))
}
webpack
不止于打包,还加入到了自动化构建的行列里;它是配置式的,除了主流程的配置,最主要的就是loader
和plugin
;loader用于解析非js模块,而plugin用于实现压缩优化,代码拆分等loader无法实现的内容;
// webpack.config.js
module.exports = {
mode: "production",
/** webpack打包入口 */
entry: path.join(__dirname, "../src/app.tsx"),
output: {},
resolve: {},
/** 配置如何处理项目中的不同类型的模块 */
module: {
rules: [
{
test: /\.(j|t)sx?$/,
exclude: /node_modules/,
loader: "babel-loader",
},
],
},
/** 插件配置 */
plugins: [],
/** 开发服务器配置 */
devServer: {},
};
最后webpack在自动化构建工具的竞争中胜出,是现在的主流,但它也有一些问题,比如
- 配置复杂
- 随项目内容增多,构建逐渐变慢
大型项目构建慢,这也是bundle类构建工具的通病,因为这些工具的思想都是先递归循环依赖包,组建依赖树,优化依赖树后生成可运行的部署包,这一打包的步骤在开发阶段也要不断重复运行,随项目复杂导致开发效率降低
Es module
2015年 es6 正式发布,带来了官方的模块化规范 es module;
import {} from '/foo.js'
const bar = {}
export default bar
esm
有以下几个特点
异步加载,等同于打开了
标签的
defer
属性,页面渲染完成后执行代码是在模块作用域之中运行,而不是在全局作用域运行,this为undefined而不是window
静态化,编译时就确定模块的依赖关系,输出需要的接口
js的运行分为两个阶段:预编译期(预处理)与执行期,esm在预编译时组织模块关系
可以进行模块的部分导出
导出的是值的引用(会随原始值变化而变化)
可以对比commonjs
- 运行时组织依赖关系,确定导出的内容
- 导出的是一个完整的对象
- 导出的是一个值的拷贝(不会随原始值变化而变化)
当在使用模块进行开发时,其实是在构建一张依赖关系图,esm将这个过程分为以下三个步骤
- 构建:查找,下载,然后把所有文件解析成模块记录。
- 实例化:为所有模块分配内存空间(此刻还没有填充值),然后依照导出、导入语句把模块指向对应的内存地址。这个过程称为链接(Linking)。
- 运行:运行代码,从而把内存空间填充为真实值。
这三个步骤可以独立运行,避免阻塞;构建模块的过程中还会生成模块映射,避免重复解析下载,具体可以看这篇文章
相比于commonjs,基于es module静态化和可以部分导出的特性,我们可以很方便的优化掉依赖中不需要使用的代码
2015发布的Rollup就是一个基于esm的专注于类库的打包工具,通过esm的特性实现tree-shaking
bundleless类的构建工具
2018 年 5 月 Firefox 60 发布 所有主流浏览器支持es module,这让浏览器自己处理模块化关系成为可能,因此出现了一批新的构建工具如vite,snowpack,在开发环境下它们以原生esm的方式提供源码,以提高开发效率,这里以vite为例
vite的官网上其实也很清楚的解释了为什么vite比webpack快,这里做个总结
vite将应用中的模块区分为依赖(第三方库)和源码(jsx/css/..)
-
启动快
- 对于依赖,vite使用esbuild进行预构建(转换commonjs/umd为esm,将内部依赖复杂的模块合并为单个模块),esbuild使用Go编写,比以 JavaScript 编写的打包器预构建依赖快 10-100 倍
- 对于源码,只有屏幕上实际使用的才会被处理
-
更新快
- 传统构建工具修改后需要重新打包构建,而Vite 只需要精确地使已编辑的模块与其最近的 HMR 边界之间的链失活,通常只需要替换被更新的模块本身
- Vite 同时利用 HTTP 头来加速整个页面的重新加载:源码模块的请求会根据
304 Not Modified
进行协商缓存,而依赖模块请求则会通过Cache-Control: max-age=31536000,immutable
进行强缓存,因此一旦被缓存它们将不需要再次请求
大体流程:vite服务器启动 => 以html为入口使用esbuild预构建依赖 => 在浏览器输入链接访问index.html入口 => 浏览器发出esm依赖请求 => vite服务器将源码(vue/jsx/tsx)编译为浏览器可识别的js返回 => 浏览器根据返回的文件继续请求相关依赖,直至依赖关系构建完成,执行代码
由于在依赖关系构建的过程中仍然需要发送大量http请求,因此生产环境仍然需要打包发布以获得最佳体验,vite使用rollup进行js打包
最后放一张一年内各工具的npm下载量,可以看到webpack还是绝对的主流,vite有了渐渐抬头的趋势
参考
- ES modules: A cartoon deep-dive 译文:图说 ES Modules
- 前端构建这十年
- 前端模块化的十年征程