解析ElementPlus打包源码(一、公共方法解析和buildModules流程)

391 阅读4分钟

文章主要介绍element-plus的打包相关的代码

去github下载源码到本地,如果没有🪜,gitee去下载一下element-plus: 🎉 Vue 3 的桌面端组件库 (gitee.com),我这边直接clone的gitee上的dev分支

拿到源码目录,找下package.json打包的scripts命令。"build": "pnpm run -C internal/build start",要去internal/build目录下面看start命令。"start": "gulp --require @esbuild-kit/cjs-loader -f gulpfile.ts"

直接看官方说明,这个包的用处就是 image.png

gulp文档打开一下先,看下说明。基本可以确定@esbuild-kit/cjs-loader用来帮助我们识别import/export语法以及加载ts的gulp文件

image.png

ok那我们可以去看gulpfile.ts写的啥了

如果不了解gulp的,请看这里gulp.js - 基于流(stream)的自动化构建工具 | gulp.js中文网 (gulpjs.com.cn)

gulpfile.ts看一下

import path from 'path'
import { copyFile, mkdir } from 'fs/promises'
import { copy } from 'fs-extra'
import { parallel, series } from 'gulp'
import {
  buildOutput,
  epOutput,
  epPackage,
  projRoot,
} from '@element-plus/build-utils'
import { buildConfig, run, runTask, withTaskName } from './src'
import type { TaskFunction } from 'gulp'
import type { Module } from './src'

export const copyFiles = () =>
  Promise.all([
    copyFile(epPackage, path.join(epOutput, 'package.json')),
    copyFile(
      path.resolve(projRoot, 'README.md'),
      path.resolve(epOutput, 'README.md')
    ),
    copyFile(
      path.resolve(projRoot, 'typings', 'global.d.ts'),
      path.resolve(epOutput, 'global.d.ts')
    ),
  ])

export const copyTypesDefinitions: TaskFunction = (done) => {
  const src = path.resolve(buildOutput, 'types', 'packages')
  const copyTypes = (module: Module) =>
    withTaskName(`copyTypes:${module}`, () =>
      copy(src, buildConfig[module].output.path, { recursive: true })
    )

  return parallel(copyTypes('esm'), copyTypes('cjs'))(done)
}

export const copyFullStyle = async () => {
  await mkdir(path.resolve(epOutput, 'dist'), { recursive: true })
  await copyFile(
    path.resolve(epOutput, 'theme-chalk/index.css'),
    path.resolve(epOutput, 'dist/index.css')
  )
}

export default series(
  withTaskName('clean', () => run('pnpm run clean')),
  withTaskName('createOutput', () => mkdir(epOutput, { recursive: true })),

  parallel(
    runTask('buildModules'),
    runTask('buildFullBundle'),
    runTask('generateTypesDefinitions'),
    runTask('buildHelper'),
    series(
      withTaskName('buildThemeChalk', () =>
        run('pnpm run -C packages/theme-chalk build')
      ),
      copyFullStyle
    )
  ),

  parallel(copyTypesDefinitions, copyFiles)
)

export * from './src'

我们直接定位到series这个任务的方法

export default series(
  withTaskName('clean', () => run('pnpm run clean')),
  withTaskName('createOutput', () => mkdir(epOutput, { recursive: true })),

  parallel(
    runTask('buildModules'),
    runTask('buildFullBundle'),
    runTask('generateTypesDefinitions'),
    runTask('buildHelper'),
    series(
      withTaskName('buildThemeChalk', () =>
        run('pnpm run -C packages/theme-chalk build')
      ),
      copyFullStyle
    )
  ),

  parallel(copyTypesDefinitions, copyFiles)
)

好多封装的函数,我们一个一个看下

internal\build\src\utils\process.ts的run函数说明

import { spawn } from 'child_process'
import chalk from 'chalk'
import consola from 'consola'
import { projRoot } from '@element-plus/build-utils'

