前端构建工具:从Rollup到Vite

15 阅读10分钟

在 Vue.js 源码中,pnpm run build reactivity 这个命令背后究竟发生了什么?为什么 Vue3 选择 Rollup 作为构建工具?ViteRollup 又是什么关系?本文将深入理解 Rollup 的核心配置,探索 Vue3 的构建体系,并理清 ViteRollup 的渊源。

Rollup 基础配置解析

什么是 Rollup?

Rollup 是一个 JavaScript 模块打包器,它可以将多个模块打包成一个单独的文件。与 Webpack 不同,Rollup 专注于 ES 模块的静态分析,以生成更小、更高效的代码。

Rollup 的核心优势

  • treeShaking:基于 ES 模块的静态分析,自动移除未使用的代码
  • 支持输出多种模块格式(ESM、CJS、UMD、IIFE)
  • 配置文件简洁直观,学习成本低
  • 插件体系完善,可以处理各种场景

核心配置:input 与 output

Rollup 的配置文件通常是 rollup.config.js,它导出一个配置对象或数组:

input:入口文件配置

// rollup.config.js
export default {
    // 单入口(最常见)
    input: 'src/index.js',
    
    // 多入口(对象形式)
    input: {
        main: 'src/main.js',
        admin: 'src/admin.js',
        utils: 'src/utils.js'
    },
    
    // 多入口(数组形式)
    input: ['src/index.js', 'src/cli.js']
};

output:输出配置

output 配置决定了打包产物的形式和位置:

export default {
    input: 'src/index.js',
    
    // 单输出配置
    output: {
        file: 'dist/bundle.js',      // 输出文件
        format: 'esm',                // 输出格式
        name: 'MyLibrary',            // UMD/IIFE 模式下的全局变量名
        sourcemap: true,               // 生成 sourcemap
        banner: '/*! MyLibrary v1.0.0 */' // 文件头注释
    },
    
    // 多输出配置(数组形式,输出多种格式)
    output: [
        {
            file: 'dist/my-lib.cjs.js',
            format: 'cjs'              // CommonJS,适用于 Node.js
        },
        {
            file: 'dist/my-lib.esm.js',
            format: 'es'                // ES Module,适用于现代浏览器/打包工具
        },
        {
            file: 'dist/my-lib.umd.js',
            format: 'umd',              // UMD,适用于所有场景
            name: 'MyLibrary'
        },
        {
            file: 'dist/my-lib.iife.js',
            format: 'iife',              // IIFE,直接用于浏览器 script 标签
            name: 'MyLibrary'
        }
    ]
};
输出格式详解
格式全称适用场景特点
es / esmES Module现代浏览器、打包工具保留 import/export,支持 Tree Shaking
cjsCommonJSNode.js 环境使用 require/module.exports
umdUniversal Module Definition通用(浏览器、Node.js)兼容 AMD、CommonJS 和全局变量
iifeImmediately Invoked Function Expression直接在浏览器用 script 脚本引入自执行函数,避免全局污染
amdAsynchronous Module DefinitionRequireJS 等异步模块加载

插件系统:扩展 Rollup 的能力

Rollup 的核心功能很精简,大多数能力需要通过插件来扩展。插件通过 plugins 数组配置,可以是单个插件实例或包含多个插件的数组:

import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import { terser } from 'rollup-plugin-terser';
import json from '@rollup/plugin-json';
import replace from '@rollup/plugin-replace';
import babel from '@rollup/plugin-babel';

export default {
    input: 'src/index.js',
    output: {
        file: 'dist/bundle.js',
        format: 'umd',
        name: 'MyLibrary'
    },
    plugins: [
        // 解析 node_modules 中的第三方模块[citation:1]
        nodeResolve(),
        
        // 将 CommonJS 模块转换为 ES 模块[citation:10]
        commonjs(),
        
        // 支持导入 JSON 文件
        json(),
        
        // 替换代码中的字符串(常用于环境变量)
        replace({
            'process.env.NODE_ENV': JSON.stringify('production')
        }),
        
        // 使用 Babel 进行代码转换
        babel({
            babelHelpers: 'bundled',
            exclude: 'node_modules/**'
        }),
        
        // 压缩代码(生产环境)
        terser()
    ]
};

external:排除外部依赖

当构建一个库时,我们通常不希望将第三方依赖(如 React、Vue、lodash)打包进最终的产物,而是将其声明为外部依赖:

export default {
    input: 'src/index.js',
    output: {
        file: 'dist/my-lib.js',
        format: 'umd',
        name: 'MyLibrary',
        // 为 UMD 模式提供全局变量名映射
        globals: {
            'react': 'React',
            'react-dom': 'ReactDOM',
            'lodash': '_'
        }
    },
    // 排除外部依赖
    external: [
        'react',
        'react-dom',
        'lodash',
        // 也可以使用正则表达式
        /^lodash\//  // 排除 lodash 的所有子模块
    ]
};

