一、前言
本篇文章将详细介绍 Element Plus 中的模块打包、类型打包和组件样式打包。由于上一篇介绍了 rollup 和 gulp 在Element Plus 中的应用手写 Element Plus : 搭建 Element Plus 组件打包配置(二)这里就不在详细的介绍了。这篇文章主要介绍打包的核心流程。搞懂这篇文章,我相信你应该对 Element Plus 打包流程有个清晰的认识了,可以去构建自己搭建的组件库了。最好按顺序来看先看完第一第二篇文章再来看这个可能更容易理解。
二、模块打包的具体流程
一)、module.ts 中导入的函数介绍
excludeFiles函数用来过滤文件路径数组,排除掉指定的目录和文件类型 。
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))
})
}
position 计算出如果 file 路径中包含 projRoot 则记录这个 projRoot 的长度否则记为0,这样做的目的是计算 file 文件是否包含排除 excludes 数组里的字符,且 path 从position 开始计算。
假设项目根路径 projRoot 为 /project,输入的文件路径数组如下:
const files = [
'/project/src/index.ts',
'/project/node_modules/some-package/index.js',
'/project/test/unit-test.js',
'/project/mock/data.json',
'/project/dist/bundle.js',
'/project/src/gulpfile.ts'
]
const projRoot = '/project'
const filteredFiles = excludeFiles(files)
//path.include(exclude,position) ===>
//path 将从'/node_modules/some-package/index.js' 排除
//而不是从 '/project/node_modules/some-package/index.js'排除
经过 excludeFiles 函数过滤后,结果将会是:
[ '/project/src/index.ts']
二)、module.ts 中 Rollup 主要打包配置
1、输入文件配置
1)、input 入口文件
const input = excludeFiles(
await glob('**/*.{js,vue,ts}', {
cwd: pkgRoot,
absolute: true,
onlyFiles: true,
})
)
input 获得是 pkgRoot 目录下所有的除了 excludeFiles 函数排除的文件路径后缀是 js 、vue 、ts 的文件路径数组。
2)、plugins Rollup 插件
plugins: [
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',
},
}),
],
模块打包的 rollup 插件配置和主文件打包的插件配置差不多。
两者配置的主要不同是什么原因?
主文件打包需要针对线上环境做出一些优化处理,而模块打包是在开发环境构建,其设计目的是为了保留开发模块中的调试信息 ,压缩代码会使得错误堆栈信息丧失很多有用的信息,因此开发环境通常会保留完整的代码和注释 ,而不是为了生产环境的性能和文件大小优化 。所以去除了 esbuild 中的部分优化配置。
3)、 external 打包排除文件
external: await generateExternal({ full: false }),
实现排除外部依赖打包,这个实现逻辑在上一篇已经解释过了,在这里就不在解释了。
4)、treeshake
treeshake:false
不开启按需打包优化。 在调试时,关闭 tree-shaking 可以保留所有导入的代码,有助于排查问题或调试依赖项的完整性。
2、输出文件配置
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
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)、format
模块打包会打包两种格式的文件,一种打包后的文件支持 esm一种是支持 Node 环境下 CommonJS。这样可以兼容到多种环境下引用打包后的文件。
2)、perserveModules
preserveModules:true ,是为了在打包指定目标文件按照原来的目录结构进行打包,这样做是为了保留原来的模块化,方便使用者来按需导入所需要的模块而不需要导入整个模块。
3)、perserveModulesRoot
preserveModulesRoot:epRoout ,preserveModulesRoot 用于指定保留的模块根目录。如果启用了 preserveModules,preserveModulesRoot 决定 Rollup 输出时保留的目录层级,从指定的目录开始输出结构。避免输出多层嵌套的目录。
这个 epRoot 为 fz-mini 所以在输出打包文件没有将这个fz-mini保留而是直接将这个目录下的文件打包输出在指定目录。
4)、entryFileNames
entryFileNames 自定义输出文件的命名格式。 [name] 是占位符,代表文件的原始名称。设置为 [name].${config.ext} 会输出文件名为原名称加上扩展名(如 .js 或 .mjs)。
3、配置 gulp.ts 全局文件
export default series(
withTaskName('clean', () => run('pnpm run clean')),
withTaskName('createOutput', () => mkdir(epOutput, { recursive: true })),
parallel(
runTask('buildModules'),
runTask('buildFullBundle'),
)
在internal\build执行打包命令 pnpm run start 打包命令出现这个就已经成功了,在检查一下输出的目录是否一致。
三、类型打包具体流程
一)、初始化配置
PS:fz-mini-ui>pnpm i vue-tsc @types/fs-extra -D -w
PS:fz-mini-ui\internal\build>pnpm i fs-extra fast-glob -D
在项目中安装这几个依赖,在类型打包中将会使用到这些工具库。
vue-tsc 是一个 TypeScript 编译器的 CLI 工具,专门为 Vue 项目生成类型声明文件。它基于 TypeScript 官方编译器 (tsc) 进行扩展,能够识别 .vue 文件中的 TypeScript 代码,从而为 Vue 组件生成相应的 .d.ts 声明文件。
在项目跟目录下创建 tsconfig.web.json 文件。
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"composite": true,
"jsx": "preserve",
"lib": ["ES2018", "DOM", "DOM.Iterable"],
"types": ["unplugin-vue-macros/macros-global"],
"skipLibCheck": true
},
"include": ["packages", "typings/env.d.ts"],
"exclude": [
"node_modules",
"**/dist",
"**/__tests__/**/*",
"**/gulpfile.ts",
"**/test-helper",
"packages/test-utils",
"**/*.md"
]
}
引入 tsconfig.web.json 是为了控制 .d.ts 类型文件的生成方式,使其适配 web 环境的构建需求 。在类型打包配置中补充额外的类型文件生成方式。
declare global {
const process: {
env: {
NODE_ENV: string
}
}
namespace JSX {
interface IntrinsicAttributes {
class?: unknown
style?: unknown
}
}
}
为项目定义全局类型,以确保在项目的任何地方使用这些类型时,TypeScript 能够正确识别。
二)、类型打包配置
import path from 'path'
import { readFile, writeFile } from 'fs/promises'
import glob from 'fast-glob'
import { copy, remove } from 'fs-extra'
import { buildOutput } from '@element-plus/build-utils'
import { pathRewriter, run } from '../utils'
export const generateTypesDefinitions = async () => {
await run(
'npx vue-tsc -p tsconfig.web.json --declaration --emitDeclarationOnly --declarationDir dist/types'
)
const typesDir = path.join(buildOutput, 'types', 'packages')
const filePaths = await glob(`**/*.d.ts`, {
cwd: typesDir,
absolute: true,
})
const rewriteTasks = filePaths.map(async (filePath) => {
const content = await readFile(filePath, 'utf8')
await writeFile(filePath, pathRewriter('esm')(content), 'utf8')
})
await Promise.all(rewriteTasks)
const sourceDir = path.join(typesDir, 'element-plus')
await copy(sourceDir, typesDir)
await remove(sourceDir)
}
下面我将尽可能的给你讲述类型打包的具体逻辑。
await run(
'npx vue-tsc -p tsconfig.web.json --declaration --emitDeclarationOnly --declarationDir dist/types'
)
1. 生成 TypeScript 声明文件
使用 vue-tsc 命令生成项目的类型声明文件(.d.ts)。
-p tsconfig.web.json:指定配置文件tsconfig.web.json。--declaration:生成类型声明。--emitDeclarationOnly:只输出声明文件,而不生成其他 JavaScript 文件。--declarationDir dist/types:将所有声明文件放在dist/types目录下。
执行完这一行命令后,dist/types 目录下将会生成所有组件和模块的 .d.ts 文件。
vue-tsc 如何识别指定文件中的 ts和 vue文件?
vue-tsc 会根据项目的 tsconfig.json 或指定的配置文件(如 tsconfig.web.json)读取编译选项。这个配置文件控制着哪些 .ts 文件会被编译,以及如何生成类型声明。
vue-tsc 会识别项目中的 .vue 文件,解析其中的 <script setup>、<script> 代码块(包括 TypeScript 代码),并将它们转换为 .d.ts 类型文件。它会提取和分析 .vue 文件中的 props、emit、computed、ref 等内容,自动生成相应的类型。
2. 获取文件路径
const typesDir = path.join(buildOutput, 'types', 'packages')
const filePaths = await glob(`**/*.d.ts`, {
cwd: typesDir,
absolute: true,
})
filePaths:使用 glob 查找 dist\types\packages 目录中所有的 .d.ts 文件路径。
**/*.d.ts匹配所有子目录中的.d.ts文件。cwd: typesDir设置根目录。absolute: true生成绝对路径,便于后续操作。
3. 重写模块路径
const rewriteTasks = filePaths.map(async (filePath) => {
const content = await readFile(filePath, 'utf8')
await writeFile(filePath, pathRewriter('esm')(content), 'utf8')
})
await Promise.all(rewriteTasks)
rewriteTasks:遍历所有文件路径,对 .d.ts 文件进行内容读取和路径重写。
- 使用
pathRewriter('esm')对内容中的模块路径进行重写,将路径改为适配esm格式的输出路径。
Promise.all(rewriteTasks) :并行执行所有重写任务,优化速度。
为什么需要重写模块路径?
假设我们有一个 Vue 组件库项目 element-plus,其中包含以下目录结构:
element-plus/
|—— packages/
│ ├── components/
│ │ ├── Button.ts
│ │ └── Input.ts
│ └── utils/
│ └── helpers.ts
└── fz-mini/
├── es/
│ ├── components/
│ │ ├── Button.d.ts
│ │ └── Input.d.ts
│ └── utils/
│ └── helpers.d.ts
└── lib/
├── components/
│ ├── Button.d.ts
│ └── Input.d.ts
└── utils/
└── helpers.d.ts
在生成 .d.ts 文件时,Button.d.ts 文件会被输出到 dst/es/components 目录中,并可能包含以下内容:
// fz-mini/es/components/Button.d.ts
import { helperFunction } from '../utils/helpers'
export declare const Button: () => void
路径 ../utils/helpers 是基于原始源代码的路径结构。但是,打包后,输出路径已经改变。 为确保 .d.ts 文件中引用的路径能与实际的输出结构保持一致,必须重写路径。
在 pathRewriter('esm') 函数中,我们可以将模块路径(如 ../utils/helpers)改写为适配 esm 构建的路径。pathRewriter('esm') 可以将引用路径改写为 fz-mini/es/utils/helpers 。
4. 复制和删除 fz-mini 目录
const sourceDir = path.join(typesDir, 'fz-mini')
await copy(sourceDir, typesDir)
await remove(sourceDir)
sourceDir:拼接路径得到 fz-mini 目录(此目录可能包含额外的封装层级),位于 typesDir 下。
copy(sourceDir, typesDir) :将 sourceDir 目录内容复制到 typesDir 的父目录,以消除冗余层级。
假设类型定义文件生成后的目录结构为:
dist/
└── types/
└── element-plus/
├── components/
│ └── Button.d.ts
└── utils/
└── helpers.d.ts
执行这段代码后,目录结构将变成:
dist/
└── types/
├── components/
│ └── Button.d.ts
└── utils/
└── helpers.d.ts
这样一来,所有类型文件不再嵌套在 element-plus 文件夹下,开发者在使用类型文件时,可以直接通过较短的路径引用这些文件。
remove(sourceDir) :删除原 element-plus 目录。
三)、配置 gulp.ts 文件
export default series(
withTaskName('clean', () => run('pnpm run clean')),
withTaskName('createOutput', () => mkdir(epOutput, { recursive: true })),
parallel(
runTask('buildModules'),
runTask('buildFullBundle'),
runTask('generateTypesDefinitions'),
),
)
export * 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')
),
])
copyFiles函数是将 从 epPackage 路径文件复制到 epOutput 目录下的 package.json ;将跟目录下的 README.md 复制到 epOutput 变量目录下;将跟目录下的 tyings\global.d.ts 复制到 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)
}
将生成的dist/types/packages下的类型文件每个类型文件递归复制到 es和 lib文件下原来文件结构如图所示。
复制后:
复制前
export default series(
withTaskName('clean', () => run('pnpm run clean')),
withTaskName('createOutput', () => mkdir(epOutput, { recursive: true })),
parallel(
runTask('buildModules'),
runTask('buildFullBundle'),
runTask('generateTypesDefinitions'),
),
parallel(copyTypesDefinitions, copyFiles)
)
export * from './src'
执行命令后会自动将这些任务执行打包,出现下面就说明打包成功。
四、样式打包具体流程
一)、初始化配置
1、安装打包样式依赖
PS:fz-mini-ui\packages\theme-chalk>pnpm i @esbuild-kit/cjs-loader @types/gulp-autoprefixer @fz-mini/build @types/gulp-rename @types/gulp-sass cssnano gulp-autoprefixer gulp-rename gulp-sass postcss -D
2、初始化 Icon 组件样式文件
1)、初始化 theme-chalk/src/base.scss
@use 'icon.scss';
// @use 'var.scss';
base.scss 主要负责设置 Element Plus 中的基础样式,如通用的颜色、字体、排版、过渡效果等,通常是在整个项目中使用的共享变量 。icon.scss定义了与图标相关的样式和配置,因为其他的组件中也会使用到 icon组件所以它放在整个组件库的共享变量一起。
:root {
--el-color-primary: #409eff;
--el-font-size-base: 14px;
--el-transition-duration: 0.3s;
}
这个var.scss文件就是将组件库的所有共享变量注册在root中。上面的只是一个简化的例子,实际代码后面会继续介绍,这个 icon组件样式没有使用到全局变量。
2)、初始化 packages/components/base/style
css.ts :
import '@fz-mini/theme-chalk/base.css'
导入 theme-chalk下的打包后的 base.css文件。方便用户在 css环境下导入组件库中的某个组件样式。
为什么不是导入 '@fz-mini/theme-chalk/dist/base.css'
在 Node.js 环境中, @fz-mini/theme-chalk 可以解析为一个已安装的包,而不必指明到具体的 dist 目录。Webpack、Vite 等构建工具会通过其配置来自动解析并处理该路径。
index.ts :
import '@element-plus/theme-chalk/src/base.scss'
在一些项目中,可能需要使用未编译的 .scss 文件,以便自定义主题或覆盖变量。这时可以通过引入 index.ts 文件来实现。
3)、初始化 packages/components/icon/style
css.ts :
import '@element-plus/components/base/style/css'
导入 base/style/css 而不是直接导入打包后的 base.css 文件是方便维护代码,如果打包目录或者样式目录发生变化,只需要调整 packages/components/base/style/css.ts 文件。
在组件中保持 style/css.ts这种目录目的是用户可以通过 style/css.ts 文件来按需引入 Button 的样式。
下面我拿 Element Plus 来举例:
import { ElButton } from '@element-plus/components/button'
import '@element-plus/components/button/style/css'
这里的 @element-plus/components/button/style/css 路径对应到 Button 组件的 style/css.ts 文件,它内部引入了 @element-plus/theme-chalk/el-button.css。
index.ts :
import '@element-plus/components/base/style'
二)、样式打包配置
1、处理 css 文件
1)、压缩 css 文件
它使用 postcss 和 cssnano 来压缩文件。
function compressWithCssnano() {
const processor = postcss([
cssnano({
preset: [
'default', // 使用默认压缩配置
{
colormin: false, // 关闭颜色压缩,避免可能的颜色变化
minifyFontValues: false, // 关闭字体值压缩,避免字体缩写引起的问题
},
],
}),
])
}
通过 postcss 创建一个处理器,使用 cssnano 来压缩 CSS。
配置 cssnano,禁用颜色和字体的最小化,以防止样式失真。
2)、检验流文件类型
return new Transform({
objectMode: true, // 设置为对象模式,允许在流中传递文件对象
transform(chunk, _encoding, callback) {
const file = chunk as Vinly// 将 `chunk` 转换为 Vinyl 类型,表示当前处理的文件
if (file.isNull()) { // 如果文件为空,直接调用回调函数并传入 `file`
callback(null, file)
return
}
if (file.isStream()) { // 如果是流模式,则不支持,返回错误
callback(new Error('Streaming not supported'))
return
}
}
})
创建一个新的 Transform 流,用于逐个处理文件。
检查 file 是否为空或是流,如果是文件是流文件不支持。
3)、输出压缩前后 css 文件大小
const cssString = file.contents!.toString() // 获取文件内容的字符串表示
processor.process(cssString, { from: file.path }).then((result) => { // 使用 processor 对 CSS 内容进行压缩处理
const name = path.basename(file.path) // 获取文件名
file.contents = Buffer.from(result.css) // 将压缩后的 CSS 内容写回文件的 contents 属性
consola.success(
`${chalk.cyan(name)}: ${chalk.yellow(cssString.length / 1000)} KB -> ${chalk.green(result.css.length / 1000)} KB`
) // 在控制台输出文件压缩前后大小对比
callback(null, file) // 传递文件到下一个处理流
})
将文件内容转成字符串,然后用 cssnano 进行压缩。打印压缩前后的文件大小。
2、gulp.ts 配置文件
const distFolder = path.resolve(__dirname, 'dist')
const distBundle = path.resolve(epOutput, 'theme-chalk')
function buildThemeChalk() {
const sass = gulpSass(dartSass) // 用 dartSass Scss 编译器作为 gulpSass 解析 scss 文件
const noElPrefixFile = /(index|base|display)/ // 不需要添加fz- 前缀的正则表达式
return src(path.resolve(__dirname, 'src/*.scss'))
.pipe(sass.sync())
.pipe(autoprefixer({ cascade: false })) //自动生成浏览器前缀
.pipe(compressWithCssnano()) //压缩css 文件
.pipe( // 根据正则表达式来判断是否需要给目标文件下的scss 文件添加前缀
rename((path) => {
if (!noElPrefixFile.test(path.basename)) {
path.basename = `fz-${path.basename}` // --->fz-icon.css
}
})
)
.pipe(dest(distFolder)) // 将文件打包在 theme-chalk 下 dist 目录下
}
//将 theme-chalk/dist 下的打包目录复制到 dist/fz-mini/theme-chalk 下
export function copyThemeChalkBundle() {
return src(`${distFolder}/**`).pipe(dest(distBundle))
}
// 将 theme-chalk/src 下的样式源文件复制到 dist/fz-mini/theme-chalk/src 目录下
export function copyThemeChalkSource() {
return src(path.resolve(__dirname, 'src/**')).pipe(
dest(path.resolve(distBundle, 'src'))
)
}
export const build: TaskFunction = parallel(
copyThemeChalkSource,
series(buildThemeChalk, copyThemeChalkBundle)
)
export default build
这个配置我就不在详细的讲述了,相信看过前面几章的应该能看懂注释了。
复制文件 gulp 任务打包成功的结果如图。
package.json 命令行配置。执行以下命令即可完成样式打包。
"scripts": {
"clean": "rimraf dist",
"build": "gulp --require @esbuild-kit/cjs-loader"
},
PC:packages/theme-chalk>pnpm run build
不过可以在 internal\build\gulpfile.ts 文件中配置以下配置项即可完成样式文件和其他任务一起自动打包。
//将dist/fz-mini/theme-chalk/index.css 文件复制到 dist/fz-mini/dist/index.css
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'),
series(
withTaskName('buildThemeChalk', () =>
run('pnpm run -C packages/theme-chalk build')
),
copyFullStyle
)
),
parallel(copyTypesDefinitions, copyFiles)
)
以上 gulp 任务就已经完成模块打包,主文件打包,类型打包和样式打包的全部流程。Element Plus 中还有一些打包配置可以自己去阅读了。
五、总结
整个 Element Plus 到此就全部介绍完了,码字实属不易,留个关注激励我继续前行。组件打包篇幅比较长,可能会存在一些错误还请多多指正。但我相信看完这些多多少少会有点帮助的。