写在前面
其实刚入行前端的时候,看到element-ui
、antd
等组件库的时候,就一直觉得,作为一名前端er,即时市面上成熟的产品很多,但是想着拥有一套自己的组件库应该是很酷的一件事情。大概在一个月前,公司这边考虑到项目一些底层基础组件放着,总会有人往里添加业务逻辑和一些特殊逻辑,是否有必要抽取封装后。于是我真正的开始去了解如何去开发一套自己的组件库。
经过对element-plus
的源码的结构进行分析,然后模仿,外加薅了一节免费的相关课程,所以本文基本上能完成一个基本组件的开发、打包、项目引入使用等环节。而且重点也就是在开发和打包上
这篇文章既是分享,也是记录,希望此刻阅读的你们能有收获,也希望有人能一起搞一套完整的ui组件库
本文略长,没有耐心的同学可以,直接扒代码:https://github.com/chenjing0823/xbb-plus,跑一遍,
按照【五、模拟打包后的使用】方式使用一遍。感受一下
使用的相关技术
架构方面,看了市面上众多优秀的组件库,同样采用的是 monorepo 的模式开发。
技术方面大概有以下技术
- vue3
- TypeScript
- pnpm
- gulp + rollup
- vite
一、搭建 monorepo 环境
使用 pnpm + workspace 来实现 monorepo
npm install pnpm -g # 全局安装pnpm
pnpm init # 初始化package.json配置文件
pnpm install vue@next typescript -D 全局下添加依赖 npx tsc --init # 初始化ts配置文件
npx tsc --init # 初始化ts配置文件
image.pngpnpm init时,建议 package name 合理取名,方便后面一些操作,例如 demo-ui,我这边就模仿element-plus,叫xbb-plus
tsconfig.json :
{
"compilerOptions": {
"module": "ESNext", // 打包模块类型ESNext
"declaration": false, // 默认不要声明文件
"noImplicitAny": false, // 支持类型不标注可以默认any
"removeComments": true, // 删除注释
"moduleResolution": "node", // 按照node模块来解析
"esModuleInterop": true, // 支持es6,commonjs模块
"jsx": "preserve", // jsx 不转
"noLib": false, // 不处理类库
"target": "es6", // 遵循es6版本
"sourceMap": true,
"lib": [ // 编译时用的库
"ESNext",
"DOM"
],
"allowSyntheticDefaultImports": true, // 允许没有导出的模块中导入
"experimentalDecorators": true, // 装饰器语法
"forceConsistentCasingInFileNames": true, // 强制区分大小写
"resolveJsonModule": true, // 解析json模块
"strict": true, // 是否启动严格模式
"skipLibCheck": true // 跳过类库检测
},
"exclude": [ // 排除掉哪些类库
"node_modules",
"**/__tests__",
"dist/**"
]
}
tips:
使用 pnpm 必须在项目根目录下建立 .npmrc 文件, shamefully-hoist = true ,否则安装的模块无法放置到 node_modules 目录下
项目根目录下建立文件:pnpm-workspace.yaml
packages:
- 'packages/**' # 存放编写的组件的
- play # 测试编写组件
二、搭建组件测试环境
mkdir play && cd play
pnpm init
pnpm install vite @vitejs/plugin-vue # 安装vite及插件
此时的 pnpm init ,package name 可以与根目录有关联,'@xbb-plus/play',后续有用
play/package.json
这里只需要注意下 命名 。最好是保持一个类似的格式,便于公共包的使用,也直观方便阅读
{
"name": "@xbb-plus/play",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "vite"
}
}
play/vite.config.ts:
使用 vite 启动组件测试的本地服务
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()]
});
play/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app"></div>
<script src="/main.ts" type="module"></script>
</body>
</html>
play/app.vue
<template>
<div>测试</div>
</template>
play/main.ts:
import { createApp } from "vue";
import App from "./app.vue";
const app = createApp(App);
app.mount("#app");
typings/vue-shim.d.ts
declare module '*.vue'{
import type {DefineComponent} from 'vue'
const component:DefineComponent
export default component
}
在play目录下:
npm run dev
image.png
到目前为主,准备工作基本完成,目录结构如下:
node_modules # 项目依赖
play
├─node_modules # play的依赖
├─app.vue
├─index.html
├─main.ts
├─package.json
└─vite.config.ts
typings
└─vue-shim.d.ts # typescript 声明文件
.npmrc
package.json
pnpm-workspace.yaml
tsconfig.json
三、编写组件库组件
根目录下新建文件夹 packages ,同时新建以下目录结构
packages
├─components # 存放所有的组件
├─utils # 存放工具方法
└─theme-chalk # 存放对应的样式
1. 初始化
cd components && pnpm init # @xbb-plus/components
cd utils && pnpm init # @xbb-plus/utils
cd theme-chalk && pnpm init # @xbb-plus/theme-chalk
package name的格式,与前面 play 一致
2. 模块间相互引用
前面一直强调的 package name的格式,其实就是在接下来这步操作上,有所帮助。在开发的过程中,需要把 packages 下的几个模块,作为一个个独立的依赖,方便开发、调试上以及互相之间引用。所以需要将他们放在根模块下,进行依赖添加:(-w 的意思是 将依赖添加的workspace下)
pnpm install @xbb-plus/components -w
pnpm install @xbb-plus/theme-chalk -w
pnpm install @xbb-plus/utils -w
最终在根目录的 package.json 可以看到这样的内容:
image.png
3. 实现一个组件
本文以一个 Icon 组件为例:
packages/components/icon/src/icon.ts
import { ExtractPropTypes } from "vue"
export const iconProps = {
size: {
type: Number
}, color: {
type: String
}
} as const
export type IconProps = ExtractPropTypes<typeof iconProps>
packages/components/icon/src/icon.vue
这里简单编写了一个 Icon 组件
<template>
<i class="xbb-icon" :style="style">
<slot></slot>
</i>
</template>
<script lang="ts">
import { computed, defineComponent } from "vue";
import { iconProps } from './icon'
export default defineComponent({
name: 'XbbIcon',
props: iconProps,
setup(props) {
const style = computed(() => {
if (!props.size && !props.color) {
return {}
}
const style = {
...(props.size ? { 'font-size': props.size + 'px' } : {}),
...(props.color ? { 'color': props.color } : {}),
}
console.log('style', style)
return style
})
return { style }
}
})
</script>
4. 导出组件
所有的组件都需要一个入口,icon组件入口 packages/components/icon/index.ts
import Icon from "./src/icon.vue";
import { Plugin, App } from "vue";
type SFCWithInstall<T> = T & Plugin;
const withInstall = <T>(comp: T) => {
(comp as SFCWithInstall<T>).install = function (app: App):void {
app.component((comp as any).name, comp);
};
return comp as SFCWithInstall<T>;
};
export const XbbIcon = withInstall(Icon);
export default XbbIcon; // 导出组件
export * from "./src/icon"; // 导出组件的属性类型
因为每个组件都需要增添 install 方法,所以我们将 withInstall 方法拿到 utils 中,所以上面的代码会被拆分成两块,一块是公共方法,一块是组件代码
拆分后的icon组件入口 packages/components/icon/index.ts
import Icon from "./src/icon.vue";
import { withInstall } from "@xbb-plus/utils/with-install";
const XbbIcon = withInstall(Icon);
export {
XbbIcon
}
export default XbbIcon;
// 两种导出方式
packages/utils/with-install.ts
import type { App, Plugin } from 'vue' // 只导入类型 而不是导入值
// 类型必须导出 否则生成不了.d.ts
export type SFCWithInstall<T> = T & Plugin
export const withInstall = <T>(comp: T) => {
(comp as SFCWithInstall<T>).install = function (app: App) {
app.component((comp as any).name, comp)
}
return comp as SFCWithInstall<T>
}
5. 测试组件使用
play 目录就是我们本地测试组件的地方,进入到 play 目录下,引入组件并且注册
play/main.ts
import { createApp } from "vue";
import App from "./app.vue";
import XbbIcon from "@xbb-plus/components/icon";
const app = createApp(App);
app.use(XbbIcon)
app.mount("#app");
在 app.vue 内使用:
<template>
<div>测试</div>
<xbb-icon color="red" :size="30">你好</xbb-icon>
</template>
image.png
ok,看到效果!
因为这是图标组件,此时还没有任何图标,所以看到文本,也能证明组件编写有效,导出有效。
6. 字体图标
正常组件一般到上一步就结束了,但是icon组件涉及各式各样的图表和字体,所以还需要引入相关文件。
首先新建以下目录
theme-chalk
├─src
├─fonts
├─mixins
然后 要有自己的图标库,打开阿里巴巴矢量图标库,新建一个 xbb-plus 项目
随意添加几个可使用的图标进入项目后,下载至本地
image.png
解压后,将 iconfont.ttf 、iconfont.woff 、iconfont.woff2 文件放入 theme-chalk/src/fonts 内。新建以下文件内容
packages/theme-chalk/src/mixins/config.scss
$namespace: 'xbb'
// 命名规范 BEM规范
packages/theme-chalk/src/mixins/mixins.scss
// 声明公共的sass方法
@use 'config' as *;
@forward 'config';
复制 iconfront.css 内代码,放入文件 packages/theme-chalk/src/icon.scss
@use './mixins/mixin.scss' as *;
@font-face {
font-family: "xbb-ui-icons"; /* Project id 2900485 */
src: url('./fonts/iconfont.woff2') format('woff2'),
url('./fonts/iconfont.woff') format('woff'),
url('./fonts/iconfont.ttf') format('truetype');
}
[class^='#{$namespace}-icon'],[class*='#{$namespace}-icon'] {
font-family: "xbb-ui-icons" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.#{$namespace}-icon-duihao:before {
content: "\e6ca";
}
.#{$namespace}-icon-bumengaikuang:before {
content: "\e60d";
}
第一行引入mixins
修改三个字体文件对应的路径 ./fonts/iconfont.woff2 、woff、ttf
修改 namespace
packages/theme-chalk/src/index.scss
@use 'icon.scss'
最后在mian.ts 引入样式
import { createApp } from "vue";
import App from "./app.vue";
import XbbIcon from "@xbb-plus/components/icon";
import '@xbb-plus/theme-chalk/src/index.scss';
const app = createApp(App);
app.use(XbbIcon)
app.mount("#app");
修改 app.vue
<template>
<XbbIcon color="red" :size="30" class="xbb-icon-duihao"></XbbIcon>
</template>
image.png
看到效果~👏
至此,从0开始,开发一套ui组件库的组件编写、导出、测试工作就完成了。
接下来就是更重要的 打包 环节了。打包意味着可以发布 使用
四、打包
整体打包思路:
通过 gulp 控制流程,清除 dist、打包样式、打包工具方法、打包所有组件、打包每个组件、生成一个组件库、发布组件
1. 打包样式和工具模块
整个打包流程是使用 gulp 进行流程控制:
pnpm install gulp @types/gulp sucrase -w -D
根目录上 package.json 内添加命令
"scripts": {
"build": "gulp -f build/gulpfile.ts"
}
1.1 gulp 控制打包流程
build/gulpfile.ts
目前只有一个清楚 dist 目录的任务
import { series, parallel } from 'gulp';
import { withTaskName, run } from './utils'
export default series(
withTaskName('clean', () => run('rm -rf ./dist')),
)
build/utils/paths.ts
一些路径相关的变量,统一维护
import path from 'path'
export const projectRoot = path.resolve(__dirname, "../../");
build/utils/index.ts
封装打包使用的工具方法 run
import { spawn } from 'child_process';
import { projectRoot } from './paths';
export const withTaskName = <T>(name: string, fn: T) => Object.assign(fn, {
displayName: name });
// 在node使用子进程来运行脚本
export const run = async (command: string) => {
// rf -rf
return new Promise((resolve) => {
const [cmd, ...args] = command.split(" ");
// execa这些库
const app = spawn(cmd, args, {
cwd: projectRoot,
stdio: "inherit", // 直接将这个子进程的输出
shell: true, // 默认情况下 linux 才支持 rm -rf (我再电脑里安装了git bash)
});
app.on("close", resolve);
});
};
1.2 样式模块打包
修改 build/gulpfile.ts ,在打包流程内加入样式和工具模块打包入口,会依次调用packages目录下对应包的build命令
parallel的目的是,后面会有组件的打包,可以同时进行。即都可放在里面
import { series, parallel } from 'gulp';
import { withTaskName, run } from './utils'
export default series(
withTaskName('clean', () => run('rm -rf ./dist')),
parallel(
withTaskName("buildPackages", () =>
run("pnpm run --filter ./packages --parallel build")
)
)
)
buildPackages 任务会依次调用 packages 目录下对应包的 build 命令,所以在 package/theme-chalk 和 package/utils 的目录内,都添加命令:
"scripts": {
"build": "gulp"
}
安装相关依赖:
pnpm install gulp-sass @types/gulp-sass @types/sass @types/gulp-autoprefixer gulp-autoprefixer @types/gulp-clean-css gulp-clean-css sass -D -w
packages/theme-chalk/gulpfile.ts
最终的产物输出都是在dist目录下
// 打包样式
import gulpSass from "gulp-sass";
import dartSass from "sass";
import autoprefixer from "gulp-autoprefixer";
import cleanCss from "gulp-clean-css";
import path from "path";
import { series, src, dest } from "gulp";
function compile() {
const sass = gulpSass(dartSass);
return src(path.resolve(__dirname, "./src/*.scss"))
.pipe(sass.sync())
.pipe(autoprefixer())
.pipe(cleanCss())
.pipe(dest("./dist"));
}
function copyfont() {
return src(path.resolve(__dirname, "./src/fonts/**"))
// .pipe(cleanCss())
.pipe(dest("./dist/fonts"));
}
function copyfullStyle() {
return src(path.resolve(__dirname, "./dist/**")).pipe(
dest(path.resolve(__dirname, "../../dist/theme-chalk"))
);
}
export default series(compile, copyfont, copyfullStyle);
1.3 工具模块打包
依赖安装
pnpm install gulp-typescript -w -D
packages/utils/gulpfile.ts
import {buildPackages} from '../../build/packages'
export default buildPackages(__dirname, 'utils');
build/packages.ts
这里打包格式需要注意,存在两种类型,当然如果场景单一的话,也可以只设置 CommonJS 类型一种
import {series,parallel,src,dest} from 'gulp'
import { buildConfig } from './utils/config'
import path from 'path';
import { outDir, projectRoot } from './utils/paths';
import ts from 'gulp-typescript'
import { withTaskName } from './utils';
export const buildPackages = (dirname:string, name:string)=>{
// 打包的格式需要是什么类型的? 模块规范 cjs es模块规范
// umd 是在浏览器中用的
// 可以用rollup, 这个逻辑知识让ts-> js即可
const tasks = Object.entries(buildConfig).map(([module, config])=>{
const output = path.resolve(dirname, config.output.name);
return series(
withTaskName(`buld:${dirname}`, () => {
const tsConfig = path.resolve(projectRoot, 'tsconfig.json'); // ts的配置文件的路径
const inputs = ['**/*.ts', "!gulpfile.ts", '!node_modules'];
return src(inputs).pipe(ts.createProject(tsConfig,{
declaration:true, // 需要生成声明文件
strict:false,
module:config.module
})()).pipe(dest(output))
}),
withTaskName(`copy:${dirname}`, () => {
// 放到es-> utils 和 lib -> utils
// 将utils 模块拷贝到dist 目录下的es目录和lib目录
return src(`${output}/**`).pipe(dest(path.resolve(outDir, config.output.name, name)))
})
)
})
console.log(tasks)
return parallel(...tasks)
// 最终发布的是dist 最终在项目中引入的都是es6模块。 按需加载
}
build/utils/config.ts
组件库最终需要支持esm和cjs两种使用方案
import path from "path";
import { outDir } from "./paths";
export const buildConfig = {
esm: {
module: "ESNext", // tsconfig输出的结果es6模块
format: "esm", // 需要配置格式化化后的模块规范
output: {
name: "es", // 打包到dist目录下的那个目录
path: path.resolve(outDir, "es"),
},
bundle: {
path: "xbb-plus/es",
},
},
cjs: {
module: "CommonJS",
format: "cjs",
output: {
name: "lib",
path: path.resolve(outDir, "lib"),
},
bundle: {
path: "xbb-plus/lib",
},
},
};
export type BuildConfig = typeof buildConfig;
此时运行npm run build 后,应该会生成以下目录及文件
image.png
2. 打包完整组件库
- 在 components 下创建入口文件导出所有的组件
packages/components/index.ts
export * from './icon';
// ....
- 创建打包组件库的入口
新建文件夹 packages/xbb-plus ,在目录下 初始化,package name 就命名为 xbb-plus ,即最终我们的ui组件库的命名
pnpm init
packages/xbb-plus/index.ts
import { XbbIcon } from "@xbb-plus/components";
import type { App } from "vue"; // ts中的优化只获取类型
// ....
const components = [XbbIcon];
const install = (app: App) => {
// 每个组件在编写的时候都提供了install方法
// 有的是组建 有的可能是指令 xxx.install = ()=>{app.directive()}
components.forEach((component) => app.use(component));
};
export default {
install,
};
export * from "@xbb-plus/components";
//app.use(XbbPlus)
- 打包组件库
build/gulpfile.ts 内,添加打包组件库任务
export default series(
withTaskName("clean", async () => run('rm -rf ./dist')),
parallel(
withTaskName("buildPackages", () =>
run("pnpm run --filter ./packages --parallel build")
),
withTaskName("buildFullComponent", () =>
run("pnpm run build buildFullComponent")
), // 执行build命令时会调用rollup, 我们给rollup传递参数buildFullComponent 那么就会执行导出任务叫 buildFullComponent )
);
export * from "./full-component";
- 安装打包所需相关依赖
pnpm install rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs rollup-plugin-typescript2 rollup-plugin-vue -D -w
- 打包 umd 和 es 模块
*build/utils/paths.ts 内添加定义的地址变量
export const outDir = path.resolve(__dirname,'../../dist')
export const zpRoot = path.resolve(__dirname,'../../packages/xbb-plus')
build/full-component.ts 打包组件库代码
import { nodeResolve } from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import vue from "rollup-plugin-vue";
import typescript from "rollup-plugin-typescript2";
import { parallel } from "gulp";
import path from "path";
import { outDir, projectRoot, zpRoot } from "./utils/paths";
import { rollup, OutputOptions} from "rollup";
const buildFull = async () => {
// rollup打包的配置信息
const config = {
input: path.resolve(zpRoot, "index.ts"), // 打包的入口
plugins: [nodeResolve(), typescript(), vue(), commonjs()],
external: (id) => /^vue/.test(id), // 表示打包的时候不打包vue代码
};
// 整个组件库 两种使用方式 import 导入组件库 在浏览器中使用 script
// esm umd
const buildConfig = [
{
format: "umd", // 打包的个数
file: path.resolve(outDir, "index.js"),
name: "XbbPlus", // 全局的名字
exports: "named", // 导出的名字 用命名的方式导出 liraryTarget:"var" name:""
globals: {
// 表示使用的vue是全局的
vue: "Vue",
},
},
{
format:'esm',
file: path.resolve(outDir, "index.esm.js")
}
];
let bundle = await rollup(config);
return Promise.all(buildConfig.map(config => bundle.write(config as OutputOptions)))
}
export const buildFullComponent = parallel(buildFull);
3. 对组件依次打包
安装依赖
pnpm install fast-glob -w -D
pnpm install ts-morph -w -D #给每个组件添加类型声明文件
build/gulpfile.ts 内,添加打包组件任务
export default series(
withTaskName("clean", async () => run('rm -rf ./dist')),
parallel(
withTaskName("buildPackages", () =>
run("pnpm run --filter ./packages --parallel build")
),
withTaskName("buildFullComponent", () =>
run("pnpm run build buildFullComponent")
), // 执行build命令时会调用rollup, 我们给rollup传递参数buildFullComponent 那么就会执行导出任务叫 buildFullComponent
withTaskName("buildComponent", () => run("pnpm run build buildComponent"))
),
parallel(genTypes, copySourceCode())
);
// 这是一个任务
// 任务执行器 gulp 任务名 就会执行对应的任务
export * from "./full-component";
export * from "./component";
*build/utils/paths.ts 内添加定义的地址变量
export const compRoot = path.resolve(projectRoot, "packages/components");
build/component.ts 打包组件代码
import { series,parallel } from "gulp";
import { sync } from "fast-glob";
import { compRoot, outDir, projectRoot } from "./utils/paths";
import path from "path";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import vue from "rollup-plugin-vue";
import typescript from "rollup-plugin-typescript2";
import { rollup, OutputOptions } from "rollup";
import { buildConfig } from "./utils/config";
import { pathRewriter, run } from "./utils";
import { Project, SourceFile } from "ts-morph";
import glob from "fast-glob";
import fs from "fs/promises";
import * as VueCompiler from "@vue/compiler-sfc";
const buildEachComponent = async () => {
// 打包每个组件
const files = sync("*", {
cwd: compRoot,
onlyDirectories: true,
});
// 分别把components 文件夹下的组件 放到dist/es/components下 和 dist/lib/compmonents
const builds = files.map(async (file: string) => {
const input = path.resolve(compRoot, file, "index.ts"); // 每个组件的入口
const config = {
input,
plugins: [nodeResolve(), vue(), typescript(), commonjs()],
external: (id) => /^vue/.test(id) || /^@xbb-plus/.test(id),
};
const bundle = await rollup(config);
const options = Object.values(buildConfig).map((config) => ({
format: config.format,
file: path.resolve(config.output.path, `components/${file}/index.js`),
paths: pathRewriter(config.output.name), // @xbb-plus => xbb-plus/es xbb-plus/lib
}));
await Promise.all(
options.map((option) => bundle.write(option as OutputOptions))
);
});
return Promise.all(builds);
}
async function genTypes() {
const project = new Project({
// 生成.d.ts 我们需要有一个tsconfig
compilerOptions: {
allowJs: true,
declaration: true,
emitDeclarationOnly: true,
noEmitOnError: true,
outDir: path.resolve(outDir, "types"),
baseUrl: projectRoot,
paths: {
"@xbb-plus/*": ["packages/*"],
},
skipLibCheck: true,
strict: false,
},
tsConfigFilePath: path.resolve(projectRoot, "tsconfig.json"),
skipAddingFilesFromTsConfig: true,
});
const filePaths = await glob("**/*", {
// ** 任意目录 * 任意文件
cwd: compRoot,
onlyFiles: true,
absolute: true,
});
const sourceFiles: SourceFile[] = [];
await Promise.all(
filePaths.map(async function (file) {
if (file.endsWith(".vue")) {
const content = await fs.readFile(file, "utf8");
const sfc = VueCompiler.parse(content);
const { script } = sfc.descriptor;
if (script) {
let content = script.content; // 拿到脚本 icon.vue.ts => icon.vue.d.ts
const sourceFile = project.createSourceFile(file + ".ts", content);
sourceFiles.push(sourceFile);
}
} else {
const sourceFile = project.addSourceFileAtPath(file); // 把所有的ts文件都放在一起 发射成.d.ts文件
sourceFiles.push(sourceFile);
}
})
);
await project.emit({
// 默认是放到内存中的
emitOnlyDtsFiles: true,
});
const tasks = sourceFiles.map(async (sourceFile: any) => {
const emitOutput = sourceFile.getEmitOutput();
const tasks = emitOutput.getOutputFiles().map(async (outputFile: any) => {
const filepath = outputFile.getFilePath();
await fs.mkdir(path.dirname(filepath), {
recursive: true,
});
// @xbb-plus -> xbb-plus/es -> .d.ts 肯定不用去lib下查找
await fs.writeFile(filepath, pathRewriter("es")(outputFile.getText()));
});
await Promise.all(tasks);
});
await Promise.all(tasks)
}
function copyTypes() {
const src = path.resolve(outDir,'types/components/')
const copy = (module) => {
let output = path.resolve(outDir, module, 'components')
return () => run(`cp -r ${src}/* ${output}`)
}
return parallel(copy('es'),copy('lib'))
}
// 打包入口文件
async function buildComponentEntry() {
const config = {
input: path.resolve(compRoot, "index.ts"),
plugins: [typescript()],
external: () => true,
};
const bundle = await rollup(config);
return Promise.all(
Object.values(buildConfig)
.map((config) => ({
format: config.format,
file: path.resolve(config.output.path, "components/index.js"),
}))
.map((config) => bundle.write(config as OutputOptions))
);
}
export const buildComponent = series(buildEachComponent, genTypes, copyTypes(), buildComponentEntry)
4. 打包组件库入口 xbb-plus
build/full-component.ts 内添加相关逻辑
async function buildEntry() {
const entryFiles = await fs.readdir(zpRoot, { withFileTypes: true });
const entryPoints = entryFiles
.filter((f) => f.isFile())
.filter((f) => !["package.json"].includes(f.name))
.map((f) => path.resolve(zpRoot, f.name));
const config = {
input: entryPoints,
plugins: [nodeResolve(), vue(), typescript()],
external: (id: string) => /^vue/.test(id) || /^@xbb-plus/.test(id),
};
const bundle = await rollup(config);
return Promise.all(
Object.values(buildConfig)
.map((config) => ({
format: config.format,
dir: config.output.path,
paths: pathRewriter(config.output.name),
}))
.map((option) => bundle.write(option as OutputOptions))
);
}
export const buildFullComponent = parallel(buildFull, buildEntry);
build/gen-types.ts
import { outDir, projectRoot, zpRoot } from "./utils/paths";
import glob from 'fast-glob';
import {Project,ModuleKind,ScriptTarget,SourceFile} from 'ts-morph'
import path from 'path'
import fs from 'fs/promises'
import { parallel, series } from "gulp";
import { run, withTaskName } from "./utils";
import { buildConfig } from "./utils/config";
export const genEntryTypes = async () => {
const files = await glob("*.ts", {
cwd: zpRoot,
absolute: true,
onlyFiles: true,
});
const project = new Project({
compilerOptions: {
declaration: true,
module: ModuleKind.ESNext,
allowJs: true,
emitDeclarationOnly: true,
noEmitOnError: false,
outDir: path.resolve(outDir, "entry/types"),
target: ScriptTarget.ESNext,
rootDir: zpRoot,
strict: false,
},
skipFileDependencyResolution: true,
tsConfigFilePath: path.resolve(projectRoot, "tsconfig.json"),
skipAddingFilesFromTsConfig: true,
});
const sourceFiles: SourceFile[] = [];
files.map((f) => {
const sourceFile = project.addSourceFileAtPath(f);
sourceFiles.push(sourceFile);
});
await project.emit({
emitOnlyDtsFiles: true,
});
const tasks = sourceFiles.map(async (sourceFile) => {
const emitOutput = sourceFile.getEmitOutput();
for (const outputFile of emitOutput.getOutputFiles()) {
const filepath = outputFile.getFilePath();
await fs.mkdir(path.dirname(filepath), { recursive: true });
await fs.writeFile(
filepath,
outputFile.getText().replace(/@xbb-plus/g, "."),
"utf8"
);
}
});
await Promise.all(tasks);
};
export const copyEntryTypes = () => {
const src = path.resolve(outDir, "entry/types");
const copy = (module) =>
parallel(
withTaskName(`copyEntryTypes:${module}`, () =>
run(
`cp -r ${src}/* ${path.resolve(
outDir,
buildConfig[module].output.path
)}/`
)
)
);
return parallel(copy("esm"), copy("cjs"));
}
export const genTypes = series(genEntryTypes,copyEntryTypes())
最后,build/gulpfile.ts 的完整代码如下:
import { series, parallel } from "gulp";
import { run, withTaskName } from "./utils";
import { genTypes } from "./gen-types";
import { outDir, zpRoot } from "./utils/paths";
// gulp 不叫打包 做代码转化 vite
const copySourceCode = () => async () => {
await run(`cp ${zpRoot}/package.json ${outDir}/package.json`)
}
//1.打包样式 2.打包工具方法 2.打包所有组件 3.打包每个组件 4.生成一个组件库 5.发布组件
export default series(
withTaskName("clean", async () => run('rm -rf ./dist')),
parallel(
withTaskName("buildPackages", () =>
run("pnpm run --filter ./packages --parallel build")
),
withTaskName("buildFullComponent", () =>
run("pnpm run build buildFullComponent")
), // 执行build命令时会调用rollup, 我们给rollup传递参数buildFullComponent 那么就会执行导出任务叫 buildFullComponent
withTaskName("buildComponent", () => run("pnpm run build buildComponent"))
),
parallel(genTypes, copySourceCode())
);
// 这是一个任务
// 任务执行器 gulp 任务名 就会执行对应的任务
export * from "./full-component";
export * from "./component";
每个包都有自己的 package.json ,所以上面的 copySourceCode 方法将 package.json 拷贝至 dist。
OK,到目前位置,从0开始,开发一套ui组件库就约等于全部完成了,运行打包命令:
npm run build
看到如下产物,这个 dist 文件夹,就是将来我们要发布的包。我们只需要将它发布即可。
image.png
五、模拟打包后的使用
这里就不讲发布了,一个比较简单的流程,具体发布流程可参考基于TypeScript,发布一个npm包 的发布部分
那么,本地如何测试呢?我们将 dist 重命名成 xbb-plus,放入 play/node_modules 下即可。这样操作,约等于我们将包下载安装到 node_modules 下。
此时 使用如下
运行 play 项目,效果一样!
到此,从0到1,开发一套ui组件库,完结了啦
本文代码git仓库:https://github.com/chenjing0823/xbb-plus
有问题欢迎交流~
网友评论