【源码】Element Plus项目构建分析
# 前言
“读开源项目源码”听起来似乎是一个很困难、让人听着就退缩的事情,很多人可能更愿意去看官网介绍、或找一些源码分析的博客进行阅读。当然,实际上也是这样,特别对于跟我一样刚毕业入行不久的小前端来说,源码是那么的遥不可及、苦涩难懂。
但是,我不认为我们就应该惧怕读源码,所谓恐惧来源于未知,我们看源码前,怕的时遇到不熟悉的库、复杂的函数调用、难懂的逻辑设计、以及眼花缭乱的构建过程。随着AI不断强大,AI已经成长到能够大大帮助我们阅读源码,已经大大降低了源码阅读的门槛,让我们能更加轻松的汲取开源项目中的精髓。
先前我已经有做过Vue源码阅读的博客,到目前还只是停留在简单的分析构建过程,后续应该还会慢慢的更新,而本系列则记录一下我在阅读Element Plus源码时的过程,希望能对大家有所帮助。
# 项目概述
Element Plus项目采用的是monorepo的模式,即一个仓库中包含多个项目。后续我想再写一篇文章来详细整理下这个模式吧。
主要的目录和文件有这些:
- docs:存放项目文档的文件夹,用的是VitePress
- packages:存放各组件的文件夹,也是项目的核心文件夹
- play:一个“舞台”,可以在线测试组件
- internal:存放构建相关的代码
我们主要看的也就是packages文件夹下的内容了。
# 构建流程
# 命令分析
目前我读源码的顺序是习惯先分析项目的构建流程,也就是项目是如何组织起来的,这样有利于我们对整个项目的认识。
当然是先来看根目录下的package.json文件:
// package.json
"scripts": {
...
"dev": "pnpm -C play dev",
"clean": "pnpm run clean:dist && pnpm run -r --parallel clean",
"clean:dist": "rimraf dist",
"build": "pnpm run -C internal/build start",
"build:theme": "pnpm run -C packages/theme-chalk build",
"format": "prettier --write --cache .",
"lint": "eslint . --ext .vue,.js,.ts,.jsx,.tsx,.md,.json --max-warnings 0 --cache",
....
}
命令有很多,我们看“build”命令。
pnpm run -C internal/build start
可以看到,当运行build命令后,会使用pnpm来执行internal/build文件下的start命令。过去看看这个命令:
// internal/build/package.json
"scripts": {
"start": "gulp --require @esbuild-kit/cjs-loader -f gulpfile.ts",
"dev": "pnpm run stub",
"stub": "unbuild --stub"
}
这其实就是monorepo项目的特点了,internal/build文件夹下其实也是个项目,有自己的package.json文件,而这个项目负责的就是整个项目构建的任务。
它的指令也很简单,执行start命令后开始使用gulp进行打包,并用“--require @esbuild-kit/cjs-loader -f”指明要提前加载loader,这个loader的作用是将TS解析成JS。具体的打包命令则是执行gulpfile.ts文件
gulp跟webpack、vite一样,都是用来打包的工具。在JQuery的时代非常热门,等vue、reack等现代前端框架出来后,webpack则更加热门,gulp则退居幕后了。gulp主要用于定义和规范开发的流程,运用的是流式的思想,在组件库等项目中还非常常用,但在应用层则使用的比较少了。
# 构建分析
gulpfile就是构建的入口,可以直接看最后的构建命令:
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)
)
series是gulp的串行构建命令、parallel是并行构建命令。可以通过这两个方法,来搭建整个构建的流程,组织构建的任务。直接看任务命名也能大概猜出来每个任务干了什么了:
- 先进行清理工作
- 创建dist文件夹
- 定义一堆并行的构建任务,这些任务可以同时进行,提高构建效率
- 并行构建1:构建模块
- 并行构建2:构建全部的bundle
- 并行构建3:生成类型定义
- 并行构建4:构建辅助函数
- 并行构建5:定义两个串行的任务,先构建主题,然后拷贝样式代码
- 并行构建结束后,拷贝类型定义
用到的series和parallel都是gulp提供的构建方法,而withTaskName和runTask则是一个包装后的方法。看看具体的实现:
// internal/build/src/utils/gulp.ts
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实际上就是对普通函数的包装,通过Object.assign方法,将displayName属性设置到了传入的fn上。将包装后的方法直接交由gulp进行执行。
而runTask方法则返回withTaskName包装后的方法,这里的name统一命名成了shellTask:xxx,而要执行的方法,则是通过pnpm run start xxx来进行执行。同时给run方法传递第二个参数代表执行脚本的路径。buildRoot实际就是internal/build文件夹,当gulp执行到这个命令的时候,就会去internal/build下查找传入name的这个方法,并执行。
这就是整个项目的构建流程了,我们就可以以这个大纲为主线,分别看看各步的任务做了什么,一步步分析项目是如何进行构建的。
# 前期工作
前期工作指的就是构建的前两步了,而且具体的实现逻辑也已经列出来了。 通过gulp提供的withTaskName,直接定义并执行了clean和createOutput这两个任务。
- clean:执行pnpm run clean,清理dist文件夹
- createOutput:在epOutput(dist/element-plus)下,创建dist文件夹
epOutput是一个常量,定义在internal/build-utils/src/paths.ts中,指的是输出的路径。这里也顺便列出其他类似的定义: buildOutput:dist epOutput:dist/element-plus epPackage: packages/element-plus/package.json projRoot: 项目根目录
# 并行构建
中间这一堆的并行构建我看作是中期的构建任务,也是核心的逻辑。
runTask('buildModules'),
runTask('buildFullBundle'),
runTask('generateTypesDefinitions'),
runTask('buildHelper'),
series(
withTaskName('buildThemeChalk', () =>
run('pnpm run -C packages/theme-chalk build')
),
copyFullStyle
)
这里不像前两步,直接用withTaskName来定义并执行构建任务了,而是使用runTask来,并传递名字来执行对应的构建任务,而这些任务的具体逻辑则定义在不同的文件中,且执行的方法名字跟传入的name对应。
# buildModules
从名字看的出来,这个任务的目的是构建各个模块,进一步说是package里面的各个组件。
看看具体实现:
export const buildModules = async () => {
const input = excludeFiles(
await glob('**/*.{js,ts,vue}', {
...
})
)
const bundle = await rollup({
input,
plugins: [
...
],
...
})
await writeBundles(
bundle,
buildConfigEntries.map(([module, config]): OutputOptions => {
return {
...
}
})
)
}
代码分为三个部分:
- 定于input,即要打包的入口文件,并且排除不需要打包的文件
- 使用rollup构建,根据传入的input和plugins进行打包。
- 使用writeBundles方法,将构建好的bundle实例写入到各个模块的dist文件夹中。
先看看第一部分:
// internal/build/src/tasks/modules.ts
const input = excludeFiles(
await glob('**/*.{js,ts,vue}', {
cwd: pkgRoot,
absolute: true,
onlyFiles: true,
})
)
// internal/build-utils/src/pkg.ts
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))
})
}
这里利用了glob库进行了文件查找,目标文件夹:pkgRoot(packages目录)下的所有js、ts、vue文件均查找出来,并通过excludeFiles方法排除一些不需要打包进去的文件。查看excludeFiles方法的实现可以看到排除了'node_modules', 'test', 'mock', 'gulpfile', 'dist'这些文件或文件夹。
这里看excludeFiles方法的时候可能会有疑惑:直接进行路径比对不就好了吗?为什么还要加个position呢?
其实,这是为了防止路径中packages文件夹前面的路径进行干扰,如果不做这一步且路径名中,packages的任意上级目录和要查找的目录同名,那就会匹配失败、被意外排除了。
第二部分是执行rollup进行打包,本身只是传递配置项,并没有什么特别的。可以关注一下使用到的plugins:
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',
},
}),
ElementPlusAlias是项目中定义的一个插件,用来给别名进行替换,也就是将项目中用到@element-plus/xxx的地方替换成packages/element-plus/xxx的实际路径。
后续还用到了VueMacros,Vue宏,值得注意的是将setup语法糖自动转换给关闭了、以及用到了vue的jsx。
最后执行writeBundles方法,将构建好的bundle实例写入到各个模块的dist文件夹中。
// internal/build/src/tasks/modules.ts
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}`,
}
})
)
这里的逻辑是遍历buildConfigEntries数组,从ts定义看得出来数组是一个二位数组,遍历每一项时,二级数组的第一个参数代表模块,第二个代表配置。进去看看这个配置数组:
// internal/build/src/build-info.ts
export const buildConfigEntries = Object.entries(
buildConfig
) as BuildConfigEntries
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`,
},
},
}
这里是将一个配置的对象转换成了配置数组,可能是为了方便遍历。也就是传到writeBundles方法时,第一个参数module分别是esm和cjs,代表es module和commonJS两种模块打包方式。第二个参数的配置项中指定了两种打包的输出路径。
这里看得出来,打包后的产物是分为es和lib两个文件夹,分别对应es module和commonJS两种模块化打包,也就是支持这两种模块化引入。
# buildFullBundle
这个任务的目的在于构建出element-plus的入口文件index.ts,以便可以被其他项目所引入。并且还构建出了压缩和未压缩两个版本,通过minifyPlugin这个插件进行实现。
async function buildFullEntry(minify: boolean) {
const plugins: Plugin[] = [
...
]
if (minify) {
plugins.push(
minifyPlugin({
target,
sourceMap: true,
})
)
}
const bundle = await rollup({
input: path.resolve(epRoot, 'index.ts'),
plugins,
external: await generateExternal({ full: true }),
treeshake: true,
})
await writeBundles(bundle, [
{
format: 'umd',
...
},
{
format: 'esm',
...
},
])
}
async function buildFullLocale(minify: boolean) {
const files = await glob(`**/*.ts`, {
cwd: path.resolve(localeRoot, 'lang'),
absolute: true,
})
return Promise.all(
files.map(async (file) => {
...
const bundle = await rollup({
...
})
await writeBundles(bundle, [
{
format: 'umd',
...
},
{
format: 'esm',
...
},
])
})
)
}
export const buildFull = (minify: boolean) => async () =>
Promise.all([buildFullEntry(minify), buildFullLocale(minify)])
export const buildFullBundle: TaskFunction = parallel(
withTaskName('buildFullMinified', buildFull(true)),
withTaskName('buildFull', buildFull(false))
)
可以看到,这个并行任务时开启另外两个并行任务,分别构建出压缩和未压缩两个版本。最核心的代码是buildFullEntry里面的
if (minify) {
plugins.push(
minifyPlugin({
target,
sourceMap: true,
})
)
}
通过传入minify,决定是否添加minifyPlugin插件进行代码压缩。
# generateTypesDefinitions
这一步是构建类型定义文件,通过typescript的tsc进行编译,生成d.ts文件。
// internal/build/src/tasks/types-definitions.ts
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)
}
具体做的事情如下:
- 生成类型定义文件
定义了一个名为 generateTypesDefinitions 的异步函数,该函数负责生成类型定义文件。
- 使用 vue-tsc 生成类型定义文件
- 使用 run 函数执行 vue-tsc 命令,该命令根据 tsconfig.web.json 配置文件生成 .d.ts 文件。
参数说明:
- -p tsconfig.web.json: 指定 TypeScript 的配置文件。
- --declaration: 生成声明文件。
- --emitDeclarationOnly: 只生成声明文件,不生成 JavaScript 文件。
- --declarationDir dist/types: 指定声明文件的输出目录。
- 重写类型定义文件:
- 使用 glob 获取 dist/types/packages 目录下的所有 .d.ts 文件。
- 对每个文件使用 pathRewriter 函数重写内容,这通常涉及到修改类型定义文件中的路径引用,以便更好地适应实际的项目结构。
- 使用 readFile 和 writeFile 异步读取和写入文件。
- 复制和清理:
- 将 element-plus 子目录的内容复制到 types/packages 目录。
- 删除 element-plus 子目录。
# buildHelper
这部分是构建用于自动化生成Element Plus组件的文档和类型定义,以便在开发过程中提供更好的编辑器支持,暂不分析。
# buildThemeChalk
到这一步,是一个串行的任务,目的是构建打包样式文件
series(
withTaskName('buildThemeChalk', () =>
run('pnpm run -C packages/theme-chalk build')
),
copyFullStyle
)
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')
)
}
可以看到,首先是执行了pnpm run -C packages/theme-chalk build 到package/theme-chalk目录去执行打包命令,然后是执行了copyFullStyle任务,将打包好的样式文件复制到dist之下。
# 构建样式
上面提到,到这一步会执行pnpm run -C packages/theme-chalk build命令去packages/theme-chalk目录下打包样式,我们去看看这个目录下的gulpfile打包文件。 直接看最后的步骤组织:
// packages/theme-chalk/gulpfile.js
const distFolder = path.resolve(__dirname, 'dist')
const distBundle = path.resolve(epOutput, 'theme-chalk')
export const build: TaskFunction = parallel(
copyThemeChalkSource,
series(buildThemeChalk, buildDarkCssVars, copyThemeChalkBundle)
)
这里同样用了parallel和series来组织起了构建任务。 看看并行构建的第一步copyThemeChalkSource:
export function copyThemeChalkSource() {
return src(path.resolve(__dirname, 'src/**')).pipe(
dest(path.resolve(distBundle, 'src'))
)
}
第一步很直接,就是将当前项目根目录下src目录下的所有文件,通过gulp复制到dist/element-plus/theme-chalk的打包目录下。src目录下的文件就是组件库中所有的scss样式文件。
第二步是三个串行任务:
series(buildThemeChalk, buildDarkCssVars, copyThemeChalkBundle)
看看buildThemeChalk:
function buildThemeChalk() {
const sass = gulpSass(dartSass)
const noElPrefixFile = /(index|base|display)/
return src(path.resolve(__dirname, 'src/*.scss'))
.pipe(sass.sync())
.pipe(autoprefixer({ cascade: false }))
.pipe(compressWithCssnano())
.pipe(
rename((path) => {
if (!noElPrefixFile.test(path.basename)) {
path.basename = `el-${path.basename}`
}
})
)
.pipe(dest(distFolder))
}
这步的目的是构建Chalk主题的CSS文件。首先利用gulp的gulpSass插件和dartSass的scss编译器生成了sass的编译方法,然后用了Node的src方法读取了根目录src目录下的所有scss文件,读取完毕后利用gulp的pipe方法组织了一系列的构建任务:
- 对读取到的文件,执行sass.sync()方法,将scss编译成css文件
- 执行autoprefixer方法,添加浏览器前缀
- 执行compressWithCssnano方法,压缩css文件
- 执行rename方法,重命名css文件,将需要的文件名前缀添加el-
- 执行dest方法,将编译好的css文件输出到dist目录下
主要是看看compressWithCssnano这个方法,它用来压缩css文件:
function compressWithCssnano() {
const processor = postcss([
cssnano({
preset: [
'default',
{
// avoid color transform
colormin: false,
// avoid font transform
minifyFontValues: false,
},
],
}),
])
return new Transform({
objectMode: true,
transform(chunk, _encoding, callback) {
const file = chunk as Vinly
if (file.isNull()) {
callback(null, file)
return
}
if (file.isStream()) {
callback(new Error('Streaming not supported'))
return
}
const cssString = file.contents!.toString()
processor.process(cssString, { from: file.path }).then((result) => {
const name = path.basename(file.path)
file.contents = Buffer.from(result.css)
consola.success(
`${chalk.cyan(name)}: ${chalk.yellow(
cssString.length / 1000
)} KB -> ${chalk.green(result.css.length / 1000)} KB`
)
callback(null, file)
})
},
})
}
这个方法利用到了postcss和cssnano来进行压缩任务。首先给postcss配置了cssnano插件,获得了processor实例,然后返回了一个Transform转换流,这个流能够读取对应文件,对文件进行对应的处理。
这里的处理当然是进行css压缩了,执行processor.process方法,传入从文件中获取的css字符串,传入带有from属性的配置对象(用于构建映射),处理完后将文件里的内容替换成压缩后的内容。
后面的buildDarkCssVars也是一样,只是构建了黑暗主题的样式:
function buildDarkCssVars() {
const sass = gulpSass(dartSass)
return src(path.resolve(__dirname, 'src/dark/css-vars.scss'))
.pipe(sass.sync())
.pipe(autoprefixer({ cascade: false }))
.pipe(compressWithCssnano())
.pipe(dest(`${distFolder}/dark`))
}
最后的copyThemeChalkBundle方法将dist目录下的文件复制到dist/element-plus/theme-chalk目录下。
export function copyThemeChalkBundle() {
return src(`${distFolder}/**`).pipe(dest(distBundle))
}
由此完成了样式的打包构建
# 构建类型定义
构建的最后一步是构建类型定义,以便组件库能够支持TS类型检测。
parallel(copyTypesDefinitions, copyFiles)
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 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, 'global.d.ts'),
path.resolve(epOutput, 'global.d.ts')
),
])
到这一步也很简单了,只是把类型文件copy到打包输出的文件夹下,最后将package.json、READEME、global.d.ts文件都复制到打包输出的文件夹下,由此完成了打包构建任务。
# 总结
本文以Element Plus的gulp构建为主线,了解了整个项目是如何进行构建的,同时也能够大致的了解整个项目是如何进行开发运作的,这也符合gulp在项目里用于规范前端开发流程的作用。而且也能够发现,项目是分别打包esm和cmj的,并且组件和样式也是分开进行打包。后续我们就可以进一步的去看组件的实现以及样式文件的打包了。