Vite构建工具

346 阅读11分钟

Rollup

简介

一款基于 ES Module 模块化规范实现的 JavaScript 打包工具,在 Vite 架构体系中发挥着重要的作用。

简单应用

搭建项目框架

image.png

// index.js
import { add } from './util';
console.log(add(1,2));

// util.js
export const add = (a,b)=>{
    return a+b;
}
export const multi = (a,b)=>{
    return a*b;
}

// rollup.config.mjs
export default{
    input: ["src/index.js"],
    output: {
        dir: "dist/es",
        format: "esm",
    }
};

执行npm run build,在根目录生成一个dist文件夹,包含es/index.js

// dist/es/index.js
const add = (a,b)=>{
    return a+b;
};
console.log(add(1,2));

输出文件不包含没有用到的模块,因为 Rollup 具有天然的 Tree Shaking 功能,可以分析出未使用到的模块并自动擦除。

Tree Shaking(摇树),是计算机编译原理中DCE(Dead Code Elimination,即消除无用代码) 技术的一种实现。由于 ES 模块依赖关系是确定的,和运行时状态无关。因此 Rollup 可以在编译阶段分析出依赖关系,对 AST 语法树中没有使用到的节点进行删除,从而实现 Tree Shaking。

常用配置

  1. 多产物配置

作用:将同一份文件打包成多种格式,并暴露到外部供其他人使用。包括 ESMCommonJSUMD等,保证良好的兼容性。

output: [
    { dir: "dist/es", format: "esm" },
    { dir: "dist/cjs", format: "cjs" }
]

image.png

  1. 多入口配置

作用:按照多入口文件打包,减少打包后体积

input: ["src/index.js","src/util.js"],
  1. 自定义output配置项
output: {
  dir: path.resolve(__dirname, 'dist'), // 产物输出目录
  entryFileNames: `[name].js`, // 入口模块的输出文件名
  chunkFileNames: 'chunk-[hash].js', // 非入口模块(如动态模块)的输出文件名
  assetFileNames: 'assets/[name]-[hash][extname]', // 静态资源文件输出文件名
  format: 'cjs', // 产物输出格式
  sourcemap: true, // 是否生成 sourcemap 文件
  name: 'MyBundle', // iife/umd 格式需要对外暴露出一个全局变量,通过 name 配置变量名
  globals: { // 全局变量声明
    jquery: '$' // 项目中可以直接用`$`代替`jquery`
  }
}
  • [name]:去除文件后缀后的文件名
  • [hash]:根据文件名和文件内容生成的 hash 值
  • [format]:产物模块格式,如 es、cjs
  • [extname]:产物后缀名
  1. 排除打包模块external

作用:使rollup不对第三方包进行打包。

{ 
    external: ['react', 'react-dom'] 
 }
  1. 接入插件

虽然 Rollup 能够打包输出CommonJS 格式的产物,但对于输入给 Rollup 的代码并不支持 CommonJS,仅仅支持 ESM。需要借助插件进行处理。

  • @rollup/plugin-node-resolve:允许加载第三方依赖,识别依赖导入语句
  • @rollup/plugin-commonjs:将 CommonJS 格式的代码转换为 ESM 格式
  • @rollup/plugin-typescript:支持使用 TypeScript 开发
  • @rollup/plugin-babel:使用 Babel 进行 JS 代码的语法转译
import resolve from "@rollup/plugin-node-resolve"; 
import commonjs from "@rollup/plugin-commonjs";

plugins: [resolve(), commonjs()],

JS API调用

  1. rollup.rollup

作用:以编程的方式调用 Rollup 打包的完整过程

async function build() {
  let bundle;
  let buildFailed = false;
  try {
    // 1. 调用 rollup.rollup 生成 bundle 对象
    bundle = await rollup.rollup(inputOptions);
    for (const outputOptions of outputOptionsList) {
      // 2. 拿到 bundle 对象,根据每一份输出配置,调用 generate 和 write 方法分别生成和写入产物
      const { output } = await bundle.generate(outputOptions);
      await bundle.write(outputOptions);
    }
  } catch (error) {
    buildFailed = true;
    console.error(error);
  }
  if (bundle) {
    // 3. 最后调用 bundle.close 方法结束打包
    await bundle.close();
  }
  process.exit(buildFailed ? 1 : 0);
}
  1. rollup.watch

作用:每次源文件变动后自动进行重新打包

创建一个watch.js文件:

const rollup = require("rollup");

const watcher = rollup.watch({
  input: "./src/index.js",
  output: [{
      dir: "dist/es",
      format: "esm",
    }],
  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);
  }
});