Tree Shaking

Rollup 最令人津津乐道的就是其 Tree Shaking 功能,它通过静态分析移除未使用的代码,减小打包体积:

export default {
    input: 'src/index.js',
    output: {
        file: 'dist/bundle.js',
        format: 'esm'
    },
    treeshake: {
        // 模块级别的副作用分析
        moduleSideEffects: false,
        
        // 属性访问分析(更精确的 Tree Shaking)
        propertyReadSideEffects: false,
        
        // 尝试合并模块
        tryCatchDeoptimization: false,
        
        // 未知全局变量分析
        unknownGlobalSideEffects: false
    }
};

// 更简单的用法:直接使用布尔值
treeshake: true // 开启默认的摇树优化[citation:1]

watch:监听模式

在开发过程中,我们可以开启监听模式,当文件变化时自动重新打包:

export default {
    input: 'src/index.js',
    output: {
        file: 'dist/bundle.js',
        format: 'esm'
    },
    watch: {
        include: 'src/**',      // 监听的文件
        exclude: 'node_modules/**', // 排除的文件
        clearScreen: false        // 不清除屏幕
    }
};

// 或者在命令行中开启
// rollup -c --watch
// rollup -c -w (简写)

Vue3 使用的关键 Rollup 插件

Vue3 的源码采用 monorepo 管理,使用 Rollup 进行构建。让我们看看 Vue3 在构建过程中使用了哪些关键插件:

@rollup/plugin-node-resolve

作用:允许 Rollup 从 node_modules 中导入第三方模块。

// 为什么需要这个插件?
import { reactive } from '@vue/reactivity'; // 这个模块在 node_modules 中
// 没有插件时,Rollup 无法解析这个路径

// Vue3 中的使用
import nodeResolve from '@rollup/plugin-node-resolve';

export default {
    plugins: [
        nodeResolve({
            // 指定解析的模块类型
            mainFields: ['module', 'main'], // 优先使用 module 字段[citation:5]
            extensions: ['.js', '.json', '.ts'], // 支持的文件扩展名
            preferBuiltins: false // 不优先使用 Node 内置模块
        })
    ]
};

@rollup/plugin-commonjs

作用:将 CommonJS 模块转换为 ES 模块,使得 Rollup 可以处理那些尚未提供 ES 模块版本的依赖:

import commonjs from '@rollup/plugin-commonjs';

export default {
    plugins: [
        commonjs({
            // 指定哪些文件需要转换
            include: 'node_modules/**',
            
            // 扩展名
            extensions: ['.js', '.cjs'],
            
            // 忽略某些模块的转换
            ignore: ['conditional-runtime-dependency']
        })
    ]
};

@rollup/plugin-replace

作用:在打包时替换代码中的字符串,常用于注入环境变量或特性开关(Feature Flags):

// Vue3 中的特性开关示例[citation:2]
// packages/compiler-core/src/errors.ts
export function createCompilerError(code, loc, messages, additionalMessage) {
    // __DEV__ 在构建时被替换为 true 或 false
    if (__DEV__) {
        // 开发环境才执行的代码
    }
}

// rollup 配置
import replace from '@rollup/plugin-replace';

export default {
    plugins: [
        replace({
            // 防止被 JSON.stringify 转义
            preventAssignment: true,
            
            // 定义环境变量
            __DEV__: process.env.NODE_ENV !== 'production',
            __VERSION__: JSON.stringify('3.2.0'),
            
            // 特性开关
            __FEATURE_OPTIONS_API__: true,
            __FEATURE_PROD_DEVTOOLS__: false
        })
    ]
};

@rollup/plugin-json

作用:支持从 JSON 文件导入数据:

import json from '@rollup/plugin-json';

export default {
    plugins: [
        json({
            // 指定 JSON 文件的大小限制,超过限制则作为单独文件引入
            preferConst: true,
            indent: '  '
        })
    ]
};

// 使用时
import pkg from './package.json';
console.log(pkg.version);

rollup-plugin-terser

作用:压缩代码,减小生产环境的包体积:

import { terser } from 'rollup-plugin-terser';

export default {
    plugins: [
        // 只在生产环境使用
        process.env.NODE_ENV === 'production' && terser({
            compress: {
                drop_console: true,      // 移除 console
                drop_debugger: true,      // 移除 debugger
                pure_funcs: ['console.log'] // 移除特定的函数调用
            },
            output: {
                comments: false           // 移除注释
            }
        })
    ]
};

@rollup/plugin-babel

作用:使用 Babel 进行代码转换,处理语法兼容性问题:

import babel from '@rollup/plugin-babel';

