vite学习笔记

933 阅读28分钟

以下内容大部分来自掘金小册《深入浅出 Vite

1. 在 Vite 中,bundlechunk 分别具有不同的含义:

Bundle(打包): Bundle 是将多个源代码文件(如 JavaScript、CSS 或其他资源文件)组合成一个或多个输出文件的过程。这些输出文件可以是单个最终的应用程序文件,也可以是拆分成更小的代码块,以便于按需加载和懒加载等优化。

Chunk(代码块): Chunk 是由打包过程生成的较小份代码,它是一个独立的代码片段,可以按需加载和执行。这样可以避免加载整个应用程序的所有代码,从而实现性能优化。在 Vite 中,当使用代码分割(Code Splitting)功能时,会将代码分解成多个 chunk 文件。

总之,在 Vite 中,bundle 是代码打包的过程,而 chunk 是打包后的代码片段,用于按需加载和优化应用程序性能。

2. Vite 的拆包能力

一方面 Vite 实现了自动 CSS 代码分割的能力,即实现一个 chunk 对应一个 css 文件,比如上面产物中index.js对应一份index.css,而按需加载的 chunk Danamic.js也对应单独的一份Danamic.css文件,与 JS 文件的代码分割同理,这样做也能提升 CSS 文件的缓存复用率。

而另一方面, Vite 基于 Rollup 的manualChunksAPI 实现了应用拆包的策略:

  • 对于 Initital Chunk 而言,业务代码和第三方包代码分别打包为单独的 chunk,在上述的例子中分别对应index.jsvendor.js。需要说明的是,这是 Vite 2.9 版本之前的做法,而在 Vite 2.9 及以后的版本,默认打包策略更加简单粗暴,将所有的 js 代码全部打包到 index.js 中。
  • 对于 Async Chunk 而言 ,动态 import 的代码会被拆分成单独的 chunk,如上述的Dynacmic组件。

小结一下,Vite 默认拆包的优势在于实现了 CSS 代码分割与业务代码、第三方库代码、动态 import 模块代码三者的分离,但缺点也比较直观,第三方库的打包产物容易变得比较臃肿,上述例子中的vendor.js的大小已经达到 500 KB 以上,显然是有进一步拆包的优化空间的,这个时候我们就需要用到 Rollup 中的拆包 API ——manualChunks 了。(出自深入浅出Vite)

3. CDN

CDN(Content Delivery Network,内容分发网络)是一种用于优化网站内容加载速度和提高用户访问体验的技术。其核心思想是将网站的静态资源(如 HTML 页面、图片、JavaScript 和 CSS 文件等)存储在遍布全球各地的服务器节点上,从而使这些资源更接近请求它们的用户。

当用户访问一个使用 CDN 的网站时,CDN 会根据用户的位置自动选择最近的服务器节点向用户提供所需资源。简单来说,CDN 可以减少用户从源服务器获取数据的距离,降低延迟并缩短页面加载时间。

CDN 的主要优势包括:

  1. 加速内容传输:通过将内容缓存在靠近用户的服务器上,可以大幅提高下载速度和响应时间。
  2. 负载均衡:流量分散到多个服务器节点上,有助于减轻源服务器的压力,并确保更好的可用性和稳定性。
  3. 降低带宽消耗:CDN 缓存技术可以减少重复内容的传输,从而降低整体带宽需求。
  4. 提高安全性:CDN 可以抵御各种攻击,如 DDoS 攻击,提高网站的安全性。

总之,CDN 是一种通过在全球范围内分发网站内容的方式来提高网站性能、可用性和安全性的技术。这对于那些需要快速响应并具有大量用户流量的网站尤为重要。

4. CommonJS

CommonJS 本身约定以同步的方式进行模块加载,这种加载机制放在服务端是没问题的,一来模块都在本地,不需要进行网络 IO,二来只有服务启动时才会加载模块,而服务通常启动后会一直运行,所以对服务的性能并没有太大的影响。但如果这种加载机制放到浏览器端,会带来明显的性能问题。它会产生大量同步的模块请求,浏览器要等待响应返回后才能继续解析模块。也就是说,模块请求会造成浏览器 JS 解析过程的阻塞,导致页面加载速度缓慢。

4. ES Module

现代浏览器中,如果在 HTML 中加入含有type="module"属性的 script 标签,那么浏览器会按照 ES Module 规范来进行依赖加载和模块解析,这也是 Vite 在开发阶段实现 no-bundle 的原因,由于模块加载的任务交给了浏览器,即使不打包也可以顺利运行模块代码 ES Module 能够同时在浏览器与 Node.js 环境中执行,拥有天然的跨平台能力。

在 Node.js 环境中,可以在package.json中声明type: "module"属性:

// package.json
{
  "type": "module"
}

在 Node.js 中,即使是在 CommonJS 模块里面,也可以通过 import 方法顺利加载 ES 模块:

async function func() {
  // 加载一个 ES 模块
  // 文件名后缀需要是 mjs
  const { a } = await import("./module-a.mjs");
  console.log(a);
}

func();

module.exports = {
  func,
};

4. 前端模块化的意义

