element-plus组件库搭建
说到组件库,大家很熟悉的element-ui,和AndDesign, 我们参考一下element-plus,从0到1实现一个组件库;
先来看看element-plus的目录结构,可以发现:
- 采用monorepo + pnpm来搭建的组件库
为什么选择pnpm来作为包管理器搭建组件库,可以看上一篇文章pnpm入门, 目前pnpm内置了对monorepo的workspace的天然支持
用monorepo + pnpm搭建组件库
想实现的效果是实现一两个组件,并完善内部的结构,构建打包等流程;
- components组件库
-
- button组件
- theme主题
用pnpm初始化一个全新的仓库
初始化目录结构
mkdir component-demo
// 初始化package.json
pnpm init
cd component-demo
mkdir pacakage.json
touch pnpm-workspace.yaml
- 组装完以下目录文件
- 在实现完components-button/ components-input基础功能之后,开始继续打包构建
element-plus组件构建流程分析
package.json script中表述了构建脚本,利用pnpm执行internal/build目录下的 start脚本;
"build": "pnpm run -C internal/build start",
在start脚本中,采用gulp,可以看上一篇文章gulp入门, 执行gulpfile.ts来进行任务的构建:
"start": "gulp --require @esbuild-kit/cjs-loader -f gulpfile.ts",
series是gulp中同步执行多个任务,主要是先clean,然后再执行构建,先将上一次打包构建的结果清空后,开始并行执行这次的打包构建;
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)
)
在buildModules中,主要是用rollup打包element-plus组件的相关代码,然后输出commonJS, ES Module的包,现在来分析buildModules任务中主要做了什么,是怎么实现的
组件库从0到1构建
之前我们初始化的组件库
组件打包输出CommonJS&ES-module
为什么组件打包构建要输出两种模块类型,ES Module是浏览器新提出的模块规范,在Node.js 13之前不支持ES Module,只支持CommonJS,所以为了向下兼容,一般的npm都提供两种模块的输出, 以下是我们基于rollup来构建的产物
- 所谓打包,就是将代码进行一定的转换,然后从入口文件开始,分析其依赖关系,转成一定关系的js文件;
基于rollup来进行,我们执行指定input和external,内部依赖关系,都由rollup来进行处理了。
- 对于特殊文件,比如vue,采用插件@vitejs/plugin-vue来进行解析;
- 对于TS文件,采用rollup-plugin-esbuild插件来进行解析;
import fs from 'node:fs';
import { rollup } from 'rollup';
import vue from '@vitejs/plugin-vue';
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 type { OutputOptions } from 'rollup';
import { resolvePackagePath } from './util';
// 将第三方包给剔除在外;
const getExternal = async (pkgDirName: string) => {
const pkgPath = resolvePackagePath(pkgDirName, 'package.json');
const manifest = require(pkgPath) as any;
const {
dependencies = {},
peerDependencies = {},
devDependencies = {}
} = manifest;
const deps: string[] = [
...new Set([
...Object.keys(dependencies),
...Object.keys(peerDependencies),
...Object.keys(devDependencies)
])
];
return (id: string) => {
if (id.endsWith('.less')) {
return true;
}
return deps.some((pkg) => id === pkg || id.startsWith(`${pkg}/`));
};
};
const build = async (pkgDirName: string) => {
const pkgDistPath = resolvePackagePath(pkgDirName, 'dist');
// 判断是否有dist目录,如果有就移除
if (fs.existsSync(pkgDistPath) && fs.statSync(pkgDistPath).isDirectory()) {
fs.rmSync(pkgDistPath, { recursive: true });
}
// 扫描所有的资源;
const input = await glob(['**/*.{js,jsx,ts,tsx,vue}', '!node_modules'], {
cwd: resolvePackagePath(pkgDirName, 'src'),
absolute: true,
onlyFiles: true
});
// 1. rollup打包,插件支持和配置;
const bundle = await rollup({
input,
plugins: [
// 插件VueMacros, 实现的是vue非官方提案和想法的库,探索并扩展其功能和语法;
VueMacros({
setupComponent: false,
setupSFC: false,
plugins: {
vue: vue({
isProduction: true
}),
}
}),
// 可以在Rollup打包时解析Node.js模块
nodeResolve({
extensions: ['.mjs', '.js', '.json', '.ts']
}),
// 可以将commonjs模块转换成ES模块,来进行打包;
commonjs(),
// 它结合了 ESBuild 和 Rollup 来转换 ESNext 和 TypeScript 代码
esbuild({
sourceMap: true,
target: 'es2015',
loaders: {
'.vue': 'ts'
}
})
],
// 将node_modules中模块剔除
external: await getExternal(pkgDirName),
treeshake: false
});
const options: OutputOptions[] = [
{
format: 'cjs',
dir: resolvePackagePath(pkgDirName, 'dist', 'cjs'),
exports: 'named',
preserveModules: true,
preserveModulesRoot: resolvePackagePath(pkgDirName, 'src'),
sourcemap: true,
entryFileNames: '[name].cjs'
},
{
format: 'esm',
dir: resolvePackagePath(pkgDirName, 'dist', 'esm'),
exports: undefined,
preserveModules: true,
preserveModulesRoot: resolvePackagePath(pkgDirName, 'src'),
sourcemap: true,
entryFileNames: '[name].mjs'
}
];
// 开始打包,采用rollup打包, 打包输出common esmodule;
return Promise.all(options.map((option) => bundle.write(option)));
};
await build('components');
Style的构建
组件中,我们将style单独维护在style中,采用css预处理器来编写,所以在对style文件进行打包构建的时候,还需要利用css预处理器的转换功能转换成css。
TypeScripe类型文件的构建
要生成TS类型文件,本质上是对TS代码进行编译,操作,然后生成单独的类型声明文件,借助ts-morph编译器来实现对TS文件类型的获取,但是对于vue文件,我们还需要借助compiler-src进行对vue文件的编译,提取出内部的script文件后借助ts-morph来进行产出类型文件
import process from 'node:process';
import path from 'node:path';
import fs from 'node:fs';
import * as vueCompiler from 'vue/compiler-sfc';
import glob from 'fast-glob';
import { Project } from 'ts-morph';
import type { CompilerOptions, SourceFile } from 'ts-morph';
import { resolveProjectPath, resolvePackagePath } from './util';
const tsWebBuildConfigPath = resolveProjectPath('tsconfig.json');
// 将*.d.ts文件复制到指定格式模块目录里
async function copyDts(pkgDirName: string) {
const dtsPaths = await glob(['**/*.d.ts'], {
cwd: resolveProjectPath('dist', 'types', 'packages', pkgDirName, 'src'),
absolute: false,
onlyFiles: true
});
dtsPaths.forEach((dts: string) => {
const dtsPath = resolveProjectPath(
'dist',
'types',
'packages',
pkgDirName,
'src',
dts
);
const cjsPath = resolvePackagePath(pkgDirName, 'dist', 'cjs', dts);
const esmPath = resolvePackagePath(pkgDirName, 'dist', 'esm', dts);
const content = fs.readFileSync(dtsPath, { encoding: 'utf8' });
fs.writeFileSync(cjsPath, content);
fs.writeFileSync(esmPath, content);
});
}
// 添加源文件到项目里
async function addSourceFiles(project: Project, pkgSrcDir: string) {
project.addSourceFileAtPath(resolveProjectPath('env.d.ts'));
const globSourceFile = '**/*.{js?(x),ts?(x),vue}';
const filePaths = await glob([globSourceFile], {
cwd: pkgSrcDir,
absolute: true,
onlyFiles: true
});
const sourceFiles: SourceFile[] = [];
await Promise.all([
...filePaths.map(async (file) => {
if (file.endsWith('.vue')) {
const content = fs.readFileSync(file, { encoding: 'utf8' });
const hasTsNoCheck = content.includes('@ts-nocheck');
// vue还是采用vue的编译来编译
const sfc = vueCompiler.parse(content);
const { script, scriptSetup } = sfc.descriptor;
if (script || scriptSetup) {
let content =
(hasTsNoCheck ? '// @ts-nocheck\n' : '') + (script?.content ?? '');
if (scriptSetup) {
const compiled = vueCompiler.compileScript(sfc.descriptor, {
id: 'temp'
});
content += compiled.content;
}
const lang = scriptSetup?.lang || script?.lang || 'js';
const sourceFile = project.createSourceFile(
`${path.relative(process.cwd(), file)}.${lang}`,
content
);
sourceFiles.push(sourceFile);
}
} else {
const sourceFile = project.addSourceFileAtPath(file);
sourceFiles.push(sourceFile);
}
})
]);
return sourceFiles;
}
// 生产Typescript类型描述文件
async function generateTypesDefinitions(
pkgDir: string,
pkgSrcDir: string,
outDir: string
) {
const compilerOptions: CompilerOptions = {
emitDeclarationOnly: true,
outDir
};
const project = new Project({
compilerOptions,
tsConfigFilePath: tsWebBuildConfigPath
});
const sourceFiles = await addSourceFiles(project, pkgSrcDir);
// 就调用emit() 将ts代码编译成js;
await project.emit({
emitOnlyDtsFiles: true
});
const tasks = sourceFiles.map(async (sourceFile) => {
const relativePath = path.relative(pkgDir, sourceFile.getFilePath());
const emitOutput = sourceFile.getEmitOutput();
const emitFiles = emitOutput.getOutputFiles();
if (emitFiles.length === 0) {
throw new Error(`异常文件: ${relativePath}`);
}
const subTasks = emitFiles.map(async (outputFile) => {
const filepath = outputFile.getFilePath();
fs.mkdirSync(path.dirname(filepath), {
recursive: true
});
});
await Promise.all(subTasks);
});
await Promise.all(tasks);
}
async function build(pkgDirName: string) {
const outDir = resolveProjectPath('dist', 'types');
const pkgDir = resolvePackagePath(pkgDirName);
const pkgSrcDir = resolvePackagePath(pkgDirName, 'src');
await generateTypesDefinitions(pkgDir, pkgSrcDir, outDir);
await copyDts(pkgDirName);
}
await build('components');
总结
参考element-plus, 我们采用pnpm + monorepo来进行组件库的搭建,完成了基本组件的编写后,分析element-plus组件的构建流程,我们将组件库的代码进行打包构建,最后输出了相应的产物;