export const run = async (command: string, cwd: string = projRoot) =>
  new Promise<void>((resolve, reject) => {
    const [cmd, ...args] = command.split(' ')
    consola.info(`run: ${chalk.green(`${cmd} ${args.join(' ')}`)}`)
    const app = spawn(cmd, args, {
      cwd,
      stdio: 'inherit',
      shell: process.platform === 'win32',
    })

    const onProcessExit = () => app.kill('SIGHUP')

    app.on('close', (code) => {
      process.removeListener('exit', onProcessExit)

      if (code === 0) resolve()
      else
        reject(
          new Error(`Command failed. \n Command: ${command} \n Code: ${code}`)
        )
    })
    process.on('exit', onProcessExit)
  })

spawn是干啥的呢,创建子进程执行命令的。

chalk,改终端字符串颜色的,这样输出更好看噻,做命令行工具常用的东西

consola,是一个日志输出的,比咋们常用的console更友好

projRoot,明显就是我们项目根目录😏😏😏

这段代码这个 run 函数,就是帮助在 Node.js 中执行 shell 命令

里面会打印一下日志,这个consola.info(`run: ${chalk.green(`${cmd} ${args.join(' ')}`)}`)就是打印日志image.png

紧接着下面这个代码,其实就是使用 spawn 创建子进程执行我们的命令。stdio: 'inherit'就是子进程共享父进程的终端。shell: process.platform === 'win32'使得构建脚本能够在 Windows 和非 Windows 平台上一致地工作(就是兼容一下大家的系统嘛,我是用的windows💻)

const app = spawn(cmd, args, {
  cwd,
  stdio: 'inherit',
  shell: process.platform === 'win32',
})

主进程退出,肯定要杀死子进程,所以有这个代码

const onProcessExit = () => app.kill('SIGHUP')
process.on('exit', onProcessExit)

子进程退出,要移除主进程的监听,以及正常退出就resolve,异常就直接报错

app.on('close', (code) => {
  process.removeListener('exit', onProcessExit)

  if (code === 0) resolve()
  else
    reject(
      new Error(`Command failed. \n Command: ${command} \n Code: ${code}`)
    )
})

总结一下,run函数就是帮我们运行命令的,并且输出一下运行日志。(自己的项目可以也可以用一下😼😼😼)

internal\build\src\utils\gulp.ts的两个函数withTaskNamerunTask函数说明

import { buildRoot } from '@element-plus/build-utils'
import { run } from './process'

import type { TaskFunction } from 'gulp'

export const withTaskName = <T extends TaskFunction>(name: string, fn: T) =>
  Object.assign(fn, { displayName: name })

export const runTask = (name: string) =>
  withTaskName(`shellTask:${name}`, () =>
    run(`pnpm run start ${name}`, buildRoot)
  )

我们先来看下withTaskName,这个函数传入一个name(string类型)和一个fn(要是TaskFunction的类型),然后给这个函数的displayName字段添加上name。经过我的分析,其实就是给对应的gulp任务定义一下打印日志的名称。我们可以写个demo看一下,修改一下gulpfile.tsexport default series这里的代码

export const gege = async () => {
  console.log('唱、跳、rap、篮球')
}
gege.displayName = '鸽鸽'
export default series(gege)

然后我们执行pnpm build看下效果,可以看到gulp帮我们输出了对应的任务名称 image.png

然后看下runTask,参数有一个name,函数里面调用withTaskName,给对应函数添加上了displayName为shellTask:${name},这个函数是调用上面已经说过的run方法,执行了一下pnpm run start ${name},执行的目录是buildRoot(/internal/build)。

举例说明:例如这个runTask('buildModules'),这里的/internal/build下package.json的start命令是"start": "gulp --require @esbuild-kit/cjs-loader -f gulpfile.ts",,也就是会执行gulp --require @esbuild-kit/cjs-loader -f gulpfile.ts "buildModules"。也就会执行gulpfile.ts导出的buildModules任务

image.png

在gulpfile.ts中有一行导出export * from './src',一层一层查找可以找到对应的buildModules这个任务的定义,在这个文件internal\build\src\tasks\modules.ts

cleancreateOutput任务