前端模块化是一个重要的编程概念,特别是在开发大型和复杂的前端应用时。模块化有很多优点,包括但不限于以下几点:

  1. 代码重用:模块化可以使你的代码更容易被重用。你可以创建通用模块,然后在多个地方使用,而无需重复编写代码。

  2. 可维护性:模块化可以将代码分解成可管理的部分,使得维护和修改代码变得更简单。每个模块都有其特定的功能,如果需要修改某个功能,只需要找到对应的模块进行修改即可。

  3. 命名空间管理:在没有模块化的情况下,所有的变量和函数都在全局作用域中,容易引发命名冲突。而模块化可以为我们的代码提供独立的命名空间,避免了命名冲突的问题。

  4. 依赖管理:在模块化中,可以清晰地指定模块间的依赖关系。这对于依赖加载顺序、版本管理等都非常有帮助。

  5. 可测试性:模块化的代码更易于进行单元测试。每个模块可以被单独测试,确保它的功能正确。

  6. 提高开发速度:团队可以并行开发多个模块,大大提高了开发速度和效率。

因此,前端模块化对于构建高效,可维护和可扩展的应用程序至关重要。

5. Vite 所倡导的no-bundle理念的真正含义

利用浏览器原生 ES 模块的支持,实现开发阶段的 Dev Server,进行模块的按需加载,而不是先整体打包再进行加载。相比 Webpack 这种必须打包再加载的传统构建模式,Vite 在开发阶段省略了繁琐且耗时的打包过程,这也是它为什么快的一个重要原因。 项目中的一个 import 语句代表一个 HTTP 请求,而正是 Vite 的 Dev Server 来接收这些请求、进行文件转译以及返回浏览器可以运行的代码,从而让项目正常运行。

6. 样式方案的意义

  1. 开发体验欠佳。比如原生 CSS 不支持选择器的嵌套
  2. 样式污染问题。如果出现同样的类名,很容易造成不同的样式互相覆盖和污染。
  3. 浏览器兼容问题。为了兼容不同的浏览器,我们需要对一些属性(如transition)加上不同的浏览器前缀,比如 -webkit--moz--ms--o-,意味着开发者要针对同一个样式属性写很多的冗余代码。
  4. 打包后的代码体积问题。如果不用任何的 CSS 工程化方案,所有的 CSS 代码都将打包到产物中,即使有部分样式并没有在代码中使用,导致产物体积过大。

解决方案:

  1. CSS 预处理器:主流的包括Sass/ScssLessStylus。这些方案各自定义了一套语法,让 CSS 也能使用嵌套规则,甚至能像编程语言一样定义变量、写条件判断和循环语句,大大增强了样式语言的灵活性,解决原生 CSS 的开发体验问题
  2. CSS Modules:能将 CSS 类名处理成哈希值,这样就可以避免同名的情况下样式污染的问题。
  3. CSS 后处理器PostCSS,用来解析和处理 CSS 代码,可以实现的功能非常丰富,比如将 px 转换为 rem、根据目标浏览器情况自动加上类似于--moz---o-的属性前缀等等。
  4. CSS in JS 方案,主流的包括emotionstyled-components等等,顾名思义,这类方案可以实现直接在 JS 中写样式代码,基本包含CSS 预处理器和 CSS Modules 的各项优点,非常灵活,解决了开发体验和全局样式污染的问题。
  5. CSS 原子化框架,如Tailwind CSSWindi CSS,通过类名来指定样式,大大简化了样式写法,提高了样式开发的效率,主要解决了原生 CSS 开发体验的问题。

7. Web Worker

JavaScript 性能利器 —— Web Worker

Web Worker 的意义在于可以将一些耗时的数据处理操作从主线程中剥离,使主线程更加专注于页面渲染和交互。

  • 懒加载
  • 文本分析
  • 流媒体数据处理
  • canvas 图形绘制
  • 图像处理
  • ...

需要注意的点

  • 有同源限制
  • 无法访问 DOM 节点
  • 运行在另一个上下文中,无法使用Window对象
  • Web Worker 的运行不会影响主线程,但与主线程交互时仍受到主线程单线程的瓶颈制约。换言之,如果 Worker 线程频繁与主线程进行交互,主线程由于需要处理交互,仍有可能使页面发生阻塞
  • 共享线程可以被多个浏览上下文(Browsing context)调用,但所有这些浏览上下文必须同源(相同的协议,主机和端口号)

8. vite静态资源的构建方式

一种是打包成一个单文件,另一种是通过 base64 编码的格式内嵌到代码中。

对于比较小的资源,适合内联到代码中,一方面对代码体积的影响很小,另一方面可以减少不必要的网络请求,优化网络性能。而对于比较大的资源,就推荐单独打包成一个文件,而不是内联了,否则可能导致上 MB 的 base64 字符串内嵌到代码中,导致代码体积瞬间庞大,页面加载性能直线下降。

Vite 中内置的优化方案是下面这样的:

  • 如果静态资源体积 >= 4KB,则提取成单独的文件
  • 如果静态资源体积 < 4KB,则作为 base64 格式的字符串内联

svg 格式的文件不受这个临时值的影响,始终会打包成单独的文件,因为它和普通格式的图片不一样,需要动态设置一些属性。

9. 如何注入环境变量

为了区分不同环境加上了NODE_ENV,也可以根据需要添加别的环境变量。

// .env.development
NODE_ENV=development

// .env.production
NODE_ENV=production

10. vite的no-bundle

模块代码其实分为两部分,一部分是源代码,也就是业务代码,另一部分是第三方依赖的代码,即node_modules中的代码。所谓的no-bundle只是对于源代码而言,对于第三方依赖而言,Vite 还是选择 bundle(打包),并且使用速度极快的打包器 Esbuild 来完成这一过程,达到秒级的依赖编译速度。

11. 依赖预构建(转换格式、打包代码)

  1. 做了两件事:
  • 将其他格式(如 UMD 和 CommonJS)的产物转换为 ESM 格式,使其在浏览器通过 <script type="module"><script>的方式正常加载。

  • 打包第三方库的代码,将各个第三方库分散的文件合并到一起,减少 HTTP 请求数量,避免页面加载性能劣化。