export default {
    plugins: [
        babel({
            // 排除 node_modules
            exclude: 'node_modules/**',
            
            // 包含的文件
            include: ['src/**/*.js', 'src/**/*.ts'],
            
            // Babel helpers 的处理方式
            babelHelpers: 'bundled', // 或 'runtime'
            
            // 扩展名
            extensions: ['.js', '.jsx', '.ts', '.tsx']
        })
    ]
};

@rollup/plugin-typescript

作用:支持 TypeScript 编译:

import typescript from '@rollup/plugin-typescript';

export default {
    plugins: [
        typescript({
            tsconfig: './tsconfig.json',
            declaration: true,      // 生成 .d.ts 文件
            declarationDir: 'dist/types'
        })
    ]
};

如何构建指定包(以 pnpm run build reactivity 为例)

Vue3 采用 monorepo 管理多个包,使用 pnpm 作为包管理器。理解 pnpm run build reactivity 背后的机制,能帮助我们更好地理解现代构建流程:

项目结构

vue-next/
├── packages/               # 所有子包
│   ├── reactivity/         # 响应式系统
│   │   ├── src/
│   │   ├── package.json    # 包级配置
│   │   └── ...
│   ├── runtime-core/       # 运行时核心
│   ├── runtime-dom/        # 浏览器运行时
│   ├── compiler-core/      # 编译器核心
│   ├── vue/                # 完整版本
│   └── ...
├── package.json            # 根配置
├── pnpm-workspace.yaml     # pnpm 工作区配置
└── rollup.config.js        # Rollup 配置文件

pnpm-workspace.yaml 配置

# pnpm-workspace.yaml
packages:
  - 'packages/*'  # 声明 packages 下的所有目录都是工作区的一部分

这个配置告诉 pnpm:packages 目录下的每个子目录都是一个独立的包,它们之间可以互相引用而不需要发布到 npm。

根 package.json 的脚本配置

// 根目录 package.json
{
  "private": true,
  "scripts": {
    "build": "node scripts/build.js",                 // 构建所有包
    "build:reactivity": "pnpm run build reactivity",  // 只构建 reactivity 包
    "dev": "node scripts/dev.js",                      // 开发模式
    "test": "jest"                                      // 运行测试
  }
}

pnpm run 的底层原理

当我们在命令行执行 pnpm run build reactivity 时,背后发生了以下步骤:

  1. 解析命令:pnpm run build reactivity
  2. 读取根目录 package.json 中的 scripts
  3. 找到 "build": node scripts/build.js
  4. 将参数 "reactivity" 传递给脚本
  5. 在 PATH 环境变量中查找 node
  6. 执行 node scripts/build.js reactivity
  7. 脚本根据参数决定构建哪个包

build.js 脚本分析

Vue3 的构建脚本会解析命令行参数,决定构建哪些包:

// scripts/build.js (简化版)
const fs = require('fs');
const path = require('path');
const execa = require('execa');
const { targets: allTargets } = require('./utils');

// 获取命令行参数
const args = require('minimist')(process.argv.slice(2));
const targets = args._; // 获取到的参数数组

async function build() {
    // 如果没有指定目标,构建所有包
    if (!targets.length) {
        await buildAll(allTargets);
    } else {
        // 只构建指定的包
        await buildSelected(targets);
    }
}

async function buildSelected(targets) {
    for (const target of targets) {
        await buildPackage(target);
    }
}

async function buildPackage(packageName) {
    console.log(`开始构建: @vue/${packageName}`);
    
    // 切换到包目录
    const pkgDir = path.resolve(__dirname, '../packages', packageName);
    
    // 使用 rollup 构建该包
    await execa(
        'rollup',
        [
            '-c',                                      // 使用配置文件
            '--environment',                           // 设置环境变量
            `TARGET:${packageName}`,                   // 告诉 rollup 要构建哪个包
            '--watch'                                   // 开发模式时可能开启
        ],
        {
            stdio: 'inherit',                          // 继承输入输出
            cwd: pkgDir                                 // 在包目录执行
        }
    );
}

build();

Rollup 配置如何区分不同的包

// rollup.config.js (简化版)
import { createRequire } from 'module';
import path from 'path';
import fs from 'fs';

// 获取所有包
const packagesDir = path.resolve(__dirname, 'packages');
const packages = fs.readdirSync(packagesDir)
    .filter(f => fs.statSync(path.join(packagesDir, f)).isDirectory());

// 根据环境变量决定构建哪个包
const target = process.env.TARGET;

