前端工程化之自动化构建

发布于:2025-04-15 ⋅ 阅读:(22) ⋅ 点赞:(0)


主要内容:

  • 自动化构建的介绍
    • DevOps
    • 发展历史
  • 打包工具:encode-bundle(vite:esbuild和swc实现)

自动化构建的基本知识

历史

  1. 2004年前,是没有前端构建的,当时,一般 php 写前端后端的,切图的方式+css 组织起来
    gmail,google doc,共享文档开始
    gmail出现之前,使用邮件客户端集成自己的项目
    引入gmail后,开始做好用的网页,然后网页变得越来越复杂
    直接使用编写的 HTML/JS/CSS,写什么样,浏览器就运行什么样
  2. 框架出现后,整个应用文件很多后,涉及到将文件合并,压缩,混淆等各种各样操作后,这时候,构建工具出现了
    当年的构建工具,gulp / grunt / webpack / vite 打包工具出现
    发布:本地手动构建 -> 上传到服务器 / cdn 上去
    shell脚本实现,七牛cdn,提供命令行工具,access token,secrete token,本地路径,远程路径等写到配置文件中,通过执行七牛的cdn工具就可以把 文件 从本地打包成dist文件传到服务器上去,来实现半自动化操作。
  3. 自动化的打包构建:jenkins(针对前后端的打包构建工具,插件实现构建过程)
  4. 云构建: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的区别
  1. commonJS:使用require方式引入,require是一种函数
    ecma中是没有 require的,esm中 import 的关键字是从语法方面,JS引擎方面进行执行

为什么vite项目启动快?
因为借助了esm, no-bundle的方式实现的,vite借助浏览器能够直接使用import关键字实现的

  1. 模块加载的时机:commonJS 是在使用的时候才会加载,esm是在编译的阶段就会加载
  2. 导出:commonJS导出时候,基本类型的时候是拷贝,引用类型的时候是地址,esm统一是地址
  3. 动态加载:commonJS中的require是可以动态加载的,import关键字是不能动态加载的,import函数是动态加载的(import 关键字:import xxx from xxx;import 函数:import { xxx } from xxx)
  4. commonJS里两个文件是不能相互引用的,esm中是不存在这种情况
  5. 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 })
      }
    }
  }
}

网站公告

今日签到

点亮在社区的每一天
去签到