工程化、构建打包
ESM 和 CJS
ESM 是 es6 新增的模块功能,浏览器端的模块机制。
特征:
import 是只读属性,相当于 const,实时绑定时用 const 来定义变量。
import/export 必须位于模块的顶级,模块内会被提 vue v-model 升到模块顶部。
编译时加载,无法实现条件加载,静态分析时,就知道模块间的依赖关系。
变量时引用的,实时绑定,import 和 export 指向同一地址。
使用 :
|
CJS,服务端模块机制
特征:
- 运行时加载,同步加载,CJS 作用于服务端,模块文件存放在本地,读取快
- 变量是拷贝的
- 模块可以被加载多次,但是只有第一次加载时运行一次,之后存放在缓存,在加载是直接从缓存取值
使用:
|
AMD、CMD、CommonJS、ESM
AMD/CMD/CommonJs 是 js 模块化开发的规范,对应的实现是 require.js/sea.js/Node.js
CommonJs 主要针对服务端,AMD/CMD/ES Module 主要针对浏览器端,容易混淆的是 AMD/CMD。(顺便提一下,针对服务器端和针对浏览器端有什么本质的区别呢?服务器端一般采用同步加载文件,也就是说需要某个模块,服务器端便停下来,等待它加载再执行。这里如果有其他后端语言,如 java。而浏览器端要保证效率,需要采用异步加载,这就需要一个预处理,提前将所需要的模块文件并行加载好。)
AMD/CMD 区别,虽然都是并行加载 js 文件,但还是有所区别,AMD 是预加载,在并行加载 js 文件同时,还会解析执行该模块(因为还需要执行,所以在加载某个模块前,这个模块的依赖模块需要先加载完成);而 CMD 是懒加载,虽然会一开始就并行加载 js 文件,但是不会执行,而是在需要的时候才执行。
AMD/CMD 的优缺点.一个的优点就是另一个的缺点, 可以对照浏览。
AMD 优点:加载快速,尤其遇到多个大文件,因为并行解析,所以同一时间可以解析多个文件。
AMD 缺点:并行加载,异步处理,加载顺序不一定,可能会造成一些困扰,甚至为程序埋下大坑。CMD 优点:因为只有在使用的时候才会解析执行 js 文件,因此,每个 JS 文件的执行顺序在代码中是有体现的,是可控的。
CMD 缺点:执行等待时间会叠加。因为每个文件执行时是同步执行(串行执行),因此时间是所有文件解析执行时间之和,尤其在文件较多较大时,这种缺点尤为明显。(PS:重新看这篇文章,发现这里写的不是很准确。确切来说,JS 是单线程,所有 JS 文件执行时间叠加在 AMD 和 CMD 中是一样的。但是 CMD 是使用时执行,没法利用空闲时间,而 AMD 是文件加载好就执行,往往可以利用一些空闲时间。这么来看,CMD 比 AMD 的优点还是很明显的,毕竟 AMD 加载好的时候也未必就是 JS 引擎的空闲时间!)
CommonJS 和 ES Module 区别:CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用
如何使用?CommonJs 的话,因为 NodeJS 就是它的实现,所以使用 node 就行,也不用引入其他包。AMD 则是通过
<script>
标签引入 require.js,CMD 则是引入 sea.js
ESBuild、rollup
都是 JavaScript 打包器和压缩器,而向 webpack、vite 这些是开发配置,有 HMR、CSS 预处理等等
esbuild
esbuild 是一种新型的前端构建工具,它能够快速地打包 JavaScript、CSS、TypeScript 和 JSX 等资源。它使用 Go 语言编写,利用多核并行处理和高效的算法来实现极快的速度。它还提供了一些主要特性,如模块化、tree shaking、压缩、source maps、插件等。esbuild 的目标是提供一个易于使用的现代构建工具,并开创构建工具性能的新时代。
它的构建速度是 webpack 的几十倍。为什么这么快 ?
js 是单线程串行,esbuild 是新开一个进程,然后多线程并行,充分发挥多核优势
go 是纯机器码,肯定要比 JIT 快。
Esbuild 选择重写包括 js、ts、jsx、css 等语言在内的转译工具,所以它更能保证源代码在编译步骤之间的结构一致性,比如在 Webpack 中使用 babel-loader 处理 JavaScript 代码时,可能需要经过多次数据转换:
- Webpack 读入源码,此时为字符串形式
- Babel 解析源码,转换为 AST 形式
- Babel 将源码 AST 转换为低版本 AST
- Babel 将低版本 AST generate 为低版本源码,字符串形式
- Webpack 解析低版本源码
- Webpack 将多个模块打包成最终产物
源码需要经历
string => AST => AST => string => AST => string
,在字符串与 AST 之间反复横跳。而 Esbuild 重写大多数转译工具之后,能够在多个编译阶段共用相似的 AST 结构,尽可能减少字符串到 AST 的结构转换,提升内存使用效率。
虽然 Esbuild 这么牛,但其也有些问题:
- Code splitting ,Css content type 问题较多
- ESbuild 没有提供 AST 的操作能力———–不能兼容一些低版本浏览器(ESbuild 只能将代码转成 es6)
后果就是,Esbuild 当下与未来都不能替代 Webpack 等高层构建工具,它不适合直接用于生产环境,而更适合作为一种偏底层的模块打包工具,需要在它的基础上二次封装,扩展出一套既兼顾性能又有完备工程化能力的工具链,例如 Snowpack, Vite, SvelteKit, Remix Run 等。
rollup
Rollup 是一种 JavaScript 模块打包器,可以将小块代码编译成大块复杂的代码,例如库或应用程序。它使用了 ES6 的模块标准,这使得 Rollup 可以进行高效的树摇(tree-shaking)以删除无用代码,从而产生更小的打包文件。
Rollup 的主要特点包括:
- 高效的树摇:由于使用 ES6 模块标准,Rollup 能够静态分析代码中的 import 和 export ,从而去除那些未实际使用的代码。
- 输出格式多样:Rollup 可以将你的代码编译成 CommonJS、AMD、SystemJS、IIFE(自执行函数)或 ES6 模块等格式,这使得你的库或应用程序能够在各种使用环境中运行。
- 支持插件:Rollup 支持插件系统,你可以使用插件来扩展 Rollup 的功能,例如转换编译代码、导入第三方模块等。
- 代码分割和动态导入:Rollup 支持代码分割和动态导入,可以帮助你构建更高效的应用。
Rollup 是一个非常强大的模块打包器,尤其适合用来构建库或框架。
esbuild 和 rollup 对比
Rollup 和 esbuild 都是 JavaScript 的打包工具,但它们的设计目标和特性有所不同。
Rollup 非常注重打包的结果和效果,它支持高效的树摇(tree-shaking),可以去除未使用的代码,减小打包结果的大小。Rollup 使用了 ES6 的模块标准,并支持多种输出格式,这使得你的库或应用程序可以在各种环境中运行。此外,Rollup 还支持插件系统,你可以使用插件来扩展它的功能。
esbuild 则主要注重打包的速度,它的目标是尽可能地提高打包速度。为了实现这一目标,esbuild 使用 Go 语言重新实现了打包器,并采用并行化和缓存等技术进行优化。据称,esbuild 的打包速度比其他打包器(如 webpack、Rollup)快上 10-100 倍。但是,esbuild 的功能相对较少,不支持插件系统,并且它的树摇效果可能不如 Rollup。
总的来说,Rollup 和 esbuild 各有优势:
- 如果你需要构建一个库或框架,并且关心打包结果的大小和兼容性,那么 Rollup 可能是更好的选择。
- 如果你正在构建一个大型应用,并且关心打包速度,那么你可能会更倾向于使用 esbuild。
需要注意的是,这两者并不是互斥的,你可以在适当的场景下选择适合的工具。比如,你可以在开发环境中使用 esbuild 以获得快速的编译速度,而在生产环境中使用 Rollup 以获得更优的打包结果。
Vite 的前世今生:如何与 ESM/esbuild/Rollup 交织出一段爱恨情仇
HMR
Vite 的 HMR(Hot Module Replacement)是一种非常强大的功能,它允许开发者在不刷新整个页面的情况下,只替换/更新已更改的部分代码。这将大大提高开发的效率,因为开发者不需要每次修改代码后都重新加载整个页面。
Vite 的 HMR 是基于 ESM(ECMAScript Modules)的,这是一种浏览器原生支持的模块系统。当文件发生改变时,Vite 会通过 WebSocket 将改动的文件推送到前端,然后前端会重新请求这个文件并替换旧的模块,而不会影响到其他模块。
此外,Vite 的 HMR 还支持组件级的热更新,对于 Vue、React 等现代框架,当你修改了某个组件的代码,只有这个组件会被重新渲染,页面的其他部分不会受到影响。这无疑将进一步提高开发效率。
总的来说,Vite 的 HMR 功能可以帮助开发者快速迭代和调试代码,提高开发效率。
该部分下面来源于Vite 的前世今生:如何与 ESM/esbuild/Rollup 交织出一段爱恨情仇 - 掘金 (juejin.cn)
ESM 原理
ES 模块在浏览器中的工作流程可以分为三个步骤:
- 构建:找到、下载并解析所有的模块文件,生成模块记录。
- 实例化:在内存中为所有的导出值分配空间(但不填充具体的值),然后让导出和导入都指向这些内存空间。
- 评估:按照依赖顺序,执行每个模块的代码,填充导出值,并触发副作用。
这三个步骤是分开进行的,也就是说,浏览器不会等待一个模块完成所有的步骤才开始处理下一个模块,而是会并行地处理多个模块,提高效率。
下面我们将结合一个例子来说明 ES 模块工作的三个步骤:
假设有两个模块文件,main.js
和utils.js
,它们的内容分别是:
|
如果在一个 HTML 文件中,用<script type="module" src="main.js">
标签来加载main.js
模块,那么浏览器会进行以下三个步骤:
构建
浏览器会下载main.js
文件,并将它解析为一个模块记录,记录它的导入列表(./utils.js
)和导出列表(空)。然后,浏览器会根据导入列表,下载并解析utils.js
文件,记录它的导入列表(空)和导出列表( add
函数)。
这一步是将模块文件从 URL 转换为模块记录的过程。模块记录是一个内部的数据结构,它包含了模块的元信息,比如它的导入和导出列表,以及它的代码。浏览器会根据
<script type="module">
标签或者import()
函数的参数,找到并下载相应的模块文件,然后用解析器将它们转换为模块记录。这一步不会执行模块的代码,也不会检查模块的依赖是否存在或有效。
实例化
浏览器会为每个模块的导出值分配内存空间。对于main.js
模块,它没有导出值,所以不需要分配空间。对于utils.js
模块,它有一个导出值,就是add
函数,所以浏览器会为它创建一个内存空间,但不会给它赋予具体的值。然后,浏览器会将每个模块的导入和导出都指向对应的内存空间。对于main.js
模块,它的导入列表中有一个名为add
的变量,浏览器会让它指向刚刚为utils.js
模块创建的内存空间。对于utils.js
模块,它没有导入列表,所以不需要指向任何内存空间。
这一步是为模块的导出值分配内存空间,并建立导入和导出之间的联系的过程。浏览器会遍历所有的模块记录,为每个导出值创建一个内存空间,但不会给它赋予具体的值。然后,浏览器会将每个模块的导入和导出都指向对应的内存空间,形成一个实时绑定。这一步也不会执行模块的代码,但会检查模块的依赖是否存在或有效,如果有问题,会抛出错误。
评估
浏览器会按照依赖顺序执行每个模块的代码。首先,浏览器会执行utils.js
模块的代码,定义并赋值给add
函数,并将这个值填充到之前为它分配的内存空间中。然后,浏览器会执行main.js
模块的代码,调用并打印出add(1, 2)
的结果。由于之前已经建立了实时绑定,所以当main.js
模块引用了名为add
的变量时,就相当于引用了刚刚定义好的函数。
这一步是执行模块的代码,并给导出值赋予具体的值的过程。浏览器会按照依赖顺序,从最底层的模块开始,依次执行每个模块的代码。当一个模块的代码执行完毕后,它的导出值就会被填充到内存空间中,并且其他引用了它的模块也可以看到变化。这一步也会触发模块的副作用,比如修改全局变量或者调用其他函数。
使用
让我们来看看如何在浏览器中使用 ESM:
|
我们可以发现,只需要在 script
标签上面加一行 type="module"
,就可以 Import from URL 了。
由于前端跑在浏览器中,因此它也只能从 URL 中引入 Package
- 绝对路径:
https://cdn.sykpack.dev/lodash
- 相对路径:
./lib.js
现在打开浏览器控制台,把以下代码粘贴在控制台中。由于 http import
的引入,你发现你调试 lodash
此列工具库更加方便了。
|
当然我们马上就发现,这样 import
太麻烦了,每次都需要输入完全的 URL。ESM 贴心的给我们提供了一个 importmap
的机制,使得裸导入(bare import specifiers
)可正常工作:
|
在 Vite 中的应用
在开发模式下,Vite 通过 ESM 来直接在浏览器中加载源代码,无需打包。这样,Vite 只需要按需转换和服务源代码,而不需要处理整个模块图。这使得 Vite 的启动速度和热更新速度非常快。
esbuild 和 vite 都是现代前端开发工具,它们之间并非完全的替代关系,具体哪个更适合使用,取决于你的具体需求。
esbuild 是一个 JavaScript 打包器和压缩器,它以极快的速度执行打包和压缩操作。如果你需要一个快速的打包器,而不需要其他高级功能,那么 esbuild 可能会是一个很好的选择。
vite 则提供了一个更完整的开发体验。它基于 esbuild,但在此基础上添加了许多额外的功能,如热模块替换(HMR)、CSS 预处理、Vue/React/Preact 支持等。这使得 vite 更适合用于复杂的前端项目。
所以,如果你只需要快速打包,esbuild 可能是更好的选择。但如果你需要一个完整的开发环境,那么 vite 可能会更适合。
在生产模式下,Vite 通过 Rollup 来打包源代码为 ESM 格式的静态资源,以便浏览器高效地加载和缓存。Vite 还支持多种构建目标,可以兼容不同版本的 ESM 支持。
预构建
vite 对 esbuild 的一个主要用途就是预构建阶段对依赖进行快速构建。vite 将代码分为源码和依赖两部分并且分别处理。依赖便是应用使用的第三方包,一般存在于node_modules
目录中,一个较大项目的依赖及其依赖的依赖,加起来可能达到上千个包。
这些代码可能远比我们源码代码量要大,这些依赖通常是不会改变的(除非你要进行本地依赖调试)所以无论是 webpack 或者 vite 在启动时都会编译后将其缓存下来。
但是 webpack 和 vite 的预构建依旧存在区别,**vite 会使用 esbuild 进行依赖编译和转换(commonjs 包转为 esm)**而 webpack 则是使用 acorn 或者 tsc 进行编译,而 esbuild 是使用 Go 语言写的,其速度比使用 js 编写的 acorn 速度要快得多。
读者可能要问了,在开发环境你 vite 启动速度确实薄纱 webpack,但生产环境的打包呢?
能不能优化 webpack 构建速度,优化到接近 vite 的速度呢?
webpack 作为老牌的构建器,拥有各种花里胡哨的的插件、配置来变相规避其短板,比如:
- 指定固定路径
- 使用 esbuild-loader 加快打包速度
- HappyPack 多进程 loader
- ParallelUglifyPlugin 多进程压缩
- DllPlugin 减少依赖编译
但是不管 webpack 装了啥插件,其打包速度还是和 vite 里面的 rollup 比。下面我们来看看 Rollup。
Vite 与 Rollup 的关系
我们知道,Vite 开发时,用的是 esbuild 进行构建,而在生产环境,则是使用 Rollup 进行打包。
为什么生产环境仍需要打包?为什么不用 esbuild 打包?
Vite 官方文档已经做出解析:尽管原生 ESM 现在得到了广泛支持,但由于嵌套导入会导致额外的网络往返,在生产环境中发布未打包的 ESM 仍然效率低下(即使使用 HTTP/2)。为了在生产环境中获得最佳的加载性能,最好还是将代码进行 tree-shaking、懒加载和 chunk 分割(以获得更好的缓存)
虽然 esbuild
快得惊人,并且已经是一个在构建库方面比较出色的工具,但一些针对构建应用的重要功能仍然还在持续开发中 —— 特别是代码分割和 CSS 处理方面。就目前来说,Rollup 在应用打包方面更加成熟和灵活。尽管如此,当未来这些功能稳定后,我们也不排除使用 esbuild
作为生产构建器的可能。
我们总结一下,使用 Rollup,而不是 webpack 或者 esbuild 作为打包工具的原因:
- Rollup 使用新的 ESM,而 Webpack 用的是旧的 CommonJS。
- Rollup 的打包文件体积很小。
- Rollup 支持相对路径,webpack 需要使用 path 模块。
- 尽管 esbuild 速度更快,但 Vite 采用了 Rollup 灵活的插件 API 和基础建设,这对 Vite 在生态中的成功起到了重要作用。
我们都知道,构建工具的一个很重要的功能点就是插件,既然使用了 rollup 来进行生产环境的构建,那 Vite 需要保证,同一套 Vite 配置文件和源码,在开发环境和生产环境下的表现是一致的。
想要达到这个效果,只能是 Vite 在开发环境模拟 Rollup 的行为 ,在生产环境打包时,将这部分替换成 Rollup 打包。
简单来说,Vite 有自己的生态,同时也需要兼容 Rollup 的生态,实现 Rollup 的插件机制。
webpack–优化
webpack-bundle-analyzer 使用该插件可以看到打包后文件比例
空间:
Terser – 帮助我们压缩、丑化代码,让我们的 bundle 变得更小
css 压缩 css 压缩通常是去除无用的空格等 压缩插件 css-minimizer-webpack-plugin
scope hosting
减少函数声明,将被需要(仅引入一次)的函数直接放在需要的函数里面,实现作用域提升
|
原理:将所有模块的代码按照引用顺序放在一个函数作用域里,然后适当的重命名一些变量以防 止变量名冲突。
作用:
打包后文件体积比之前更小。
运行代码时创建的函数作用域也比之前少了,开销也变小。
CommonJS 不支持,因为 cmj 可以动态加载,无法在编译时分析出模块间的依赖关系
使用方式—new webpack.optimize.ModuleConcatenationPlugin()
- tree shaking –依据模块依赖关系对无用代码删除
webpack2 开始支持 仅支持 esm 不支持 cmj(webpack5 提供了部分 cmj 的支持)
webpack 如何实现:
useExports 通过标记某些函数是否被使用,之后通过 Terser 来进行优化的;
sideEffects:查看某些文件是否有副作用,而不单单凭借模块依赖关系
package.json 设置 sideEffests 的值:[“./a.js”,”./k.css”] 在 loader 上设置 sideEffests:true
css treeShaking 插件–PurgeCSS
- Webpack 对文件压缩 -gzip deflate br 等等
- externals
时间:
- 升级 webpack 版本
- dll 提前把一些常用又不更改的包(类似 vue elemnt 等)先打包,再次打包的时候可以直接用之前打包好的 (已经被 vue-cli 等抛弃,因为 wabpack 的优化已经使得 dll 得不偿失)
- 采用 esbuild 替换 babel terser
- 采用多进程打包 thread-loader
- 使用缓存,cache-loader、开启 loader 自带的 cache 选项、dll、hardsource、webpack5(webpack5 持久化缓存结果至硬盘上)
- 团队测试和沙箱环境构建时可以不分离 sourcemap
- externals:防止将某些
import
的包(package)(无需改动 如 vue react jquery 等)打包到 bundle 中,而是在运行时(runtime)再去从外部获取这些*扩展依赖(external dependencies)*。
1.在 index.html 通过 cdn 等引入 2.配置 webpack.config.js
Webpack 构建原理
(1)初始化参数
解析 Webpack 配置参数,合并 Shell 传入和 webpack.config.js
文件配置的参数,形成最后的配置结果。
(2)开始编译
上一步得到的参数初始化 compiler
对象,注册所有配置的插件,插件监听 Webpack 构建生命周期的事件节点,做出相应的反应,执行对象的 run
方法开始执行编译。
(3)确定入口
从配置文件( webpack.config.js
)中指定的 entry
入口,开始解析文件构建 AST 语法树,找出依赖,递归下去。
(4)编译模块
递归中根据文件类型和 loader 配置,调用所有配置的 loader 对文件进行转换,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理。
(5)完成模块编译并输出
递归完后,得到每个文件结果,包含每个模块以及他们之间的依赖关系,根据 entry
配置生成代码块 chunk
。
(6)输出完成
输出所有的 chunk
到文件系统。
注意:在构建生命周期中有一系列插件在做合适的时机做合适事情,比如 UglifyPlugin
会在 loader 转换递归完对结果使用 UglifyJs
压缩覆盖之前的结果。
在 Webpack
运行的生命周期中会广播出许多事件,Plugin
可以监听这些事件,在合适的时机通过Webpack
提供的API
改变输出结果。
webpack 打包产物
它会创建一个包含所有模块的对象,并分配给每个模块一个唯一的 ID。然后,Webpack 会生成一个函数,这个函数接受一个 ID,并从上述对象中返回对应的模块。最后,Webpack 会将这个函数和一个数组一起打包,数组中的每个元素都是一个包装了模块代码的函数。这样,就可以在浏览器中运行这段代码,并通过调用 Webpack 生成的函数来导入模块。
假设我们有两个文件,a.js
和 b.js
。在 a.js
中,我们定义并暴露一个函数,用于计算两个数的和。然后,在 b.js
中,我们引入并使用这个函数。
下面是这两个文件的源代码:
a.js:
|
b.js:
|
当我们使用 Webpack 打包这两个文件时,生成的代码大致会是这样的:
|
以上就是 Webpack 打包 ESM 模块的一个简单例子。Webpack 将所有的模块都包裹在一个自执行函数中,然后通过一个公共的__webpack_require__
函数来加载模块。每个模块都有一个唯一的 ID,Webpack 通过这个 ID 来区分和管理不同的模块。对于 ESM 模块,Webpack 还会使用__webpack_exports__
对象来处理模块的导出。
Rollup 打包产物
Rollup 打包的结果会将 a.js 和 b.js 这两个模块的内容合并到一起,同时根据 ES6 的模块标准进行静态分析和优化。下面是 Rollup 打包上述代码的可能结果:
|
在这个例子中,add 函数被直接嵌入到了同一个自执行函数表达式中,不再需要通过 import 和 export 进行模块间的通信。这降低了运行时的开销,并使得代码在更多的环境中可以运行(例如,不支持 ES6 模块的浏览器)。
npm 和 npx 的区别
npx 主要用于命令行的寻址等辅助功能上,而 npm 是管理依赖的
当执行npx xxx
的时候,npx 先看 xxxz 在$PATH 里有没有,如果没有,找当前目录的node_modules
里有没有,如果还是没有,就安装这个 xxx 来执行。
package-lock.json package.json
package.json 为记录当前项目的简述和依赖包
package-lock.json 是在运行“npm install”时生成的一个文件,用于记录当前状态下项目中实际安装的各个 package 的版本号、模块下载地址、及这个模块又依赖了哪些依赖。
path 和 publicPath 的区别
path 是 webpack 所有文件的输出的路径,必须是绝对路径,比如:output 输出的 js,url-loader 解析的图片,HtmlWebpackPlugin 生成的 html 文件,都会存放在以 path 为基础的目录下。
publicPath 并不会对生成文件的路径造成影响,主要是对你的页面里面引入的资源的路径做对应的补全,常见的就是 css 文件里面引入的图片
总而言之,path 是文件打包 dist 的位置,而 publicPath 是资源文件的公共头部 url