export default series(
  withTaskName('clean', () => run('pnpm run clean')),
  withTaskName('createOutput', () => mkdir(epOutput, { recursive: true }))

  // parallel(
  //   runTask('buildModules'),
  //   runTask('buildFullBundle'),
  //   runTask('generateTypesDefinitions'),
  //   runTask('buildHelper'),
  //   series(
  //     withTaskName('buildThemeChalk', () =>
  //       run('pnpm run -C packages/theme-chalk build')
  //     ),
  //     copyFullStyle
  //   )
  // )

  // parallel(copyTypesDefinitions, copyFiles)
)

clean任务呢,在项目根目录目录帮我们执行了pnpm run clean命令,

image.png

这个命令先执行了pnpm run clean:dist,也就是rimraf dist清理一下dist目录,打包之前一般都要先干的事情。然后pnpm run -r --parallel clean-r表示对所有子项目也都执行clean命令,--parallel表示并行执行。这里其实清理项目和项目的子包下面的打包文件。

然后是createOutput任务,这个任务就是创建一下文件夹,这个文件夹epOutputdist/element-plus image.png

我们只保留这两个任务运行一下pnpm build,可以看到删除了原来的dist,也创建了对应的目录 image.png

🥱🥱🥱🥱🥱🥱🥱🥱🥱🥱🥱🥱🥱🥱🥱🥱累了我先更新到这,后续请看下回分解



上期我们了解了 runwithTaskNamerunTask函数的作用,以及clean和createOutput任务

我们接着看代码,看后面的打包流程

这里的parallel下面的任务,都会并行执行

我先注释其他任务保留buildModules image.png

runTask('buildModules')

runTask我们已经说过了,这个方法会帮我执行对应导出的 buildModules 任务,这个任务在这个路径下internal\build\src\tasks\modules.ts

import path from 'path'
import { series } from 'gulp'
import { rollup } from 'rollup'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import VueMacros from 'unplugin-vue-macros/rollup'
import { nodeResolve } from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import esbuild from 'rollup-plugin-esbuild'
import glob from 'fast-glob'
import { epRoot, excludeFiles, pkgRoot } from '@element-plus/build-utils'
import { generateExternal, withTaskName, writeBundles } from '../utils'
import { ElementPlusAlias } from '../plugins/element-plus-alias'
import { buildConfigEntries, target } from '../build-info'
import type { TaskFunction } from 'gulp'

import type { OutputOptions, Plugin } from 'rollup'

const plugins: Plugin[] = [
  ElementPlusAlias(),
  VueMacros({
    setupComponent: false,
    setupSFC: false,
    plugins: {
      vue: vue({
        isProduction: true,
        template: {
          compilerOptions: {
            hoistStatic: false,
            cacheHandlers: false,
          },
        },
      }),
      vueJsx: vueJsx(),
    },
  }),
  nodeResolve({
    extensions: ['.mjs', '.js', '.json', '.ts'],
  }),
  commonjs(),
  esbuild({
    sourceMap: true,
    target,
    loaders: {
      '.vue': 'ts',
    },
  }),
]

async function buildModulesComponents() {
  const input = excludeFiles(
    await glob(['**/*.{js,ts,vue}', '!**/style/(index|css).{js,ts,vue}'], {
      cwd: pkgRoot,
      absolute: true,
      onlyFiles: true,
    })
  )
  const bundle = await rollup({
    input,
    plugins,
    external: await generateExternal({ full: false }),
    treeshake: { moduleSideEffects: false },
  })

  await writeBundles(
    bundle,
    buildConfigEntries.map(([module, config]): OutputOptions => {
      return {
        format: config.format,
        dir: config.output.path,
        exports: module === 'cjs' ? 'named' : undefined,
        preserveModules: true,
        preserveModulesRoot: epRoot,
        sourcemap: true,
        entryFileNames: `[name].${config.ext}`,
      }
    })
  )
}