而这两件事情全部由性能优异的 Esbuild (基于 Golang 开发)完成,而不是传统的 Webpack/Rollup,所以也不会有明显的打包性能问题,反而是 Vite 项目启动飞快(秒级启动)的一个核心原因。

  1. 根目录下的node_modules.vite目录,这就是预构建产物文件存放的目录
  2. 依赖的请求结果,Vite 的 Dev Server 会设置强缓存。
  3. 除了 HTTP 缓存,Vite 还设置了本地文件系统的缓存,所有的预构建产物默认缓存在node_modules/.vite目录中。如果以下 3 个地方都没有改动,Vite 将一直使用缓存文件:
  • package.json 的 dependencies 字段。
  • 各种包管理器的 lock 文件。
  • optimizeDeps 配置内容。
  1. Vite 会根据应用入口(entries)自动搜集依赖,然后进行预构建,但某些情况下 Vite 默认的扫描行为并不完全可靠,例如:
  • 场景一: 动态 import:在某些动态 import 的场景下,由于 Vite 天然按需加载的特性,经常会导致某些依赖只能在运行时被识别出来。
// src/locales/zh_CN.js
import objectAssign from "object-assign";
console.log(objectAssign);

// main.tsx
const importModule = (m) => import(`./locales/${m}.ts`);
importModule("zh_CN");

动态 import 的路径只有运行时才能确定,无法在预构建阶段被扫描出来。为避免二次预构建,可以通过include参数提前声明需要按需加载的依赖。

// vite.config.ts
{
  optimizeDeps: {
    include: [
      // 按需加载的依赖都可以声明到这个数组里
      "object-assign",
    ];
  }
}
  • 场景二: 某些包被手动 exclude
  • 自定义 Esbuild 行为

12.vite底层所使用的两个构建引擎:Esbuild、Rollup

开发阶段使用 Esbuild,生产环境用 Rollup

性能利器——Esbuild

  1. 依赖预构建——作为 Bundle 工具:对于第三方依赖,需要在应用启动前进行打包并且转换为 ESM 格式

  2. 单文件编译——作为 TS 和 JSX 编译工具: 当 Vite 使用 Esbuild 做单文件编译之后,提升可以说相当大了,但是其没有TS类型检查的能力,在编译 TS(或者 TSX) 文件时仅仅抹掉了类型相关的代码,暂时没有能力实现类型检查。

  3. 代码压缩——作为压缩工具:在生产环境中 Esbuild 压缩器通过插件的形式融入到了 Rollup 的打包流程中, 传统的方式都是使用 Terser 这种 JS 开发的压缩器来实现,在 Webpack 或者 Rollup 中作为一个 Plugin 来完成代码打包后的压缩混淆的工作。但 Terser 其实很慢,原因如下:压缩这项工作涉及大量 AST 操作,并且在传统的构建流程中,AST 在各个工具之间无法共享,比如 Terser 就无法与 Babel 共享同一个 AST,造成了很多重复解析的过程;JS 本身属于解释性 + JIT(即时编译) 的语言,对于压缩这种 CPU 密集型的工作,其性能远远比不上 Golang 这种原生语言。(Esbuild 这种从头到尾共享 AST 以及原生语言编写的 Minifier 在性能上能够甩开传统工具的好几十倍。)

Esbuild 作为打包工具也有一些缺点。

  • 不支持降级到 ES5 的代码。这意味着在低端浏览器代码会跑不起来。
  • 不支持 const enum 等语法。这意味着单独使用这些语法在 esbuild 中会直接抛错。
  • 不提供操作打包产物的接口,像 Rollup 中灵活处理打包产物的能力(如renderChunk钩子)在 Esbuild 当中完全没有。
  • 不支持自定义 Code Splitting 策略。传统的 Webpack 和 Rollup 都提供了自定义拆包策略的 API,而 Esbuild 并未提供,从而降级了拆包优化的灵活性。

构建基石——Rollup

  1. 生产环境 Bundle: Vite 默认选择在生产环境中利用 Rollup 打包,并基于 Rollup 本身成熟的打包能力进行扩展和优化,主要包含 3 个方面:
  • CSS 代码分割。如果某个异步模块中引入了一些 CSS 代码,Vite 就会自动将这些 CSS 抽取出来生成单独的文件,提高线上产物的缓存复用率
  • 自动预加载。Vite 会自动为入口 chunk 的依赖自动生成预加载标签<link rel="modulepreload">, 这种适当预加载的做法会让浏览器提前下载好资源,优化页面性能。
  • 异步 Chunk 加载优化。在异步引入的 Chunk 中,通常会有一些公用的模块,如现有两个异步引入的 Chunk: A 和 B,而且两者有一个公共依赖 C,如下图: image.png 一般情况下,Rollup 打包之后,会先请求 A,然后浏览器在加载 A 的过程中才决定请求和加载 C,但 Vite 进行优化之后,请求 A 的同时会自动预加载 C,通过优化 Rollup 产物依赖加载方式节省了不必要的网络开销。
  1. 兼容插件机制: 无论是开发阶段还是生产环境,Vite 都根植于 Rollup 的插件机制和生态。 在开发阶段,Vite 借鉴了 WMR 的思路,自己实现了一个 Plugin Container,用来模拟 Rollup 调度各个 Vite 插件的执行逻辑,而 Vite 的插件写法完全兼容 Rollup,因此在生产环境中将所有的 Vite 插件传入 Rollup 也没有问题。反过来说,Rollup 插件却不一定能完全兼容 Vite

13.vite和rollup的关系

