自动化构建
主要内容:
- 自动化构建的介绍
- DevOps
- 发展历史
- 打包工具:encode-bundle(vite:esbuild和swc实现)
自动化构建的基本知识
历史
- 2004年前,是没有前端构建的,当时,一般 php 写前端后端的,切图的方式+css 组织起来
gmail,google doc,共享文档开始
gmail出现之前,使用邮件客户端集成自己的项目
引入gmail后,开始做好用的网页,然后网页变得越来越复杂
直接使用编写的 HTML/JS/CSS,写什么样,浏览器就运行什么样 - 框架出现后,整个应用文件很多后,涉及到将文件合并,压缩,混淆等各种各样操作后,这时候,构建工具出现了
当年的构建工具,gulp / grunt / webpack / vite 打包工具出现
发布:本地手动构建 -> 上传到服务器 / cdn 上去
shell脚本实现,七牛cdn,提供命令行工具,access token,secrete token,本地路径,远程路径等写到配置文件中,通过执行七牛的cdn工具就可以把 文件 从本地打包成dist文件传到服务器上去,来实现半自动化操作。 - 自动化的打包构建:jenkins(针对前后端的打包构建工具,插件实现构建过程)
- 云构建:github action / gitlab / 内部的打包构建平台(一般基于gitlab生态做二次开发的)
配置yaml 文件
,布置流水线
云构建 和 自动化构建 的区别:
与 Docker & VM
有很大关系
Docker:
借助linux特性,像namespace来实现物理隔离的
整体上还是 linux 上一个一个的进程
优势:快速启动,物理机的损耗小
利用类似 git 来实现的
Dockerfile:每个配置就是一层,层和层之间是相互独立的
通过 Dockerfile 可以快速构建一个容器
VM:
裸机上的物理隔离,两个虚拟机之间是完全隔离的
物理机的损耗大,由于要模拟整套硬件
部署
部署文件、npm包、docker镜像
复制文件,发布文件,npm包别人可以复用
环境:
(1)本地环境local:做开发,联调用的
(2)测试环境test:联调完了,发布到测试环境上让测试,产品看一看
(3)预发布环境pre/灰度环境:发布前与正式环境相同的环境,甚至可以被一些用户访问到,给预发布环境导入5%的流量/拉白名单
(4)正式环境 prod
(5)AB环境:测试某些新功能,性能优化的程度,新环境与老环境都运行,导入不同的流量进入,分析数据。前端页面优化,分别给不同用户看不同的页面,分析用户留痕,页面效率,性能等进行对比
构建:
(1)loader:解析,编译,类型处理。处理通道,一个loader处理完后,给下一个loader接着处理,还可以做 同步/异步 操作。webpack可以动态的加载和执行loader。一般是处理文件
的
(2)plugin:提供了一种类似钩子的功能,在特定时机执行plugin,各种不同的时机执行各种操作,也是可以 同步/异步的。一方面可以处理文件
,另一方面可以做各种其他的事情
,例如:文件转换
等。
(3)plugin和loader的区分:plugin更广泛一些,但是,执行同一个配置文件的构建流程,plugin运行时机,还有数量等各种操作是固定的
。loader是不一定的
。
例如,针对没有scss文件,即使写了scss-loader,但是也是不会执行的。但是在插件中,写了 scss-plugin,是肯定要执行的,除非要自己判断。
loader是串联使用的
,处理的是一个链条,一个loader的输出是另一个loader的输入。可以理解为搞翻译的,webpack不认识的语言,都可以编写对应的loader帮助webpack翻译成AST的,通过AST做各种各样的转换
plugin可以自定义各种执行时机
,特定操作:压缩代码,输入输出目录清理,生成额外文件等都比较适合plugin来做
plugin可以分多个阶段来触发
,设定不同的时期触发同一个plugin,但是loader从生命周期开始到生命周期结束,一次性就完成了
构建产物
构建产物一定是浏览器能够看懂的代码
es5,js,css这些
构建和打包的性能优化
面试题:
前端性能优化分很多方面,那么在构建打包流程时候怎么做性能优化?
页面加载优化
- tree shaking:将所编写的代码生成AST,通过AST的结果进行检查,发现哪些代码虽然你引用了,但是你没用到
为什么esmodule方式可以在构建的时候就能知道哪些代码没用呢?
因为 import 是静态的操作,在编写的时候就已经确定的知道哪些代码要被舍,哪些代码不被舍。
比如,在写JS代码的时候,已经有一些规则,import语句必须写头部,一开始就知道当前的文件需要哪些模块
- esm / commonJS 多使用 esm,减少使用commonJS
commonJS和ESM的区别
- commonJS:使用require方式引入,require是一种函数
ecma中是没有 require的,esm中 import 的关键字是从语法方面,JS引擎方面进行执行
为什么vite项目启动快?
因为借助了esm, no-bundle的方式实现的,vite借助浏览器能够直接使用import关键字实现的
- 模块加载的时机:commonJS 是在使用的时候才会加载,esm是在编译的阶段就会加载
- 导出:commonJS导出时候,基本类型的时候是拷贝,引用类型的时候是地址,esm统一是地址
- 动态加载:commonJS中的require是可以动态加载的,import关键字是不能动态加载的,import函数是动态加载的(import 关键字:import xxx from xxx;import 函数:import { xxx } from xxx)
- commonJS里两个文件是不能相互引用的,esm中是不存在这种情况
- require是不能在浏览器中直接执行的,可以执行的情况:umd,cmd支持require;import是可以在浏览器中直接执行的
- 按需加载
- 异步组件
- vender.js 第三方的依赖
vue/echarts 这种一般是不会变的,将这些单独打成一个包,在浏览器存储起来,下次使用缓存调用,只有业务变动的通过文件加载上去
微前端是怎么提升性能的?不同的应用直接是可以共享依赖的,就是类似 vender 概念的。
以上基本都是页面加载的优化,那么有什么办法能够提升构建速度呢?
构建速度优化
- 空间换时间:将能够缓存下来的东西,不要重复计算,存到内存中
- cache:打包结果存成单独的文件,下次构建时候,先将这种文件利用起来。就类似:rspack,用的就是这种方法。只有在modules中文件变化,才会重新编译生成出来
- 利用CPU的多核性能:happypack,esbuild,利用子进程实现快速跑起来。
DevOps
将我们的发布,部署变得稳定有效
除了日常开发需求工作外,都叫 DevOps
工程,规范,流程都属于DevOps
像tslint,tsconfig,webpack.config这种都可以叫做 DevOps,因为将我们的代码满足规范,生成出满足特定要求的代码
原则
流动原则:其实就是加速开发
反馈:一旦有什么问题要快速解决掉,将这种解决的过程变成可重复的模式,变成一种规范
持续的改进和迭代:不是一成不变的,是不停的根据开发的情况,系统整体的变更情况,生产环境的情况等不断变化
反馈的技术实践
建立所谓的遥测系统:追踪,指标,日志
追踪:发布流程的追踪,用户的追踪。比如,用户出现一个bug,自己复现不好复现。借助用户追踪还原现场,实现对bug的快速定位
指标:对前端来说就是性能指标,LCP:页面最长加载时间
日志:错误日志,网络请求错误,JS错误
投简历最好在早上投递,每天最好投递几十个。每天:八股文+常见前端算法题+写代码题:promise,并发控制,发布订阅写一写。一般上半年行情比下半年行情好,这几年没有前几年行情好,一面的面试官很可能是你的同事,面试的时候不要有情绪,一上来就给hard难度,是不想要你,但是一开始是简单的,后来给hard难度的,证明面试官看好你
encode-bundle
打包工具
当执行这个命令安装项目的时候
pnpm i encode-bundle -D
就会生成可执行文件链接,之后在命令行输出"encode-bundle"时候,就会执行 bin 下encode-bundle对应的路径文件
tests 测试目录:encode日常运行的参数/命令做的测试
package.json解读
{
"name": "encode-bundle",//命令行工具
"version": "1.4.1",
"main": "dist/index.js", //默认入口,是在构建完后才会存在的
"bin": {
"encode-bundle": "dist/cli-default.js", //encode-bundle命令执行的文件
"encode-bundle-node": "dist/cli-node.js" //encode-bundle-node命令执行的文件
},
"types": "dist/index.d.ts",//描述文件
//发布到npm registry时的目录
"files": [
"/dist",
"/assets",
"/schema.json",
"README.md"
],
"keywords": [
"encode",
"bundle",
"esbuild",
"swc"
],
"scripts": {
"preinstall": "npx only-allow pnpm", //npm i 之前执行的命令,npx是直接执行一个only-allow仓库的代码,找到注册的机构,执行only-allow下的bin的命令,传递了pnpm参数,然后限制只有pnpm可以安装
"prepare": "husky install", //commit的前置钩子
"init": "pnpm install && (cd docs && pnpm install)",//pnpm安装并且进入到docs目录下,也执行了pnpm安装
"dev": "pnpm run build:fast --watch",
"dev:docs": "cd docs && pnpm run start",
"start": "pnpm run dev",
"start:docs": "pnpm run dev:docs",
"build": "encode-bundle src/cli-*.ts src/index.ts src/rollup.ts --clean --splitting",
//实现了自启动,用的encode-bundle开发的encode-bundle,执行的src目录下的所有cli开头的ts文件,index.ts文件,src目录下的rollup.ts文件都作为entry,--clean清除dist目录下的文件,--splitting拆分,拆分成多个文件
"build:docs": "cd docs && pnpm run build",//生成站点
"build:fast": "npm run build --no-dts",//--no-dts不生成描述文件
"prepublishOnly": "pnpm run build",//发布前执行build
"pub:beta": "pnpm -r publish --tag beta",//发布到npm registry时,指定beta标签
"pub": "pnpm -r publish",//发布正式版
"test": "pnpm run build && pnpm run testOnly",
"testOnly": "vitest run",
"encode-fe-lint-scan": "encode-fe-lint scan",
"encode-fe-lint-fix": "encode-fe-lint fix"
},
"dependencies": {
"bundle-require": "^4.0.0",
"cac": "^6.7.12",//做命令行的
"chokidar": "^3.5.1",
"debug": "^4.3.1",
"esbuild": "^0.18.2",//golang写的构建工具,但是有些功能是没有的
"execa": "^5.0.0",
"globby": "^11.0.3",
"joycon": "^3.0.1",
"postcss-load-config": "^4.0.1",
"resolve-from": "^5.0.0",
"rollup": "^3.2.5",
"semver": "^7.5.4",
"source-map": "0.8.0-beta.0",
"sucrase": "^3.20.3",
"tree-kill": "^1.2.2"
},
"devDependencies": {
"@rollup/plugin-json": "5.0.1",
"@swc/core": "1.2.218",//替代babel,rust写的
"@types/debug": "4.1.7",
"@types/flat": "5.0.2",
"@types/fs-extra": "9.0.13",
"@types/node": "14.18.12",
"@types/resolve": "1.20.1",
"@vitest/runner": "^0.34.3",
"colorette": "2.0.16",
"consola": "2.15.3",
"encode-bundle": "^0.1.0",
"encode-fe-lint": "^1.0.3",
"flat": "5.0.2",
"fs-extra": "10.0.0",
"husky": "^8.0.0",
"postcss": "8.4.12",
"postcss-simple-vars": "6.0.3",
"resolve": "1.20.0",
"rollup-plugin-dts": "5.3.0",
"rollup-plugin-hashbang": "3.0.0",
"sass": "1.62.1",
"strip-json-comments": "4.0.0",
"svelte": "3.46.4",
"svelte-preprocess": "5.0.3",
"terser": "^5.16.0",
"ts-essentials": "9.1.2",
"tsconfig-paths": "3.12.0",
"typescript": "5.0.2",
"vitest": "^0.34.3",
"wait-for-expect": "3.0.2"
},
"peerDependencies": {
"@swc/core": "^1",
"postcss": "^8.4.12",
"typescript": ">=4.1.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
},
"postcss": {
"optional": true
},
"@swc/core": {
"optional": true
}
},
"engines": {
"node": ">=16.14"
},
"packageManager": "pnpm@8.6.0",
"husky": {
"hooks": {
"pre-commit": "encode-fe-lint commit-file-scan",
"commit-msg": "encode-fe-lint commit-msg-scan"
}
}
}
src/cli-default.ts
#!/usr/bin/env node
import { handleError } from './errors'
import { main } from './cli-main'
main().catch(handleError)
cli-main的影射
src/cli-node.ts
#!/usr/bin/env node
import { handleError } from './errors'
import { main } from './cli-main'
main({
skipNodeModulesBundle: true,//将node_modules中有的不打到包的,只打手动编写的代码
}).catch(handleError)
src/cli-main.ts
import { cac } from 'cac'
import flat from 'flat'
import { Format, Options } from '.'
import { version } from '../package.json'
import { slash } from './utils'
function ensureArray(input: string): string[] {
return Array.isArray(input) ? input : input.split(',')
}
export async function main(options: Options = {}) {
const cli = cac('encode-bundle')
cli
.command('[...files]', 'Bundle files', {
ignoreOptionDefaultValue: true,
})
.option('--entry.* <file>', 'Use a key-value pair as entry files')
.option('-d, --out-dir <dir>', 'Output directory', { default: 'dist' })
.option('--format <format>', 'Bundle format, "cjs", "iife", "esm"', {
default: 'cjs',
})
.option('--minify [terser]', 'Minify bundle')
.option('--minify-whitespace', 'Minify whitespace')
.option('--minify-identifiers', 'Minify identifiers')
.option('--minify-syntax', 'Minify syntax')
.option(
'--keep-names',
'Keep original function and class names in minified code'
)
.option('--target <target>', 'Bundle target, "es20XX" or "esnext"', {
default: 'es2017',
})
.option(
'--legacy-output',
'Output different formats to different folder instead of using different extensions'
)
.option('--dts [entry]', 'Generate declaration file')
.option('--dts-resolve', 'Resolve externals types used for d.ts files')
.option('--dts-only', 'Emit declaration files only')
.option(
'--sourcemap [inline]',
'Generate external sourcemap, or inline source: --sourcemap inline'
)
.option(
'--watch [path]',
'Watch mode, if path is not specified, it watches the current folder ".". Repeat "--watch" for more than one path'
)
.option('--ignore-watch <path>', 'Ignore custom paths in watch mode')
.option(
'--onSuccess <command>',
'Execute command after successful build, specially useful for watch mode'
)
.option('--env.* <value>', 'Define compile-time env variables')
.option(
'--inject <file>',
'Replace a global variable with an import from another file'
)
.option('--define.* <value>', 'Define compile-time constants')
.option(
'--external <name>',
'Mark specific packages / package.json (dependencies and peerDependencies) as external'
)
.option('--global-name <name>', 'Global variable name for iife format')
.option('--jsxFactory <jsxFactory>', 'Name of JSX factory function', {
default: 'React.createElement',
})
.option('--jsxFragment <jsxFragment>', 'Name of JSX fragment function', {
default: 'React.Fragment',
})
.option('--replaceNodeEnv', 'Replace process.env.NODE_ENV')
.option('--no-splitting', 'Disable code splitting')
.option('--clean', 'Clean output directory')
.option(
'--silent',
'Suppress non-error logs (excluding "onSuccess" process output)'
)
.option('--pure <express>', 'Mark specific expressions as pure')
.option('--metafile', 'Emit esbuild metafile (a JSON file)')
.option('--platform <platform>', 'Target platform', {
default: 'node',
})
.option('--loader <ext=loader>', 'Specify the loader for a file extension')
.option('--tsconfig <filename>', 'Use a custom tsconfig')
.option('--config <filename>', 'Use a custom config file')
.option('--no-config', 'Disable config file')
.option('--shims', 'Enable cjs and esm shims')
.option('--inject-style', 'Inject style tag to document head')
.option(
'--treeshake [strategy]',
'Using Rollup for treeshaking instead, "recommended" or "smallest" or "safest"'
)
.option('--publicDir [dir]', 'Copy public directory to output directory')
.option(
'--killSignal <signal>',
'Signal to kill child process, "SIGTERM" or "SIGKILL"'
)
.option('--cjsInterop', 'Enable cjs interop')
// files:文件列表,flags:各种配置
.action(async (files: string[], flags) => {
const { build } = await import('.') //从index中引入build方法
//将main函数的参数和命令行的参数合并
Object.assign(options, { //options是main函数传进来的参数
...flags,
})
// 如果没有entry,并且有files,就将files赋值给entry
if (!options.entry && files.length > 0) {
options.entry = files.map(slash)
}
//以下是各种处理参数的逻辑
if (flags.format) {
const format = ensureArray(flags.format) as Format[]
options.format = format
}
if (flags.external) {
const external = ensureArray(flags.external)
options.external = external
}
if (flags.target) {
options.target =
flags.target.indexOf(',') >= 0
? flags.target.split(',')
: flags.target
}
if (flags.dts || flags.dtsResolve || flags.dtsOnly) {
options.dts = {}
if (typeof flags.dts === 'string') {
options.dts.entry = flags.dts
}
if (flags.dtsResolve) {
options.dts.resolve = flags.dtsResolve
}
if (flags.dtsOnly) {
options.dts.only = true
}
}
if (flags.inject) {
const inject = ensureArray(flags.inject)
options.inject = inject
}
if (flags.define) {
const define: Record<string, string> = flat(flags.define)
options.define = define
}
if (flags.loader) {
const loader = ensureArray(flags.loader)
options.loader = loader.reduce((result, item) => {
const parts = item.split('=')
return {
...result,
[parts[0]]: parts[1],
}
}, {})
}
// 最终运行build函数,并将options传给它
await build(options)
})
cli.help()
cli.version(version)
cli.parse(process.argv, { run: false })
await cli.runMatchedCommand()
}
src/index.ts
export async function build(_options: Options) {
// 分析config
const config =
_options.config === false
? {}
: await loadEncodeBundleConfig(
process.cwd(),
_options.config === true ? undefined : _options.config,
);
const configData = typeof config.data === 'function' ? await config.data(_options) : config.data;
// 整个build里所有做的事情
await Promise.all(
[...(Array.isArray(configData) ? configData : [configData])].map(async (item) => {
const logger = createLogger(item?.name);
const options = await normalizeOptions(logger, item, _options);
logger.info('CLI', `encode-bundle v${version}`);
if (config.path) {
logger.info('CLI', `Using encode-bundle config: ${config.path}`);
}
if (options.watch) {
logger.info('CLI', 'Running in watch mode');
}
// 生成描述文件
const dtsTask = async () => {
if (options.dts) {
await new Promise<void>((resolve, reject) => {
// _dirname:当前文件所在目录的绝对路径
const worker = new Worker(path.join(__dirname, './rollup.js')); //运行一个worker子进程,执行rollup.js文件
// worker.postMessage:向子进程发送消息,也就是给rollup传递参数
worker.postMessage({
configName: item?.name,
options: {
...options, // functions cannot be cloned
banner: undefined,
footer: undefined,
esbuildPlugins: undefined,
esbuildOptions: undefined,
plugins: undefined,
treeshake: undefined,
onSuccess: undefined,
outExtension: undefined,
},
});
// 抛出message事件,监听子进程的消息
worker.on('message', (data) => {
if (data === 'error') {
reject(new Error('error occured in dts build'));
} else if (data === 'success') {
resolve();
} else {
const { type, text } = data;
if (type === 'log') {
console.log(text);
} else if (type === 'error') {
console.error(text);
}
}
});
});
}
};
// 生成真正的文件
const mainTasks = async () => {
if (!options.dts?.only) {
let onSuccessProcess: ChildProcess | undefined;
let onSuccessCleanup: (() => any) | undefined | void;
/** Files imported by the entry */
const buildDependencies: Set<string> = new Set();
let depsHash = await getAllDepsHash(process.cwd());
const doOnSuccessCleanup = async () => {
if (onSuccessProcess) {
await killProcess({
pid: onSuccessProcess.pid,
signal: options.killSignal || 'SIGTERM',
});
} else if (onSuccessCleanup) {
await onSuccessCleanup();
}
// reset them in all occasions anyway
onSuccessProcess = undefined;
onSuccessCleanup = undefined;
};
const debouncedBuildAll = debouncePromise(
() => {
return buildAll();
},
100,
handleError,
);
const buildAll = async () => {
await doOnSuccessCleanup();
// Store previous build dependencies in case the build failed
// So we can restore it
const previousBuildDependencies = new Set(buildDependencies);
buildDependencies.clear();
if (options.clean) {
const extraPatterns = Array.isArray(options.clean) ? options.clean : [];
// .d.ts files are removed in the `dtsTask` instead
// `dtsTask` is a separate process, which might start before `mainTasks`
if (options.dts) {
extraPatterns.unshift('!**/*.d.{ts,cts,mts}');
}
//清理旧文件
await removeFiles(['**/*', ...extraPatterns], options.outDir);
logger.info('CLI', 'Cleaning output folder');
}
const css: Map<string, string> = new Map();
await Promise.all([
...options.format.map(async (format, index) => {
//pluginContainer插件管理器,将插件列表一个一个给到,它就可以按顺序在特定的时机按照定义一个一个执行我们的插件
const pluginContainer = new PluginContainer([
shebang(),
...(options.plugins || []),
treeShakingPlugin({
treeshake: options.treeshake,
name: options.globalName,
silent: options.silent,
}),
cjsSplitting(),
cjsInterop(),
//重点看怎么生成es5的
es5(),
sizeReporter(),
terserPlugin({
minifyOptions: options.minify,
format,
terserOptions: options.terserOptions,
globalName: options.globalName,
logger,
}),
]);
//执行runEsbuild函数
await runEsbuild(options, {
pluginContainer,
format,
css: index === 0 || options.injectStyle ? css : undefined,
logger,
buildDependencies,
}).catch((error) => {
previousBuildDependencies.forEach((v) => buildDependencies.add(v));
throw error;
});
}),
]);
if (options.onSuccess) {
if (typeof options.onSuccess === 'function') {
onSuccessCleanup = await options.onSuccess();
} else {
onSuccessProcess = execa(options.onSuccess, {
shell: true,
stdio: 'inherit',
});
onSuccessProcess.on('exit', (code) => {
if (code && code !== 0) {
process.exitCode = code;
}
});
}
}
};
const startWatcher = async () => {
if (!options.watch) return;
const { watch } = await import('chokidar');
const customIgnores = options.ignoreWatch
? Array.isArray(options.ignoreWatch)
? options.ignoreWatch
: [options.ignoreWatch]
: [];
const ignored = ['**/{.git,node_modules}/**', options.outDir, ...customIgnores];
const watchPaths =
typeof options.watch === 'boolean'
? '.'
: Array.isArray(options.watch)
? options.watch.filter((path): path is string => typeof path === 'string')
: options.watch;
logger.info(
'CLI',
`Watching for changes in ${
Array.isArray(watchPaths)
? watchPaths.map((v) => '"' + v + '"').join(' | ')
: '"' + watchPaths + '"'
}`,
);
logger.info(
'CLI',
`Ignoring changes in ${ignored.map((v) => '"' + v + '"').join(' | ')}`,
);
const watcher = watch(watchPaths, {
ignoreInitial: true,
ignorePermissionErrors: true,
ignored,
});
watcher.on('all', async (type, file) => {
file = slash(file);
if (options.publicDir && isInPublicDir(options.publicDir, file)) {
logger.info('CLI', `Change in public dir: ${file}`);
copyPublicDir(options.publicDir, options.outDir);
return;
}
// By default we only rebuild when imported files change
// If you specify custom `watch`, a string or multiple strings
// We rebuild when those files change
let shouldSkipChange = false;
if (options.watch === true) {
if (file === 'package.json' && !buildDependencies.has(file)) {
const currentHash = await getAllDepsHash(process.cwd());
shouldSkipChange = currentHash === depsHash;
depsHash = currentHash;
} else if (!buildDependencies.has(file)) {
shouldSkipChange = true;
}
}
if (shouldSkipChange) {
return;
}
logger.info('CLI', `Change detected: ${type} ${file}`);
debouncedBuildAll();
});
};
logger.info('CLI', `Target: ${options.target}`);
await buildAll();
copyPublicDir(options.publicDir, options.outDir);
startWatcher();
}
};
await Promise.all([dtsTask(), mainTasks()]);
}),
);
}
src/rollup.ts
import { parentPort } from 'worker_threads';
import { InputOptions, OutputOptions, Plugin } from 'rollup';
import { NormalizedOptions } from './';
import ts from 'typescript';
import hashbangPlugin from 'rollup-plugin-hashbang';
import jsonPlugin from '@rollup/plugin-json';
import { handleError } from './errors';
import { defaultOutExtension, removeFiles } from './utils';
import { TsResolveOptions, tsResolvePlugin } from './rollup/ts-resolve';
import { createLogger, setSilent } from './log';
import { getProductionDeps, loadPkg } from './load';
import path from 'path';
import { reportSize } from './lib/report-size';
import resolveFrom from 'resolve-from';
const logger = createLogger();
const parseCompilerOptions = (compilerOptions?: any) => {
if (!compilerOptions) return {};
const { options } = ts.parseJsonConfigFileContent({ compilerOptions }, ts.sys, './');
return options;
};
const dtsPlugin: typeof import('rollup-plugin-dts') = require('rollup-plugin-dts');
type RollupConfig = {
inputConfig: InputOptions;
outputConfig: OutputOptions[];
};
const findLowestCommonAncestor = (filepaths: string[]) => {
if (filepaths.length <= 1) return '';
const [first, ...rest] = filepaths;
let ancestor = first.split('/');
for (const filepath of rest) {
const directories = filepath.split('/', ancestor.length);
let index = 0;
for (const directory of directories) {
if (directory === ancestor[index]) {
index += 1;
} else {
ancestor = ancestor.slice(0, index);
break;
}
}
ancestor = ancestor.slice(0, index);
}
return ancestor.length <= 1 && ancestor[0] === '' ? '/' + ancestor[0] : ancestor.join('/');
};
const toObjectEntry = (entry: string[]) => {
entry = entry.map((e) => e.replace(/\\/g, '/'));
const ancestor = findLowestCommonAncestor(entry);
return entry.reduce((result, item) => {
const key = item
.replace(ancestor, '')
.replace(/^\//, '')
.replace(/\.[a-z]+$/, '');
return {
...result,
[key]: item,
};
}, {});
};
const getRollupConfig = async (options: NormalizedOptions): Promise<RollupConfig> => {
setSilent(options.silent);
const compilerOptions = parseCompilerOptions(options.dts?.compilerOptions);
const dtsOptions = options.dts || {};
dtsOptions.entry = dtsOptions.entry || options.entry;
if (Array.isArray(dtsOptions.entry) && dtsOptions.entry.length > 1) {
dtsOptions.entry = toObjectEntry(dtsOptions.entry);
}
let tsResolveOptions: TsResolveOptions | undefined;
if (dtsOptions.resolve) {
tsResolveOptions = {};
// Only resolve specific types when `dts.resolve` is an array
if (Array.isArray(dtsOptions.resolve)) {
tsResolveOptions.resolveOnly = dtsOptions.resolve;
}
// `paths` should be handled by rollup-plugin-dts
if (compilerOptions.paths) {
const res = Object.keys(compilerOptions.paths).map(
(p) => new RegExp(`^${p.replace('*', '.+')}$`),
);
tsResolveOptions.ignore = (source) => {
return res.some((re) => re.test(source));
};
}
}
const pkg = await loadPkg(process.cwd());
const deps = await getProductionDeps(process.cwd());
const encodeBundleCleanPlugin: Plugin = {
name: 'encode-bundle:clean',
async buildStart() {
if (options.clean) {
await removeFiles(['**/*.d.{ts,mts,cts}'], options.outDir);
}
},
};
const ignoreFiles: Plugin = {
name: 'encode-bundle:ignore-files',
load(id) {
if (!/\.(js|cjs|mjs|jsx|ts|tsx|mts|json)$/.test(id)) {
return '';
}
},
};
const fixCjsExport: Plugin = {
name: 'encode-bundle:fix-cjs-export',
renderChunk(code, info) {
if (
info.type !== 'chunk' ||
!/\.(ts|cts)$/.test(info.fileName) ||
!info.isEntry ||
info.exports?.length !== 1 ||
info.exports[0] !== 'default'
)
return;
return code.replace(/(?<=(?<=[;}]|^)\s*export\s*){\s*([\w$]+)\s*as\s+default\s*}/, `= $1`);
},
};
return {
inputConfig: {
input: dtsOptions.entry,
onwarn(warning, handler) {
if (
warning.code === 'UNRESOLVED_IMPORT' ||
warning.code === 'CIRCULAR_DEPENDENCY' ||
warning.code === 'EMPTY_BUNDLE'
) {
return;
}
return handler(warning);
},
plugins: [
encodeBundleCleanPlugin,
tsResolveOptions && tsResolvePlugin(tsResolveOptions),
hashbangPlugin(),
jsonPlugin(),
ignoreFiles,
dtsPlugin.default({
tsconfig: options.tsconfig,
compilerOptions: {
...compilerOptions,
baseUrl: compilerOptions.baseUrl || '.',
// Ensure ".d.ts" modules are generated
declaration: true,
// Skip ".js" generation
noEmit: false,
emitDeclarationOnly: true,
// Skip code generation when error occurs
noEmitOnError: true,
// Avoid extra work
checkJs: false,
declarationMap: false,
skipLibCheck: true,
preserveSymlinks: false,
// Ensure we can parse the latest code
target: ts.ScriptTarget.ESNext,
},
}),
].filter(Boolean),
external: [
// Exclude dependencies, e.g. `lodash`, `lodash/get`
...deps.map((dep) => new RegExp(`^${dep}($|\\/|\\\\)`)),
...(options.external || []),
],
},
outputConfig: options.format.map((format): OutputOptions => {
const outputExtension =
options.outExtension?.({ format, options, pkgType: pkg.type }).dts ||
defaultOutExtension({ format, pkgType: pkg.type }).dts;
return {
dir: options.outDir || 'dist',
format: 'esm',
exports: 'named',
banner: dtsOptions.banner,
footer: dtsOptions.footer,
entryFileNames: `[name]${outputExtension}`,
plugins: [format === 'cjs' && options.cjsInterop && fixCjsExport].filter(Boolean),
};
}),
};
};
async function runRollup(options: RollupConfig) {
const { rollup } = await import('rollup');
try {
const start = Date.now();
const getDuration = () => {
return `${Math.floor(Date.now() - start)}ms`;
};
logger.info('dts', 'Build start');
// 生成dts文件
const bundle = await rollup(options.inputConfig);
const results = await Promise.all(options.outputConfig.map(bundle.write));
const outputs = results.flatMap((result) => result.output);
logger.success('dts', `⚡️ Build success in ${getDuration()}`);
reportSize(
logger,
'dts',
outputs.reduce((res, info) => {
const name = path.relative(
process.cwd(),
path.join(options.outputConfig[0].dir || '.', info.fileName),
);
return {
...res,
[name]: info.type === 'chunk' ? info.code.length : info.source.length,
};
}, {}),
);
} catch (error) {
handleError(error);
logger.error('dts', 'Build error');
}
}
async function watchRollup(options: { inputConfig: InputOptions; outputConfig: OutputOptions[] }) {
const { watch } = await import('rollup');
watch({
...options.inputConfig,
plugins: options.inputConfig.plugins,
output: options.outputConfig,
}).on('event', (event) => {
if (event.code === 'START') {
logger.info('dts', 'Build start');
} else if (event.code === 'BUNDLE_END') {
logger.success('dts', `⚡️ Build success in ${event.duration}ms`);
parentPort?.postMessage('success');
} else if (event.code === 'ERROR') {
logger.error('dts', 'Build failed');
handleError(event.error);
}
});
}
// 入口函数
const startRollup = async (options: NormalizedOptions) => {
// options就是index.ts文件中传递的options
const config = await getRollupConfig(options);
if (options.watch) {
watchRollup(config);
} else {
try {
// 执行runRollup函数
await runRollup(config);
parentPort?.postMessage('success');
} catch (error) {
parentPort?.postMessage('error');
}
parentPort?.close();
}
};
// 监听父进程传递过来的消息
parentPort?.on('message', (data) => {
logger.setName(data.configName);
const hasTypescript = resolveFrom.silent(process.cwd(), 'typescript');
if (!hasTypescript) {
logger.error('dts', `You need to install "typescript" in your project`);
parentPort?.postMessage('error');
parentPort?.close();
return;
}
startRollup(data.options);
});
src/esbuild/index.ts
import fs from 'fs';
import path from 'path';
import { build as esbuild, BuildResult, formatMessages, Plugin as EsbuildPlugin } from 'esbuild';
import { NormalizedOptions, Format } from '..';
import { getProductionDeps, loadPkg } from '../load';
import { Logger, getSilent } from '../log';
import { nodeProtocolPlugin } from './node-protocol';
import { externalPlugin } from './external';
import { postcssPlugin } from './postcss';
import { sveltePlugin } from './svelte';
import consola from 'consola';
import { defaultOutExtension, truthy } from '../utils';
import { swcPlugin } from './swc';
import { nativeNodeModulesPlugin } from './native-node-module';
import { PluginContainer } from '../plugin';
import { OutExtensionFactory } from '../options';
const getOutputExtensionMap = (
options: NormalizedOptions,
format: Format,
pkgType: string | undefined,
) => {
const outExtension: OutExtensionFactory = options.outExtension || defaultOutExtension;
const defaultExtension = defaultOutExtension({ format, pkgType });
const extension = outExtension({ options, format, pkgType });
return {
'.js': extension.js || defaultExtension.js,
};
};
/**
* Support to exclude special package.json
*/
const generateExternal = async (external: (string | RegExp)[]) => {
const result: (string | RegExp)[] = [];
for (const item of external) {
if (typeof item !== 'string' || !item.endsWith('package.json')) {
result.push(item);
continue;
}
let pkgPath: string = path.isAbsolute(item)
? path.dirname(item)
: path.dirname(path.resolve(process.cwd(), item));
const deps = await getProductionDeps(pkgPath);
result.push(...deps);
}
return result;
};
export async function runEsbuild(
options: NormalizedOptions,
{
format,
css,
logger,
buildDependencies,
pluginContainer,
}: {
format: Format;
css?: Map<string, string>;
buildDependencies: Set<string>;
logger: Logger;
pluginContainer: PluginContainer;
},
) {
const pkg = await loadPkg(process.cwd());
const deps = await getProductionDeps(process.cwd());
// 哪些东西是不需要打包进来的
const external = [
// Exclude dependencies, e.g. `lodash`, `lodash/get`
...deps.map((dep) => new RegExp(`^${dep}($|\\/|\\\\)`)),
...(await generateExternal(options.external || [])),
];
const outDir = options.outDir;
const outExtension = getOutputExtensionMap(options, format, pkg.type);
const env: { [k: string]: string } = {
...options.env,
};
if (options.replaceNodeEnv) {
env.NODE_ENV = options.minify || options.minifyWhitespace ? 'production' : 'development';
}
logger.info(format, 'Build start');
const startTime = Date.now();
let result: BuildResult | undefined;
const splitting =
format === 'iife'
? false
: typeof options.splitting === 'boolean'
? options.splitting
: format === 'esm';
const platform = options.platform || 'node';
const loader = options.loader || {};
const injectShims = options.shims;
// 设置上下文,变量,日志打印工具
pluginContainer.setContext({
format,
splitting,
options,
logger,
});
// pluginContainer在特定的时机会触发,在某个时机要做某个事情
await pluginContainer.buildStarted();
const esbuildPlugins: Array<EsbuildPlugin | false | undefined> = [
format === 'cjs' && nodeProtocolPlugin(),
{
name: 'modify-options',
setup(build) {
pluginContainer.modifyEsbuildOptions(build.initialOptions);
if (options.esbuildOptions) {
options.esbuildOptions(build.initialOptions, { format });
}
},
},
// esbuild's `external` option doesn't support RegExp
// So here we use a custom plugin to implement it
format !== 'iife' &&
externalPlugin({
external,
noExternal: options.noExternal,
skipNodeModulesBundle: options.skipNodeModulesBundle,
tsconfigResolvePaths: options.tsconfigResolvePaths,
}),
options.tsconfigDecoratorMetadata && swcPlugin({ logger }),
nativeNodeModulesPlugin(),
postcssPlugin({
css,
inject: options.injectStyle,
cssLoader: loader['.css'],
}),
sveltePlugin({ css }),
...(options.esbuildPlugins || []),
];
const banner = typeof options.banner === 'function' ? options.banner({ format }) : options.banner;
const footer = typeof options.footer === 'function' ? options.footer({ format }) : options.footer;
try {
// esbuild来自于第三方esbuild,入口是一个JS文件,但是也只是收集参数生成配置,最终将参数给到go编译出来的exe文件
result = await esbuild({
entryPoints: options.entry,
format: (format === 'cjs' && splitting) || options.treeshake ? 'esm' : format,
bundle: typeof options.bundle === 'undefined' ? true : options.bundle,
platform,
globalName: options.globalName,
jsxFactory: options.jsxFactory,
jsxFragment: options.jsxFragment,
sourcemap: options.sourcemap ? 'external' : false,
target: options.target,
banner,
footer,
tsconfig: options.tsconfig,
loader: {
'.aac': 'file',
'.css': 'file',
'.eot': 'file',
'.flac': 'file',
'.gif': 'file',
'.jpeg': 'file',
'.jpg': 'file',
'.mp3': 'file',
'.mp4': 'file',
'.ogg': 'file',
'.otf': 'file',
'.png': 'file',
'.svg': 'file',
'.ttf': 'file',
'.wav': 'file',
'.webm': 'file',
'.webp': 'file',
'.woff': 'file',
'.woff2': 'file',
...loader,
},
mainFields: platform === 'node' ? ['module', 'main'] : ['browser', 'module', 'main'],
plugins: esbuildPlugins.filter(truthy),
define: {
ENCODE_BUNDLE_FORMAT: JSON.stringify(format),
...(format === 'cjs' && injectShims
? {
'import.meta.url': 'importMetaUrl',
}
: {}),
...options.define,
...Object.keys(env).reduce((res, key) => {
const value = JSON.stringify(env[key]);
return {
...res,
[`process.env.${key}`]: value,
[`import.meta.env.${key}`]: value,
};
}, {}),
},
inject: [
format === 'cjs' && injectShims ? path.join(__dirname, '../assets/cjs_shims.js') : '',
format === 'esm' && injectShims && platform === 'node'
? path.join(__dirname, '../assets/esm_shims.js')
: '',
...(options.inject || []),
].filter(Boolean),
outdir: options.legacyOutput && format !== 'cjs' ? path.join(outDir, format) : outDir,
outExtension: options.legacyOutput ? undefined : outExtension,
write: false,
splitting,
logLevel: 'error',
minify: options.minify === 'terser' ? false : options.minify,
minifyWhitespace: options.minifyWhitespace,
minifyIdentifiers: options.minifyIdentifiers,
minifySyntax: options.minifySyntax,
keepNames: options.keepNames,
pure: typeof options.pure === 'string' ? [options.pure] : options.pure,
metafile: true,
});
} catch (error) {
logger.error(format, 'Build failed');
throw error;
}
if (result && result.warnings && !getSilent()) {
const messages = result.warnings.filter((warning) => {
if (
warning.text.includes(`This call to "require" will not be bundled because`) ||
warning.text.includes(`Indirect calls to "require" will not be bundled`)
)
return false;
return true;
});
const formatted = await formatMessages(messages, {
kind: 'warning',
color: true,
});
formatted.forEach((message) => {
consola.warn(message);
});
}
// Manually write files
if (result && result.outputFiles) {
await pluginContainer.buildFinished({
outputFiles: result.outputFiles,
metafile: result.metafile,
});
const timeInMs = Date.now() - startTime;
logger.success(format, `⚡️ Build success in ${Math.floor(timeInMs)}ms`);
}
if (result.metafile) {
for (const file of Object.keys(result.metafile.inputs)) {
buildDependencies.add(file);
}
if (options.metafile) {
const outPath = path.resolve(outDir, `metafile-${format}.json`);
await fs.promises.mkdir(path.dirname(outPath), { recursive: true });
await fs.promises.writeFile(outPath, JSON.stringify(result.metafile), 'utf8');
}
}
}
src/plugins/es5.ts
import { PrettyError } from '../errors'
import { Plugin } from '../plugin'
import { localRequire } from '../utils'
// 返回是Plugin类型
export const es5 = (): Plugin => {
let enabled = false
// 返回的是一个对象
return {
name: 'es5-target',
// 这里有esbuildOptions,renderChunk两个特定方法,就会传给PluginContainer,插件管理器就会在特定的时机触发运行这些函数
esbuildOptions(options) {
if (options.target === 'es5') {
options.target = 'es2020'
enabled = true
}
},
async renderChunk(code, info) {
if (!enabled || !/\.(cjs|js)$/.test(info.path)) {
return
}
// 寻找swc
const swc: typeof import('@swc/core') = localRequire('@swc/core')
if (!swc) {
throw new PrettyError(
'@swc/core is required for es5 target. Please install it with `npm install @swc/core -D`'
)
}
// 调用swc的transform方法
const result = await swc.transform(code, {
filename: info.path,
sourceMaps: this.options.sourcemap,
minify: Boolean(this.options.minify),
jsc: {
target: 'es5',
parser: {
syntax: 'ecmascript',
},
minify: this.options.minify === true ? {
compress: false,
mangle: {
reserved: this.options.globalName ? [this.options.globalName] : []
},
} : undefined,
},
})
return {
code: result.code,
map: result.map,
}
},
}
}
encode-bundle做的事情其实就是
规范化的整理各种各样的参数
,打造了一个自己的插件机制
src/plugins.ts
像webpack和vite是怎样去做插件管理的,也能加深了前面 loaders 和 plugins 的区别
export class PluginContainer {
plugins: Plugin[]
context?: PluginContext
constructor(plugins: Plugin[]) {
this.plugins = plugins
}
setContext(context: PluginContext) {
this.context = context
}
getContext() {
if (!this.context) throw new Error(`Plugin context is not set`)
return this.context
}
modifyEsbuildOptions(options: EsbuildOptions) {
for (const plugin of this.plugins) {
if (plugin.esbuildOptions) {
plugin.esbuildOptions.call(this.getContext(), options)
}
}
}
async buildStarted() {
for (const plugin of this.plugins) {
if (plugin.buildStart) {
await plugin.buildStart.call(this.getContext())
}
}
}
async buildFinished({
outputFiles,
metafile,
}: {
outputFiles: OutputFile[]
metafile?: Metafile
}) {
const files: Array<ChunkInfo | AssetInfo> = outputFiles
.filter((file) => !file.path.endsWith('.map'))
.map((file): ChunkInfo | AssetInfo => {
if (isJS(file.path) || isCSS(file.path)) {
const relativePath = path.relative(process.cwd(), file.path)
const meta = metafile?.outputs[relativePath]
return {
type: 'chunk',
path: file.path,
code: file.text,
map: outputFiles.find((f) => f.path === `${file.path}.map`)?.text,
entryPoint: meta?.entryPoint,
exports: meta?.exports,
imports: meta?.imports,
}
} else {
return { type: 'asset', path: file.path, contents: file.contents }
}
})
const writtenFiles: WrittenFile[] = []
await Promise.all(
files.map(async (info) => {
for (const plugin of this.plugins) {
if (info.type === 'chunk' && plugin.renderChunk) {
const result = await plugin.renderChunk.call(
this.getContext(),
info.code,
info
)
if (result) {
info.code = result.code
if (result.map) {
const originalConsumer = await new SourceMapConsumer(
parseSourceMap(info.map)
)
const newConsumer = await new SourceMapConsumer(
parseSourceMap(result.map)
)
const generator =
SourceMapGenerator.fromSourceMap(originalConsumer)
generator.applySourceMap(newConsumer, info.path)
info.map = generator.toJSON()
originalConsumer.destroy()
newConsumer.destroy()
}
}
}
}
const inlineSourceMap = this.context!.options.sourcemap === 'inline'
const contents =
info.type === 'chunk'
? info.code +
getSourcemapComment(
inlineSourceMap,
info.map,
info.path,
isCSS(info.path)
)
: info.contents
await outputFile(info.path, contents, {
mode: info.type === 'chunk' ? info.mode : undefined,
})
writtenFiles.push({
get name() {
return path.relative(process.cwd(), info.path)
},
get size() {
return contents.length
},
})
if (info.type === 'chunk' && info.map && !inlineSourceMap) {
const map =
typeof info.map === 'string' ? JSON.parse(info.map) : info.map
const outPath = `${info.path}.map`
const contents = JSON.stringify(map)
await outputFile(outPath, contents)
writtenFiles.push({
get name() {
return path.relative(process.cwd(), outPath)
},
get size() {
return contents.length
},
})
}
})
)
for (const plugin of this.plugins) {
if (plugin.buildEnd) {
await plugin.buildEnd.call(this.getContext(), { writtenFiles })
}
}
}
}