执行node watch.js开启 Rollup 的 watch 打包模式。

插件机制

Rollup 设计出了一套完整的插件机制,将自身的核心逻辑与插件逻辑分离,可以按需引入插件功能,提高 Rollup 的可扩展性。

Rollup 的打包过程中,会定义一套完整的构建生命周期,从开始打包到产物输出,中途会经历一些标志性的阶段,并且在不同阶段会自动执行对应的插件(钩子函数Hook)。

当执行npm build之后,主要经历了两个阶段:Build 和 Output

  • Build阶段:初始化各个模块的AST,确定模块之间的依赖关系,暴露generatewrite方法

  • Output阶段:利用generatewrite方法完成打包及输出的过程

write和generate方法唯一的区别在于前者打包完产物会写入磁盘,而后者不会

插件的各种 Hook 可以根据这两个构建阶段分为两类: Build Hook 与 Output Hook

  • Build Hook:在Build阶段执行的钩子函数,在这个阶段主要进行模块代码的转换、AST 解析以及模块依赖的解析,本阶段的 Hook 对于代码的操作粒度一般为模块级别,也就是单文件级别

  • Ouput Hook:主要进行代码的打包,对于代码而言,操作粒度一般为 chunk级别(一个 chunk 通常指很多文件打包到一起的产物)

插件的各种 Hook 按照执行方式也会有不同的分类,主要包括Async同步Sync异步Parallel并行Squential串行First顺序执行这五种。

Build阶段Hook工作流程:

1️⃣:利用 options 钩子进行配置的转换,获取配置对象

2️⃣:调用 buildStart 钩子,正式开始构建流程

3️⃣:进入到 resolveId 钩子中解析文件路径(入口文件)

4️⃣:调用 load 钩子加载模块内容

5️⃣:执行所有的 transform 钩子来对模块内容进行进行自定义的转换

6️⃣:对模块内容进行 AST 分析,得到所有的 import 内容,调用 moduleParsed 钩子处理import

  • 普通的import:执行 resolveId 钩子,继续回到3️⃣
  • 动态的import:执行 resolveDynamicImport 钩子解析路径,解析成功回到4️⃣加载模块,否则回到3️⃣通过 resolveId 解析路径

7️⃣:执行 buildEnd 钩子,Build 阶段结束

Output阶段Hook工作流程:

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

2️⃣:并发执行 renderStart 钩子,正式开始打包

3️⃣:并发执行所有插件的banner、footer、intro、outro 钩子(Promise.all),往打包产物的固定位置(比如头部和尾部)插入一些自定义的内容

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

5️⃣:对每个即将生成的 chunk,执行 augmentChunkHash钩子,来决定是否更改 chunk 的哈希值

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

  • 对于 import.meta.url 语句调用 resolveFileUrl 来自定义 url 解析逻辑
  • 对于其他import.meta 属性,则调用 resolveImportMeta 来进行自定义的解析

7️⃣:生成所有 chunk 的内容,针对每个 chunk 会依次调用插件的renderChunk方法进行自定义操作

8️⃣:调用 generateBundle 钩子,可以删除一些 chunk 或者 asset,最终这些内容将不会作为产物输出

9️⃣:触发 closeBundle 钩子,到这里 Output 阶段正式结束

Rollup和Webpack对比

  1. 设计理念与功能
  • Webpack

    • 全能型的模块打包工具:支持 JavaScript、CSS、HTML、图片等各种静态资源
    • 丰富的Loader机制:可以转换非 JavaScript 资源
    • 代码分割 Code Splitting:路由懒加载代码块,提升页面加载速度
    • 热模块替换 HMR:开发过程中实时刷新
    • 配合Plugin系统实现复杂的构建流程定制
  • Rollup

    • Rollup专注于JavaScript模块的打包和优化,专注于ES6模块规范
    • 严格静态分析,出色的tree-shaking能力
    • 输出的bundle倾向于更小、更纯净
  1. 适用场景
  • Webpack

    • 大型SPA或企业级Web应用等需要处理多种资源和复杂的构建流程
    • 需要做代码分割和按需加载的场景
    • 有大量第三方库依赖和复杂业务逻辑的项目
  • Rollup

    • 开发和发布独立的JavaScript库或组件,特别是那些遵循ES6模块规范的库
    • 项目主要关注打包后代码大小和纯净性,而非处理大量非JS资源
  1. 如何选择
  • 选择Webpack

    • 当项目同时包含多个资源类型
    • 当项目需要实现复杂的代码分割和动态加载策略
    • 当项目规模较大,需要高度定制化构建流程时
  • 选择Rollup

    • 当项目主要是编写一个独立的、面向外部发布的JavaScript库时
    • 当需要最大程度地优化代码大小,去除无用模块时
    • 当项目不涉及过多的非JS资源,只需要专注JS模块打包优化时