在 Vite 当中,无论是插件机制、还是底层的打包手段,都基于 Rollup 来实现,可以说 Vite 是对于 Rollup 一种场景化的深度扩展,将 Rollup 从传统的 JS 库打包场景扩展至完整 Web 应用打包。

14. Esbuild性能高的原因

  1. Golang开发
  2. 多核并行
  3. 从零造轮子: 几乎不会使用任何第三方的库,所有逻辑自己编写
  4. 高效的内存利用: Esbuild 中从头到尾尽可能地复用一份 AST 节点数据,而不用像 JS 打包工具中频繁地解析和传递 AST 数据(如 string -> TS -> JS -> string),造成内存的大量浪费。

使用

1. 命令行

2. 代码调用

Esbuild 对外暴露了一系列的 API,主要包括两类: Build APITransform API,我们可以在 Nodejs 代码中通过调用这些 API 来使用 Esbuild 的各种功能。

项目打包——build API

Build API主要用来进行项目打包,包括buildbuildSyncserve三个方法。

  1. build
const { build, buildSync, serve } = require("esbuild");

async function runBuild() {
  // 异步方法,返回一个 Promise
  const result = await build({
    // ----  如下是一些常见的配置  --- 
    // 当前项目根目录
    absWorkingDir: process.cwd(),
    // 入口文件列表,为一个数组
    entryPoints: ["./src/index.jsx"],
    // 打包产物目录
    outdir: "dist",
    // 是否需要打包,一般设为 true
    bundle: true,
    // 模块格式,包括`esm`、`commonjs`和`iife`
    format: "esm",
    // 需要排除打包的依赖列表
    external: [],
    // 是否开启自动拆包
    splitting: true,
    // 是否生成 SourceMap 文件
    sourcemap: true,
    // 是否生成打包的元信息文件
    metafile: true,
    // 是否进行代码压缩
    minify: false,
    // 是否开启 watch 模式,在 watch 模式下代码变动则会触发重新打包
    watch: false,
    // 是否将产物写入磁盘
    write: true,
    // Esbuild 内置了一系列的 loader,包括 base64、binary、css、dataurl、file、js(x)、ts(x)、text、json
    // 针对一些特殊的文件,调用不同的 loader 进行加载
    loader: {
      '.png': 'base64',
    }
  });
  console.log(result);
}

runBuild();
  1. buildSync
function runBuild() {
  // 同步方法
  const result = buildSync({
    // 省略一系列的配置
  });
  console.log(result);
}

runBuild();
  1. serve 特点:
  • 开启 serve 模式后,将在指定的端口和目录上搭建一个静态文件服务,这个服务器用原生 Go 语言实现,性能比 Nodejs 更高。
  • 类似 webpack-dev-server,所有的产物文件都默认不会写到磁盘,而是放在内存中,通过请求服务来访问。
  • 每次请求到来时,都会进行重新构建(rebuild),永远返回新的产物。后续每次在浏览器请求都会触发 Esbuild 重新构建,而每次重新构建都是一个增量构建的过程,耗时也会比首次构建少很多(一般能减少 70% 左右)。

触发 rebuild 的条件并不是代码改动,而是新的请求到来。

比如:

// build.js
const { build, buildSync, serve } = require("esbuild");

function runBuild() {
  serve(
    {
      port: 8000,
      // 静态资源目录
      servedir: './dist'
    },
    {
      absWorkingDir: process.cwd(),
      entryPoints: ["./src/index.jsx"],
      bundle: true,
      format: "esm",
      splitting: true,
      sourcemap: true,
      ignoreAnnotations: true,
      metafile: true,
    }
  ).then((server) => {
    console.log("HTTP Server starts at port", server.port);
  });
}

runBuild();

image.png

单文件转译——Transform API

分为同步和异步方法:transformSynctransform

  1. 在根目录新建
// transform.js
const { transform, transformSync } = require("esbuild");

async function runTransform() {
  // 第一个参数是代码字符串,第二个参数为编译配置
  const content = await transform(
    "const isNull = (str: string): boolean => str.length > 0;",
    {
      sourcemap: true,
      loader: "tsx",
    }
  );
  console.log(content);
}

runTransform();
建transform.js
// transform.js
const { transform, transformSync } = require("esbuild");

async function runTransform() {
  // 第一个参数是代码字符串,第二个参数为编译配置
  const content = await transform(
    "const isNull = (str: string): boolean => str.length > 0;",
    {
      sourcemap: true,
      loader: "tsx",
    }
  );
  console.log(content);
}

runTransform();

15. Esbuild插件开发

插件开发其实就是基于原有的体系结构中进行扩展自定义。,通过 Esbuild 插件我们可以扩展 Esbuild 原有的路径解析、模块加载等方面的能力,并在 Esbuild 的构建过程中执行一系列自定义的逻辑。

Esbuild 插件结构被设计为一个对象,里面有namesetup两个属性,name是插件的名称,setup是一个函数,其中入参是一个 build 对象,这个对象上挂载了一些钩子可供我们自定义一些钩子函数逻辑。

let envPlugin = {
  name: 'env',
  setup(build) {
    build.onResolve({ filter: /^env$/ }, args => ({
      path: args.path,
      namespace: 'env-ns',
    }))

    build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({
      contents: JSON.stringify(process.env),
      loader: 'json',
    }))
  },
}

require('esbuild').build({
  entryPoints: ['src/index.jsx'],
  bundle: true,
  outfile: 'out.js',
  // 应用插件
  plugins: [envPlugin],
}).catch(() => process.exit(1))

