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

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

作者: 超人s | 来源:发表于2021-11-26 16:06 被阅读0次

    写在前面

    其实刚入行前端的时候,看到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
    有问题欢迎交流~

    相关文章

      网友评论

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

          本文链接:https://www.haomeiwen.com/subject/ipuhtrtx.html