前端工程化之自动化构建
自动化构建
- 自动化构建的基本知识
- 历史
- 云构建 和 自动化构建 的区别:
- 部署
- 环境:
- 构建:
- 构建产物
- 构建和打包的性能优化
- 页面加载优化
- 构建速度优化
- DevOps
- 原则
- 反馈的技术实践
- encode-bundle
- package.json解读
- src/cli-default.ts
- src/cli-node.ts
- src/cli-main.ts
- src/index.ts
- src/rollup.ts
- src/esbuild/index.ts
- src/plugins/es5.ts
- src/plugins.ts
主要内容:
- 自动化构建的介绍
- 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赋值给entryif (!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) {// 分析configconst 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 clonedbanner: 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 anywayonSuccessProcess = 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 itconst 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 changelet 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 arrayif (Array.isArray(dtsOptions.resolve)) {tsResolveOptions.resolveOnly = dtsOptions.resolve;}// `paths` should be handled by rollup-plugin-dtsif (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 generateddeclaration: true,// Skip ".js" generationnoEmit: false,emitDeclarationOnly: true,// Skip code generation when error occursnoEmitOnError: true,// Avoid extra workcheckJs: false,declarationMap: false,skipLibCheck: true,preserveSymlinks: false,// Ensure we can parse the latest codetarget: 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文件中传递的optionsconst 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 itformat !== '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 filesif (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}// 寻找swcconst 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?: PluginContextconstructor(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.codeif (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.contentsawait 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.mapconst 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 })}}}
}