这段代码是使用 esbuild 打包器构建项目的例子,并使用了一个自定义插件 envPlugin。esbuild 是一个用 JavaScript 编写的快速的构建管道,可以打包、压缩和编译代码。它的插件系统允许用户扩展 esbuild 的功能。

envPlugin 是一个自定义插件,其目的是将环境变量注入到代码中。这个插件有两个部分:onResolveonLoad

  1. onResolve 用于告诉 esbuild 如何查找某个特定的模块。这个例子中,当遇到模块名为 envimport 时,这个插件会介入处理。onResolve 函数返回一个对象,包含路径 (path) 和命名空间 (namespace)。这里,路径被设置为模块的路径,命名空间被设为 env-ns,这样 esbuild 就知道应该用这个插件来处理 env 模块了。

  2. onLoad 用于告诉 esbuild 如何加载(或者说读取)这个模块的内容。在这个例子中,当 esbuild 在 env-ns 命名空间中需要加载任何模块时,这个插件都会返回一个对象,该对象的 contents 是一个包含所有环境变量的 JSON 字符串,loaderjson,这告诉 esbuild 这个模块的内容是 JSON 格式的。

接下来,esbuild 的 build 函数被调用,以配置构建过程。entryPoints 是输入文件的列表,bundle 设置为 true 表示要进行打包,outfile 是输出文件的路径,plugins 是一个包含插件的数组,在这个例子中,我们将自定义的 envPlugin 插件添加进去。

build 函数返回一个 Promise,如果构建过程中发生错误,catch 会捕获错误并退出进程。

钩子函数的使用

onResolve钩子和onLoad钩子的使用

  1. onResolve (路径解析)用于告诉 esbuild 如何查找某个特定的模块。
build.onResolve(Options, Callback)

Options: 是一个对象, 包含filternamespace两个属性

interface Options {
  filter: RegExp;
  namespace?: string;
}

filter 为必传参数,是一个正则表达式,它决定了要过滤出的特征文件。 namespace 为选填参数。 2. 一般在 onResolve 钩子中的回调参数返回namespace属性作为标识,我们可以在onLoad钩子中通过 namespace 将模块过滤出来。如上述插件示例就在onLoad钩子通过env-ns这个 namespace 标识过滤出了要处理的env模块。

16.Rollup

1. 如何使用

安装依赖

pnpm i rollup

添加rollup.config.js配置文件

// rollup.config.js
// 以下注释是为了能使用 VSCode 的类型提示
/**
 * @type { import('rollup').RollupOptions }
 */
const buildOptions = {
  input: ["src/index.js"],
  output: {
    // 产物输出目录
    dir: "dist/es",
    // 产物格式
    format: "esm",
  },
};

export default buildOptions;

package.json加入如下构建脚本

{
  // rollup 打包命令,`-c` 表示使用配置文件中的配置
  "build": "rollup -c"
}

运行npm run build

2. rollup常用配置

1)多产物配置

生成不同格式的产物供他人使用,例如生成esm、cjs格式

// rollup.config.js
/**
 * @type { import('rollup').RollupOptions }
 */
const buildOptions = {
  input: ["src/index.js"],
  // 将 output 改造成一个数组
  output: [
    {
      dir: "dist/es",
      format: "esm",
    },
    {
      dir: "dist/cjs",
      format: "cjs",
    },
  ],
};

export default buildOptions;

output属性配置成一个数组,数组中每个元素都是一个描述对象,决定了不同产物的输出行为。

2) 多入口配置

将 input 设置为一个数组或者一个对象,如下所示:

{
  input: ["src/index.js", "src/util.js"]
}
// 或者
{
  input: {
    index: "src/index.js",
    util: "src/util.js",
  },
}

如果不同入口对应的打包配置不一样,我们也可以默认导出一个配置数组,如下所示:

// rollup.config.js
/**
 * @type { import('rollup').RollupOptions }
 */
const buildIndexOptions = {
  input: ["src/index.js"],
  output: [
    // 省略 output 配置
  ],
};

/**
 * @type { import('rollup').RollupOptions }
 */
const buildUtilOptions = {
  input: ["src/util.js"],
  output: [
    // 省略 output 配置
  ],
};

export default [buildIndexOptions, buildUtilOptions];

3)自定义output配置

output常用来配置输出相关信息,output常用的配置如下:

output: {
  // 产物输出目录
  dir: path.resolve(__dirname, 'dist'),
  // 以下三个配置项都可以使用这些占位符:
  // 1. [name]: 去除文件后缀后的文件名
  // 2. [hash]: 根据文件名和文件内容生成的 hash 值
  // 3. [format]: 产物模块格式,如 es、cjs
  // 4. [extname]: 产物后缀名(带`.`)
  // 入口模块的输出文件名
  entryFileNames: `[name].js`,
  // 非入口模块(如动态 import)的输出文件名
  chunkFileNames: 'chunk-[hash].js',
  // 静态资源文件输出文件名
  assetFileNames: 'assets/[name]-[hash][extname]',
  // 产物输出格式,包括`amd`、`cjs`、`es`、`iife`、`umd`、`system`
  format: 'cjs',
  // 是否生成 sourcemap 文件
  sourcemap: true,
  // 如果是打包出 iife/umd 格式,需要对外暴露出一个全局变量,通过 name 配置变量名
  name: 'MyBundle',
  // 全局变量声明
  globals: {
    // 项目中可以直接用`$`代替`jquery`
    jquery: '$'
  }
}

4) 依赖external

对于某些第三方包,有时候我们不想让 Rollup 进行打包,也可以通过 external 进行外部化:

{
  external: ['react', 'react-dom']
}

5) 接入插件的能力