async function buildModulesStyles() {
  const input = excludeFiles(
    await glob('**/style/(index|css).{js,ts,vue}', {
      cwd: pkgRoot,
      absolute: true,
      onlyFiles: true,
    })
  )
  const bundle = await rollup({
    input,
    plugins,
    treeshake: false,
  })

  await writeBundles(
    bundle,
    buildConfigEntries.map(([module, config]): OutputOptions => {
      return {
        format: config.format,
        dir: path.resolve(config.output.path, 'components'),
        exports: module === 'cjs' ? 'named' : undefined,
        preserveModules: true,
        preserveModulesRoot: epRoot,
        sourcemap: true,
        entryFileNames: `[name].${config.ext}`,
      }
    })
  )
}

export const buildModules: TaskFunction = series(
  withTaskName('buildModulesComponents', buildModulesComponents),
  withTaskName('buildModulesStyles', buildModulesStyles)
)

这里顺序执行了两个任务,一个是buildModulesComponents,一个是buildModulesStyles

buildModulesComponents任务

async function buildModulesComponents() {
  const input = excludeFiles(
    await glob(['**/*.{js,ts,vue}', '!**/style/(index|css).{js,ts,vue}'], {
      cwd: pkgRoot,
      absolute: true,
      onlyFiles: true,
    })
  )
  const bundle = await rollup({
    input,
    plugins,
    external: await generateExternal({ full: false }),
    treeshake: { moduleSideEffects: false },
  })

  await writeBundles(
    bundle,
    buildConfigEntries.map(([module, config]): OutputOptions => {
      return {
        format: config.format,
        dir: config.output.path,
        exports: module === 'cjs' ? 'named' : undefined,
        preserveModules: true,
        preserveModulesRoot: epRoot,
        sourcemap: true,
        entryFileNames: `[name].${config.ext}`,
      }
    })
  )
}
1. input、treeshake、plugins、external简单说明

先看input,打印一下看看input的内容,其实就是文件路径字符串数组

image.png

其实这里的input,使用了fast-glob库匹配文件路径,匹配出来了packages目录下的所有 .js.ts.vue 文件,但是排除了样式目录下的index.{js,ts,vue} 和 css.{js,ts,vue} 文件,排除的文件举例说明一下就是这个 image.png

然后还要注意的就是,得到的文件调用了下excludeFiles方法,排除从项目根目录位置开始往后的路径中有excludes数组定义的字符串的文件

export const excludeFiles = (files: string[]) => {
  const excludes = ['node_modules', 'test', 'mock', 'gulpfile', 'dist']
  return files.filter((path) => {
    const position = path.startsWith(projRoot) ? projRoot.length : 0
    return !excludes.some((exclude) => path.includes(exclude, position))
  })
}

然后拿到input了之后,通过rollup进行打包。传入input入口文件;打包的插件plugins;external视为外部依赖不打包进bundle;treeshake配置启用 Tree-shaking 优化,moduleSideEffects: false 表示模块无副作用,可安全删除未引用的代码

input和treeshake配置都比较好理解。一个是打包的入口文件,一个是移除未使用代码。external是给出视为外部依赖的,这样对应引入的代码就还是原样的引入的方式,不会将引用的模块进行打包。

2. external 详细说明