使用Rollup打包库本身,使用Webpack来整合应用的所有资源并进行优化

Vite

简介

Vite是尤大开发的一种新型前端构建工具,可以用于Vue、React等多种框架中,显著提升前端开发体验。

采用了全新的unbundle思想利用浏览器ESM特性导入组织代码,在服务器端进行按需编译返回。

简单应用

npm create vite@latest
npm install
npm run dev

image.png

开发模式

Vite开发模式下通过拦截浏览器发出的ES imports请求,对请求文件进行编译。在一定程度上Vite的热模块替换速度更快。

使用Vite的前提是必须基于原生的ES import

// test.js 
export default function hello(){
    console.log('hello world'); 
} 

// index.html 
<script type="module">
import hello from './test.js';   

hello(); // hello world 
</scirpt>

ESM

Vite将模块分为依赖和源码两部分:

  • 依赖:开发时不会发生改变的纯JavaScript
  • 源码:通常为CSS、JSX,可以进行编辑、转换的资源,需要基于路由拆分
<script type="module"> 
    import {a} from './a.js';
</script>

image.png

当声明一个script标签类型为module时,对于内部的import 引用,浏览器会发起一个HTTP请求来获取模块内容。Vite会劫持这些HTTP请求,并在后端进行相应的处理,然后再将处理后的内容返回给浏览器。

由于浏览器只会对用到的模块发起HTTP请求,所以Vite不会打包项目里所有的文件,而是只编译浏览器发起HTTP请求的模块。

热更新

Vite通过WebSocket来实现的热更新通信。

当Vite监听到文件变更后,会用websocket通知浏览器,重新发起新的请求,只对该模块进行重新编译,然后进行替换。

  • Webpack的HMR:重新编译打包,请求打包后的文件,客户端进行重新加载
  • Vite的HMR:请求变更后的模块,浏览器直接重新加载

缓存

Vite利用浏览器的缓存策略,针对源码模块做了协商缓存处理,针对依赖模块做了强缓存处理,这样项目的访问的速度也就更快了。

Vite处理modules时只编译不打包,当热更新时,如果a模块发生了改变,只需要更新a模块以及用到a模块的其他模块。由于b模块没有发生改变,所以Vite无需重新编译b模块,可以直接从缓存中读取编译结果。所以理论上热更新的速度不会随着文件增加而变慢。

生产模式

Vite生产模式下的打包不是Vite自身提供的,如果生产模式想用Webpack打包也是可以的。从这个角度来看,Vite更像是替代了webpack-dev-server的一个工具。

默认情况下生产模式是通过 rollup 打包。

Vite与webpack对比

  • webpack打包项目:缓慢的服务器启动、缓慢的更新

image.png

当使用webpack打包项目时,webpack会根据配置文件中的入口文件entry,分析项目中所有的依赖关系,然后打包成一个文件bundle.js并交给浏览器去加载渲染。

  • Vite打包项目:更快的服务器启动和更新速度

image.png

<script type="module">中,浏览器遇到内部的import引用时,会自动发起http请求,去加载对应的模块。使用vite运行项目时,首先会用esbuild进行预构建,将所有模块转换为es module,不需要对整个项目进行编译打包,而是在浏览器需要加载某个模块时,拦截浏览器发出的HTTP请求,根据请求进行按需编译,然后返回给浏览器。

优势

  1. esbuild

esbuild 使用go编写,cpu密集下更具性能优势,编译速度更快,相比较其他打包工具的速度提升10~100倍的差距。

  1. 编译打包

Vite其核心原理是利用浏览器现在已经支持ES6的import,碰见type=module的import就会发送一个HTTP请求去加载文件,Vite启动一个 koa 服务器拦截这些请求,并在后端进行相应的处理将项目中使用的文件通过简单的分解与整合,然后再以ESM格式返回给浏览器。

Vite整个过程中没有对文件进行打包编译,做到了真正的按需加载,所以其运行速度比原始的webpack开发时还需进行编译编译打包的速度相比快出许多。

  1. 缓存

充分利用http缓存做优化,依赖(不会变动的代码)部分用强缓存,源码部分用304协商缓存,提升页面打开速度。