rollup比较常见的插件库:

  • @rollup/plugin-node-resolve是为了允许我们加载第三方依赖,否则像import React from 'react' 的依赖导入语句将不会被 Rollup 识别。
  • @rollup/plugin-commonjs 的作用是将 CommonJS 格式的代码转换为 ESM 格式
  • @rollup/plugin-json: 支持.json的加载,并配合rollupTree Shaking机制去掉未使用的部分,进行按需打包。
  • @rollup/plugin-babel:在 Rollup 中使用 Babel 进行 JS 代码的语法转译。
  • @rollup/plugin-typescript: 支持使用 TypeScript 开发。
  • @rollup/plugin-alias:支持别名配置。
  • @rollup/plugin-replace:在 Rollup 进行变量字符串的替换。
  • rollup-plugin-visualizer: 对 Rollup 打包产物进行分析,自动生成产物体积可视化分析图。

3. JavaScript API 方式调用

rollup.rollup

用来一次性地进行 Rollup 打包

主要的执行步骤如下:

    1. 通过 rollup.rollup方法,传入 inputOptions,生成 bundle 对象;
    1. 调用 bundle 对象的 generate 和 write 方法,传入outputOptions,分别完成产物和生成和磁盘写入。
    1. 调用 bundle 对象的 close 方法来结束打包。

rollup.watch

rollup.watch来完成watch模式下的打包,即每次源文件变动后自动进行重新打包

// watch.js
const rollup = require("rollup");

const watcher = rollup.watch({
  // 和 rollup 配置文件中的属性基本一致,只不过多了`watch`配置
  input: "./src/index.js",
  output: [
    {
      dir: "dist/es",
      format: "esm",
    },
    {
      dir: "dist/cjs",
      format: "cjs",
    },
  ],
  watch: {
    exclude: ["node_modules/**"],
    include: ["src/**"],
  },
});

// 监听 watch 各种事件
watcher.on("restart", () => {
  console.log("重新构建...");
});

watcher.on("change", (id) => {
  console.log("发生变动的模块id: ", id);
});

watcher.on("event", (e) => {
  if (e.code === "BUNDLE_END") {
    console.log("打包信息:", e);
  }
});

17. Tree Shaking(摇树)

"Tree Shaking" 是一个术语,主要在 JavaScript 模块打包过程中用到,用来描述移除那些导入了但并未被使用的代码的过程。其名称的灵感来源于想象你将一棵树(代表你的代码)摇晃(打包过程),落下的枯叶(未被使用的代码)就被移除。

Tree Shaking 的主要目的是减小最终打包后的文件大小,这样可以使得加载和执行速度更快。因为无论你的代码库多么大,用户最终只会下载实际需要执行的代码。

值得注意的是,Tree Shaking 通常只对 ES6 模块系统有效(即 import 和 export 语句)。这是因为 ES6 模块的静态结构使得在打包过程中可以安全地确定哪些导出和导入被使用了,哪些没有。而像 CommonJS 这样的动态模块系统(即 require 和 module.exports)则不能很好地支持 Tree Shaking。

为了使得 Tree Shaking 生效,你需要一个能够支持 Tree Shaking 的打包工具,比如 Webpack 或 Rollup。此外,你可能还需要配置 Babel 或其他 JavaScript 编译器,确保它们不会将你的 ES6 模块转换成 CommonJS 模块,否则 Tree Shaking 将无法生效。

你也需要在你的项目的 package.json 文件中添加 "sideEffects" 属性,指明你的项目中哪些文件可能包含副作用(例如改变全局变量),这样打包工具就不会尝试移除这些文件中可能未被使用的代码。对于那些不包含副作用的文件,打包工具将可以自由地进行 Tree Shaking,移除那些未被使用的代码。

18.rollup插件机制

1.rollup整体构建阶段

在执行 rollup 命令之后,在 cli 内部的主要逻辑简化如下:

// Build 阶段
const bundle = await rollup.rollup(inputOptions);

// Output 阶段
await Promise.all(outputOptions.map(bundle.write));

// 构建结束
await bundle.close();

rollup内部主要经历了buildoutput两大阶段:

image.png Build 阶段主要负责创建模块依赖图,初始化各个模块的 AST 以及模块之间的依赖关系, 经过 Build 阶段的 bundle 对象其实并没有进行模块的打包,这个对象的作用在于存储各个模块的内容及依赖关系,同时暴露generatewrite方法,以进入到后续的 Output 阶段(writegenerate方法唯一的区别在于前者打包完产物会写入磁盘,而后者不会)。

所以,真正进行打包的过程会在 Output 阶段进行,即在bundle对象的 generate或者write方法中进行。

对于一次完整的构建过程而言,  Rollup 会先进入到 Build 阶段,解析各模块的内容及依赖关系,然后进入Output阶段,完成打包及输出的过程

不同的阶段,Rollup 插件会有不同的插件工作流程。

2. 插件hook类型

  1. Build Hook即在Build阶段执行的钩子函数,在这个阶段主要进行模块代码的转换、AST 解析以及模块依赖的解析,那么这个阶段的 Hook 对于代码的操作粒度一般为模块级别,也就是单文件级别。
  2. Ouput Hook(官方称为Output Generation Hook),则主要进行代码的打包,对于代码而言,操作粒度一般为 chunk级别(一个 chunk 通常指很多文件打包到一起的产物)。

3. 执行方式

Hook 执行方式也会有不同的分类,主要包括AsyncSyncParallelSquentialFirst这五种,不同的类型是可以叠加的。

