从ElementPlus了解如何开发一个组件库(二)
上一节讲了如何在本地环境调试写好的组件库内容。
这一节讲讲打包。
该库使用gulp+rollup来构建打包。gulp可以使用并联或者串联的方式来构建工作流;rollup负责打包。
首先从打包脚本看起 "build": "pnpm -C internal/build run start",
通过pnpm run build
实际上是进入到子项目internal/build中执行start脚本,我们再具体看看/internal/build/package.json文件
{
"name": "@element-plus/build",
"private": true,
"version": "0.0.1",
"description": "Build Toolchain for Element Plus",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"scripts": {
"start": "gulp --require sucrase/register/ts -f gulpfile.ts",
"dev": "pnpm run stub",
"stub": "unbuild --stub"
},
"dependencies": {
"@pnpm/find-workspace-packages": "^4.0.0",
"@rollup/plugin-commonjs": "^21.0.3",
"@rollup/plugin-node-resolve": "^13.1.3",
"@vitejs/plugin-vue": "^2.3.1",
"@vitejs/plugin-vue-jsx": "^1.3.9",
"chalk": "^4.1.2",
"components-helper": "^2.0.0",
"consola": "^2.15.3",
"esbuild": "^0.14.29",
"fast-glob": "^3.2.11",
"fs-extra": "^10.0.1",
"gulp": "^4.0.2",
"lodash": "^4.17.21",
"rollup": "^2.70.1",
"rollup-plugin-esbuild": "^4.8.2",
"ts-morph": "^14.0.0",
"unplugin-vue-define-options": "^0.6.0",
"vue": "^3.2.31"
},
"devDependencies": {
"@pnpm/types": "^8.0.0",
"unbuild": "^0.7.2"
}
}
在看具体的start脚本之前,我们先看看声明的入口文件"main": "./dist/index.cjs",
刚把代码down下来的时候,dist这个文件夹是不存在的,那它是怎么来的呢?我们回到根目录下的package.json文件中可以看到下面的脚本:
"postinstall": "pnpm gen:version && pnpm stub",
"stub": "pnpm run -C internal/build stub",
这个脚本会在我们下载依赖(pnpm install)之后执行。也就是说在我们打包之前,会先执行internal/build项目的stub脚本:"stub": "unbuild --stub"
如果是自己手动从头开始创建一个组件库,并且借鉴了ElementPlus的目录结构和一些代码的小伙伴一定不要忘了添加这个脚本、或者自己写个index.ts入口文件,否则在引用项目@element-plus/build的时候将不会有任何导出。
这个脚本会根据项目目录下的build.config.ts配置文件去生成入口文件。
// element-plus-dev/internal/build/build.config.ts
import { defineBuildConfig } from 'unbuild'
export default defineBuildConfig({
entries: ['src/index'],
clean: true,
declaration: true,
rollup: {
emitCJS: true,
},
})
上面配置文件的意思是:根据src/index去生成esm模块和cjs模块的入口文件以及声明文件。clean应该是更新文件前先删除旧文件;declaration是生成声明文件(我试了一下,注释掉也一样会生成声明文件);emitCJS是生成cjs文件。对这个配置感兴趣的可以去看看unbuild。下面是unbuild的Options
export interface BuildOptions {
rootDir: string
entries: BuildEntry[],
clean: boolean
declaration?: boolean
outDir: string
stub: boolean
externals: string[]
dependencies: string[]
peerDependencies: string[]
devDependencies: string[]
alias: { [find: string]: string },
replace: { [find: string]: string },
rollup: RollupBuildOptions
}
紧接着我们继续看start脚本
"start": "gulp --require sucrase/register/ts -f gulpfile.ts",
其中-f --gulpfile 指定gulpfile文件;因为gulp只能解析js文件,因此需要sucrase/register/ts将gulpfile.ts解析成js文件。==注意,如果你的gulpfile.ts文件解析失败,那么运行的时候可能就会提示你找不到gulpfile文件==。
接下来看看整个打包流程
export default series(
withTaskName('clean', () => run('pnpm run clean')), // 删除dist文件夹
withTaskName('createOutput', () => mkdir(epOutput, { recursive: true })), // 递归生成dist/element-plus
parallel(
runTask('buildModules'), // 单个组件打包 esm模块和cjs模块
runTask('buildFullBundle'), // 合并打包 esm模块和umd模块
runTask('generateTypesDefinitions'), // 生成对应的类型声明文件并放到dist/type文件夹下
runTask('buildHelper'), // 生成vetur插件和webstorm的帮助文档
series( // 将所有scss文件编译成css文件,并通过文件流放到合适的地方
withTaskName('buildThemeChalk', () =>
run('pnpm run --filter ./packages/** build --parallel')
),
copyFullStyle // 将dist/element-plus/theme-chalk/index.css拷贝到dist/element-plus/dist/index.css
)
),
// 将dist/type下的声明文件分别拷贝到esm、cjs模块文件夹下
// 将根目录下global.d.ts、README.md、packages/element-plus/package.json拷贝到dist/element-plus下
parallel(copyTypesDefinitions, copyFiles)
)
上面代码注释部分我已经简要说明了每一个任务做的事,接下来我们逐一往下看。
最开始的clean和createOutput就不多介绍了,就如注释所描述的那样。
parallel函数包裹,表示里面的任务是并发的。一开始runTask('buildModules')
import { run } from './process'
export const runTask = (name: string) =>
withTaskName(`shellTask:${name}`, () =>
run(`pnpm run start ${name}`, buildRoot)
)
runTask函数注册了任务的执行函数,实际上是执行pnpm run start
的脚本,并且使用形参传递的任务的名字。
gulp --require sucrase/register/ts -f gulpfile.ts "buildModules
我们很容易忽视gulpfile.ts文件里的最后一段代码:export * from './src'
,我们可以试一试,将这段代码注释掉,再执行runTask('buildModules')
是不成功的,这个就要说到gulp的私有任务和公有任务了。
只有公有任务可以被gulp命令直接调用,而注册公有任务的方式就是在gulpfile文件中export导出。
因此export * from './src'
绝对不能漏。
buildModules
我们进入element-plus-dev/internal/build/src/tasks/modules.ts查看buildModules任务的具体打包过程:
// element-plus-dev/internal/build/src/tasks/modules.ts
export const buildModules = async () => {
const input = excludeFiles(
await glob('**/*.{js,ts,vue}', {
cwd: pkgRoot,
absolute: true,
onlyFiles: true,
})
)
const bundle = await rollup({
input,
plugins: [
ElementPlusAlias(), // 将引用的@element-plus/*替换成element-plus/*(因为生产)
DefineOptions(), // Vue3 defineOptions
vue({
isProduction: false,
}),
vueJsx(),
// 帮助rollup在node_module中查找模块 比如import 'lodash/index' => import 'lodash/index.ts'
nodeResolve({
extensions: ['.mjs', '.js', '.json', '.ts'], // 解析顺序
}),
commonjs(), // rollup本身是不支持解析CommonJs模块的,需要将CommonJs转换成ES6模块
esbuild({ // 使用esbuild打包,加快打包速度
sourceMap: true,
target,
loaders: {
'.vue': 'ts',
},
}),
],
external: await generateExternal({ full: false }), // 排除第三方的包,不要将第三方的代码打进包里
treeshake: false,
})
await writeBundles(
bundle,
// 打成esm模块和cjs模块
buildConfigEntries.map(([module, config]): OutputOptions => {
return {
// amd, cjs, es, iife, umd, system
format: config.format,
// 打包到指定目录
dir: config.output.path,
// 导出方式,仅针对cjs
exports: module === 'cjs' ? 'named' : undefined,
// 保持原有目录结构
preserveModules: true,
// 将原有结构这个目录下的文件放到根目录dir下
preserveModulesRoot: epRoot,
// 是否生成sourceMap
sourcemap: true,
// 文件名
entryFileNames: `[name].${config.ext}`,
}
})
)
}
每一项配置具体的作用已经在代码中有所注释。简单的再缕一下:
- 首先需要打包的文件是element-plus/packages/下所有的js,ts,vue文件。
- rollup会解析这些input文件里的import导入,并通过plugins进行处理。
- ElementPlusAlias插件将monorepo项目引用的@element-plus/xxx全部转化成正常项目引用element-plus/xxx;
- vue解析,vueJsx解析,DefineOptions
- nodeResolve 将文件导入按Node算法处理 Node resolution algorithm 例如'lodash/index' => import 'lodash/index.ts'
- 将commonJs转成es6
- 使用esbuild进行打包
- external 通过解析element-plus-dev/packages/element-plus/package.json的dependencies和peerDependencies,将第三方库从打包文件中移除。
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}/`)
)
}
}
- 输出
writeBundles(bundle, buildConfigEntries)
// element-plus-dev/internal/build/src/utils/rollup.ts
export function writeBundles(bundle: RollupBuild, options: OutputOptions[]) {
return Promise.all(options.map((option) => bundle.write(option)))
}
// element-plus-dev/internal/build/src/build-info.ts
export const buildConfig: Record<Module, BuildInfo> = {
esm: {
module: 'ESNext',
format: 'esm',
ext: 'mjs',
output: {
name: 'es',
path: path.resolve(epOutput, 'es'),
},
bundle: {
path: `${EP_PKG}/es`,
},
},
cjs: {
module: 'CommonJS',
format: 'cjs',
ext: 'js',
output: {
name: 'lib',
path: path.resolve(epOutput, 'lib'),
},
bundle: {
path: `${EP_PKG}/lib`,
},
},
}
export const buildConfigEntries = Object.entries(
buildConfig
) as BuildConfigEntries
这里分别输出两种格式的包,分别是esm模块和cjs模块。
具体的OutputOptions的说明注释上已经写了,可能exports选项需要再深入一点说明: 这是专门给cjs模块使用的,output.exports有四个值:auto、default、named、none(没有导出);如果input module仅使用export default,那么output.exports可以选default;如果input module使用具名导出 export const hellow = ‘world’,那么output.exports需要选named;output.exports: 'named' 后,可以接收具名变量 const { hello } = require('your-lib');
至此,真正意义上的第一个Task buildModules就完成了。总的来说这个Task做的就是将element-plus/packages里的js ts vue文件,保持原有目录结构(preserveModulesRoot目标目录提到根目录),分别打成esm模块和cjs模块,放到dist/element-plus/下,其中es文件夹下存放esm模块代码,lib文件夹下存放cjs模块代码。
buildFullBundle
export const buildFullBundle = parallel(
withTaskName('buildFullMinified', buildFull(true)),
withTaskName('buildFull', buildFull(false))
)
export const buildFull = (minify: boolean) => async () =>
Promise.all([buildFullEntry(minify), buildFullLocale(minify)])
buildFullBundle并发的执行两个任务,执行的函数都是buildFull,从参数minify可以看出,表达的意思是是否压缩,所以buildFullBundle是并发生成压缩和不压缩的包。
buildFull执行两个函数:buildFullEntry和buildFullLocale
async function buildFullEntry(minify: boolean) {
const bundle = await rollup({
input: path.resolve(epRoot, 'index.ts'),
plugins: [
ElementPlusAlias(),
DefineOptions(),
vue({
isProduction: true,
}),
vueJsx(),
nodeResolve({
extensions: ['.mjs', '.js', '.json', '.ts'],
}),
commonjs(),
esbuild({
exclude: [],
minify,
sourceMap: minify,
target,
loaders: {
'.vue': 'ts',
},
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
},
}),
],
external: await generateExternal({ full: true }),
})
await writeBundles(bundle, [
{
format: 'umd',
file: path.resolve(
epOutput,
'dist',
formatBundleFilename('index.full', minify, 'js')
),
exports: 'named',
// Necessary for iife/umd bundles
// it is the global variable name representing your bundle
// 引用后的全局变量 相当于引用后可以用ElementPlus访问
name: 'ElementPlus',
// necessary for external imports in umd/iife bundles
// 告诉rollup vue是第三方的模块,而且这个模块的id等同于全局变量Vue
// globals: {
// jquery: '$'
// }
globals: {
vue: 'Vue',
},
sourcemap: minify,
banner,
},
{
format: 'esm',
file: path.resolve(
epOutput,
'dist',
formatBundleFilename('index.full', minify, 'mjs')
),
sourcemap: minify,
banner,
},
])
}
相比于buildModules的inputOption和outputOption,buildFullEntry有以下几个不太一样的配置:
inputOption:
- input文件仅有element-plus-dev/packages/element-plus/index.ts。正如我在第一章时说到的,element-plus-dev/packages/element-plus/index.ts是整个库的入口文件,所以buildFullEntry仅仅将入口文件和入口文件需要import的文件进行打包即可。
- @vitejs/plugin-vue插件设置参数isProduction为true,设置该参数具体做了哪些操作,感兴趣的小伙伴可以去了解一下,想来应该是减少了一些体积。
- esbuild打包,根据传递的参数决定是否压缩
- external,不剔除@vue/开头的工具包,之前单个模块打包时,会把@vue/工具包也一并剔除。
outputOption
- 打两种类型的包,一个umd,一个esm
- iife和umd类型的包需要name选项,标识引入后的模块id,也就是require('element-plus')后可以通过全局变量ElementPlus访问。
- globals:告诉rollup vue是第三方的模块,而且这个模块的id等同于全局变量Vue
- banner:在打包文件的最上方标识
async function buildFullLocale(minify: boolean) {
const files = await glob(`${path.resolve(localeRoot, 'lang')}/*.ts`, {
absolute: true,
})
return Promise.all(
files.map(async (file) => {
const filename = path.basename(file, '.ts')
// 连字符 => 驼峰 + 首字母大写
const name = upperFirst(camelCase(filename))
const bundle = await rollup({
input: file,
plugins: [
esbuild({
minify,
sourceMap: minify,
target,
}),
],
})
await writeBundles(bundle, [
{
format: 'umd',
file: path.resolve(
epOutput,
'dist/locale',
formatBundleFilename(filename, minify, 'js')
),
exports: 'default',
name: `ElementPlusLocale${name}`,
sourcemap: minify,
banner,
},
{
format: 'esm',
file: path.resolve(
epOutput,
'dist/locale',
formatBundleFilename(filename, minify, 'mjs')
),
sourcemap: minify,
banner,
},
])
})
)
}
buildFullLocale具体的选项就不多说了,都已经在上面解释过,这个方法的作用是,将element-plus-dev/packages/locale/lang/*.ts的语言文件分别打包成umd模块和esm模块,放在element-plus-dev/dist/element-plus/dist/locale/目录下。
总的来说,buildFullBundle的任务是将element-plus/packages/element-plus/index.ts通过rollup打成umd和esm两种类型的包放在element-plus-dev/dist/element-plus/dist/目录下并且将语言文件打包。之所以将@vue/工具函数一同打成umd包,是为了给unpkg和jsdelivr使用。
// element-plus-dev/packages/element-plus/package.json
"main": "lib/index.js",
"module": "es/index.mjs",
"style": "dist/index.css",
"unpkg": "dist/index.full.js",
"jsdelivr": "dist/index.full.js",
generateTypesDefinitions
generateTypesDefinitions任务,根据element-plus/packages/下的文件生成对应的类型声明文件,并输出到element-plus-dev/dist/types/下。这部分内容就暂时不说了,后面如果有需要再单独讲生成类型声明文件这一块。
buildHelper
buildHelper的细节也不具体介绍了,这个任务的作用是在dist/element-plus/下面生成attribute.json、tags.json、web-types.json三个文件。这三个文件是通过解析element-plus-dev/docs/en-US/component/下的每一个组件的markdown文件生成的。介绍这三个文件的作用之前,首先看一下下面的一段话:
blog.jetbrains.com/webstorm/20… when developing a Vue application, I often had to switch back and forth between my editor and the online library documentation. I wasted a lot of time tracking down information, which was simply frustrating. What is web-types? Web-types is an open source standard for documenting various web frameworks. In its first iteration, it’s focused only on Vue support. The documentation consists of a single JSON file. The good news is that it’s already supported by some major Vue libraries – you can get detailed documentation for bootstrap-vue, quasar, vuetify, nuxt.js, and @ionic/vue while coding in WebStorm.
意思是说,这老哥用WebStorm写页面的时候需要来回的切换ide和UI库的官网,然后老哥觉得这很不程序员。于是就有了web-types.json这个东西,只要在package.json配置了"web-types": "web-types.json",
,WebStorm就会去相应目录寻找这个文件并进行解析,最后达到的效果就是下面这张图,这样就不需要切换到官网看文档啦。
同理,attribute.json、tags.json做的也是相同的工作,不过这两个文件是给vetur插件做的,需要配置:
"vetur": {
"tags": "tags.json",
"attributes": "attributes.json"
},
buildThemeChalk
这个任务是并发执行element-plus/packages/下的所有子项目的build脚本,就我目前的这个版本,只是有element-plus/packages/theme-chalk/有build脚本 "build": "gulp --require sucrase/register/ts"
。加载目录下的gulpfile文件
function buildThemeChalk() {
const sass = gulpSass(dartSass)
const noElPrefixFile = /(index|base|display)/
// autoprefixer插件作用
// ::placeholder {
// color: gray;
// }
// 加前缀后
// ::-moz-placeholder {
// color: gray;
// }
// :-ms-input-placeholder {
// color: gray;
// }
// ::placeholder {
// color: gray;
// }
return src(path.resolve(__dirname, 'src/*.scss'))
.pipe(sass.sync())
.pipe(autoprefixer({ cascade: false })) // 给css加前缀
.pipe(
cleanCSS({}, (details) => { // 压缩
consola.success(
`${chalk.cyan(details.name)}: ${chalk.yellow(
details.stats.originalSize / 1000 // 原始css文件大小
)} KB -> ${chalk.green(details.stats.minifiedSize / 1000)} KB` // 压缩后css文件大小
)
})
)
.pipe(
rename((path) => {
// 不是/(index|base|display)/的文件名 + 前缀 el-
if (!noElPrefixFile.test(path.basename)) {
path.basename = `el-${path.basename}`
}
})
)
.pipe(dest(distFolder))
}
export function copyThemeChalkBundle() {
return src(`${distFolder}/**`).pipe(dest(distBundle))
}
export function copyThemeChalkSource() {
return src(path.resolve(__dirname, 'src/**')).pipe(
dest(path.resolve(distBundle, 'src'))
)
}
export const build = parallel(
// 将./src/*的scss文件复制到 dist/element-plus/theme-chalk/src/*
copyThemeChalkSource,
// 1. ./dist/下生成css文件
// 2. 将./dist/下的css文件拷贝到dist/element-plus/theme-chalk/*
series(buildThemeChalk, copyThemeChalkBundle)
)
export default build
具体的内容注释已经写清楚了。这个任务的作用是:
- 将./src/的scss文件复制到 dist/element-plus/theme-chalk/src/
- 将scss转成css放到当前目录下的dist目录
- 将当前目录dist下的css再拷贝到dist/element-plus/theme-chalk/*
copyFullStyle
将dist/element-plus/theme-chalk/index.css拷贝到dist/element-plus/dist/
copyTypesDefinitions
将dist/types/下的类型声明文件按目录结构拷贝分别拷贝到dist/element-plus/es和dist/element-plus/lib下
copyFiles
将element-plus/packages/element-plus/package.json、element-plus/README.md、element-plus/global.d.ts拷贝到element-plus-dev/dist/element-plus
至此,整个打包流程就介绍完了,可以看到,完成一个简单的打包,通常我们只需要buildModules任务和buildThemeChalk任务,也就是模块化打包和将scss打成css。如果项目需要支持unpkg和jsdelivr,那么就添加buildFullBundle任务打个umd的包。如果要上typescript,再添加generateTypesDefinitions任务。如果要给ide添加文档提示,再添加buildHelper任务。
从Element-plus了解如何开发一个组件
转载自:https://juejin.cn/post/7125443756291014687