external这里有个方法调用我们需要看下。external: await generateExternal({ full: false }),。对应的方法也会引用其他方法,我们挨个来看

  • internal\build-utils\src\pkg.ts

    • getPackageManifest方法,读取指定路径的 package.json 文件,并且返回内容。require(pkgPath) 动态导入 JSON 文件(Node.js 支持直接 require JSON)。这个ProjectManifest可以学习到有对应的包可以声明出来package.json中的字段类型。
    • getPackageDependencies方法,就是读出对应的dependenciespeerDependencies依赖中的名称列表
    import findWorkspacePackages from '@pnpm/find-workspace-packages'
    import { projRoot } from './paths'
    
    import type { ProjectManifest } from '@pnpm/types'
    
    ......
    
    export const getPackageManifest = (pkgPath: string) => {
      // eslint-disable-next-line @typescript-eslint/no-var-requires
      return require(pkgPath) as ProjectManifest
    }
    
    export const getPackageDependencies = (
      pkgPath: string
    ): Record<'dependencies' | 'peerDependencies', string[]> => {
      const manifest = getPackageManifest(pkgPath)
      const { dependencies = {}, peerDependencies = {} } = manifest
    
      return {
        dependencies: Object.keys(dependencies),
        peerDependencies: Object.keys(peerDependencies),
      }
    }
    
    export const excludeFiles = (files: string[]) => {
      ......
    }
    
    
  • internal\build\src\utils\rollup.ts

    • 会拿到packages\element-plus\package.json路径下的依赖 image.png image.png
    • full为true的话就只是排除peerDependencies依赖,表示是全量打包。这个适合发布全量包的情况,例如CDN版本,用户可以直接引入使用,不需要其他的@vue和dependencies的依赖,只需要vue即可。
    • full为false的话,那就要把peerDependencies@vue相关和dependencies相关依赖的都视为外部包。适合作为库给别人使用的场景了。
    export const generateExternal = async (options: { full: boolean }) => {
      const { dependencies, peerDependencies } = getPackageDependencies(epPackage)
    
      return (id: string) => {
        const packages: string[] = [...peerDependencies]
        if (!options.full) {
          packages.push('@vue', ...dependencies)
        }
    
        return [...new Set(packages)].some(
          (pkg) => id === pkg || id.startsWith(`${pkg}/`)
        )
      }
    }
    
3. plugins详细说明
  • @rollup/plugin-node-resolve:帮助 Rollup 查找和解析 node_modules 中的第三方模块。通常会和下面的@rollup/plugin-commonjs插件一起使用
  • @rollup/plugin-commonjs:将 CommonJS 模块转换为 ES6 模块,使 Rollup 能够处理 CommonJS 格式的依赖项
  • rollup-plugin-esbuild:加快转译,这个插件就是用来替代rollup-plugin-typescript2@rollup/plugin-typescript and rollup-plugin-terser
  • Vue Macros:是一个实现 Vue 非官方提案和想法的库,探索并扩展了其功能和语法。这里使用这个插件,方便可以使用更多的vue语法。
  • @vitejs/plugin-vue 和 @vitejs/plugin-vue-jsx:就是vue和vueJsx语法编译的插件。传入Vue Macros中。
VueMacros({
  setupComponent: false,  // 禁用 setup 组件支持
  setupSFC: false,       // 禁用 SFC 中的 setup 语法支持
  plugins: {
    vue: vue({           // 配置 Vue 插件
      isProduction: true, // 生产模式
      template: {
        compilerOptions: {
          hoistStatic: false,  // 不提升静态节点
          cacheHandlers: false // 不缓存内联事件处理器
        }
      }
    }),
    vueJsx: vueJsx()     // 支持 Vue JSX 语法
  }
})
  • ElementPlusAlias:这个是自定义的一个插件

    import { PKG_NAME, PKG_PREFIX } from '@element-plus/build-constants'
    
    import type { Plugin } from 'rollup'
    
    export function ElementPlusAlias(): Plugin {
      const themeChalk = 'theme-chalk'
      const sourceThemeChalk = `${PKG_PREFIX}/${themeChalk}` as const
      const bundleThemeChalk = `${PKG_NAME}/${themeChalk}` as const
    
      return {
        name: 'element-plus-alias-plugin',
        resolveId(id) {
          if (!id.startsWith(sourceThemeChalk)) return
          return {
            id: id.replaceAll(sourceThemeChalk, bundleThemeChalk),
            external: 'absolute',
          }
        },
      }
    }
    
    • PKG_NAME 和 PKG_PREFIX是两个字符串

      export const PKG_PREFIX = '@element-plus'
      export const PKG_NAME = 'element-plus'
      
    • 这里的name是插件的名称,用于在警告和错误消息中标识插件

    • 这里的resolveId是一个钩子,用于自定义依赖,具体见rollup插件开发的resolveId。这里插件的作用就是,将 @element-plus/theme-chalk 的前缀变为 element-plus/theme-chalk。external: "absolute" 来保持其为绝对 id

    • 这里我比较疑惑的是:glob函数这里其实排除了style文件夹下面的引入,@element-plus/theme-chalk这个引入也都是在style文件夹下我才会看到,感觉这个插件没有匹配到对应的路径解析,我往后再看看 image.png

