rollup cli开发全面系统性rollup源码分析
Victor 人气:0引言
在学习 rollup CLI 之前我们需要了解 npm 中的 prefix,symlink,Executables 这三个概念。
prefix
当我们使用 --global/-g 选项的时候会将包安装到 prefix 目录下,prefix 默认为 node 的安装位置。在大多数系统上,它是 /usr/local。
在 Windows 上,它是 %AppData%\npm 目录中。在 Unix 系统上,它向上一级,因为 node 通常安装在{prefix}/bin/node 而不是{prefix}/node.exe。
如果未使用 --global/-g 选项的时候,它将安装在当前包的根目录,或者当前工作目录。
具体请参阅 folders
symlink
许多包都有一个或多个可执行文件,并且希望将其安装到 PATH 中。npm 刚好提供了这个功能。
如果想要在用户安装包的时候创建可执行文件,请在 package.json 中提供一个 bin 字段,该字段是命令名称到本地文件名的映射。在安装时,npm 会将该文件符号链接到 prefix/bin 以进行全局安装,或 ./node_modules/.bin/ 用于本地安装。
Executables(可执行文件)
举个例子:
npm install --global rollup
当我们使用上述方式全局安装 rollup 的时候,我们可以 cd 到任何文件目录下直接使用 rollup 命令来使用它。其中的原理就是我们需要了解的可执行文件的概念:
- 在全局模式下,可执行文件在 Unix 上链接到 {prefix}/bin,或在 Windows 上直接链接到 {prefix}。
- 在本地模式下,可执行文件链接到 ./node_modules/.bin 中,以便它们可以可用于通过 npm 运行的脚本。
简单来说就是当你使用 npm install 的时候 npm 会自动为你创建对应的可执行文件。如果是使用 npm install 的方式则会将对应的可执行文件放在 /node_modules/.bin 目录下。如果使用 npm install --global 的方式,对应的可执行文件在 Unix 上会放在{prefix}/bin 目录,在 Windows 上则是 {prefix} 目录。
当你执行 npm run 的时候,npm 会在 node 环境变量(Path)中(例如 C:\Users\victorjiang\AppData\Roaming\npm)找到对应的 node 可执行文件并且运行它。可执行文件包括三个:
- rollup:Unix 系统默认的可执行文件,必须输入完整文件名
- rollup.cmd:windows cmd 中默认的可执行文件
- rollup.ps1:Windows PowerShell 中可执行文件,可以跨平台
在了解了 prefix,symlink,Executables 这三个概念之后我们就可以开始学习 rollup 的 CLI 的功能了。
rollup 命令行的开发
Rollup 命令行的源码在项目的根目录的 cli 下:
cli ├─ run //定义了runRollup函数,以及加载配置文件等业务代码 ├─ cli.ts //命令行解析入口 ├─ help.md //rollup帮助文档 ├─ logging.ts //handleError方法定义
cli/cli.ts 代码定义:
import process from 'node:process'; import help from 'help.md'; import { version } from 'package.json'; import argParser from 'yargs-parser'; import { commandAliases } from '../src/utils/options/mergeOptions'; import run from './run/index'; /** commandAliases: { c: 'config', d: 'dir', e: 'external', f: 'format', g: 'globals', h: 'help', i: 'input', m: 'sourcemap', n: 'name', o: 'file', p: 'plugin', v: 'version', w: 'watch' }; */ // process 是一个全局变量,即 global 对象的属性。 // 它用于描述当前Node.js 进程状态的对象,提供了一个与操作系统的简单接口。 // process.argv 属性返回一个数组,由命令行执行脚本时的各个参数组成。 // 它的第一个成员总是node,第二个成员是脚本文件名,其余成员是脚本文件的参数。 // process.argv: [ // 'C:\\Program Files\\nodejs\\node.exe', // 'C:\\Program Files\\nodejs\\node_modules\\rollup\\dist\\bin\\rollup' // ] /** * 1. process.argv.slice(2) 则是从 argv数组下标为2的元素开始直到末尾提取元素,举例来说就是提取诸如 rollup -h 中除了 rollup 之外的参数 * 2. yargs-parser这个包的作用是把命令行参数转换为json对象,方便访问。 * 例如:"rollup -h" 会被argParser解析成 { _: [], h: true, help: true } * "rollup --help" 会被argParser解析成 { _: [], help: true, h: true } * 'camel-case-expansion' 表示连字符参数是否应该扩展为驼峰大小写别名?默认是true. * 例如: node example.js --foo-bar 会被解析成 { _: [], 'foo-bar': true, fooBar: true } * */ const command = argParser(process.argv.slice(2), { alias: commandAliases, //alias参数表示键的别名对象 configuration: { 'camel-case-expansion': false } //为 argParser 解析器提供配置选项, 'camel-case-expansion': false 表示连字符参数不会被扩展为驼峰大小写别名 }); //process.stdin.isTTY 用于检测我们的程序是否直接连到终端 if (command.help || (process.argv.length <= 2 && process.stdin.isTTY)) { console.log(`\n${help.replace('__VERSION__', version)}\n`); } else if (command.version) { console.log(`rollup v${version}`); } else { try { // eslint-disable-next-line unicorn/prefer-module //浏览器是支持source maps的,但node环境原生不支持source maps。所以我们可以通过'source-map-support'包来实现这个功能。这样当程序执行出错的时候方便通过控制台定位到源码位置。 require('source-map-support').install(); } catch { // do nothing } run(command); }
上面代码中的 run 方法就是 cli/run/index.ts 中定义的 runRollup 方法,它的主要作用就是为了解析用户输入的命令行参数。
cli/run/index.ts 代码定义:
import { env } from 'node:process'; import type { MergedRollupOptions } from '../../src/rollup/types'; import { errorDuplicateImportOptions, errorFailAfterWarnings } from '../../src/utils/error'; import { isWatchEnabled } from '../../src/utils/options/mergeOptions'; import { getAliasName } from '../../src/utils/relativeId'; import { loadFsEvents } from '../../src/watch/fsevents-importer'; import { handleError } from '../logging'; import type { BatchWarnings } from './batchWarnings'; import build from './build'; import { getConfigPath } from './getConfigPath'; import { loadConfigFile } from './loadConfigFile'; import loadConfigFromCommand from './loadConfigFromCommand'; export default async function runRollup(command: Record<string, any>): Promise<void> { let inputSource; //获取input的值 if (command._.length > 0) { //获取非选项值 //例如终端输入"rollup -i input.js f es" => command: { _: [ 'f', 'es' ], i: 'input.js', input: 'input.js' } if (command.input) { handleError(errorDuplicateImportOptions()); } inputSource = command._; } else if (typeof command.input === 'string') { inputSource = [command.input]; } else { inputSource = command.input; } if (inputSource && inputSource.length > 0) { if (inputSource.some((input: string) => input.includes('='))) { //"rollup -i input.js f=es" => { _: [ 'f=es' ], i: 'input.js', input: 'input.js' } command.input = {}; //处理多入口文件的情况 for (const input of inputSource) { const equalsIndex = input.indexOf('='); const value = input.slice(Math.max(0, equalsIndex + 1)); //获取等号右边的字符=> “es” const key = input.slice(0, Math.max(0, equalsIndex)) || getAliasName(input); //获取等号左边的字符=> “f” command.input[key] = value; } } else { //处理单入口文件的情况 command.input = inputSource; } } if (command.environment) { //获取environment参数用于设置process.env.[XX] const environment = Array.isArray(command.environment) ? command.environment : [command.environment]; for (const argument of environment) { for (const pair of argument.split(',')) { const [key, ...value] = pair.split(':'); env[key] = value.length === 0 ? String(true) : value.join(':'); } } } if (isWatchEnabled(command.watch)) { //观察模式 await loadFsEvents(); const { watch } = await import('./watch-cli'); watch(command); } else { //非观察模式 try { const { options, warnings } = await getConfigs(command); try { //因为配置文件可以返回一个数组,所以需要挨个执行 for (const inputOptions of options) { //内部执行 rollup(inputOptions) 进行打包 await build(inputOptions, warnings, command.silent); } if (command.failAfterWarnings && warnings.warningOccurred) { warnings.flush(); handleError(errorFailAfterWarnings()); } } catch (error: any) { warnings.flush(); handleError(error); } } catch (error: any) { handleError(error); } } } async function getConfigs( command: any ): Promise<{ options: MergedRollupOptions[]; warnings: BatchWarnings }> { if (command.config) { //获取配置文件 const configFile = await getConfigPath(command.config); //读取配置文件获取配置项 const { options, warnings } = await loadConfigFile(configFile, command); return { options, warnings }; } return await loadConfigFromCommand(command); }
打包生成 rollup 文件
在 rollup.config.ts 文件中有导出一个方法:
//rollup.config.ts export default async function ( command: Record<string, unknown> ): Promise<RollupOptions | RollupOptions[]> { const { collectLicenses, writeLicense } = getLicenseHandler( fileURLToPath(new URL('.', import.meta.url)) ); const commonJSBuild: RollupOptions = { // 'fsevents' is a dependency of 'chokidar' that cannot be bundled as it contains binary code external: ['fsevents'], input: { 'loadConfigFile.js': 'cli/run/loadConfigFile.ts', 'rollup.js': 'src/node-entry.ts' }, onwarn, output: { banner: getBanner, chunkFileNames: 'shared/[name].js', dir: 'dist', entryFileNames: '[name]', exports: 'named', externalLiveBindings: false, format: 'cjs', freeze: false, generatedCode: 'es2015', interop: 'default', manualChunks: { rollup: ['src/node-entry.ts'] }, sourcemap: true }, plugins: [ ...nodePlugins, addCliEntry(), //添加cli入口文件 esmDynamicImport(), !command.configTest && collectLicenses(), !command.configTest && copyTypes('rollup.d.ts') ], strictDeprecations: true, treeshake }; /** * 当我们执行npm run build 的时候就相当于执行了 rollup --config rollup.config.ts --configPlugin typescript 此时 command 就是如下对象: { _: [], config: 'rollup.config.ts', c: 'rollup.config.ts', configPlugin: 'typescript' } */ if (command.configTest) { return commonJSBuild; } const esmBuild: RollupOptions = { ...commonJSBuild, input: { 'rollup.js': 'src/node-entry.ts' }, output: { ...commonJSBuild.output, dir: 'dist/es', format: 'es', minifyInternalExports: false, sourcemap: false }, plugins: [...nodePlugins, emitModulePackageFile(), collectLicenses(), writeLicense()] }; const { collectLicenses: collectLicensesBrowser, writeLicense: writeLicenseBrowser } = getLicenseHandler(fileURLToPath(new URL('browser', import.meta.url))); const browserBuilds: RollupOptions = { input: 'src/browser-entry.ts', onwarn, output: [ { banner: getBanner, file: 'browser/dist/rollup.browser.js', format: 'umd', name: 'rollup', plugins: [copyTypes('rollup.browser.d.ts')], sourcemap: true }, { banner: getBanner, file: 'browser/dist/es/rollup.browser.js', format: 'es', plugins: [emitModulePackageFile()] } ], plugins: [ replaceBrowserModules(), alias(moduleAliases), nodeResolve({ browser: true }), json(), commonjs(), typescript(), terser({ module: true, output: { comments: 'some' } }), collectLicensesBrowser(), writeLicenseBrowser(), cleanBeforeWrite('browser/dist') ], strictDeprecations: true, treeshake }; return [commonJSBuild, esmBuild, browserBuilds]; }
请注意上面使用了 addCliEntry 插件。它的代码定义在 build-plugins/add-cli-entry.ts:
import { chmod } from 'node:fs/promises'; import { resolve } from 'node:path'; import MagicString from 'magic-string'; import type { Plugin } from 'rollup'; const CLI_CHUNK = 'bin/rollup'; export default function addCliEntry(): Plugin { return { buildStart() { this.emitFile({ fileName: CLI_CHUNK, id: 'cli/cli.ts', preserveSignature: false, type: 'chunk' }); }, name: 'add-cli-entry', renderChunk(code, chunkInfo) { if (chunkInfo.fileName === CLI_CHUNK) { const magicString = new MagicString(code); //声明在 shell 中使用 node来运行 magicString.prepend('#!/usr/bin/env node\n\n'); return { code: magicString.toString(), map: magicString.generateMap({ hires: true }) }; } return null; }, writeBundle({ dir }) { return chmod(resolve(dir!, CLI_CHUNK), '755'); //修改文件可读写权限,保证执行的权限 /* 在Node.js中,可以调用fs模块,有一个方法chmod,可以用来修改文件或目录的读写权限。方法chmod有三个参数,文件路径、读写权限和回调函数,其中读写权限是用代号表示的, (1)0600:所有者可读写,其他的用户不行 (2)0644:所有者可读写,其他的用户只读 (3)0740:所有者可读写,所有者所在的组只读 (4)0755:所有者可读写,其他用户可读可执行 */ } }; }
addCliEntry 插件将 /cli/cli.ts 源码添加到输出的 chunk 中,并且在文件的头部增加一行代码:'#!/usr/bin/env node\n\n'。
首先解释一下 #!/usr/bin/env node
- # 在 shell 脚本中单独使用代表注释
- #! 组合使用表示要用在 shell 脚本中
- env 是 Mac 或者 Linux 系统的环境变量,是一个可执行命令
- env node : 指的是使用当前 env 环境内的配置的 Path 路径下的 node 执行
- 当前脚本在执行 shell 时,会自动从 env 内调用合适的解释器执行
这样做的目的是为了能够解析当前脚本文件,该命令会自动从当前 env 环境中查找配置的 node 版本来执行脚本。
最终我们使用 npm run build 的命令打包 rollup 源码的时候就会生成 dist/bin/rollup 这个文件了
#!/usr/bin/env node /* @license Rollup.js v3.2.3 Sat, 28 Jan 2023 07:43:49 GMT - commit 5fa73d941c16a6bcbebaa3ae5bb6aaca8b97d0b7 https://github.com/rollup/rollup Released under the MIT License. */ 'use strict'; Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: 'Module' } }); const process$1 = require('node:process'); const rollup = require('../shared/rollup.js'); const require$$2 = require('util'); const require$$0 = require('path'); const require$$0$1 = require('fs'); const node_fs = require('node:fs'); const node_path = require('node:path'); const loadConfigFile_js = require('../shared/loadConfigFile.js'); require('node:perf_hooks'); require('node:crypto'); require('node:events'); require('tty'); require('node:url'); # ... const command = argParser(process$1.argv.slice(2), { alias: rollup.commandAliases, configuration: { 'camel-case-expansion': false } //为 argParser 解析器提供配置选项, 'camel-case-expansion': false 表示连字符参数不会被扩展为驼峰大小写别名 }); //process.stdin.isTTY 用于检测我们的程序是否直接连到终端 if (command.help || (process$1.argv.length <= 2 && process$1.stdin.isTTY)) { console.log(`\n${help.replace('__VERSION__', rollup.version)}\n`); } else if (command.version) { console.log(`rollup v${rollup.version}`); } else { try { // eslint-disable-next-line unicorn/prefer-module //浏览器是支持source maps的,但node环境原生不支持source maps。所以我们可以通过'source-map-support'包来实现这个功能。这样当程序执行出错的时候方便通过控制台定位到源码位置。 require('source-map-support').install(); } catch { // do nothing } runRollup(command); } exports.getConfigPath = getConfigPath; exports.loadConfigFromCommand = loadConfigFromCommand; exports.prettyMilliseconds = prettyMilliseconds; exports.printTimings = printTimings; //# sourceMappingURL=rollup.map
加载全部内容