function createConfig(packageName) {
    const pkgDir = path.resolve(packagesDir, packageName);
    const pkg = require(path.join(pkgDir, 'package.json'));
    
    // 为每个包生成不同的配置
    return {
        input: path.resolve(pkgDir, 'src/index.ts'),
        output: [
            {
                file: path.resolve(pkgDir, pkg.main),
                format: 'cjs',
                sourcemap: true
            },
            {
                file: path.resolve(pkgDir, pkg.module),
                format: 'es',
                sourcemap: true
            }
        ],
        plugins: [
            // 共用插件
        ],
        external: [
            ...Object.keys(pkg.dependencies || {}),
            ...Object.keys(pkg.peerDependencies || {})
        ]
    };
}

// 如果指定了 target,只构建那个包
if (target) {
    module.exports = createConfig(target);
} else {
    // 否则构建所有包
    module.exports = packages.map(createConfig);
}

包级 package.json 的配置

每个包都有自己的 package.json,定义了该包的元信息和构建产物的入口:

// packages/reactivity/package.json
{
  "name": "@vue/reactivity",
  "version": "3.2.0",
  "main": "dist/reactivity.cjs.js",     // CommonJS 入口
  "module": "dist/reactivity.esm.js",    // ES Module 入口
  "unpkg": "dist/reactivity.global.js",  // 直接引入的 UMD 版本
  "types": "dist/reactivity.d.ts",       // TypeScript 类型定义
  "dependencies": {
    "@vue/shared": "3.2.0"
  }
}

Vite 与 Rollup 的关系

为什么需要 Vite?

虽然 Rollup 很优秀,但在开发大型应用时,它和 Webpack 一样面临着性能瓶颈:随着项目变大,启动开发服务器的时间越来越长。

Vite 的双引擎架构

Vite 在开发环境和生产环境使用不同的引擎:

  • 开发环境:利用浏览器原生 ES 模块 + esbuild 预构建
  • 生产环境:使用 Rollup 进行深度优化打包

开发环境:利用原生 ES 模块

<!-- Vite 开发服务器的原理 -->
<script type="module">
    // 浏览器直接请求模块,服务器实时编译返回
    import { createApp } from '/node_modules/.vite/vue.js'
    import App from '/src/App.vue'
    
    createApp(App).mount('#app')
</script>

esbuild 使用 Go 编写,比 JS 编写的打包器快 10-100 倍,可以预构建依赖,并转换 TypeScript/JSX。

生产环境:使用 Rollup 打包

Vite 在生产环境构建时,会使用 Rollup 进行打包。Vite 的插件系统也是与 Rollup 兼容的,这意味着绝大多数 Rollup 插件也可以在 Vite 中使用:

// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';

export default defineConfig({
    plugins: [
        vue()  // 这个插件同时支持开发环境和生产环境
    ],
    
    // 构建配置
    build: {
        // 底层是 Rollup 配置
        rollupOptions: {
            input: {
                main: resolve(__dirname, 'index.html'),
                nested: resolve(__dirname, 'nested/index.html')
            },
            output: {
                // 代码分割配置
                manualChunks: {
                    vendor: ['vue', 'vue-router']
                }
            }
        },
        
        // 输出目录
        outDir: 'dist',
        
        // 生成 sourcemap
        sourcemap: true,
        
        // 压缩配置
        minify: 'terser' // 或 'esbuild'
    }
});

Vite 与 Rollup 的配置对比

配置项RollupVite
入口文件inputbuild.rollupOptions.input
输出目录output.file / output.dirbuild.outDir
输出格式output.formatbuild.rollupOptions.output.format
外部依赖externalbuild.rollupOptions.external
插件pluginsplugins (同时支持 Vite 和 Rollup 插件)
开发服务器无(需配合 rollup -w)内置,支持 HMR

何时选择 Vite,何时选择 Rollup?

使用 Rollup

  • 开发 JavaScript/TypeScript 库
  • 需要精细控制打包过程
  • 项目不复杂,不需要开发服务器
  • 已有基于 Rollup 的构建流程

使用 Vite

  • 开发应用(Vue/React 项目)
  • 需要快速启动的开发服务器
  • 需要 HMR 热更新
  • 希望简化配置

两者结合

  • 库开发时使用 Rollup
  • 应用开发时使用 Vite
  • Vite 内部使用 Rollup 构建生产环境

总结

Rollup 的核心优势

  • 简洁性: 配置直观,学习成本低
  • TreeShaking: 基于ES模块的静态分析,产出代码极小
  • 多格式输出: 支持输出多种模块格式,适用于不同环境
  • 插件生态: 丰富的插件,可以处理各种场景
  • 源码可读性: 打包后的代码保持较好的可读性

Vite 的创新之处

  • 开发体验: 利用原生ES模块,实现极速启动和热更新
  • 双引擎架构: 开发用 esbuild,生产用 Rollup,各取所长
  • 配置简化: 内置常用配置,开箱即用
  • 插件兼容: 兼容 Rollup 插件生态

构建工具是现代前端开发的基石,深入理解它们不仅能帮助我们写出更高效的代码,还能在遇到问题时快速定位和解决。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!