4. writeBundles
  • 其实就是对同一个bundle应用不同的配置打包

    export function writeBundles(bundle: RollupBuild, options: OutputOptions[]) {
      return Promise.all(options.map((option) => bundle.write(option)))
    }
    
  • 我们看一下两个配置,其实就是打包的时候,需要达成两种规范,一个是ES Module的规范,一个CommonJS的规范

    import path from 'path'
    import { PKG_NAME } from '@element-plus/build-constants'
    import { epOutput } from '@element-plus/build-utils'
    
    import type { ModuleFormat } from 'rollup'
    
    export const modules = ['esm', 'cjs'] as const
    export type Module = typeof modules[number]
    export interface BuildInfo {
      module: 'ESNext' | 'CommonJS'
      format: ModuleFormat
      ext: 'mjs' | 'cjs' | 'js'
      output: {
        /** e.g: `es` */
        name: string
        /** e.g: `dist/element-plus/es` */
        path: string
      }
    
      bundle: {
        /** e.g: `element-plus/es` */
        path: string
      }
    }
    
    export const buildConfig: Record<Module, BuildInfo> = {
      esm: {
        module: 'ESNext',
        format: 'esm',
        ext: 'mjs',
        output: {
          name: 'es',
          path: path.resolve(epOutput, 'es'),
        },
        bundle: {
          path: `${PKG_NAME}/es`,
        },
      },
      cjs: {
        module: 'CommonJS',
        format: 'cjs',
        ext: 'js',
        output: {
          name: 'lib',
          path: path.resolve(epOutput, 'lib'),
        },
        bundle: {
          path: `${PKG_NAME}/lib`,
        },
      },
    }
    export const buildConfigEntries = Object.entries(
      buildConfig
    ) as BuildConfigEntries
    
    export type BuildConfig = typeof buildConfig
    export type BuildConfigEntries = [Module, BuildInfo][]
    
    export const target = 'es2018'
    
  • 下发具体的配置都可以查看配置选项 | rollup.js 中文文档 | rollup.js中文网

  • format:该选项用于指定生成的 bundle 的格式,打包生成一个CommonJS的,一个ESModule的

  • dir:该选项用于指定所有生成的 chunk 被放置在哪个目录中

  • preserveModules:该选项将使用原始模块名作为文件名,为所有模块创建单独的 chunk,而不是创建尽可能少的 chunk。简单说就是会保持原来的目录结构

  • preserveModulesRoot:当 preserveModules 值为 true 时,输入模块的目录路径应从路径中剥离出来到dir下。这里指定epRoot,打包之后就不会有element-plus这个文件夹,里面的文件都提升到dir下面了 image.png

  • sourcemaptrue那么将生成一个独立的 sourcemap 文件

  • entryFileNames:定义一下最终打包的文件命名格式

  • exports:该选项用于指定导出模式。默认是 auto,指根据 input 模块导出推测你的意图

    async function buildModulesComponents() {
      ...
      await writeBundles(
        bundle,
        buildConfigEntries.map(([module, config]): OutputOptions => {
          return {
            format: config.format,
            dir: config.output.path,
            exports: module === 'cjs' ? 'named' : undefined,
            preserveModules: true,
            preserveModulesRoot: epRoot,
            sourcemap: true,
            entryFileNames: `[name].${config.ext}`,
          }
        })
      )
    }
    

buildModulesStyles任务

知道了buildModulesComponentsbuildModulesStyles就简单了。这个就是打包buildModulesComponents排除的文件。在这里之前的ElementPlusAlias自定义插件就有用到了,让打包后的导入正确

image.png

image.png

这里style下面的文件其实就是一个引用关系。引用的是theme-chalk下面的相关文件,这里打包 ElementPlusAlias@element-plus/theme-chalk的引入通过external配置视为外部依赖。真正的样式的打包还在后面。