4. Build 阶段工作流

  1. 首先经历 options 钩子进行配置的转换,得到处理后的配置对象。
  2. 随之 Rollup 会调用buildStart钩子,正式开始构建流程。
  3. Rollup 先进入到 resolveId 钩子中解析文件路径。(从 input 配置指定的入口文件开始)。
  4. Rollup 通过调用load钩子加载模块内容。
  5. 紧接着 Rollup 执行所有的 transform 钩子来对模块内容进行进行自定义的转换,比如 babel 转译。
  6. 现在 Rollup 拿到最后的模块内容,进行 AST 分析,得到所有的 import 内容,调用 moduleParsed 钩子:
  • 6.1 如果是普通的 import,则执行 resolveId 钩子,继续回到步骤3
  • 6.2 如果是动态 import,则执行 resolveDynamicImport 钩子解析路径,如果解析成功,则回到步骤4加载模块,否则回到步骤3通过 resolveId 解析路径。
  1. 直到所有的 import 都解析完毕,Rollup 执行buildEnd钩子,Build 阶段结束。

5. Output阶段工作流

  1. 执行所有插件的 outputOptions 钩子函数,对 output 配置进行转换。

  2. 执行 renderStart,并发执行 renderStart 钩子,正式开始打包。

  3. 并发执行所有插件的bannerfooterintrooutro 钩子(底层用 Promise.all 包裹所有的这四种钩子函数),这四个钩子功能很简单,就是往打包产物的固定位置(比如头部和尾部)插入一些自定义的内容,比如协议声明内容、项目介绍等等。

  4. 从入口模块开始扫描,针对动态 import 语句执行 renderDynamicImport钩子,来自定义动态 import 的内容。

  5. 对每个即将生成的 chunk,执行 augmentChunkHash钩子,来决定是否更改 chunk 的哈希值,在 watch 模式下即可能会多次打包的场景下,这个钩子会比较适用。

  6. 如果没有遇到 import.meta 语句,则进入下一步,否则:

    • 6.1 对于 import.meta.url 语句调用 resolveFileUrl 来自定义 url 解析逻辑
    • 6.2 对于其他import.meta 属性,则调用 resolveImportMeta 来进行自定义的解析。
  7. 接着 Rollup 会生成所有 chunk 的内容,针对每个 chunk 会依次调用插件的renderChunk方法进行自定义操作,也就是说,在这里时候你可以直接操作打包产物了。

  8. 随后会调用 generateBundle 钩子,这个钩子的入参里面会包含所有的打包产物信息,包括 chunk (打包后的代码)、asset(最终的静态资源文件)。你可以在这里删除一些 chunk 或者 asset,最终这些内容将不会作为产物输出。

  9. 前面提到了rollup.rollup方法会返回一个bundle对象,这个对象是包含generatewrite两个方法,两个方法唯一的区别在于后者会将代码写入到磁盘中,同时会触发writeBundle钩子,传入所有的打包产物信息,包括 chunk 和 asset,和 generateBundle钩子非常相似。不过值得注意的是,这个钩子执行的时候,产物已经输出了,而 generateBundle 执行的时候产物还并没有输出。顺序如下图所示:

  1. 当上述的bundleclose方法被调用时,会触发closeBundle钩子,到这里 Output 阶段正式结束。

6. 常用hook

发 Rollup 插件就是在编写一个个 Hook 函数,一个 Rollup 插件基本就是各种 Hook 函数的组合。

路径解析:resolveId(Async + First)

resolveId 钩子一般用来解析模块路径,为Async + First类型即异步优先的钩子。

以官方插件alias为例:

export default alias(options) {
  // 获取 entries 配置
  const entries = getEntries(options);
  return {
    // 传入三个参数,当前模块路径、引用当前模块的模块路径、其余参数
    resolveId(importee, importer, resolveOptions) {
      // 先检查能不能匹配别名规则
      const matchedEntry = entries.find((entry) => matches(entry.find, importee));
      // 如果不能匹配替换规则,或者当前模块是入口模块,则不会继续后面的别名替换流程
      if (!matchedEntry || !importerId) {
        // return null 后,当前的模块路径会交给下一个插件处理
        return null;
      }
      // 正式替换路径
      const updatedId = normalizeId(
        importee.replace(matchedEntry.find, matchedEntry.replacement)
      );
      // 每个插件执行时都会绑定一个上下文对象作为 this
      // 这里的 this.resolve 会执行所有插件(除当前插件外)的 resolveId 钩子
      return this.resolve(
        updatedId,
        importer,
        Object.assign({ skipSelf: true }, resolveOptions)
      ).then((resolved) => {
        // 替换后的路径即 updateId 会经过别的插件进行处理
        let finalResult: PartialResolvedId | null = resolved;
        if (!finalResult) {
          // 如果其它插件没有处理这个路径,则直接返回 updateId
          finalResult = { id: updatedId };
        }
        return finalResult;
      });
    }
  }
}

它的入参分别是当前模块路径引用当前模块的模块路径解析参数,返回值可以是 null、string 或者一个对象,我们分情况讨论。

  • 返回值为 null 时,会默认交给下一个插件的 resolveId 钩子处理。
  • 返回值为 string 时,则停止后续插件的处理。这里为了让替换后的路径能被其他插件处理,特意调用了 this.resolve 来交给其它插件处理,否则将不会进入到其它插件的处理。
  • 返回值为一个对象,也会停止后续插件的处理,不过这个对象就可以包含更多的信息了,包括解析后的路径、是否被 enternal、是否需要 tree-shaking 等等,不过大部分情况下返回一个 string 就够用了。

load(Async + First)

通过 resolveId 解析后的路径来加载模块内容

以image 插件为例

