文章主要介绍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"
直接看官方说明,这个包的用处就是
gulp文档打开一下先,看下说明。基本可以确定@esbuild-kit/cjs-loader用来帮助我们识别import/export语法以及加载ts的gulp文件
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(' ')}`)}`)就是打印日志
紧接着下面这个代码,其实就是使用 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的两个函数withTaskName和runTask函数说明
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.ts中export default series这里的代码
export const gege = async () => {
console.log('唱、跳、rap、篮球')
}
gege.displayName = '鸽鸽'
export default series(gege)
然后我们执行pnpm build看下效果,可以看到gulp帮我们输出了对应的任务名称
然后看下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任务
在gulpfile.ts中有一行导出export * from './src',一层一层查找可以找到对应的buildModules这个任务的定义,在这个文件internal\build\src\tasks\modules.ts
clean和createOutput任务
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命令,
这个命令先执行了pnpm run clean:dist,也就是rimraf dist清理一下dist目录,打包之前一般都要先干的事情。然后pnpm run -r --parallel clean ,-r表示对所有子项目也都执行clean命令,--parallel表示并行执行。这里其实清理项目和项目的子包下面的打包文件。
然后是createOutput任务,这个任务就是创建一下文件夹,这个文件夹epOutput是dist/element-plus
我们只保留这两个任务运行一下pnpm build,可以看到删除了原来的dist,也创建了对应的目录
🥱🥱🥱🥱🥱🥱🥱🥱🥱🥱🥱🥱🥱🥱🥱🥱累了我先更新到这,后续请看下回分解
上期我们了解了 run、withTaskName和runTask函数的作用,以及clean和createOutput任务
我们接着看代码,看后面的打包流程
这里的parallel下面的任务,都会并行执行
我先注释其他任务保留buildModules
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的内容,其实就是文件路径字符串数组
其实这里的input,使用了fast-glob库匹配文件路径,匹配出来了packages目录下的所有 .js、.ts、.vue 文件,但是排除了样式目录下的index.{js,ts,vue} 和 css.{js,ts,vue} 文件,排除的文件举例说明一下就是这个
然后还要注意的就是,得到的文件调用了下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方法,就是读出对应的dependencies和peerDependencies依赖中的名称列表
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路径下的依赖
- 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}/`) ) } } - 会拿到packages\element-plus\package.json路径下的依赖
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-typescriptandrollup-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文件夹下我才会看到,感觉这个插件没有匹配到对应的路径解析,我往后再看看
-
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下面了 -
sourcemap:
true那么将生成一个独立的 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任务
知道了buildModulesComponents,buildModulesStyles就简单了。这个就是打包buildModulesComponents排除的文件。在这里之前的ElementPlusAlias自定义插件就有用到了,让打包后的导入正确
这里style下面的文件其实就是一个引用关系。引用的是theme-chalk下面的相关文件,这里打包 ElementPlusAlias 将@element-plus/theme-chalk的引入通过external配置视为外部依赖。真正的样式的打包还在后面。