原来我也可以【从0开始,开发一套自己的ui组件库】

写在前面

其实刚入行前端的时候,看到element-uiantd等组件库的时候,就一直觉得,作为一名前端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配置文件

pnpm init时,建议 package name 合理取名,方便后面一些操作,例如 demo-ui,我这边就模仿element-plus,叫xbb-plus

image.png

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

随意添加几个可使用的图标进入项目后,下载至本地


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 下。
此时 使用如下

image.png

运行 play 项目,效果一样!
到此,从0到1,开发一套ui组件库,完结了啦

本文代码git仓库:https://github.com/chenjing0823/xbb-plus
有问题欢迎交流~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,277评论 6 503
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,689评论 3 393
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,624评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,356评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,402评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,292评论 1 301
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,135评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,992评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,429评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,636评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,785评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,492评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,092评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,723评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,858评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,891评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,713评论 2 354

推荐阅读更多精彩内容