const mimeTypes = {
  '.jpg': 'image/jpeg',
  // 后面图片类型省略
};

export default function image(opts = {}) {
  const options = Object.assign({}, defaults, opts);
  return {
    name: 'image',
    load(id) {
      const mime = mimeTypes[extname(id)];
      if (!mime) {
        // 如果不是图片类型,返回 null,交给下一个插件处理
        return null;
      }
      // 加载图片具体内容
      const isSvg = mime === mimeTypes['.svg'];
      const format = isSvg ? 'utf-8' : 'base64';
      const source = readFileSync(id, format).replace(/[\r\n]+/gm, '');
      const dataUri = getDataUri({ format, isSvg, mime, source });
      const code = options.dom ? domTemplate({ dataUri }) : constTemplate({ dataUri });

      return code.trim();
    }
  };
}

load 钩子的入参是模块 id,返回值一般是 null、string 或者一个对象:

  • 如果返回值为 null,则交给下一个插件处理;
  • 如果返回值为 string 或者对象,则终止后续插件的处理,如果是对象可以包含 SourceMap、AST 等更详细的信息

代码转换:transform(Async + Sequential)

作用对加载后的模块内容进行自定义的转换

以官方插件replace为例:

import MagicString from 'magic-string';

export default function replace(options = {}) {
  return {
    name: 'replace',
    transform(code, id) {
      // 省略一些边界情况的处理
      // 执行代码替换的逻辑,并生成最后的代码和 SourceMap
      return executeReplacement(code, id);
    }
  }
}

function executeReplacement(code, id) {
  const magicString = new MagicString(code);
  // 通过 magicString.overwrite 方法实现字符串替换
  if (!codeHasReplacements(code, id, magicString)) {
    return null;
  }

  const result = { code: magicString.toString() };

  if (isSourceMapEnabled()) {
    result.map = magicString.generateMap({ hires: true });
  }

  // 返回一个带有 code 和 map 属性的对象
  return result;
}

transform 钩子的入参分别为模块代码模块 ID,返回一个包含 code(代码内容) 和 map(SourceMap 内容) 属性的对象,当然也可以返回 null 来跳过当前插件的 transform 处理。需要注意的是,当前插件返回的代码会作为下一个插件 transform 钩子的第一个入参,实现类似于瀑布流的处理。

Chunk 级代码修改: renderChunk

以 replace插件举例,在这个插件中,也同样实现了 renderChunk 钩子函数:

export default function replace(options = {}) {
  return {
    name: 'replace',
    transform(code, id) {
      // transform 代码省略
    },
    renderChunk(code, chunk) {
      const id = chunk.fileName;
      // 省略一些边界情况的处理
      // 拿到 chunk 的代码及文件名,执行替换逻辑
      return executeReplacement(code, id);
    },
  }
}

replace 插件为了替换结果更加准确,在 renderChunk 钩子中又进行了一次替换,因为后续的插件仍然可能在 transform 中进行模块内容转换,进而可能出现符合替换规则的字符串。

这里我们把关注点放到 renderChunk 函数本身,可以看到有两个入参,分别为 chunk 代码内容chunk 元信息,返回值跟 transform 钩子类似,既可以返回包含 code 和 map 属性的对象,也可以通过返回 null 来跳过当前钩子的处理。

补充:

在Rollup打包过程中,插件运行的顺序取决于它们在配置文件中出现的顺序。对于每一个模块文件,Rollup首先会按顺序执行所有插件的transform函数,然后再执行其他的生命周期函数,如buildEndrenderChunk等。所以,如果pluginAreplace插件之前出现在配置文件中,那么pluginAtransform函数会先执行,执行完后replace插件的transform函数才会执行。

在这个过程中,每个插件的transform函数只会对单个模块文件进行操作,而不会影响到其他模块文件。换句话说,pluginAtransform函数在执行完后,replace插件的transform函数会立刻执行,但是这两个函数都是在处理同一个模块文件。

当所有模块文件都处理完后,Rollup会继续执行打包过程的后续步骤,比如合并模块,生成输出文件等。在这些步骤中,Rollup还会执行插件的其他生命周期函数,比如renderChunkwriteBundle等。

总的来说,Rollup插件的生命周期函数并不是一次性按顺序执行的,而是在打包过程的不同阶段分别执行。这样可以确保每个插件都有机会在正确的时间点对代码进行操作。

产物生成最后一步: generateBundle(Async + Sequential)

可以在这个钩子里面自定义删除一些无用的 chunk 或者静态资源,或者自己添加一些文件。这里我们以 Rollup 官方的html插件来具体说明,这个插件的作用是通过拿到 Rollup 打包后的资源来生成包含这些资源的 HTML 文件,源码简化后如下所示:

export default function html(opts: RollupHtmlOptions = {}): Plugin {
  // 初始化配置
  return {
    name: 'html',
    async generateBundle(output: NormalizedOutputOptions, bundle: OutputBundle) {
      // 省略一些边界情况的处理
      // 1. 获取打包后的文件
      const files = getFiles(bundle);
      // 2. 组装 HTML,插入相应 meta、link 和 script 标签
      const source = await template({ attributes, bundle, files, meta, publicPath, title});
      // 3. 通过上下文对象的 emitFile 方法,输出 html 文件
      const htmlFile: EmittedAsset = {
        type: 'asset',
        source,
        name: 'Rollup HTML Asset',
        fileName
      };
      this.emitFile(htmlFile);
    }
  }
}

入参分别为output 配置所有打包产物的元信息对象,通过操作元信息对象你可以删除一些不需要的 chunk 或者静态资源,也可以通过 插件上下文对象的emitFile方法输出自定义文件。